Watchers
Telescope ships 10 watcher units across three categories. Each unit implements one of two contracts:
TelescopeWatcher (for event-driven and hook-based capture) or TelescopeHttpAdapter (for HTTP
traffic capture). All units feed the matching ring buffer inside TelescopeStore and expose their
data via a VM Service extension method.
Call-site guard
TelescopePlugin.install() installs no mode guard of its own. The consumer gates the entire telescope
install at the call site, and kDebugMode and !kReleaseMode both tree-shake it from release builds:
if (!kReleaseMode) {
TelescopePlugin.install();
// register additional watchers here
}
kDebugModeis whattelescope:installwrites intolib/main.dart. Debug builds carry telescope; profile and release builds do not.!kReleaseModealso keeps telescope in profile builds. Use it when you measure performance, since a frame measurement needs a profile build, since debug frame timings do not describe a shipped app. It is the guardmagic_devtoolsuses and documents. 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. This single gate
tree-shakes the entire subsystem in release builds (dart2js for web, dart2native for mobile/desktop
AOT). Individual watchers do not need their own gate except DumpWatcher, which has an additional
internal kDebugMode guard (see below) and so captures nothing in a profile build unless
allowInRelease is set.
Chain-preserve pattern
Any watcher that overrides a global Dart/Flutter hook saves the previous handler before replacing it
and calls it inside the new handler body. On uninstall(), the previous handler is restored
symmetrically. This is the Sentry/Bugsnag coexistence contract: telescope captures the record AND
forwards to whatever was registered before it. The numbered step comments in ExceptionWatcher are
the canonical reference for this pattern:
// 1. Chain-preserve FlutterError.onError (sync framework errors).
_previousOnError = FlutterError.onError;
FlutterError.onError = (details) {
TelescopeStore.recordException(...);
_previousOnError?.call(details);
};
// 2. Chain-preserve PlatformDispatcher.instance.onError (async + isolate errors).
_previousPlatformOnError = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
TelescopeStore.recordException(...);
return _previousPlatformOnError?.call(error, stack) ?? true;
};
Framework-agnostic watchers
These three units are part of the fluttersdk_telescope core package. They have no dependency on
Magic or any other application framework.
LogWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | log |
| Auto-install | Yes (auto-registered by TelescopePlugin.install()) |
| Ring buffer | TelescopeStore._logs |
| VM extension | ext.telescope.console |
| Opt-out | Call TelescopePlugin.install() then remove it by not calling LogWatcher().install() directly; or skip the default auto-install path and register manually |
Subscribes to Logger.root.onRecord from package:logging. Every log record that flows through
the root logger (at any level) is converted to a LogRecordEntry (stamped with a monotonic atUs) and
pushed to the store, through TelescopeRedaction.redactor when the host set one.
hierarchicalLoggingEnabled is set to true during install so named child loggers (Logger('http'),
Logger('auth'), etc.) funnel through root. Install is idempotent: a second call when the
subscription is already live is a no-op.
If the app does not use package:logging, the watcher is dormant (no records flow through
Logger.root). It is still registered because adding package:logging later immediately benefits
from capture without any telescope config change.
Registration (auto-installed, shown for completeness):
TelescopePlugin.install(); // LogWatcher registered automatically
Manual opt-out (skip auto-registration) is not exposed in the V1 API; file a feature request if selective watcher exclusion is needed.
ExceptionWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | exception |
| Auto-install | No (opt-in via TelescopePlugin.registerWatcher(ExceptionWatcher()) after install) |
| Ring buffer | TelescopeStore._exceptions |
| VM extension | ext.telescope.exceptions |
| Opt-out | Simply do not register; absent registration means no error capture. To coexist with Sentry / Bugsnag, install them BEFORE telescope: the chain-preserve wraps the handler that was already there, so a reporter installed after telescope keeps receiving errors only if it preserves the chain itself. |
Hooks both FlutterError.onError (synchronous framework and widget errors) and
PlatformDispatcher.instance.onError (asynchronous errors, isolate errors, plugin-originated
errors). Both hooks follow the chain-preserve pattern.
The PlatformDispatcher.onError handler returns _previousPlatformOnError?.call(...) ?? true.
When a previously-registered handler (e.g. Sentry) returns false to signal a fatal error and let
the native crash reporter take over, telescope preserves that semantics. When no previous handler
exists, the default true (handled) matches pre-install behavior.
On uninstall(), both handlers are restored to exactly the values they held before install() was
called. Calling install() while already installed is a no-op.
Registration (opt-in; add after TelescopePlugin.install()):
TelescopePlugin.install();
TelescopePlugin.registerWatcher(ExceptionWatcher());
Coexistence with Sentry (the chain-preserve contract means order matters; install Sentry first):
SentryFlutter.init((_) {}); // registers FlutterError.onError + PlatformDispatcher.onError
if (kDebugMode) {
TelescopePlugin.install(); // wraps Sentry's handlers; Sentry still fires
}
DumpWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | dump |
| Auto-install | No (opt-in via TelescopePlugin.registerWatcher(DumpWatcher())) |
| Ring buffer | TelescopeStore._dumps |
| VM extension | ext.telescope.dumps |
| Opt-out | Do not register it; it is never auto-installed |
Captures all debugPrint output by replacing the global debugPrint callback with an interceptor.
The interceptor records a DumpRecord AND calls the previous debugPrint value (chain-preserve).
On uninstall(), the previous callback is restored exactly.
Internal kDebugMode gate: install() is a no-op in release builds unless allowInRelease is set
to true before calling install(). This gate is load-bearing: in release builds debugPrint
itself is a no-op, so capture would produce empty records; the gate makes the intent explicit and
ensures AOT tree-shaking eliminates the interceptor.
Registration:
if (kDebugMode) {
TelescopePlugin.install();
TelescopePlugin.registerWatcher(DumpWatcher()); // opt-in
}
Opt-out: simply omit the registerWatcher(DumpWatcher()) call.
FramePerfWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | frame_perf |
| Auto-install | No (opt-in via TelescopePlugin.registerWatcher(FramePerfWatcher())) |
| Ring buffer | TelescopeStore._framePerf |
| VM extension | ext.telescope.frames |
| Opt-out | Do not register it; it is never auto-installed |
Joins 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 drain parks its block map in a
bounded pending map and the timings callback pops the matching map to emit 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. 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.telescope.frames response.
Attribution only flows while FlutterTimeline.debugCollectionEnabled is true. Until a measurement
session turns that on, the watcher records frame magnitude alone and every record's blocks map
is empty.
Registration:
if (kDebugMode) {
TelescopePlugin.install();
TelescopePlugin.registerWatcher(FramePerfWatcher()); // opt-in
}
Opt-out: simply omit the registerWatcher(FramePerfWatcher()) call.
HTTP adapters
HTTP adapters implement TelescopeHttpAdapter (three methods: name, install, uninstall, plus
the optional pendingCount getter). They feed TelescopeStore.recordHttp and their in-flight
counts sum into TelescopeStore.pendingHttpCount (consumed by Dusk's wait_for_network_idle).
Credential redaction
The HTTP buffer is served to AI agents over ext.telescope.requests and MCP, so
TelescopeStore.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 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 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, it never replaces them:
TelescopeRedaction.hideRequestHeaders(['X-Auth']);
TelescopeRedaction.hideRequestParameters(['pin']);
TelescopeRedaction.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. An adapter must therefore mask a body before it stringifies or truncates it:
// A body held as a Dart structure.
final Object? safe = TelescopeRedaction.redactParameters(
data,
TelescopeRedaction.hiddenRequestParameters,
);
// A body held as a string, before it is cut to size.
final String? safeText = TelescopeRedaction.redactBody(
text,
TelescopeRedaction.hiddenResponseParameters,
);
Both return a masked copy and never mutate their input.
Beyond these name lists, a host can set TelescopeRedaction.redactor to run its own text redactor over
every field of the log, event, exception and HTTP records, after the key masking above; a redactor that
throws drops the record. See Redaction.
DioHttpAdapter
| Field | Value |
|---|---|
| Contract | TelescopeHttpAdapter |
| Name | dio |
| Auto-install | No (opt-in via TelescopePlugin.registerHttpAdapter(DioHttpAdapter())) |
| Ring buffer | TelescopeStore._http |
| VM extension | ext.telescope.requests |
| Opt-out | Do not register it |
Vanilla Dio adapter for Flutter apps that use raw Dio instances (not the Magic Http facade). V1
ships as a stub: the actual Dio interceptor subclass requires package:dio, which is not in
telescope's own pubspec to keep the core package HTTP-library-agnostic. The consumer adds package:dio
to their own pubspec and wires the adapter by calling the static helper:
// consumer pubspec: dio: ^5.x
import 'package:fluttersdk_telescope/telescope.dart';
// After constructing your Dio instance:
dio.interceptors.add(_MyTelescopeInterceptor());
class _MyTelescopeInterceptor 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: ...,
);
handler.next(response);
}
}
// Register so the adapter shows up in the store's adapter list:
TelescopePlugin.registerHttpAdapter(DioHttpAdapter());
V1.x will move the Dio-coupled glue into a fluttersdk_telescope_dio sub-package so the core stays
HTTP-library-agnostic.
MagicHttpFacadeAdapter
| Field | Value |
|---|---|
| Contract | TelescopeHttpAdapter |
| Name | magic_http_facade |
| Auto-install | Yes (registered by MagicTelescopeIntegration.install()) |
| Ring buffer | TelescopeStore._http |
| VM extension | ext.telescope.requests |
| Opt-out | Call TelescopePlugin.install() without calling MagicTelescopeIntegration.install() |
Captures every request flowing through Magic's network driver by registering a
MagicNetworkInterceptor on the driver. The interceptor pairs onRequest (start stopwatch) with
onResponse/onError (stop stopwatch, emit HttpRequestRecord) via a FIFO in-flight list.
Attribution is heuristic (attributedHeuristically: true) because the MagicNetworkInterceptor
contract does not carry a correlation handle across onRequest/onResponse callbacks; best-effort
matching by call order.
pendingCount returns the current length of the in-flight FIFO, surfaced into
TelescopeStore.pendingHttpCount for Dusk's network-idle detection.
The adapter masks request and response bodies before it truncates them
(TelescopeRedaction.redactParameters for a Dart structure, redactBody for a JSON or form-encoded string), and
MagicTelescopeIntegration.install() hides the header named by magic's auth.token.header config
(default Authorization) in addition to the defaults. See
Credential redaction.
Registration (via MagicTelescopeIntegration, the only documented entry point; ships in
magic_devtools via import 'package:magic_devtools/telescope.dart'):
import 'package:magic_devtools/telescope.dart';
if (kDebugMode) {
TelescopePlugin.install();
MagicTelescopeIntegration.install(); // registers MagicHttpFacadeAdapter + 5 watchers
}
install() is a no-op if Magic.bound('network') returns false (called too early, before
Magic.init() completes). Call MagicTelescopeIntegration.install() after Magic.init().
Magic-stack watchers
These five watchers are shipped inside the magic_devtools package via MagicTelescopeIntegration.
They are not part of the fluttersdk_telescope core and are not part of the magic core package.
All five are registered by a single MagicTelescopeIntegration.install() call. Add magic_devtools
to dev_dependencies and import package:magic_devtools/telescope.dart to use them.
MagicModelWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | magic_model |
| Package | magic_devtools (import package:magic_devtools/telescope.dart) |
| Auto-install | Yes (via MagicTelescopeIntegration.install()) |
| Ring buffer | TelescopeStore._models |
| VM extension | ext.telescope.requests (via models key; MCP tool: telescope_models) |
| Opt-out | Do not call MagicTelescopeIntegration.install() |
Subscribes to ModelCreated, ModelSaved, and ModelDeleted events dispatched by Magic's
EventDispatcher. Each event is converted to a MagicModelRecord carrying: modelClass (runtime
type name), event tag (created/saved/deleted), modelKey (stringified primary key), time,
and attributes (snapshot of model.attributes at capture time).
Registration:
import 'package:magic_devtools/telescope.dart';
if (kDebugMode) {
TelescopePlugin.install();
MagicTelescopeIntegration.install(); // MagicModelWatcher is included
}
uninstall() is a no-op: EventDispatcher has no per-listener removal API. Tests that need a
clean dispatcher call EventDispatcher.instance.clear() in their setUp.
MagicCacheWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | magic_cache |
| Package | magic_devtools (import package:magic_devtools/telescope.dart) |
| Auto-install | Yes (via MagicTelescopeIntegration.install()) |
| Ring buffer | TelescopeStore._caches |
| VM extension | ext.telescope.caches |
| Opt-out | Do not call MagicTelescopeIntegration.install() |
Subscribes to five cache lifecycle events emitted by Magic's CacheManager: CacheHit, CacheMiss,
CachePut, CacheForget, and CacheFlush. Each event is converted to a MagicCacheRecord carrying
operation (hit/miss/put/forget/flush), key (or * for flush), time, and ttl
(present only for CachePut).
Registration:
import 'package:magic_devtools/telescope.dart';
if (kDebugMode) {
TelescopePlugin.install();
MagicTelescopeIntegration.install(); // MagicCacheWatcher is included
}
uninstall() is a no-op (same EventDispatcher constraint as MagicModelWatcher).
MagicEventWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | magic_event |
| Package | magic_devtools (import package:magic_devtools/telescope.dart) |
| Auto-install | Yes (via MagicTelescopeIntegration.install()) |
| Ring buffer | TelescopeStore._events |
| VM extension | ext.telescope.events |
| Opt-out | Do not call MagicTelescopeIntegration.install() |
Subscribes to a curated set of Magic app-lifecycle events: AuthLogin, AuthLogout, AuthFailed,
AuthRestored (auth lifecycle), DatabaseConnected (database connection), and GateAbilityDefined,
GateBeforeRegistered (gate definition). Model lifecycle events are excluded (owned by
MagicModelWatcher) and gate-result events are excluded (owned by MagicGateWatcher) to keep each
record on a single channel.
Current payload is the empty map for every event type. Per-event field extraction is deferred to a
follow-up release while the EventRecord wire shape stabilises.
Registration:
import 'package:magic_devtools/telescope.dart';
if (kDebugMode) {
TelescopePlugin.install();
MagicTelescopeIntegration.install(); // MagicEventWatcher is included
}
uninstall() is a no-op (same EventDispatcher constraint).
MagicGateWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | magic_gate |
| Package | magic_devtools (import package:magic_devtools/telescope.dart) |
| Auto-install | Yes (via MagicTelescopeIntegration.install()) |
| Ring buffer | TelescopeStore._gates |
| VM extension | ext.telescope.gates |
| Opt-out | Do not call MagicTelescopeIntegration.install() |
Subscribes to GateAccessChecked, which covers both Gate.allows and Gate.denies outcomes via
its allowed: bool field. GateAccessDenied is intentionally not subscribed to avoid
double-recording every denial.
Each event is converted to a GateRecord with two shape coercions:
arguments(single dynamic on the event) is wrapped intoListof length 1.user.id(dynamic primary key) is stringified; null user or null id both collapse touserId: null.
Magic model arguments are converted via toMap(); primitives and collections pass through as-is;
anything else falls back to toString().
Registration:
import 'package:magic_devtools/telescope.dart';
if (kDebugMode) {
TelescopePlugin.install();
MagicTelescopeIntegration.install(); // MagicGateWatcher is included
}
uninstall() is a no-op (same EventDispatcher constraint).
MagicQueryWatcher
| Field | Value |
|---|---|
| Contract | TelescopeWatcher |
| Name | magic_query |
| Package | magic_devtools (import package:magic_devtools/telescope.dart) |
| Auto-install | Yes (via MagicTelescopeIntegration.install()) |
| Ring buffer | TelescopeStore._queries |
| VM extension | ext.telescope.queries |
| Opt-out | Do not call MagicTelescopeIntegration.install() |
Subscribes to QueryExecuted, dispatched by Magic's QueryBuilder after every SQL run. Each event
is converted to a QueryRecord carrying: sql (the full SQL string), bindings (parameter list),
timeMs (execution duration in milliseconds), connectionName (driver connection identifier), and
time (capture timestamp).
Surfaced via the telescope:queries CLI command and the telescope_queries MCP tool.
Registration:
import 'package:magic_devtools/telescope.dart';
if (kDebugMode) {
TelescopePlugin.install();
MagicTelescopeIntegration.install(); // MagicQueryWatcher is included
}
uninstall() is a no-op (same EventDispatcher constraint as the other Magic-stack watchers).