Watchers
Every watcher and HTTP adapter that feeds crumbs: what each captures, how it is installed, and the guard and chaining rules they share.
On this page
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#
| 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:
if (!kReleaseMode) {
CrumbsPlugin.install();
// register additional watchers here
}
kDebugModeis whatcrumbs:installwrites intolib/main.dart. Debug builds carry crumbs; profile and release builds do not.!kReleaseModealso 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:
// 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.
// 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.
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 for liveness, attribution and the clock.
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:
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:
// 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.
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:
import 'package:crumbs/crumbs.dart';
import 'package:dio/dio.dart';
class CrumbsDioInterceptor extends Interceptor {
@override
void onResponse(Response<dynamic> 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:
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: liveness, attribution and the frame clock.
- Redaction: the text redactor and the
redactedflag. - Installation: where each watcher is registered in
main.dart. - Tool reference: the output shape of each buffer over MCP.