# Crumbs documentation > Crumbs watches a running Flutter app for requests, logs, exceptions and queries, redacts credentials and lets an AI agent read them. # Getting Started What crumbs captures, what it needs, and where to start: installation, the quickstart, and the host features for redaction, the file sink and buffers. - [Pick your path](#pick-your-path) - [What crumbs is](#what-crumbs-is) - [Requirements](#requirements) - [What gets captured](#what-gets-captured) - [Host features](#host-features) - [Related Documentation](#related-documentation) ## Pick your path | You want to | Read | |:------------|:-----| | Add crumbs to a Flutter app | [Installation](https://anilcancakir.com/crumbs/docs/getting-started/installation.md) | | Go from install to a first MCP tool call | [Quickstart](https://anilcancakir.com/crumbs/docs/getting-started/quickstart.md) | | Keep secrets out of the buffers | [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md) | | Keep a timeline on disk | [File sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md) | | Size the buffers | [Buffers](https://anilcancakir.com/crumbs/docs/getting-started/buffers.md) | ## What crumbs is crumbs is a passive runtime inspector for Flutter apps. Watchers and HTTP adapters record what the app does into in-memory ring buffers, and `ext.crumbs.*` VM Service extensions serve those buffers to `crumbs:*` CLI commands and `crumbs_*` MCP tools on [dowel](https://pub.dev/packages/dowel). It never changes app behaviour; it only records. ```bash flutter pub add crumbs dart run crumbs crumbs:install ``` `crumbs:install` scaffolds the dowel harness when it is missing, registers the plugin and patches `lib/main.dart` so `CrumbsPlugin.install()` runs at startup inside a `kDebugMode` guard. The guard belongs to the call site: `kDebugMode` is what the installer writes, and `!kReleaseMode` also keeps crumbs in profile builds (see [Call-site guard](https://anilcancakir.com/crumbs/docs/watchers/index.md#call-site-guard)). A release build tree-shakes crumbs either way. ## Requirements | Dependency | Minimum | Notes | |:-----------|:--------|:------| | Dart SDK | `3.13` | | | Flutter SDK | `3.47` | The VM Service extensions need the Flutter runtime. | | dowel | `^0.0.21` | Pulled in by crumbs; `crumbs:install` sets up the rest. | | Magic stack | optional | `MagicCrumbsIntegration` from a `magic_devtools` release built on crumbs adds HTTP, model, cache, event, gate and query capture. | ## What gets captured After `crumbs:install` on any Flutter app: - **Logs**: every `package:logging` record, through `LogWatcher` (installed by `CrumbsPlugin.install()`). - **Exceptions**: uncaught errors, through `ExceptionWatcher`, chained to any handler already set (Sentry, Bugsnag). - **`debugPrint` output**: through `DumpWatcher`, chained to the previous override. With the Magic stack (`MagicCrumbsIntegration.install()` after `Magic.init()`): - **HTTP traffic** through the Magic `Http` facade. - **Model lifecycle**: created, saved and deleted events on Magic models. - **Cache operations**: hit, miss, put, forget and flush through the `Cache` facade. - **In-app events**: a curated set of Magic auth, database and gate-definition events. - **Gate checks**: every `Gate.allows` and `Gate.denies` result with the user id. - **DB queries**: SQL, bindings and execution time through the Magic query builder. `FramePerfWatcher` (frame timings) and a Dio interceptor (HTTP on a vanilla app) are opt-in on any app; see [Watchers](https://anilcancakir.com/crumbs/docs/watchers/index.md). ## Host features - [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md): the credential lists and `CrumbsRedaction.redactor`, the host hook that masks every log, event, exception and HTTP record at insert. - [File sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md): `CrumbsFileSink`, a rotating JSONL timeline on disk that writes only redacted records, read back through `crumbs:files`. - [Buffers](https://anilcancakir.com/crumbs/docs/getting-started/buffers.md): per-kind capacity with `CrumbsStore.setCapacity`, the `atUs` clock, and how `limit` and `minLevel` behave. - [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md): `since`, `type`, `logger` and `cursor` on the read extensions, and `--json` and `--follow` on the CLI. ## Related Documentation - [Installation](https://anilcancakir.com/crumbs/docs/getting-started/installation.md): the installer, manual wiring and the dowel provider. - [Quickstart](https://anilcancakir.com/crumbs/docs/getting-started/quickstart.md): install, start the app and make the first MCP tool call. - [Watchers](https://anilcancakir.com/crumbs/docs/watchers/index.md): every watcher and adapter, and the call-site guard. - [MCP overview](https://anilcancakir.com/crumbs/docs/mcp/overview.md): how the `crumbs_*` tools reach the running app. --- # Installation Add `crumbs` to a Flutter app, install it behind a call-site guard, register it with dowel, and optionally write the MCP entry for your AI client. The recommended path is one command, `dart run crumbs crumbs:install`, which patches `lib/main.dart` and registers the plugin with dowel; the manual route is described step by step below. - [Quick Reference](#quick-reference) - [Requirements](#requirements) - [Run the installer](#run-the-installer) - [Manual wiring](#manual-wiring) - [Register with dowel](#register-with-dowel) - [Wire MCP tools](#wire-mcp-tools) - [Verify installation](#verify-installation) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference | Step | Command | Result | |:-----|:--------|:-------| | 1. Add the package | `flutter pub add crumbs` | `crumbs` is a dependency; `dowel` comes with it. | | 2. Wire the app | `dart run crumbs crumbs:install` | The dowel harness exists, crumbs is a registered plugin, and `lib/main.dart` installs crumbs in a `kDebugMode` block. | | 3. Register the MCP server (optional) | `dart run crumbs mcp:install` | A `dowel` entry in `.mcp.json` at the project root. | | 4. Start the app | `dart run crumbs start --device=chrome` | dowel records the session every `crumbs:*` command dials. | | 5. Read a buffer | `dart run crumbs crumbs:tail` | The newest log records of the running app. | ## Requirements `crumbs` is a Flutter package: the VM Service extensions register inside a running Flutter app, so a pure Dart program is not supported. | Dependency | Minimum | Notes | |:-----------|:--------|:------| | Dart | `3.13` | | | Flutter | `3.47` | | | dowel | `^0.0.21` | A dependency of crumbs. When `crumbs:install` scaffolds the harness, dowel's `install` step adds `dowel: any` to the app's `pubspec.yaml`, because the generated barrels import it. | ## Run the installer ```bash flutter pub add crumbs dart run crumbs crumbs:install ``` `dart run crumbs` is crumbs's own entry point: it carries the whole dowel CLI plus the crumbs commands, so the installer works on a project that has no dowel setup yet. It runs three steps in order: 1. When `bin/dispatcher.dart` is missing, it runs `dart run crumbs install`, which scaffolds the dowel harness: `bin/dispatcher.dart`, the fast `./bin/dowel` launcher and the codegen barrels under `lib/app/`. A project that already has the dispatcher skips this step. 2. It runs `dart run crumbs plugin:install crumbs`, which records crumbs in `.dowel/plugins.json` and regenerates `lib/app/_plugins.g.dart`. 3. It patches `lib/main.dart`: the `kDebugMode` and `package:crumbs/crumbs.dart` imports, `WidgetsFlutterBinding.ensureInitialized()` when missing, and a `kDebugMode` block with `CrumbsPlugin.install()` and the exception and dump watchers. The block lands before `await Magic.init(` on a Magic app, so `ExceptionWatcher` sees boot errors, and before `runApp(` otherwise. Without a `lib/main.dart` the step is skipped with a message. On a Magic app whose `pubspec.yaml` lists `magic_devtools`, step 3 also adds `import 'package:magic_devtools/crumbs.dart';` and a `kDebugMode` block with `MagicCrumbsIntegration.install()` after `Magic.init()`. That library exists only in a `magic_devtools` release built on crumbs, so the installer writes these lines only when the resolved `magic_devtools` ships it; with an older release it warns and leaves the Magic lines out. Until that release ships, `MagicTelescopeIntegration` feeds `fluttersdk_telescope`, not crumbs, so the HTTP, event, gate, query and cache buffers of crumbs stay empty in a Magic app. Every step is idempotent: re-running `crumbs:install` changes nothing. ## Manual wiring Skip the installer and edit `lib/main.dart` directly. Call `CrumbsPlugin.install()` inside a guard, after `WidgetsFlutterBinding.ensureInitialized()` and before `runApp()`: ```dart import 'package:crumbs/crumbs.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); if (kDebugMode) { CrumbsPlugin.install(); // LogWatcher and the ext.crumbs.* extensions. CrumbsPlugin.registerWatcher(ExceptionWatcher()); CrumbsPlugin.registerWatcher(DumpWatcher()); } runApp(const MyApp()); // MyApp is the host app's root widget. } ``` `kDebugMode` keeps crumbs in debug builds only; `!kReleaseMode` keeps it in profile builds too, which a frame measurement needs. Both tree-shake a release build; see [Call-site guard](https://anilcancakir.com/crumbs/docs/watchers/index.md#call-site-guard). `CrumbsPlugin.install()` is idempotent, so a hot restart registers each extension once. On a Magic app, install crumbs before `Magic.init()` and the Magic integration after it, because the integration resolves the Http facade and the event dispatcher from the container: ```dart import 'package:crumbs/crumbs.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import 'package:magic_devtools/crumbs.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); if (kDebugMode) { CrumbsPlugin.install(); CrumbsPlugin.registerWatcher(ExceptionWatcher()); CrumbsPlugin.registerWatcher(DumpWatcher()); } await Magic.init(configFactories: appConfigFactories); // The host's config list. if (kDebugMode) { MagicCrumbsIntegration.install(); } runApp(const MyApp()); } ``` On a vanilla app that uses Dio, route Dio traffic into the HTTP buffer from an interceptor; crumbs has no `dio` dependency, so the interceptor lives in the app (the bundled `example/` app has a complete one): ```dart import 'package:crumbs/crumbs.dart'; import 'package:dio/dio.dart'; class CrumbsDioInterceptor extends Interceptor { final Map _watches = {}; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { _watches[options] = Stopwatch()..start(); handler.next(options); } @override void onResponse(Response response, ResponseInterceptorHandler handler) { final options = response.requestOptions; DioHttpAdapter.recordRequest( url: options.uri.toString(), method: options.method, statusCode: response.statusCode ?? 0, durationMs: _watches.remove(options)?.elapsedMilliseconds ?? 0, ); handler.next(response); } } ``` Add it with `dio.interceptors.add(CrumbsDioInterceptor())`. `CrumbsPlugin.registerHttpAdapter(DioHttpAdapter())` is optional here: `DioHttpAdapter` hooks nothing itself, and `recordRequest` writes to the store either way. ## Register with dowel `crumbs:install` already does this. `plugin:install crumbs` writes `CrumbsDowelProvider` into `lib/app/_plugins.g.dart`, which the scaffolded `bin/dispatcher.dart` reads through `plugins.autoDiscoveredProviders()`, so `./bin/dowel list` shows the `crumbs:*` commands and `./bin/dowel mcp:serve` serves the `crumbs_*` tools. To finish a skipped step by hand: ```bash dart run crumbs install # only when bin/dispatcher.dart is missing dart run crumbs plugin:install crumbs # idempotent; refreshes the barrel on re-run ``` A dispatcher wired by hand imports the provider from the Flutter-free `cli.dart` barrel, the same import `plugins:refresh` generates: ```dart import 'dart:io'; import 'package:crumbs/cli.dart' show CrumbsDowelProvider; import 'package:dowel/dowel.dart'; Future main(List args) async { exit( await runDowel( args, baseProviders: [CrumbsDowelProvider()], delegateToConsumer: false, collectMcpTools: args.isNotEmpty && args.first == 'mcp:serve', ), ); } ``` ## Wire MCP tools ```bash dart run crumbs mcp:install ``` This writes (or updates) the `dowel` entry in `.mcp.json` at the project root and keeps other entries. With `./bin/dowel` present on a POSIX host the entry runs `./bin/dowel mcp:serve`; otherwise, and always on Windows, it runs `dart run crumbs mcp:serve`. Reconnect the client afterwards (`/mcp reconnect dowel` in Claude Code). See [MCP setup](https://anilcancakir.com/crumbs/docs/mcp/setup.md) for other clients and tool filtering. ## Verify installation Start the app through dowel, so the session (the VM Service URI every `crumbs:*` command dials) is recorded: ```bash dart run crumbs start --device=chrome ``` Then read the log buffer and list the commands: ```bash ./bin/dowel crumbs:tail ./bin/dowel list ``` `crumbs:tail` prints the newest log records; `list` shows the `crumbs:` namespace. `dart run crumbs ` runs the same commands when `./bin/dowel` is not built. If a command finds no app, start it with `dart run crumbs start` first; if it finds the app but no `ext.crumbs.*` extension, confirm `CrumbsPlugin.install()` is reachable in `main.dart` and that the app is a debug build (or a profile build under `!kReleaseMode`). Stop the app with `dart run crumbs stop`. ## Gotchas | Mistake | Fix | |:--------|:----| | `CrumbsPlugin.install()` sits outside a guard. | Wrap it in `if (kDebugMode)` or `if (!kReleaseMode)`; crumbs installs no guard of its own. | | The guard sits inside a helper function. | Keep it at the call site in `main()`; the tree-shake depends on it. | | `crumbs_exceptions` stays empty after a crash. | Register `ExceptionWatcher`: `CrumbsPlugin.install()` installs only `LogWatcher`. | | `MagicCrumbsIntegration.install()` runs before `Magic.init()`. | Call it after `Magic.init()`; the HTTP adapter registers nothing while the network driver is unbound. | | The installer warned and wrote no Magic lines. | The resolved `magic_devtools` predates crumbs; upgrade it and rerun `crumbs:install`. | | The app was started with a plain `flutter run`. | Start it with `dart run crumbs start`, which records the session the commands dial. | | `.mcp.json` points at the wrong launcher. | Rerun `dart run crumbs mcp:install`; it picks the launcher from the scaffold state. | ## Related Documentation - [Quickstart](https://anilcancakir.com/crumbs/docs/getting-started/quickstart.md): install, start the app and make the first MCP tool call. - [Watchers](https://anilcancakir.com/crumbs/docs/watchers/index.md): every watcher and adapter, and the call-site guard. - [MCP setup](https://anilcancakir.com/crumbs/docs/mcp/setup.md): client configuration, reconnecting and tool filtering. - [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md): mask secrets before a record is buffered. --- # Quickstart Take a fresh Flutter project from zero to an AI agent reading the app's logs, HTTP traffic and exceptions through the `crumbs_*` MCP tools. Prerequisites: Dart 3.13 and Flutter 3.47 or newer, and a Flutter project (a plain `flutter create` output works). `dowel` comes with crumbs, and `crumbs:install` scaffolds its harness on the first run. - [Install crumbs](#install-crumbs) - [Start the app and generate traffic](#start-the-app) - [Read the buffers from the CLI](#read-from-the-cli) - [Read the buffers from an agent](#read-from-an-agent) - [Related Documentation](#related-documentation) ## Install crumbs ```bash flutter pub add crumbs dart run crumbs crumbs:install ``` The installer scaffolds the dowel harness when it is missing, registers crumbs as a dowel plugin and patches `lib/main.dart` with a `kDebugMode` block that installs crumbs with the exception and dump watchers. On a Magic app with a `magic_devtools` release built on crumbs it also wires `MagicCrumbsIntegration.install()` after `Magic.init()`; see [Installation](https://anilcancakir.com/crumbs/docs/getting-started/installation.md#run-the-installer) for every step. From here on the fast launcher `./bin/dowel` runs every command; `dart run crumbs ` is the slower equivalent that needs no build. Confirm the plugin registered: ```bash ./bin/dowel list ``` The output lists the `crumbs:` namespace. ## Start the app and generate traffic ```bash ./bin/dowel start --device=chrome ``` dowel starts `flutter run`, records the VM Service URI of the session, and every `crumbs:*` command and `crumbs_*` tool dials that session. Use the app. On a vanilla app, `package:logging` records fill the log buffer and `debugPrint` calls the dump buffer; on a Magic app, HTTP calls through the `Http` facade, model saves, cache reads and gate checks land in their buffers too. To produce records on purpose, from anywhere in the app: ```dart import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; void emitSampleRecords() { Logger('Quickstart').info('quickstart test log'); // a log record debugPrint('quickstart test dump'); // a dump record } ``` ## Read the buffers from the CLI ```bash ./bin/dowel crumbs:tail --level=INFO ./bin/dowel crumbs:dumps ./bin/dowel crumbs:requests --limit=10 ./bin/dowel crumbs:clear ``` Each read command prints the newest records, oldest first (`--limit` defaults to 50). `crumbs:clear` empties every buffer, so the next read holds only what happens after it. `crumbs:tail --follow` keeps printing new log lines until you stop it. ## Read the buffers from an agent Write the MCP entry and reconnect the client: ```bash dart run crumbs mcp:install ``` In Claude Code, run `/mcp reconnect dowel`; the `crumbs_*` tools appear next to the `dowel_*` tools. A typical inspection: ```text crumbs_clear {} # exercise the feature crumbs_tail {"level": "WARNING", "limit": 20} crumbs_requests {} crumbs_exceptions {} ``` `crumbs_tail` returns log records at or above the level, `crumbs_requests` every captured HTTP request with its status, duration and masked headers, and `crumbs_exceptions` the uncaught errors with their stack traces. Every tool reads an `ext.crumbs.*` extension in the running app; the buffers fill passively, so nothing in the app changes between reads. ## Related Documentation - [Installation](https://anilcancakir.com/crumbs/docs/getting-started/installation.md): manual wiring, the Magic integration and a Dio interceptor. - [Watchers](https://anilcancakir.com/crumbs/docs/watchers/index.md): which watchers are opt-in and how they chain to Sentry and Bugsnag. - [Tool reference](https://anilcancakir.com/crumbs/docs/mcp/tool-reference.md): every tool's input and output shape. - [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md), [File sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md) and [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md): mask secrets, keep a timeline on disk, and follow a buffer. --- # Buffers How `CrumbsStore` holds records: one in-memory ring buffer per record kind, their capacities, the `atUs` clock, and what `limit` and `minLevel` return. - [Quick Reference](#quick-reference) - [Capacity](#capacity) - [Monotonic timestamps](#monotonic-timestamps) - [Reading with limit and minLevel](#reading-with-limit-and-minlevel) - [Related Documentation](#related-documentation) ## Quick Reference | Call | Effect | |:-----|:-------| | `CrumbsStore.setCapacity(2000)` | Caps every buffer except frame performance. | | `CrumbsStore.setCapacity(5000, kind: CrumbsKind.logs)` | Caps one buffer. | | `CrumbsStore.setFramePerfCapacity(7200)` | Caps the frame buffer; same as `kind: CrumbsKind.framePerf`. | | `CrumbsStore.recentLogs(limit: 20, minLevel: 'WARNING')` | The newest 20 log records at `WARNING` or above, oldest first. | | `CrumbsStore.clear()` | Empties every buffer. | Each buffer is a FIFO queue: once it is over its capacity the oldest record is evicted, silently. ## Capacity Every buffer holds 500 records by default, except frame performance, which holds 3600 (about a minute at 60fps). ```dart CrumbsStore.setCapacity(2000); // every buffer but frame perf CrumbsStore.setCapacity(5000, kind: CrumbsKind.logs); // one buffer CrumbsStore.setCapacity(7200, kind: CrumbsKind.framePerf); ``` `CrumbsKind` names the buffers: `http`, `logs`, `exceptions`, `models`, `caches`, `events`, `gates`, `dumps`, `queries`, `framePerf`. - A cap for one kind wins over the shared cap, whichever was set last. The shared call leaves a kind cap alone. - A cap below the buffer's current length trims on the next record, not retroactively. - A cap of zero or less keeps nothing. - `CrumbsStore.resetForTesting()` (tests only) empties the buffers, restores both default caps, clears the per-kind caps and unregisters `CrumbsRedaction.redactor`. ## Monotonic timestamps `LogRecordEntry` and `EventRecord` carry `atUs`, stamped at construction from `FlutterTimeline.now` and serialised in `toJson`, so a log line orders against an event whatever the wall clock did. `time` stays the wall-clock field for display. `atUs` is also the cursor that `ext.crumbs.console` and `ext.crumbs.events` page on (see [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md)). ## Reading with limit and minLevel `CrumbsStore.recentX(limit:)` returns the newest `limit` records, oldest first; without `limit` it returns the whole buffer. - A zero or negative `limit` returns an empty list. - `recentLogs(minLevel:)` keeps records at or above the named `package:logging` level (`FINEST`, `FINER`, `FINE`, `CONFIG`, `INFO`, `WARNING`, `SEVERE`, `SHOUT`, any case). A name that is not a level matches nothing and returns an empty list, so a typo never reads as a clean log. ## Related Documentation - [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md): `since`, `type`, `logger` and `cursor` on the read extensions. - [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md): what the store masks before a record enters its buffer. - [File sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md): a timeline on disk that outlives the in-memory buffers. --- # File sink `CrumbsFileSink` keeps a timeline on disk that outlives the in-memory buffers: a rotating set of JSONL files of the redacted log, event and exception records, read back through the VM Service, `crumbs:files`, or any tool that reads the directory. The package looks up no directory. The host passes one, so it chooses where the files live (an app support directory, a temp directory, a path inside a test sandbox). The sink writes through `dart:io`, so it is not available on the web. - [Start and stop](#start-and-stop) - [What is written](#what-is-written) - [Fail closed](#fail-closed) - [Write failures](#write-failures) - [Reading it back](#reading-it-back) - [Related Documentation](#related-documentation) ## Start and stop ```dart import 'package:crumbs/crumbs.dart'; Future startTimeline(String supportPath, String Function(String) maskSecrets) async { CrumbsRedaction.redactor = maskSecrets; // required, see "Fail closed" CrumbsPlugin.install(); await CrumbsFileSink.start( directory: '$supportPath/crumbs', maxFileBytes: 1048576, // default: 1 MiB per file maxFiles: 8, // default: keep 8 timeline files ); } ``` | Member | Meaning | |---|---| | `CrumbsFileSink.start({required directory, maxFileBytes = 1048576, maxFiles = 8})` | Creates the directory, opens the first file, subscribes to the store, and returns the running sink. A sink already running is stopped first. Throws a `FileSystemException` when the directory or first file cannot be created; the sink then neither listens nor becomes current. | | `CrumbsFileSink.current` | The running sink, or null. | | `files()` | `[{name, bytes}]` for every timeline file in the directory, earlier launches included, newest first. | | `read(name, {offset = 0, maxBytes = 65536})` | Whole lines of one file as `{lines, next}`. Null when refused. | | `flush()` | Writes the batched lines now. Throws a `FileSystemException` when the write fails. | | `stop()` | Flushes, closes the file, and clears `current`. Safe to call twice. | | `droppedUnredacted` | Count of records refused because `redacted` was false. | | `writeFailures` | Count of background batch writes that failed. | `maxFileBytes` must be at least 1 and `maxFiles` at least 1. A single line larger than `maxFileBytes` still gets a file of its own. ## What is written Every record from `onLogRecord`, `onEventRecord` and `onExceptionRecord` becomes one line: ```json {"kind": "log", "level": "INFO", "message": "...", "atUs": 81234567, "redacted": true} ``` `kind` is `log`, `event` or `exception`; the rest is the record's `toJson()`. Lines are batched and written at 64 lines or 500 ms, whichever comes first. A new file starts before a line would pass `maxFileBytes`. Files are named `timeline--.jsonl`, where the stamp is the UTC launch time (`yyyyMMddTHHmmssZ`) and `n` counts rotations within the launch. After each rotation the sink deletes the oldest timeline files in the directory beyond `maxFiles`, earlier launches included. It only ever touches names its own pattern produces. The stamp has second precision, so a sink started again within the same second (a restart, or `start()` while one runs) names the file of the sink it replaced. The new sink appends to that file and counts its existing bytes toward `maxFileBytes`; it never truncates it. ## Fail closed The sink writes only redacted records. A record whose `redacted` flag is false, which means it was buffered while no `CrumbsRedaction.redactor` was registered, is never written and is counted in `droppedUnredacted`. Register the redactor before the sink starts (see [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md)); with none registered the sink stays empty and the counter climbs. ## Write failures A batch write that no caller awaits (the 64-line or 500 ms flush) and that fails does not raise into the zone, where an exception watcher would record it and the sink would accept that record in turn. The sink counts the failure in `writeFailures`, stops writing, and is no longer `current`. Start it again once the cause is fixed. A failure inside an awaited `flush()` is thrown to the caller instead. ## Reading it back `files()` and `read()` expose the timeline files in the directory and nothing else. `read` compares `name` against the files `files()` lists before it ever builds a path, so a path, a `..` segment, a link, or any other file in the directory reaches nothing: the answer is null. `read` returns whole lines within `maxBytes` of `offset`, without their newline, and `next`, the offset to pass to continue. A line longer than `maxBytes` is returned whole. At the end of a file `lines` is empty and `next` equals `offset`. A negative `offset` or a `maxBytes` below 1 is refused. An `offset` inside a multi-byte character decodes the cut bytes as U+FFFD rather than throwing. Both calls flush first, so a read sees every accepted line. ### VM extensions | Extension | Params | Answer | |---|---|---| | `ext.crumbs.files` | none | `{files: [{name, bytes}]}`, newest first | | `ext.crumbs.file` | `name`, `offset` (default 0), `maxBytes` (default 65536) | `{lines, next}` | Both answer `{error: "No timeline file sink is running."}` when `CrumbsFileSink.current` is null. `ext.crumbs.file` answers one fixed `{error}` message for every refusal (unknown name, bad offset, bad `maxBytes`), so the answer leaks nothing about the file system. The extensions have no MCP tool; reach them through the CLI or the VM service directly. ### CLI ```bash ./bin/dowel crumbs:files # list: " " per line ./bin/dowel crumbs:files --name=timeline-20261010T091500Z-0.jsonl ./bin/dowel crumbs:files --name= --offset= ``` | Option | Default | Meaning | |---|---|---| | `--name=` | none | Print the lines of this file, as listed, instead of the list. | | `--offset=` | `0` | Byte offset to start from: 0 or a previous `next`. | Printing a file ends with `next offset: `, shown at `-v` only, so pass `-v` to page through a long file. An empty directory prints `No timeline files.` and exits 0. When no sink runs the command prints the extension's error message, not a stack, and exits 1. ## Related Documentation - [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md): the redactor the sink depends on, and the `redacted` flag. - [Buffers](https://anilcancakir.com/crumbs/docs/getting-started/buffers.md): the in-memory buffers the sink subscribes to. - [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md): follow a buffer live instead of reading a file. --- # Redaction How crumbs keeps secrets out of the buffers it serves to AI agents and writes to disk: credential lists for HTTP records, and a host-supplied text redactor for every record that carries free text. - [Credential lists (HTTP)](#credential-lists) - [The text redactor](#the-text-redactor) - [JSON-safe event payloads](#json-safe-event-payloads) - [Related Documentation](#related-documentation) ## Credential lists (HTTP) `CrumbsStore.recordHttp` masks credential values before it buffers a record. The request headers `authorization`, `proxy-authorization`, `cookie`, `set-cookie` and `x-api-key`, and credential keys in a JSON or form-encoded body (`password`, `token`, `access_token`, ...), read `********`. Names match case-insensitively and at any depth. An empty value (null, `false`, `''`, `[]`, `{}`) stays visible. Extend the lists with `CrumbsRedaction.hideRequestHeaders`, `hideRequestParameters` and `hideResponseParameters`. Additions merge with the defaults and never replace them. An adapter that holds a body as a Dart `Map` runs it through `CrumbsRedaction.redactParameters(data, keys)` or `redactBody(body, keys)` before it stringifies or truncates it, because the store cannot mask a body it cannot parse. The full default lists are on the [Watchers](https://anilcancakir.com/crumbs/docs/watchers/index.md#credential-redaction) page. ## The text redactor `CrumbsRedaction.redactor` is a host hook, `String Function(String)?`, that is null until you set it: ```dart CrumbsRedaction.redactor = (String text) => text.replaceAll(RegExp(r'\b\d{13,19}\b'), '[card]'); ``` When it is set, the store runs it at insert over every text field of a log, event, exception and HTTP record, before the record enters its queue or its stream: | Record | Text the redactor sees | |---|---| | `LogRecordEntry` | level, message, logger name, error, stack trace | | `ExceptionRecord` | exception type, message, stack trace, isolate | | `EventRecord` | event type, interaction fields, and every String value and every map key inside the payload's nested Maps and Lists | | `HttpRequestRecord` | URL, method, header values, request and response body, request id, interaction fields (after the credential key masking above) | A subtree nested past 64 levels is masked whole. ### Contract - The redactor must be pure and total: it takes one String and returns the masked String. - A redactor that throws drops the record. The record is neither buffered nor emitted on its stream, because a record that could not be masked is never let through unmasked. - A record buffered while no redactor is registered is stored as is. Setting a redactor later does not rewrite what is already in the buffers. - `CrumbsRedaction.resetForTesting()` unregisters the redactor and restores the credential lists. ### The `redacted` flag The log, event, exception and HTTP records carry a read-only `redacted` getter, serialised in `toJson`. It is true only for a record the store's redaction pass produced with a redactor registered. The constructors take no `redacted` argument, and `HttpRequestRecord.copyWith` returns an unredacted copy, so no caller can build a record that a persisting consumer would take for masked. `CrumbsFileSink` relies on this: it writes only records whose `redacted` is true (see [File sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md)). ### Per-record entry points `redactLogRecord`, `redactExceptionRecord`, `redactEventRecord` and `redactHttpRecord` apply the redactor to one record. The store calls them; a caller outside the store rarely needs to. Each returns null for a dropped record, so `redactHttpRecord` returns `HttpRequestRecord?` and a caller adds a null check. ## JSON-safe event payloads `recordEvent` stores any payload value that `jsonEncode` cannot write (a `DateTime`, a `Duration`, any object) as its `toString()`, and a non-String map key likewise, so one bad payload cannot fail a whole `ext.crumbs.events` response. A payload that is already safe keeps its identity. This runs with or without a redactor. ## Related Documentation - [File sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md): the timeline on disk that writes only redacted records. - [Watchers](https://anilcancakir.com/crumbs/docs/watchers/index.md#credential-redaction): the default credential lists and how an HTTP adapter masks a body. - [Tool reference](https://anilcancakir.com/crumbs/docs/mcp/tool-reference.md#crumbs_requests): how a masked HTTP record reads over MCP. --- # Watchers Every watcher and HTTP adapter that feeds crumbs: what each captures, how it is installed, and the guard and chaining rules they share. Each unit implements one of two contracts: `CrumbsWatcher` (hook and event capture) or `CrumbsHttpAdapter` (HTTP capture). Both feed a ring buffer in `CrumbsStore`, which a VM Service extension serves to the CLI and the MCP tools. - [Quick Reference](#quick-reference) - [Call-site guard](#call-site-guard) - [Chain-preserve pattern](#chain-preserve-pattern) - [Framework-agnostic watchers](#framework-agnostic-watchers) - [HTTP adapters](#http-adapters) - [Credential redaction](#credential-redaction) - [Magic-stack watchers](#magic-stack-watchers) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference | Unit | Contract | Installed by | Buffer (`CrumbsKind`) | Read with | |:-----|:---------|:-------------|:----------------------|:----------| | `LogWatcher` | `CrumbsWatcher` | `CrumbsPlugin.install()` | `logs` | `crumbs:tail`, `crumbs_tail` | | `ExceptionWatcher` | `CrumbsWatcher` | `CrumbsPlugin.registerWatcher()` | `exceptions` | `crumbs_exceptions` | | `DumpWatcher` | `CrumbsWatcher` | `CrumbsPlugin.registerWatcher()` | `dumps` | `crumbs:dumps`, `crumbs_dumps` | | `FramePerfWatcher` | `CrumbsWatcher` | `CrumbsPlugin.registerWatcher()` | `framePerf` | `crumbs:frames`, `crumbs_frames` | | `DioHttpAdapter` | `CrumbsHttpAdapter` | Your Dio interceptor, through `DioHttpAdapter.recordRequest()` | `http` | `crumbs:requests`, `crumbs_requests` | | `MagicHttpFacadeAdapter` | `CrumbsHttpAdapter` | `MagicCrumbsIntegration.install()` | `http` | `crumbs:requests`, `crumbs_requests` | | `MagicModelWatcher` | `CrumbsWatcher` | `MagicCrumbsIntegration.install()` | `models` | `CrumbsStore.recentModels()` in the app | | `MagicCacheWatcher` | `CrumbsWatcher` | `MagicCrumbsIntegration.install()` | `caches` | `crumbs:caches`, `crumbs_caches` | | `MagicEventWatcher` | `CrumbsWatcher` | `MagicCrumbsIntegration.install()` | `events` | `crumbs:events`, `crumbs_events` | | `MagicGateWatcher` | `CrumbsWatcher` | `MagicCrumbsIntegration.install()` | `gates` | `crumbs:gates`, `crumbs_gates` | | `MagicQueryWatcher` | `CrumbsWatcher` | `MagicCrumbsIntegration.install()` | `queries` | `crumbs:queries`, `crumbs_queries` | `crumbs:install` registers `ExceptionWatcher` and `DumpWatcher` for you; a hand wiring registers them itself. ## Call-site guard `CrumbsPlugin.install()` installs no mode guard of its own. The app gates the whole crumbs install at the call site, and `kDebugMode` and `!kReleaseMode` both tree-shake it from a release build: ```dart if (!kReleaseMode) { CrumbsPlugin.install(); // register additional watchers here } ``` - `kDebugMode` is what `crumbs:install` writes into `lib/main.dart`. Debug builds carry crumbs; profile and release builds do not. - `!kReleaseMode` also keeps crumbs in profile builds. Use it when you measure performance: debug frame timings do not describe a shipped app, so a frame measurement needs a profile build. Keep the guard at the call site, never inside a helper, or the call stays live in release and the tree-shake is lost. The snippets on this page use `kDebugMode`; either guard works in each of them. Individual watchers need no guard of their own, except that `DumpWatcher` has an internal `kDebugMode` check (see below) and so captures nothing in a profile build unless `allowInRelease` is set. ## Chain-preserve pattern A watcher that replaces a global Dart or Flutter hook saves the previous handler, calls it from the new one, and restores it on `uninstall()`. That is how crumbs coexists with Sentry and Bugsnag: it records the error and forwards it to whatever was registered before it. `ExceptionWatcher` is the reference: ```dart // 1. Chain-preserve FlutterError.onError (sync framework errors). _previousFlutterOnError = FlutterError.onError; FlutterError.onError = (FlutterErrorDetails details) { CrumbsStore.recordException(/* ... */); _previousFlutterOnError?.call(details); }; // 2. Chain-preserve PlatformDispatcher.instance.onError (async and isolate errors). _previousPlatformOnError = PlatformDispatcher.instance.onError; PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { CrumbsStore.recordException(/* ... */); return _previousPlatformOnError?.call(error, stack) ?? true; }; ``` ## Framework-agnostic watchers These units ship in `crumbs` and depend on no application framework. ### LogWatcher | Field | Value | |---|---| | Contract | `CrumbsWatcher` | | Name | `log` | | Installed by | `CrumbsPlugin.install()`, always | | Buffer | `CrumbsKind.logs` | | VM extension | `ext.crumbs.console` | Subscribes to `Logger.root.onRecord` from `package:logging`. Every record that reaches the root logger, at any level, becomes a `LogRecordEntry` (stamped with a monotonic `atUs`) and goes to the store, through `CrumbsRedaction.redactor` when the host set one. Install sets `hierarchicalLoggingEnabled` to `true` so named child loggers (`Logger('http')`, `Logger('auth')`) reach the root, and sets `Logger.root.level` to `Level.ALL` so fine-grained levels are emitted too. A second install while the subscription is live is a no-op. An app that does not use `package:logging` simply produces no records; adding it later needs no crumbs change. `CrumbsPlugin.install()` offers no way to leave `LogWatcher` out. A level the app sets on `Logger.root` after the install still decides which records are emitted at all. ### ExceptionWatcher | Field | Value | |---|---| | Contract | `CrumbsWatcher` | | Name | `exception` | | Installed by | `CrumbsPlugin.registerWatcher(ExceptionWatcher())` (written by `crumbs:install`) | | Buffer | `CrumbsKind.exceptions` | | VM extension | `ext.crumbs.exceptions` | Hooks `FlutterError.onError` (synchronous framework and widget errors) and `PlatformDispatcher.instance.onError` (asynchronous errors, isolate errors, plugin errors), both through the chain-preserve pattern. The `PlatformDispatcher` handler returns the previous handler's answer, so a reporter that returns `false` to let the native crash reporter take over keeps that behaviour; with no previous handler it returns `true` (handled), as before install. `uninstall()` restores both handlers to exactly what they held before `install()`; a second `install()` is a no-op. To coexist with Sentry, initialise Sentry first: the watcher wraps the handlers that already exist, so a reporter installed after crumbs keeps receiving errors only if it chains as well. ```dart // sentryDsn is the host's; SentryFlutter.init sets both error hooks. await SentryFlutter.init((options) => options.dsn = sentryDsn); if (kDebugMode) { CrumbsPlugin.install(); CrumbsPlugin.registerWatcher(ExceptionWatcher()); // wraps Sentry's handlers; Sentry still fires } ``` ### DumpWatcher | Field | Value | |---|---| | Contract | `CrumbsWatcher` | | Name | `dump` | | Installed by | `CrumbsPlugin.registerWatcher(DumpWatcher())` (written by `crumbs:install`) | | Buffer | `CrumbsKind.dumps` | | VM extension | `ext.crumbs.dumps` | Replaces the global `debugPrint` callback with an interceptor that records a `DumpRecord` and calls the previous callback. `uninstall()` restores the previous callback exactly. `print()` does not go through `debugPrint` and is not captured. `install()` is a no-op outside a debug build unless `allowInRelease` is set to `true` before it runs. ```dart if (kDebugMode) { CrumbsPlugin.install(); CrumbsPlugin.registerWatcher(DumpWatcher()); } ``` ### FramePerfWatcher | Field | Value | |---|---| | Contract | `CrumbsWatcher` | | Name | `frame_perf` | | Installed by | `CrumbsPlugin.registerWatcher(FramePerfWatcher())`, never automatically | | Buffer | `CrumbsKind.framePerf` | | VM extension | `ext.crumbs.frames` | Joins frame magnitude from `SchedulerBinding.addTimingsCallback` with per-frame attribution drained from `FlutterTimeline`, and exposes a static `livenessCounter` with every `ext.crumbs.frames` answer. See [FramePerfWatcher](https://anilcancakir.com/crumbs/docs/watchers/frames.md) for liveness, attribution and the clock. ```dart if (!kReleaseMode) { CrumbsPlugin.install(); CrumbsPlugin.registerWatcher(FramePerfWatcher()); } ``` ## HTTP adapters HTTP adapters implement `CrumbsHttpAdapter`: `name`, `install` and `uninstall`, plus an optional `pendingCount` getter. They feed `CrumbsStore.recordHttp`, and their in-flight counts add up in `CrumbsStore.pendingHttpCount`, which tapir's `wait_for_network_idle` reads. Register an adapter with `CrumbsPlugin.registerHttpAdapter()`, which calls its `install()` at once. ### Credential redaction The HTTP buffer is served to AI agents, so `CrumbsStore.recordHttp` masks credentials before it buffers a record, and `onHttpRecord` subscribers receive the masked copy too. The model is Laravel Telescope's: three name lists, matched case-insensitively, each value under a matching name replaced with `********`. | List | Applies to | Defaults | |---|---|---| | Request headers | `requestHeaders` | `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key` | | Request parameters | keys of a JSON or form `requestBody`, at any depth | `password`, `password_confirmation`, `current_password`, `new_password`, `token`, `access_token`, `refresh_token`, `secret`, `client_secret`, `authorization_code`, `id_token`, `two_factor_token`, `recovery_code` | | Response parameters | keys of a JSON or form `responseBody`, at any depth | `token`, `access_token`, `refresh_token`, `plain_text_token`, `secret`, `client_secret`, `id_token`, `two_factor_token`, `recovery_codes`, `qr_url`, `qr_svg` | Extend a list at install time; an addition merges with the defaults and never replaces them: ```dart CrumbsRedaction.hideRequestHeaders(['X-Auth']); CrumbsRedaction.hideRequestParameters(['pin']); CrumbsRedaction.hideResponseParameters(['recovery_codes']); ``` An empty value (null, `false`, `''`, `[]`, `{}`) stays visible: it is not a secret, and it explains a validation failure. A subtree nested deeper than 64 levels is masked whole, which keeps `recordHttp` from overflowing the stack on a pathological body. A bare list with no key (recovery codes returned directly under `data`, for example) has nothing to match and is not masked. The store reads bodies that parse as a JSON object or array, and form-encoded bodies (`grant_type=password&password=...`), where only the value of a matching pair changes and a bracketed key (`user[password]`, `codes[]`) matches on its last part. Anything else (plain text, a truncated JSON snippet, a Dart `Map.toString()`) is buffered as the adapter handed it over, so an adapter masks a body before it stringifies or truncates it: ```dart // A body held as a Dart structure. final Object? safe = CrumbsRedaction.redactParameters( data, CrumbsRedaction.hiddenRequestParameters, ); // A body held as a string, before it is cut to size. final String? safeText = CrumbsRedaction.redactBody( text, CrumbsRedaction.hiddenResponseParameters, ); ``` Both return a masked copy and never mutate their input. Beyond these lists, a host can set `CrumbsRedaction.redactor` to run its own text redactor over every field of the log, event, exception and HTTP records, after the key masking; a redactor that throws drops the record. See [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md). ### DioHttpAdapter | Field | Value | |---|---| | Contract | `CrumbsHttpAdapter` | | Name | `dio` | | Installed by | Your Dio interceptor, which calls `DioHttpAdapter.recordRequest()` | | Buffer | `CrumbsKind.http` | | VM extension | `ext.crumbs.requests` | crumbs does not depend on `dio`, so `DioHttpAdapter` hooks nothing itself: its `install()` and `uninstall()` are empty and its `pendingCount` is 0. The app adds `dio`, writes an interceptor, and records each response through the static `DioHttpAdapter.recordRequest()`, which goes through `recordHttp` and its credential masking like any adapter: ```dart import 'package:crumbs/crumbs.dart'; import 'package:dio/dio.dart'; class CrumbsDioInterceptor extends Interceptor { @override void onResponse(Response response, ResponseInterceptorHandler handler) { DioHttpAdapter.recordRequest( url: response.requestOptions.uri.toString(), method: response.requestOptions.method, statusCode: response.statusCode ?? 0, durationMs: 0, // time the request in onRequest for a real duration ); handler.next(response); } } ``` Add it with `dio.interceptors.add(CrumbsDioInterceptor())`. The bundled `example/` app has a complete interceptor that times each request and records errors. `CrumbsPlugin.registerHttpAdapter(DioHttpAdapter())` only lists the adapter in the registry; it is optional. ### MagicHttpFacadeAdapter | Field | Value | |---|---| | Contract | `CrumbsHttpAdapter` | | Name | `magic_http_facade` | | Package | `magic_devtools` (`package:magic_devtools/crumbs.dart`) | | Installed by | `MagicCrumbsIntegration.install()` | | Buffer | `CrumbsKind.http` | | VM extension | `ext.crumbs.requests` | Captures every request through Magic's `network` driver by registering a `MagicNetworkInterceptor` that pairs `onRequest` with `onResponse` or `onError` through a FIFO in-flight list. The pairing is by call order, since the interceptor contract carries no correlation handle, so records carry `attributedHeuristically: true`. `pendingCount` is the length of that in-flight list. The adapter masks request and response bodies before it truncates them, and `MagicCrumbsIntegration.install()` hides the header named by Magic's `auth.token.header` config (default `Authorization`) on top of the defaults. Its `install()` registers nothing while the `network` driver is not bound, so call `MagicCrumbsIntegration.install()` after `Magic.init()`. ## Magic-stack watchers These watchers ship in `magic_devtools`, not in `crumbs` and not in `magic` core, and one `MagicCrumbsIntegration.install()` call after `Magic.init()` registers all of them with `MagicHttpFacadeAdapter`: ```dart import 'package:magic_devtools/crumbs.dart'; if (kDebugMode) { MagicCrumbsIntegration.install(); // after Magic.init() } ``` `package:magic_devtools/crumbs.dart` exists only in a `magic_devtools` release built on crumbs. `crumbs:install` writes these lines when the resolved `magic_devtools` ships it, and warns and skips them otherwise. Until that release ships, `MagicTelescopeIntegration` feeds `fluttersdk_telescope`, not crumbs, so the HTTP, event, gate, query and cache buffers of crumbs stay empty in a Magic app. Each watcher subscribes through Magic's `EventDispatcher`, which has no per-listener removal, so its `uninstall()` is a no-op; a test that needs a clean dispatcher calls `EventDispatcher.instance.clear()` in `setUp`. | Watcher | Name | Subscribes to | Record | |:--------|:-----|:--------------|:-------| | `MagicModelWatcher` | `magic_model` | `ModelCreated`, `ModelSaved`, `ModelDeleted` | `MagicModelRecord`: `modelClass`, `event` (`created`, `saved`, `deleted`), `modelKey`, `time`, a snapshot of `attributes` | | `MagicCacheWatcher` | `magic_cache` | `CacheHit`, `CacheMiss`, `CachePut`, `CacheForget`, `CacheFlush` | `MagicCacheRecord`: `operation`, `key` (`*` for a flush), `time`, `ttl` on a put | | `MagicEventWatcher` | `magic_event` | `AuthLogin`, `AuthLogout`, `AuthFailed`, `AuthRestored`, `DatabaseConnected`, `GateAbilityDefined`, `GateBeforeRegistered` | `EventRecord` with an empty `payload`; model and gate-result events stay with their own watchers | | `MagicGateWatcher` | `magic_gate` | `GateAccessChecked` (covers allows and denies; `GateAccessDenied` is skipped to avoid a double record) | `GateRecord`: `ability`, `result`, `arguments` (the single argument wrapped in a list), `userId` (stringified, null without a user) | | `MagicQueryWatcher` | `magic_query` | `QueryExecuted`, dispatched by the query builder after every SQL run | `QueryRecord`: `sql`, `bindings`, `timeMs`, `connectionName`, `time` | The model buffer has no extension and no MCP tool: read it in the app through `CrumbsStore.recentModels()` or `CrumbsStore.onModelRecord`. ## Gotchas | Mistake | Fix | |:--------|:----| | `crumbs_exceptions` stays empty after a crash. | Register `ExceptionWatcher`; `CrumbsPlugin.install()` installs only `LogWatcher`. | | Sentry stops receiving errors. | Initialise Sentry before registering `ExceptionWatcher`, so crumbs chains to it. | | `crumbs_dumps` is empty in a profile build. | `DumpWatcher` installs only in debug builds; set `allowInRelease` before `registerWatcher` to capture in profile. | | `print()` output is missing from the dump buffer. | Use `debugPrint`; `print` bypasses the hook `DumpWatcher` replaces. | | Dio traffic does not show up. | Add an interceptor that calls `DioHttpAdapter.recordRequest()`; registering `DioHttpAdapter` alone hooks nothing. | | `crumbs_frames` answers an empty list. | Register `FramePerfWatcher`; a `livenessCounter` that does not advance means it is missing or the engine draws nothing. | | The Magic buffers stay empty. | Call `MagicCrumbsIntegration.install()` after `Magic.init()`, from a `magic_devtools` release built on crumbs. | ## Related Documentation - [FramePerfWatcher](https://anilcancakir.com/crumbs/docs/watchers/frames.md): liveness, attribution and the frame clock. - [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md): the text redactor and the `redacted` flag. - [Installation](https://anilcancakir.com/crumbs/docs/getting-started/installation.md): where each watcher is registered in `main.dart`. - [Tool reference](https://anilcancakir.com/crumbs/docs/mcp/tool-reference.md): the output shape of each buffer over MCP. --- # FramePerfWatcher `FramePerfWatcher` records the build, raster and vsync cost of every drawn frame, with optional per-widget attribution, and proves the engine is still rendering. - [Quick Reference](#quick-reference) - [Liveness](#liveness) - [Attribution](#attribution) - [Clock and interaction link](#clock-and-interaction-link) - [Registration](#registration) - [Related Documentation](#related-documentation) ## Quick Reference | Field | Value | |---|---| | Contract | `CrumbsWatcher` | | Name | `frame_perf` | | Auto-install | No (opt-in via `CrumbsPlugin.registerWatcher(FramePerfWatcher())`) | | Buffer | `CrumbsKind.framePerf` (own capacity, default 3600, independent of the shared 500-entry cap) | | VM extension | `ext.crumbs.frames` | | CLI command | `crumbs:frames` | | MCP tool | `crumbs_frames` | | Opt-out | Do not register it; it is never auto-installed | Captures per-frame performance by joining two sources the engine reports separately: frame magnitude from `SchedulerBinding.addTimingsCallback`, and per-frame attribution drained from `FlutterTimeline` at the end of every frame. The two arrive at different times, so the post-frame drain parks its block map in a bounded pending map and the timings callback is the only emission point, popping the matching map and writing one complete `FramePerfRecord` per `FrameTiming`. `addTimingsCallback` appends to a list rather than replacing a single global slot, so there is nothing to chain-preserve here: every other listener (sentry_flutter's among them) keeps receiving timings while this watcher is installed. ## Liveness A static `livenessCounter`, incremented once per drawn frame regardless of whether a measurement session is running, is the only reliable proof the engine is rendering. It is exposed alongside every `ext.crumbs.frames` response so a caller can tell an empty result caused by a quiet app from one caused by a stalled engine (a backgrounded Chrome tab suspends `requestAnimationFrame` while every other in-app health signal keeps reading healthy). ## Attribution Attribution only flows while `FlutterTimeline.debugCollectionEnabled` is true, which a measurement session turns on. Until then the watcher records frame magnitude alone and every record's `blocks` map is empty. Each block entry carries `micros` (inclusive of nested children), `selfMicros` (exclusive: `micros` minus the combined duration of directly nested children), and `count`. ## Clock and interaction link Every record carries `atUs`, a monotonic microsecond timestamp from `FlutterTimeline.now`, and the optional `interactionId` / `linkedBy` pair that correlates it to other records captured during the same interaction. `vsyncStartUs` (from `FrameTiming.timestampInMicroseconds(FramePhase.vsyncStart)`) rides alongside `atUs` but its clock parity with `atUs` is NOT proven: the engine documents its timestamp only as microseconds "from some epoch" shared across every `FrameTiming` field, and a unit test can only inject synthetic `FrameTiming` values, never a real engine-reported one. Treat `vsyncStartUs` vs. `atUs` comparisons as needing a driven, live-engine run to validate. ## Registration ```dart if (!kReleaseMode) { CrumbsPlugin.install(); CrumbsPlugin.registerWatcher(FramePerfWatcher()); // opt-in } ``` `!kReleaseMode` keeps the watcher in a profile build, the build whose frame timings describe a shipped app. To leave it out, omit the `registerWatcher(FramePerfWatcher())` call. `CrumbsStore.clearFramePerf()` empties only this buffer, without touching the HTTP, log and exception history. ## Related Documentation - [Tool reference](https://anilcancakir.com/crumbs/docs/mcp/tool-reference.md#crumbs_frames): the `crumbs_frames` input schema and output shape. - [Watchers](https://anilcancakir.com/crumbs/docs/watchers/index.md): every other watcher and the call-site guard. - [Buffers](https://anilcancakir.com/crumbs/docs/getting-started/buffers.md): `setFramePerfCapacity` and the per-kind caps. --- # MCP Overview How the `crumbs_*` MCP tools reach the running app: crumbs contributes them to the dowel MCP server, and each one reads an `ext.crumbs.*` VM Service extension. - [What crumbs contributes](#what-crumbs-contributes) - [Substrate tools and plugin tools](#substrate-vs-plugin) - [How a tool call travels](#how-tools-surface) - [Tool to extension routing](#vm-service-routing) - [Architecture diagram](#architecture-diagram) - [Related Documentation](#related-documentation) ## What crumbs contributes `crumbs` is a plugin of [dowel](https://pub.dev/packages/dowel). `CrumbsDowelProvider` contributes the `crumbs:*` CLI commands through `commands()` and the `crumbs_*` MCP tools through `mcpTools()`. The tools give an agent read access to the ring buffers crumbs keeps inside the running Flutter app: HTTP traffic, log records, uncaught exceptions, in-app events, gate checks, `debugPrint` output, database queries, cache operations and per-frame performance. `crumbs_clear` empties every buffer as a starting point before a repro. Reading never changes the app. `crumbs_clear` is the one tool that changes state, and only crumbs's own buffers. ## Substrate tools and plugin tools The dowel MCP server serves two kinds of tool: - **Substrate tools** (`dowel_*`) are built into dowel and always registered: `dowel_start`, `dowel_stop`, `dowel_status`, `dowel_logs` and the rest. - **Plugin tools** (`crumbs_*`, `tapir_*`) come from packages that extend `DowelServiceProvider`. They appear only when the provider is registered with the server (`crumbs:install` does it through `lib/app/_plugins.g.dart`, and `dart run crumbs mcp:serve` preloads it) and the server has been (re)started. They dispatch through the VM Service, so they need a running app. The provider's `providerName` is `crumbs`, the package name that the `packages` rules of `.dowel/mcp.json` match (see [Filter crumbs tools](https://anilcancakir.com/crumbs/docs/mcp/setup.md#filter-crumbs-tools)). ## How a tool call travels 1. `CrumbsDowelProvider.mcpTools()` returns one `McpToolDescriptor` per tool, each naming its `extensionMethod`. 2. The dowel MCP server collects the descriptors of every registered provider and lists them to the client. 3. The agent calls a tool by name, for example `crumbs_tail`, with optional parameters. 4. The server dispatches the call to the descriptor's extension, `ext.crumbs.console` for `crumbs_tail`, over the VM Service of the session dowel recorded. 5. The handler that `CrumbsPlugin.install()` registered in the app reads the matching `CrumbsStore` buffer, applies `limit` (and `level` on the log buffer) and answers JSON. 6. The server returns that JSON to the agent as the tool result. ## Tool to extension routing | MCP tool | VM Service extension | Answer key | |:---------|:---------------------|:-----------| | `crumbs_tail` | `ext.crumbs.console` | `messages`, `cursor` | | `crumbs_requests` | `ext.crumbs.requests` | `records` | | `crumbs_exceptions` | `ext.crumbs.exceptions` | `exceptions` | | `crumbs_events` | `ext.crumbs.events` | `events`, `cursor` | | `crumbs_gates` | `ext.crumbs.gates` | `gates` | | `crumbs_dumps` | `ext.crumbs.dumps` | `dumps` | | `crumbs_queries` | `ext.crumbs.queries` | `queries` | | `crumbs_caches` | `ext.crumbs.caches` | `caches` | | `crumbs_frames` | `ext.crumbs.frames` | `frames`, `livenessCounter` | | `crumbs_clear` | `ext.crumbs.clear` | `cleared` | These extensions have no MCP tool: - `ext.crumbs.files` and `ext.crumbs.file` read the timeline of a running [file sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md); reach them through `crumbs:files`. - `ext.crumbs.pause` and `ext.crumbs.resume` stop and restart recording into the store. The `since`, `type` and `logger` parameters and the `cursor` of `ext.crumbs.console` and `ext.crumbs.events` live on the extensions and the CLI; the tool descriptors leave them out, but dowel forwards them over MCP unchanged. See [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md). Every extension is registered through dowel's `registerExtensionIdempotent`, so a hot restart does not fail on a duplicate registration. ## Architecture diagram ```text MCP client (Claude Code, Cursor, ...) | | stdio JSON-RPC (initialize, tools/list, tools/call) | ./bin/dowel mcp:serve or dart run crumbs mcp:serve | +-- dowel_* substrate tools (in-process commands) | +-- crumbs_* plugin tools | | VM Service WebSocket of the recorded session | Flutter app isolate (debug or profile build) | ext.crumbs.* handlers | CrumbsStore ring buffers (http, logs, exceptions, events, gates, dumps, queries, caches, frame perf, models) ``` ## Related Documentation - [MCP setup](https://anilcancakir.com/crumbs/docs/mcp/setup.md): write the `.mcp.json` entry, reconnect a client and filter tools. - [Tool reference](https://anilcancakir.com/crumbs/docs/mcp/tool-reference.md): every tool's input schema and output shape. - [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md): `since`, `type`, `logger` and `cursor` on the console and events extensions. - [dowel MCP overview](https://anilcancakir.com/dowel/docs/mcp/overview): the substrate tools and the session file. --- # MCP Setup Make the `crumbs_*` tools appear in Claude Code, Cursor or any other MCP client: install crumbs, write the `dowel` entry in `.mcp.json`, reconnect, and optionally filter the tools. - [Quick Reference](#quick-reference) - [Install crumbs](#install-crumbs) - [Write the MCP entry](#write-the-mcp-entry) - [Clients](#clients) - [Start the app and verify](#start-and-verify) - [Filter crumbs tools](#filter-crumbs-tools) - [Related Documentation](#related-documentation) ## Quick Reference | Step | Command | |:-----|:--------| | 1. Install | `flutter pub add crumbs`, then `dart run crumbs crumbs:install` | | 2. Write the entry | `dart run crumbs mcp:install` | | 3. Reconnect | `/mcp reconnect dowel` in Claude Code | | 4. Start the app | `./bin/dowel start --device=chrome`, or the `dowel_start` tool | | 5. Verify | Call `crumbs_tail` with `{"limit": 5}` | ## Install crumbs ```bash flutter pub add crumbs dart run crumbs crumbs:install ``` `crumbs` goes under `dependencies`, not `dev_dependencies`, because `lib/main.dart` imports it. The installer scaffolds the dowel harness, registers `CrumbsDowelProvider` through `lib/app/_plugins.g.dart` and installs crumbs in `lib/main.dart`; see [Installation](https://anilcancakir.com/crumbs/docs/getting-started/installation.md) for the manual route. ## Write the MCP entry The `crumbs_*` tools ride on the dowel MCP server, so there is no separate crumbs entry: the server key is `dowel`. ```bash dart run crumbs mcp:install ``` The command writes the `dowel` entry into `.mcp.json` at the project root, keeps any other entry, and replaces its own on a rerun: | Scaffold state | Entry | |:---------------|:------| | `./bin/dowel` exists, POSIX host | `./bin/dowel mcp:serve` | | No `./bin/dowel`, or a Windows host | `dart run crumbs mcp:serve` | ```json { "mcpServers": { "dowel": { "command": "./bin/dowel", "args": ["mcp:serve"], "cwd": "." } } } ``` `cwd` must resolve to the project root, the directory with `pubspec.yaml` and `bin/dispatcher.dart`. ## Clients A client reads its server list at startup and does not reload a changed entry on its own. | Client | Install or reload | |:-------|:------------------| | Claude Code | Reads `.mcp.json` from the project, or `claude mcp add dowel -- ./bin/dowel mcp:serve`; run `/mcp reconnect dowel` after a change | | Cursor | `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (user); reload the MCP panel | | Windsurf | `~/.codeium/windsurf/mcp_config.json`; reload the MCP panel | | Claude Desktop | `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`); quit and relaunch | ## Start the app and verify ```bash ./bin/dowel start # chrome, the default device ./bin/dowel start --device=macos # a desktop build ``` Then ask the agent for a small read: ```text crumbs_tail {"limit": 5} ``` A working setup answers `{"messages": [...], "cursor": ...}`; an empty `messages` list means the app runs but has logged nothing yet. An error that says no session is recorded means the app was not started through dowel; one that names a missing `ext.crumbs.*` method means `CrumbsPlugin.install()` did not run in this build. Restart the app with `./bin/dowel restart` when the session is stale. ## Filter crumbs tools To hide some crumbs tools from the agent, add deny rules to `.dowel/mcp.json` in the project root: ```json { "packages": { "allow": null, "deny": [] }, "tools": { "allow": null, "deny": ["crumbs_dumps", "crumbs_gates"] } } ``` To serve only the crumbs tools, hiding the substrate and every other plugin: ```json { "packages": { "allow": ["crumbs"], "deny": [] }, "tools": { "allow": null, "deny": [] } } ``` Reconnect the client after editing the file. The [dowel filter reference](https://anilcancakir.com/dowel/docs/mcp/tool-reference#filter-configuration) covers the environment variables, the CLI flags and how the three layers combine. ## Related Documentation - [MCP overview](https://anilcancakir.com/crumbs/docs/mcp/overview.md): how the tools reach the running app. - [Tool reference](https://anilcancakir.com/crumbs/docs/mcp/tool-reference.md): every tool's input schema and output shape. - [Installation](https://anilcancakir.com/crumbs/docs/getting-started/installation.md): the installer and the manual wiring. - [dowel MCP setup](https://anilcancakir.com/dowel/docs/mcp/setup): the client matrix of the substrate. --- # MCP Tool Reference Every `crumbs_*` MCP tool: what it reads, its input schema, its output shape and the `ext.crumbs.*` extension it calls in the running app. Every tool dispatches through the VM Service, so the app must be running under dowel: start it with `./bin/dowel start` or the `dowel_start` tool, and `dowel_status` shows the recorded session. - [Quick Reference](#quick-reference) - [Common conventions](#common-conventions) - [crumbs_tail](#crumbs_tail) - [crumbs_requests](#crumbs_requests) - [crumbs_exceptions](#crumbs_exceptions) - [crumbs_events](#crumbs_events) - [crumbs_gates](#crumbs_gates) - [crumbs_dumps](#crumbs_dumps) - [crumbs_queries](#crumbs_queries) - [crumbs_caches](#crumbs_caches) - [crumbs_frames](#crumbs_frames) - [crumbs_clear](#crumbs_clear) - [Related Documentation](#related-documentation) ## Quick Reference | Tool | Reads | Parameters | Answer key | |:-----|:------|:-----------|:-----------| | `crumbs_tail` | `package:logging` records | `limit`, `level` | `messages` | | `crumbs_requests` | HTTP requests | `limit` | `records` | | `crumbs_exceptions` | Uncaught exceptions | `limit` | `exceptions` | | `crumbs_events` | In-app events | `limit` | `events` | | `crumbs_gates` | Gate checks | `limit` | `gates` | | `crumbs_dumps` | `debugPrint` output | `limit` | `dumps` | | `crumbs_queries` | DB queries | `limit` | `queries` | | `crumbs_caches` | Cache operations | `limit` | `caches` | | `crumbs_frames` | Per-frame performance | `limit` | `frames` | | `crumbs_clear` | Empties every buffer | none | `cleared` | ## Common conventions Every reading tool (all but `crumbs_clear`) follows these rules: - **Oldest first.** Records come back in chronological order: the last entry is the newest. - **`limit`.** Optional; keeps the newest N records. Omitted, the whole buffer comes back, up to its capacity (500 records by default, 3600 for frames; see [Buffers](https://anilcancakir.com/crumbs/docs/getting-started/buffers.md)). A zero or negative `limit` returns nothing. - **Envelope.** Every tool answers a JSON object whose list sits under the answer key in the table above. An empty buffer, on a fresh start or after `crumbs_clear`, answers an empty list, not an error. - **Redaction.** Log, event, exception and HTTP records are masked at insert when the host registered `CrumbsRedaction.redactor` (see [Redaction](https://anilcancakir.com/crumbs/docs/getting-started/redaction.md)), and each carries a `redacted` boolean. - **Errors.** When the app is not running or the VM Service cannot be reached, the tool result is an error with a plain-text message that says what to do. ## crumbs_tail Return recent log records from the running Flutter app. Reads the Crumbs log ring buffer populated by every `package:logging` `Logger` call in the app. Use this to inspect what the app logged without scraping `flutter run` stdout via `dowel_logs`. **VM Service extension:** `ext.crumbs.console` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer (cap enforced by ring-buffer size, 500). | | `level` | string | no | Minimum log level to include. Common values: `FINE`, `INFO`, `WARNING`, `SEVERE`, `SHOUT`. Omit for all levels. A name that is not a level matches nothing. | The extension behind this tool also accepts `since` and `logger` and answers a `cursor`; the tool descriptor does not declare them. See [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md). ### Output Shape ```json { "messages": [ { "level": "WARNING", "levelValue": 900, "message": "Monitor check timed out after 30s", "loggerName": "MonitorController", "time": "2026-05-20T14:32:10.123", "atUs": 81234567, "redacted": false, "error": "TimeoutException: ...", "stackTrace": "#0 MonitorController.check ..." } ], "cursor": 81234567 } ``` | Field | Type | Always present | Description | |---|---|---|---| | `level` | string | yes | Log level name (e.g. `INFO`, `WARNING`, `SEVERE`) | | `levelValue` | integer | yes | Numeric level value from `package:logging` | | `message` | string | yes | Log message string | | `loggerName` | string | yes | Name of the `Logger` instance that emitted the record | | `time` | string | yes | ISO 8601 device-local wall clock, no offset; join on `atUs` | | `atUs` | integer | yes | Monotonic microseconds from `FlutterTimeline.now`, the same clock as event records | | `redacted` | boolean | yes | True when `CrumbsRedaction.redactor` ran over the record before it was buffered | | `error` | string | no | Error object stringified, when one was attached | | `stackTrace` | string | no | Stack trace stringified, when one was attached | | `cursor` | integer or null | yes (envelope) | Largest `atUs` returned, else the `since` given, else null | ### Example Invocations ```text # Last 10 log lines at any level crumbs_tail {"limit": 10} # Last 20 entries at WARNING or above crumbs_tail {"limit": 20, "level": "WARNING"} # Every SEVERE entry in the buffer crumbs_tail {"level": "SEVERE"} ``` ### Related Tools - `crumbs_exceptions`: uncaught exceptions (not expected `try/catch` flows) - `crumbs_requests`: HTTP traffic - `crumbs_clear`: wipe the buffer before a repro --- ## crumbs_requests Return recent HTTP request records from the running Flutter app. Reads the HTTP buffer, filled by any `CrumbsHttpAdapter` the app registers (`MagicHttpFacadeAdapter` from `magic_devtools` on a Magic app) or by an interceptor that calls `DioHttpAdapter.recordRequest()`. Use this to debug API issues without a proxy or the network panel of DevTools. **VM Service extension:** `ext.crumbs.requests` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of HTTP records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer. | ### Output Shape ```json { "records": [ { "url": "https://api.example.com/monitors", "method": "GET", "statusCode": 200, "durationMs": 142, "isError": false, "timestamp": "2026-05-20T14:32:11.456", "requestHeaders": { "Authorization": "********" }, "responseBody": "{\"data\": []}", "atUs": 1234567, "redacted": false } ] } ``` | Field | Type | Always present | Description | |---|---|---|---| | `url` | string | yes | Full request URL | | `method` | string | yes | HTTP method (`GET`, `POST`, etc.) | | `statusCode` | integer | yes | HTTP status code; 0 when the call failed before a response | | `durationMs` | integer | yes | Round-trip time in milliseconds | | `isError` | boolean | yes | True when the adapter classified the call as failed | | `timestamp` | string | yes | ISO 8601 device-local wall clock of when the request was recorded, no offset; join on `atUs` | | `requestHeaders` | object | no | Request headers as a `Map`; credential headers read `********` (see Notes) | | `requestBody` | string | no | Request body as a string, when present and readable; credential keys of a JSON body read `********` | | `responseBody` | string | no | Response body snippet, when present; credential keys of a JSON body read `********` | | `attributedHeuristically` | boolean | no | True when the adapter used best-effort FIFO attribution for concurrent requests | | `requestId` | string | no | Adapter-assigned identifier pairing this response with its request exactly, when the adapter tracks one | | `startUs` | integer | no | Monotonic microsecond timestamp (`FlutterTimeline.now` clock) when the request was sent, when tracked | | `endUs` | integer | no | Monotonic microsecond timestamp (`FlutterTimeline.now` clock) when the response arrived, when tracked | | `atUs` | integer | yes | Monotonic microsecond timestamp from `FlutterTimeline.now`, captured at record construction | | `interactionId` | string | no | Correlates this record to others captured during the same logical interaction | | `linkedBy` | string | no | How `interactionId` was derived: `zone` \| `frame` \| `window` | | `redacted` | boolean | yes | True when `CrumbsRedaction.redactor` ran over the record before it was buffered | ### Example Invocations ```text # Last 5 HTTP requests crumbs_requests {"limit": 5} # Every request in the buffer crumbs_requests {} ``` ### Notes - Only HTTP calls that go through an installed Crumbs adapter are recorded. Raw `dart:io HttpClient` calls are invisible to this tool. - Pair with `crumbs_clear` before a user action to isolate exactly the traffic that action produces. - Credentials are masked before a record is buffered, the way Laravel Telescope does it: the value of a hidden header or a hidden JSON body key reads `********`, which is crumbs's mask and not what the app sent. See [Credential redaction](https://anilcancakir.com/crumbs/docs/watchers/index.md#credential-redaction) for the default lists and how to extend them. --- ## crumbs_exceptions Return recent uncaught exception records from the running Flutter app. Reads the exception buffer, filled by `ExceptionWatcher` through `FlutterError.onError` and `PlatformDispatcher.onError`. `CrumbsPlugin.install()` does not register it: `crumbs:install` does, and a hand wiring calls `CrumbsPlugin.registerWatcher(ExceptionWatcher())`. Use this when a crash or unhandled exception was thrown and you need the stack without scraping `flutter run` output. **VM Service extension:** `ext.crumbs.exceptions` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of exception records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer. | ### Output Shape ```json { "exceptions": [ { "exceptionType": "StateError", "message": "Bad state: Stream has already been listened to", "time": "2026-05-20T14:33:00.789", "stackTrace": "#0 _StreamController._subscribe ...", "isolate": "main", "redacted": false } ] } ``` | Field | Type | Always present | Description | |---|---|---|---| | `exceptionType` | string | yes | Runtime type name of the exception object | | `message` | string | yes | Exception message string | | `time` | string | yes | ISO 8601 device-local wall clock, no offset; this record has no `atUs` | | `stackTrace` | string | no | Full stack trace, when available | | `isolate` | string | no | Isolate name where the exception was caught | | `redacted` | boolean | yes | True when `CrumbsRedaction.redactor` ran over the record before it was buffered | ### Example Invocations ```text # Last 3 uncaught exceptions crumbs_exceptions {"limit": 3} # Every exception in the buffer crumbs_exceptions {} ``` ### Notes - Only **uncaught** errors reach `FlutterError.onError` or `PlatformDispatcher.onError`. Expected `try/catch` flows do not surface here. - The watcher chains to the handlers that were set before it (Sentry, Bugsnag), so both keep working. - Pair with `crumbs_tail` to see what the app logged in the moments before the crash. --- ## crumbs_events Return recent in-app event records from the running Flutter app. Reads the event buffer. On a Magic app `MagicEventWatcher` fills it from a curated set of Magic auth, database and gate-definition events; any app can add its own through `CrumbsStore.recordEvent`. Use this to trace event-driven side effects without adding `print` statements. **VM Service extension:** `ext.crumbs.events` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of event records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer (cap enforced by ring-buffer size, 500). | The extension behind this tool also accepts `since` and `type` and answers a `cursor`; the tool descriptor does not declare them. See [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md). ### Output Shape ```json { "events": [ { "eventType": "MonitorChecked", "payload": { "monitorId": "abc-123", "status": "up" }, "time": "2026-05-20T14:34:00.001", "listenerCount": 3, "atUs": 81234567, "redacted": false } ], "cursor": 81234567 } ``` | Field | Type | Always present | Description | |---|---|---|---| | `eventType` | string | yes | Runtime class name of the dispatched event | | `payload` | object | yes | JSON snapshot of the event payload. A value `jsonEncode` cannot write is stored as its `toString()` | | `time` | string | yes | ISO 8601 device-local wall clock, no offset; join on `atUs` | | `listenerCount` | integer | no | Number of listeners notified at dispatch time, when available | | `atUs` | integer | yes | Monotonic microseconds from `FlutterTimeline.now` | | `redacted` | boolean | yes | True when `CrumbsRedaction.redactor` ran over the record before it was buffered | | `cursor` | integer or null | yes (envelope) | Largest `atUs` returned, else the `since` given, else null | ### Example Invocations ```text # Last 10 events crumbs_events {"limit": 10} # Every event in the buffer crumbs_events {} ``` ### Notes - Only events that reach the buffer are visible: a Magic event outside the watcher's set, or a raw `ChangeNotifier.notifyListeners` call, does not surface here. - `MagicEventWatcher` records an empty `payload` for every Magic event; per-event fields are not extracted yet. - For gate checks use `crumbs_gates`; for HTTP traffic use `crumbs_requests`. --- ## crumbs_gates Return recent gate authorisation check records from the running Flutter app. Reads the gate buffer, filled by `MagicGateWatcher` on every `Gate.allows` and `Gate.denies` call through the Magic `Gate` facade. Use this to debug authorisation issues (why a button is hidden, why a route is blocked) without modifying policy classes. **VM Service extension:** `ext.crumbs.gates` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of gate check records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer (cap enforced by ring-buffer size, 500). | ### Output Shape ```json { "gates": [ { "ability": "monitors.destroy", "result": false, "arguments": ["Monitor"], "time": "2026-05-20T14:35:00.010", "userId": "user-42" } ] } ``` | Field | Type | Always present | Description | |---|---|---|---| | `ability` | string | yes | Ability name passed to `Gate.allows` / `Gate.denies` | | `result` | boolean | yes | `true` when the gate allowed the action; `false` when denied | | `arguments` | array | yes | List of argument values (class name strings or serialised primitives) | | `time` | string | yes | ISO 8601 device-local wall clock, no offset; this record has no `atUs` | | `userId` | string | no | Authenticated user ID at check time, when available | ### Example Invocations ```text # Last 20 gate checks crumbs_gates {"limit": 20} # Every gate check in the buffer crumbs_gates {} ``` ### Notes - Only checks routed through the Magic `Gate` facade are captured. Direct policy class calls are not recorded. - Pair with `crumbs_clear` before walking a guarded flow to isolate just the relevant checks. --- ## crumbs_dumps Return recent `debugPrint` output records from the running Flutter app. Reads the dump buffer, filled by `DumpWatcher`, which replaces the global `debugPrint` callback and chains to the previous one. Use this to read `debugPrint()` output from within the running app without scraping `flutter run` output. **VM Service extension:** `ext.crumbs.dumps` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of dump records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer (cap enforced by ring-buffer size, 500). | ### Output Shape ```json { "dumps": [ { "message": "[MonitorController] reloading monitors...", "time": "2026-05-20T14:36:00.200", "wrapWidth": 80 } ] } ``` | Field | Type | Always present | Description | |---|---|---|---| | `message` | string | yes | Full message string passed to `debugPrint` | | `time` | string | yes | ISO 8601 device-local wall clock, no offset; this record has no `atUs` | | `wrapWidth` | integer | no | Wrap width passed to the original `debugPrint` call, when provided | ### Example Invocations ```text # Last 10 debugPrint calls crumbs_dumps {"limit": 10} # Every dump record in the buffer crumbs_dumps {} ``` ### Notes - Only output routed through `debugPrint` is captured. `print()` and `stdout.write` calls are invisible to this tool. - `DumpWatcher` installs nothing outside a debug build unless `allowInRelease` is set, so a profile build under `!kReleaseMode` records no dumps. - For structured logs use `crumbs_tail`; for crashes use `crumbs_exceptions`. --- ## crumbs_queries Return recent database query records from the running Flutter app. Reads the query buffer, filled by `MagicQueryWatcher` from the `QueryExecuted` event the Magic query builder dispatches after every SQL run. Use this to inspect what queries the app ran without attaching a separate SQL profiler. **VM Service extension:** `ext.crumbs.queries` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of query records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer (cap enforced by ring-buffer size, 500). | ### Output Shape ```json { "queries": [ { "sql": "SELECT * FROM monitors WHERE team_id = ? LIMIT 50", "bindings": ["team-abc"], "timeMs": 4, "connectionName": "default", "time": "2026-05-20T14:37:00.100", "atUs": 1234567 } ] } ``` | Field | Type | Always present | Description | |---|---|---|---| | `sql` | string | yes | SQL string the query builder dispatched to the driver | | `bindings` | array | yes | Positional or named query bindings | | `timeMs` | integer | yes | Execution time in milliseconds reported by the query builder | | `connectionName` | string | yes | Connection name; `"default"` when the app did not name it | | `time` | string | yes | ISO 8601 device-local wall clock, no offset; join on `atUs` | | `atUs` | integer | yes | Monotonic microsecond timestamp from `FlutterTimeline.now` | | `interactionId` | string | no | Correlates this record to others captured during the same interaction | | `linkedBy` | string | no | How `interactionId` was derived: `zone` \| `frame` \| `window` | ### Example Invocations ```text # Last 5 queries crumbs_queries {"limit": 5} # Every query in the buffer crumbs_queries {} ``` ### Notes - Only queries that go through magic's QueryBuilder (dispatching `QueryExecuted` via `EventDispatcher`) are recorded. Raw `sqlite3` or direct Dio SQL calls bypass this tool. - For Magic Cache traffic use `crumbs_caches`; for HTTP use `crumbs_requests`; for log lines use `crumbs_tail`. --- ## crumbs_caches Return recent Magic cache operation records from the running Flutter app. Reads the cache buffer, filled by `MagicCacheWatcher` from Magic's `CacheHit`, `CacheMiss`, `CachePut`, `CacheForget` and `CacheFlush` events. Use this to inspect cache traffic without instrumenting the app. **VM Service extension:** `ext.crumbs.caches` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of cache records to return: the most recent N, in chronological order (oldest of that window first, newest last). Omit for the whole buffer (cap enforced by ring-buffer size, 500). | ### Output Shape ```json { "caches": [ { "operation": "put", "key": "monitors.team-abc", "time": "2026-05-20T14:38:00.300", "ttlMs": 300000, "atUs": 1234567 } ] } ``` | Field | Type | Always present | Description | |---|---|---|---| | `operation` | string | yes | One of: `hit`, `miss`, `put`, `forget`, `flush` | | `key` | string | yes | Cache key string (`*` for a flush) | | `time` | string | yes | ISO 8601 device-local wall clock, no offset; join on `atUs` | | `ttlMs` | integer | no | TTL in milliseconds, when the operation carried one | | `atUs` | integer | yes | Monotonic microsecond timestamp from `FlutterTimeline.now` | | `interactionId` | string | no | Correlates this record to others captured during the same interaction | | `linkedBy` | string | no | How `interactionId` was derived: `zone` \| `frame` \| `window` | ### Example Invocations ```text # Last 20 cache operations crumbs_caches {"limit": 20} # Every cache operation in the buffer crumbs_caches {} ``` ### Notes - Only Magic `Cache` facade calls dispatch the watcher events. Raw driver-level cache calls bypass this tool. - For DB queries use `crumbs_queries`; for HTTP use `crumbs_requests`. --- ## crumbs_frames Return recent per-frame performance records from the running Flutter app. Reads the frame buffer, filled by `FramePerfWatcher`, which joins `SchedulerBinding` frame timings with a per-frame `FlutterTimeline` block-attribution drain. Each record carries frame number, build/raster/vsync-overhead/total-span durations in microseconds, a timestamp, and a `blocks` map of named spans to their aggregated duration and call count. Use this to attribute jank to a specific widget or layout phase after driving an interaction. **VM Service extension:** `ext.crumbs.frames` ### Input Schema | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | Maximum number of frame records to return (oldest-first; last entry is newest). Omit for the whole buffer (cap enforced by the frame-perf buffer size, default 3600). | ### Output Shape ```json { "frames": [ { "frameNumber": 128, "buildMicros": 1400, "rasterMicros": 2100, "vsyncOverheadMicros": 300, "totalSpanMicros": 3800, "time": "2026-05-20T14:38:00.300", "blocks": { "Widget.build": { "micros": 600, "selfMicros": 400, "count": 5 } }, "vsyncStartUs": 998200, "atUs": 1234567 } ], "livenessCounter": 128 } ``` | Field | Type | Always present | Description | |---|---|---|---| | `frames` | array | yes | The frame records, may be empty | | `frames[].frameNumber` | integer | yes | The engine's frame number | | `frames[].buildMicros` | integer | yes | Build phase duration in microseconds | | `frames[].rasterMicros` | integer | yes | Raster phase duration in microseconds | | `frames[].vsyncOverheadMicros` | integer | yes | Vsync overhead in microseconds | | `frames[].totalSpanMicros` | integer | yes | Total frame span in microseconds | | `frames[].time` | string | yes | ISO 8601 device-local wall clock, no offset; join on `atUs` | | `frames[].blocks` | object | yes | Named span to `{micros, selfMicros, count}`; empty when no measurement session is collecting. `micros` is inclusive of nested children, `selfMicros` is exclusive (`micros` minus directly nested children's combined duration) | | `frames[].vsyncStartUs` | integer | no | Vsync signal timestamp from `FrameTiming.timestampInMicroseconds(FramePhase.vsyncStart)`. Clock parity with `atUs` is NOT proven; treat a comparison as needing a live-engine run, not a unit-test guarantee | | `frames[].atUs` | integer | yes | Monotonic microsecond timestamp from `FlutterTimeline.now`, captured at record construction | | `frames[].interactionId` | string | no | Correlates this record to others captured during the same logical interaction | | `frames[].linkedBy` | string | no | How `interactionId` was derived: `zone` \| `frame` \| `window` | | `livenessCounter` | integer | yes | Monotonic count of frames actually drawn since `FramePerfWatcher` was installed; present even when `frames` is empty, so a caller can tell a quiet app from a stalled engine | ### Example Invocations ```text # Last 100 frames crumbs_frames {"limit": 100} # Every frame in the buffer crumbs_frames {} ``` ### Notes - `FramePerfWatcher` is opt-in and not auto-installed. An empty `frames` list with a non-advancing `livenessCounter` means it is not registered, not that the app is idle. - `blocks` is only populated while a measurement session has `FlutterTimeline` collection enabled; otherwise records carry frame magnitude with an empty `blocks` map. --- ## crumbs_clear Clear every crumbs ring buffer. Empties every buffer in one call, so the next read of any `crumbs_*` tool returns only records produced **after** this clear. Use it as a starting point before reproducing a bug or capturing the output of one user action. **VM Service extension:** `ext.crumbs.clear` ### Input Schema No parameters. ### Output Shape ```json { "cleared": true } ``` | Field | Type | Always present | Description | |---|---|---|---| | `cleared` | boolean | yes | Always `true` on success | ### Example Invocations ```text # Clear, perform the action, then read crumbs_clear {} # the user action runs crumbs_requests {"limit": 20} crumbs_tail {"limit": 50} ``` ### Notes - Idempotent: safe to call when the buffers are already empty. - Does not touch the live `package:logging` stream or any other sink (Sentry, Bugsnag); only the buffers crumbs keeps. - `ext.crumbs.pause` and `ext.crumbs.resume`, which stop and restart recording, have no MCP tool. --- ## Related Documentation - [Cursor and filters](https://anilcancakir.com/crumbs/docs/mcp/cursor-and-filters.md): `since`, `type`, `logger` and `cursor` on the console and events extensions. - [MCP overview](https://anilcancakir.com/crumbs/docs/mcp/overview.md): how the tools reach the running app through `CrumbsDowelProvider`. - [MCP setup](https://anilcancakir.com/crumbs/docs/mcp/setup.md): write the `.mcp.json` entry and connect a client. - [dowel MCP tool reference](https://anilcancakir.com/dowel/docs/mcp/tool-reference): the `dowel_*` substrate tools (`dowel_start`, `dowel_stop`, `dowel_status`) and filter configuration. --- # Cursor and filters Follow the log and event buffers without re-reading them or skipping a record: the filters and the `cursor` of `ext.crumbs.console` and `ext.crumbs.events`, the matching CLI flags, and the `followCursor` loop. The parameters belong to the VM Service extensions and the CLI. The MCP descriptors of `crumbs_tail` and `crumbs_events` declare only `limit` (and `level` on `crumbs_tail`), but dowel's MCP server forwards a call's arguments unchanged, so `since`, `logger` and `type` work over MCP too and the answer carries the same `cursor`. - [Extension parameters](#extension-parameters) - [Which records limit keeps](#which-records-limit-keeps) - [The cursor](#the-cursor) - [CLI](#cli) - [followCursor](#followcursor) - [Related Documentation](#related-documentation) ## Extension parameters All values are strings on the wire, as with every VM Service extension. | Extension | Parameter | Meaning | |---|---|---| | `ext.crumbs.console` | `level` | Minimum `package:logging` level name. An unknown name matches nothing. | | `ext.crumbs.console` | `logger` | Exact logger name. | | `ext.crumbs.events` | `type` | Prefix of `eventType`. | | both | `since` | Microseconds, exclusive, compared with the record's `atUs`. | | both | `limit` | Applied after the filters. A zero or negative value returns nothing. | A `since` that is not an integer is refused with `invalidParams` (`since must be an integer number of microseconds.`) rather than read as "from the start", so a typo cannot return the whole buffer as if it were new. ## Which records limit keeps The filters run first, then `limit`: - With `since`, `limit` keeps the OLDEST N records after it. Paging by `cursor` then skips nothing. A page cut inside a run of records sharing one `atUs` keeps the whole run, so it may exceed `limit` by those ties. - Without `since`, `limit` keeps the NEWEST N records that matched. Records come back oldest first either way. ## The cursor Both answers carry `cursor`: the largest `atUs` among the returned records, else the `since` you passed, else null. Pass it back as `since` and the next answer holds only newer records. ```json { "messages": [ {"level": "INFO", "message": "synced", "loggerName": "Sync", "atUs": 81234567, "redacted": true} ], "cursor": 81234567 } ``` `ext.crumbs.console` answers its records under `messages`, `ext.crumbs.events` under `events`. Every other read extension keeps its own envelope and takes `limit` only. ## CLI `crumbs:tail` and `crumbs:events` expose the same parameters as flags: | Flag | `crumbs:tail` | `crumbs:events` | Meaning | |---|---|---|---| | `--limit=` | yes (50) | yes (50) | Records to print. | | `--level=` | yes | no | Minimum log level. | | `--since=` | yes | yes | Only records after this `atUs`. | | `--logger=` | yes | no | Exact logger name. | | `--type=` | no | yes | Event types starting with this prefix. | | `--json` | yes | yes | One JSON object per line, the payload as JSON. | | `--follow` | yes | yes | Keep polling until interrupted. | `--follow` re-reads once a second from the cursor the previous read returned. After the first read it drops `--limit`, since the cursor already bounds the read. A null cursor from an empty poll keeps the previous one, so a quiet buffer never rewinds. Without `--json` an empty read prints the usual empty-buffer hint; with `--json` or `--follow` it prints nothing. ```bash ./bin/dowel crumbs:tail --logger=Sync --json --follow ./bin/dowel crumbs:events --type=Auth --since=81234567 ``` ## followCursor The loop behind `--follow` is a pure Dart function, exported from `package:crumbs/cli.dart` so a command that runs under `dart run` can build on it: ```dart import 'package:crumbs/cli.dart' show followCursor; /// [fetch] calls the extension with `since`; [handle] prints the answer. Both are the caller's. Future follow( Future> Function(int? since) fetch, void Function(Map response) handle, ) => followCursor( interval: const Duration(seconds: 1), read: (int? since) async { final response = await fetch(since); handle(response); return response['cursor'] as int?; // the next cursor }, ); ``` | Parameter | Default | Meaning | |---|---|---| | `read` | required | Called with the cursor the previous round returned; returns the next cursor. A null keeps the last one. | | `since` | null | The cursor of the first round. | | `interval` | 1 second | Wait between rounds. | | `delay` | real clock | Replaceable wait, so a test runs the loop without a clock. | | `shouldStop` | never | Asked after each read; the loop ends when it returns true. By default it ends only when the process does. | ## Related Documentation - [Tool reference](https://anilcancakir.com/crumbs/docs/mcp/tool-reference.md): the `crumbs_tail` and `crumbs_events` answers the cursor belongs to. - [Buffers](https://anilcancakir.com/crumbs/docs/getting-started/buffers.md): the `atUs` clock and how `limit` and `minLevel` behave in the store. - [File sink](https://anilcancakir.com/crumbs/docs/getting-started/file-sink.md): a timeline on disk for reads that must outlive the app.