search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs

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
}
  • kDebugMode is what telescope:install writes into lib/main.dart. Debug builds carry telescope; profile and release builds do not.
  • !kReleaseMode also 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 guard magic_devtools uses 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 into List of length 1.
  • user.id (dynamic primary key) is stringified; null user or null id both collapse to userId: 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).