debug_deck
Isolated, drop-in in-app debug tools for Flutter — fully decoupled from the host app and switched on with a single flag.

Bug chip → inspector → a failing call → search a response → Autopsy grade → live FPS
![]() Inspector · calls, errors & duplicates |
![]() Search a response · jump to each match |
![]() Call detail · insight chips & export |
![]() Secrets masked · safe to paste anywhere |
![]() …revealed on demand · one tap, reversible |
![]() Pin a row · survives the 200-entry cap |
![]() Performance · FPS, jank & frames |
![]() App-info snapshot · copy for reports |
A runnable demo lives in
example/—cd example && flutter run. It wires the overlay and seeds sample traffic, so every tab has content to explore. The same app powers these screenshots (seeexample/integration_test/screenshot_test.dart).
What you get, as a floating overlay (a draggable bug chip → full viewer):
- App Autopsy — a one-tap health diagnosis that grades the whole app (A–F / 0–100) across Network, Rendering and Stability, with prioritized, plain-language findings and a Markdown export for a bug report or PR.
- API inspector — every Dio request/response with headers, query, bodies, timings, status, duplicate-call detection, and per-tab search that jumps to the exact matching line.
- Logs & errors console — captured
FlutterError/ uncaught platform errors. - Performance monitor — live FPS (scroll-time), UI vs raster jank split, stalls, worst frame, frame-time sparkline, a plain-language verdict, and the current screen name so a reading is never ambiguous.
- Layout grid inspector — spacing/alignment/bounds overlay.
- App-info snapshot — build/env/device/a11y, copyable for bug reports.
It renders nothing and does no work unless you enable it.
Why debug_deck
A few things it does that most in-app inspectors don't:
🩺 App Autopsy — one tap tells you what the app is right now
Every other inspector shows you raw logs and leaves the diagnosis to you. The Autopsy tab does the diagnosis: it reads the network traffic, the frame timings and the captured errors it already recorded and synthesizes them into a single graded verdict —
- an overall grade (A–F) and 0–100 score, with a one-line headline a
junior can read (
Healthy — nothing urgent, one thing to watch); - three subsystem scores — Network, Rendering, Stability — so a senior sees exactly where the health went;
- a prioritized findings list (most-urgent first) that diagnoses and prescribes rather than restating numbers you can already see — "failures are server-side, not in the app", "the same request is firing more than once — debounce or guard it", "the UI thread is the bottleneck — move heavy work off build()";
- one-tap Markdown export of the whole report, ready to paste into a bug report, PR description or ticket.
It recomputes live as calls complete and frames land. The grading is a pure, unit-tested function you can also call yourself:
final autopsy = AppAutopsy.diagnose(
entries: DebugLogger.instance.entries.value,
perf: PerfMonitor.instance.stats.value,
);
print(autopsy.grade.letter); // A / B / C / D / F
print(autopsy.toMarkdown());
Duplicate-call detection runs automatically over entries. Two calls are
duplicates when they send the identical request — same method, URL, query,
headers and body — from the same screen. Timing is not a factor: the same
request twice is a duplicate however far apart it fired. The detectors are
public too, if you want them on their own:
findDuplicateApiCalls(entries); // entry id → how many times it fired
findDuplicateCallClusters(entries); // distinct requests, for counting problems
🧭 Breadcrumbs from any state layer — no coupling
Want state transitions and user actions in the trail (and in the Autopsy's context)? Drop a breadcrumb from anywhere — it's deliberately library-agnostic, so it works with Bloc, Riverpod, Redux, provider or a plain button handler without the package depending on your state management:
DebugTools.breadcrumb('CartBloc', 'AddItem(sku: 42)'); // from onTransition
DebugTools.breadcrumb('Tapped “Checkout”'); // from a tap handler
It's a no-op when the tools are disabled, so it's safe to leave in shipping code.
🔎 Search inside every request and response — and jump to the hit
The standout feature. Open any API call and each tab (Overview · Request · Headers · Response) carries its own field-and-body search. As you type:
- every match is highlighted in place across key/value rows and inside the pretty-printed, syntax-highlighted JSON body;
- a results bar lists each hit with the section it lives in (e.g.
Response body) and a snippet; - prev / next chevrons (or tapping a result) scroll straight to that exact field or line and ring it — so finding one token in a 2,000-line payload is one tap, not a manual scroll.
No more copying a body into another editor just to Ctrl-F it.
🧠 Insight at a glance
Open a call and auto-derived insight chips call out what matters —
SLOW · 1480ms, SERVER 500, NOT MODIFIED · CACHED, LARGE · 1.2MB,
UNAUTHORIZED — so you read the verdict before the numbers. In the list,
latency is colour-banded (green → amber → red) and oversized payloads are
flagged, so the problem call is obvious without opening anything.
🪪 Duplicate-call detection
The inspector flags requests that send the identical request — method, URL, query, headers and body — from the same screen (a classic double-tap / rebuild bug), badges each row with the count, and shows a warning bar with the total. Matching is on request data alone; timing is not part of it.
📤 One-tap export
Copy any call as cURL, a flat JSON dump, or a HAR 1.2 archive you can drop into browser devtools or any HAR viewer — ready to paste into a bug report.
Or export the whole session from the header: a Markdown bug report leading with the grade, the environment, what failed and the trail that led there; a HAR archive of every request; or just the Autopsy. Callable directly too:
SessionExport.toMarkdown(
entries: DebugLogger.instance.entries.value,
perf: PerfMonitor.instance.stats.value,
);
SessionExport.toHar(entries: DebugLogger.instance.entries.value);
🔬 Filter down to the one call that matters
Beyond the tabs, the filter button opens type (API / Errors / Events), status band (2xx…5xx, plus Failed for calls that never got a response), HTTP method, and pinned-only. It badges how many filters are on, and previews the match count before you apply — so a filtered-empty list never masquerades as "no logs".
Events are your breadcrumbs; pinned rows are exempt from the 200-entry eviction, so the call you're investigating can't scroll out of existence while you read it. Long-press any row to pin.
💾 Survive the crash that lost your log
The buffer lives in memory, so the run that died takes its evidence with it. Opt in and the session's tail is written to disk for the next launch to restore:
DebugTools.init(enabled: true, persistSession: true);
Off by default — it writes request and response bodies to disk, which is a call
your app should make knowingly. Writes are debounced and bounded (60 entries),
consumed on restore so they can't resurface a third time, and a no-op on web.
No new dependency: it uses dart:io behind a conditional import, keeping dio
the package's only dependency.
🧭 Keeps your place
Minimise to the app and the viewer is preserved exactly — filter tab, search text, the open API detail, its tab, and every scroll offset all survive the round-trip. Close resets to the start; minimise picks up where you left off.
⚡ Zero cost when off
DebugTools.enabled == false short-circuits the logger, interceptor, perf
monitor, route observer and overlay to no-ops, and DebugToolsHost becomes a
transparent pass-through — no debug widgets or listeners exist in the tree in
production.
Install
# pubspec.yaml
dependencies:
debug_deck:
path: packages/debug_deck
Integrate (4 touch-points)
1. Initialise once in main()
Gate it on your own dev/staging flag and feed it your app facts:
DebugTools.init(
enabled: EnvironmentConfig.isDevelopment,
appInfo: DebugAppInfo(
version: AppConstants.versionNumber,
environmentName: EnvironmentConfig.environmentName,
baseUrl: ApiEndpoint.baseURL,
isNativeCall: AppConstants.isNativeCall,
),
);
init also installs Flutter/platform error capture, starts the perf monitor,
and stamps app-start time — but only when enabled is true.
Secrets are masked by default
Because the cURL / JSON / HAR exports are meant to be pasted into a bug report or a PR, credentials are masked at capture time — a raw token never enters the log buffer, so it can't leak through an export, a screenshot of the Headers tab, or a surface added later.
Authorization: Bearer ••••4f2a # scheme kept, 4-char tail kept
Keeping the scheme means a Basic-where-you-expected-Bearer bug is still
visible, and the tail is enough to tell two tokens apart or confirm the app is
sending the one you expect. Values shorter than 12 characters are masked
entirely, since revealing 4 of 8 characters gives away too much.
The default set covers authorization, cookie / set-cookie, x-api-key,
access_token, refresh_token, CSRF tokens and similar, matched
case-insensitively across headers and query parameters. Add your own names,
or unmask one that's inert in your API:
DebugTools.init(
enabled: true,
redaction: DebugRedaction.standard(
also: {'x-tenant-signature'},
except: {'x-csrf-token'},
),
);
Letting the user decide
Sometimes you genuinely need to read the token. RedactionMode makes the
trade-off explicit, because a secret you can reveal later is a secret that is
still in memory:
| Mode | Stored | Shown | Revealable |
|---|---|---|---|
drop (default) |
masked only | masked | no — the raw value is gone |
hide |
raw | masked | yes — eye toggle in the Headers view |
off |
raw | raw | n/a |
DebugTools.init(
enabled: true,
redaction: DebugRedaction.standard(mode: RedactionMode.hide),
);
Under hide, the API detail header grows an eye button. Tapping it swaps every
surface at once — headers, query params, cURL, JSON and HAR — so an export can
never carry a value the screen says is hidden. It resets to hidden on every
launch, so revealing is always a deliberate act rather than a state you forgot
you left on. Under drop the button isn't shown at all, since a toggle that
silently does nothing is worse than no toggle.
DebugRedaction.disabled (mode: off) turns masking off entirely — only
sensible for a local session you aren't going to export or screenshot.
2. Mount the overlay
MaterialApp.router(
builder: (context, child) =>
DebugToolsHost(child: child ?? const SizedBox.shrink()),
// ...
);
DebugToolsHost is a transparent pass-through when disabled — no debug
widgets, controllers or listeners exist in the tree in production.
3. Capture network traffic
dio.interceptors.add(DebugTools.dioInterceptor());
4. Track the current screen (optional, powers the Perf banner)
GoRouter(
observers: [
if (DebugTools.enabled) DebugTools.routeObserver,
],
// ...
);
The master switch
DebugTools.enabled is the single source of truth. When false, the logger,
interceptor, perf monitor, route observer and overlay all short-circuit to
no-ops. Flip it from any signal you like (build mode, env, remote flag).
Decoupling
The package never imports the host app. The only thing it needs from the app is
the four DebugAppInfo values, passed in via init. That keeps it reusable
across projects — drop the folder in, add the path dependency, wire the four
touch-points.
Libraries
- debug_deck
- Isolated, drop-in Flutter debug tools.







