devtray 0.6.3
devtray: ^0.6.3 copied to clipboard
A draggable in-app debugging overlay for Flutter: network, logs, state, storage and device inspection in a tabbed panel over your running app. Zero dependencies.
Changelog #
0.6.3 #
Documentation and packaging only — no code changed, and nothing to migrate.
Added #
-
An
example/inside the package. pub.dev builds its Example tab from a package's ownexample/directory; without one it falls back to scraping a code block out of the README, which is what the 0.6.2 page showed.The example at the repo root can't fill that role — it's a workspace member wired to dio, Hive and sqflite, and the core promises zero runtime dependencies. So this one uses only the core: four pages, plus buttons that generate a request, a failing request, a log line, a reported error, and an 800ms freeze for the Timeline's jank lane.
It depends on the hosted
devtrayrather than resolving through the workspace, so it installs exactly the way your app does. -
Screenshots and a hero GIF in the README. The assets live outside the package archive, so they add nothing to
pub get— the archive grew 651 KB → 653 KB, all of it the example.
0.6.2 #
Added #
-
hideHeaders/hideHeader/hideAllHeadersin..network(...)— redact header values from everything the tools can copy. A capturedauthorizationheader carries a live token, and the pane, cURL, exports and bug report all make it easy to hand that token to someone.configure: (d) => d ..network(hideHeaders: {'authorization', 'cookie', 'x-api-key'}),A redacted header keeps its name and has its value replaced with
••••••— everywhere it would appear: the detail pane, copy-as-cURL,toJson, everyNetworkSink, andDebugReport. So a reader sees anauthorizationheader was sent without seeing what it was.hideHeadertakes a predicate for a family that doesn't enumerate ((name) => name.startsWith('x-internal-'));hideAllHeaders: truehides the lot. By default a hidden header is masked;headerHiding: HeaderHiding.omitdrops it entirely instead, as if it were never sent. Matching is case-insensitive — HTTP header names are, a DartSetisn't — and the predicate receives the name already lowercased.Masking is on the way out: the value is still on the live entry in memory, so this governs what leaves the device, not what is captured. To keep a header out of memory entirely, strip it in your adapter before it reaches
DevtrayNet.add. And it's for secrets, not clutter — a maskeduser-agentstill shows auser-agent: ••••••line.The Logs page's network-error detail redacts identically, including its whole-report copy button, so a token can't slip out through one copy path while being masked in another.
0.6.1 #
Added #
-
..capture(bool)inconfigure— the same switch asDevtray.enabled, so an app that decides this at startup can say it alongside everything else rather than in a separate statement before the call...launcher(bool)already existed for visibility; capture is the more fundamental of the two and was reachable only through..raw(...).configure: (d) => d ..capture(true) // record in every build… ..launcher(kDebugMode), // …but no visible affordance outside debugThat pairing is the point: a build shipping the tray on purpose — QA pulling network logs off TestFlight, launcher gated to a few accounts — wants recording on for everyone, so the tray holds a full session the moment it is opened.
0.6.0 #
Context reaches network requests, the overlay can tell which screen the app is on, and your app can get a callback on anything it captures.
Added #
-
Listeners on every store. Devtray could only be read — the
ticknotifiers say "something changed, rebuild", coalesced and with no payload, so acting on a specific capture meant diffing a buffer. Each store now hands the item itself back:configure: (d) => d ..onError((e) => Sentry.captureException(e.error ?? e.message, stackTrace: e.stackTrace)) ..onResponse((r) { if (r.statusCode == 401) authBloc.add(SessionExpired()); }) ..onScreen((v) => analytics.screenView(v.name)),onLog/onError,onRequest/onResponse/onFailure,onScreen/onScreenLeave,onStateChange/onStateError,onFreeze/onSlowFrame.onErrorcovers every route into the log store at once — your ownreportcalls, the framework and platform hooks, and failed requests — so one registration sees them all.onFailurefires regardless oferrorReporting, which governs only whether a failure also becomes a log line.Observe-only. Listeners run after the item is recorded and cannot change or suppress it. One that throws is caught and reported as an error line naming the list it was on, and the rest still run: a broken callback should cost you the callback, not the entry it was watching, and certainly not the app being debugged.
The store methods return a disposer for a listener scoped to a widget (
final off = DevtrayNet.instance.onFailure(...);off()indispose) — a returned disposer rather thanremoveListener(fn), since the registration is usually an inline closure and there'd otherwise be nothing to pass back.onSlowFrameis the one to be careful with: it runs inside the frame pipeline and can fire every frame on a bad scroll, so a listener doing real work there becomes the jank it is measuring. -
Devtray.clearListeners(), plusclearLogListeners,clearNetworkListeners,clearNavListeners,clearStateListenersandclearJankListeners— for a sign-out that should undo whatever the session registered, and for tests, where the stores are singletons and a listener left behind fires for every test after it. Blunt by design: they drop anything's registrations, so hold the disposer when you only mean to undo your own. Unrelated toenabled, which stops listeners firing without dropping them. -
Context on network requests. Ambient values and enrichers used to reach log lines only, so a line could say which screen it came from and a request could not — the more useful half, since a failing request is usually what you are chasing. Both stores now share one
DevtrayContext, so a single..enrich('nav', ...)labels everything.Fields appear on the request detail's own Context tab, kept away from Request Headers and Request Body: there they read as something the app sent, where they are the opposite — state recorded on the device and transmitted nowhere. Resolved when the request is made, not when it completes, because a slow request routinely outlives the screen that fired it.
-
DevtrayNavObserver— which screen the app is on, with no call sites:MaterialApp(navigatorObservers: [DevtrayNavObserver()], ...)Every log line and request then carries
screen. An unnamed route reports<unnamed MaterialPageRoute>rather than silently keeping the previous screen — a field that quietly names a page you already left is worse than one that admits it doesn't know.nameOfderives names yourself.A dialog does not replace the screen; it adds
overlayalongside it, so a request fired from behind it still says which page it came from.logNavigation: trueadds a line per navigation, off by default because the field already puts the route on every entry. -
Devtray.screen(name)— for navigation an observer cannot see. AnIndexedStackorPageViewthat swaps its body pushes no route, so nothing can observe it; this is the one line at the place that already knows. Both routes feed the same history. -
A
navtimeline lane. Route changes draw as spans rather than marks — "which screen was I on at this moment" is an interval question — labelled with the journey (/ → /settings,/ ← /settings) since the same pair of names in the other direction is a different trip. Contiguous spans alternate their shading so the joins are visible.
Fixed #
- A timeline lane with no detail view opened an empty dialog. The dialog's
switch (event.source)fell through to aSizedBox.shrink(), so adding a lane without its detail produced a dialog containing literally nothing. The dispatch is now a testable function and the fall-through says which type it could not render.
0.5.0 #
Devtray is now the one control surface. Switching capture off, opening the panel and hiding
the launcher were three unrelated objects; two of them are gone.
Breaking #
| Was | Now |
|---|---|
DevtrayKillSwitch.enabled |
Devtray.enabled |
DevtrayKillSwitch.addDisableListener |
Devtray.addDisableListener |
DevtrayController() + controller: |
Devtray.open() / .close() / .toggle() |
controller.showLauncher.value = x |
Devtray.showLauncher = x |
runDebugApp(enabled: ...) |
your own if around the call |
runDebugApp(app: MyApp()) / appBuilder: |
runDebugApp(() => MyApp()) |
DevtrayController is gone, along with controller: on runDebugApp and
DevtrayOverlay. A process has one panel, so its state lives on Devtray — nothing to
construct, inject, or thread through your app. This also removes a duplication: showLauncher
existed on the widget, on runDebugApp and on the controller, and the first two were
silently ignored whenever a controller was supplied.
runDebugApp(enabled:) is gone. It used to make the call a plain runApp — no Zone, no
hooks, no overlay. That was real, but the flag could only be read after the capture Zone and
the error hooks were installed, so enabled: false still left a Zone wrapping your app,
debugPrint replaced and FlutterError.onError replaced. Keeping devtray out of a build is
now your own if, which is total in a way the flag never was:
void main() {
if (kDebugMode) {
runDebugApp(() => const MyApp(), pages: [...]);
} else {
runApp(const MyApp());
}
}
Devtray.enabled still defaults to kDebugMode, so a release build that does call
runDebugApp records nothing.
app and appBuilder are now one positional builder. Two parameters, exactly one of
which had to be passed, enforced by an assert that fired at runtime:
runDebugApp(() => const MyApp(), pages: [...]);
Positional because it is the one argument every call has, and it mirrors runApp(MyApp()).
A builder because the old app: widget was constructed at the call site — before
runDebugApp was even entered — so an app whose tree read something setup initialised threw
before the bootstrap ran, and the fix was to notice and switch parameters. Now the tree is
always built after setup, and the failure mode is gone rather than documented.
Added #
configure: (d) => d..launcher(false)— the launcher's visibility alongside everything else you configure, and..openOnStart()to open the panel at launch, for iterating on a page inside the overlay itself.
Fixed #
runDebugAppnever forwardedenabledto the overlay it built, sorunDebugApp(enabled: true)in a release build gave you capture on and the UI silently off — the exact drift the flag's own documentation claimed to prevent. Moot now that the flag is gone, but it was wrong for two releases.DevtrayJankkept running after capture was switched off. Alone among the stores it registered no disable-listener, so its heartbeat timer and frame callback — the one steady-state cost in the package — survived a switch-off with its buffers intact.- The tools panel could throw during teardown.
DebugToolsScreen'sTabControllerwas a lazylatefield, anddispose()was its first read whenever the panel was closed without anyone touching a tab — constructing a controller at a point where the ancestor lookup its ticker needs is illegal.
0.4.0 #
Setup used to be spread across three mechanisms — arguments to runDebugApp, mutating
singletons, and imperative start() calls — in an order nobody stated. This release gives
it one place, and renames the stores so a call site says which tool it belongs to.
Breaking #
Every rename is mechanical; the behaviour is unchanged.
| Was | Now |
|---|---|
Devtray (the widget) |
DevtrayOverlay |
LogStore |
DevtrayLog |
NetworkLogStore |
DevtrayNet |
StateInspector |
DevtrayState |
MockStore |
DevtrayMocks |
LogExporter |
DevtrayExport |
FreezeWatchdog |
DevtrayJank |
Devtray is now the configuration facade and the shorthand for logging, which is why the
widget had to move aside. A find-and-replace on whole words covers the rename; the analyzer
finds anything missed.
Why rename at all. LogStore.instance.log(...) reads like a generic utility that
happens to be in scope. Someone reading unfamiliar code should be able to tell where a log
line goes, and every new name is shorter than the one it replaces.
Added #
-
configure:onrunDebugApp— one place for every store setting:runDebugApp( app: const MyApp(), configure: (devtray) => devtray ..excludeUrls(['/health']) ..context({'build': '1.4.2'}) ..inspect<CartCubit>((c) => {'items': c.items.length}) ..detectFreezes(), );It runs after the kill switch is set and the capture hooks are installed, and before your
setupbootstrap. That ordering is the point: configuring a store before the kill switch silently does nothing, which was easy to hit when setup was scattered. It is skipped entirely whenenabledis false, so anything expensive inside it costs a release build nothing.Covers every tunable on every store, with
raw(() { ... })for anything it doesn't — a missing convenience method must never be a reason to configure something outside the callback and lose the guarantee. A test asserts the full surface and fails when a new knob is added without a route to it. -
Devtray.log/.report/.setContext/.withContext— statics for the call sites an app hits constantly.Devtray.log('signed in')rather thanDevtrayLog.instance.log('signed in'). The stores stay public for everything else. -
logToAsync— for sinks that must be opened asynchronously, like a file sink that creates a directory.runDebugAppawaits it before running your app, so lines logged during bootstrap still reach the sink. A sink that fails to open is reported into the log and skipped, rather than taking down the launch of the app it exists to observe. -
inspectAll— several typed extractors in one call:..inspectAll([ Inspect<CartCubit>((c) => {'items': c.items.length}), Inspect<Session>((s) => {'signIns': s.signIns}), ])Each entry is an
Inspect<T>rather than a bare callback because the registry is keyed by the source type — a list of plain functions would erase it.inspect<T>is unchanged and still right for a single one.
0.3.0 #
The Timeline page, and UI-freeze detection. No breaking changes.
Added #
-
TimelineDebugPage— requests, logs and state changes on one shared time axis. Every other page answers "what happened to this"; this one answers "what just happened", which is the question you have when a screen breaks and you don't yet know which subsystem to blame.It owns no data. Every store already timestamps its entries, so this is a view over the three existing stores rather than a fourth to keep in sync, and it costs nothing until you open it. Requests draw as bars (they have duration), logs and state as marks. Tapping anything opens the same detail dialog its owning page would show.
Live-following by default; any drag pauses it. Pan by dragging or with step buttons, zoom from 200ms to 10 minutes on a logarithmic slider. Zoom survives the live/paused toggle — the reset button is the explicit way back to the default.
-
DevtrayJank— detects periods where the UI isolate stopped responding, and frames that rendered too slowly, drawn as a fourth lane on the timeline. Opt-in viaTimelineDebugPage(detectFreezes: true)orDevtrayJank.instance.start(), because it is the only capture in the overlay with a real steady-state cost.Detection is retrospective and cannot be otherwise: a blocked isolate runs no timer, frame callback or microtask, including the one watching it. So a freeze is reported once it ends, a terminal hang is reported by nothing, and there is no stack trace — by the time the gap is measurable, whatever caused it has returned. Tapping a freeze instead shows what else was happening in that window, which the dialog labels as circumstantial rather than implying a cause it cannot prove.
Both a heartbeat and
addTimingsCallbackare used, because neither is sufficient alone: the timings callback only fires for frames that rendered, so a three-second block produces no timings at all. -
JumpToLatestButtonmoved towidgets/and is now exported — the Logs and Network pages share it rather than each carrying a copy.
0.2.0 #
Performance, log persistence, and structured log context.
Breaking #
Devtray.enabled now defaults to kDebugMode instead of true.
The stores already refused to record in release builds, so the one thing that survived into production was the floating bug button. If you deliberately ship the overlay in a release flavour (staging, dogfood), pass it explicitly:
Devtray(enabled: true, child: MyApp()); // was the default; now opt-in
runDebugApp(enabled: ...) is unaffected — it always required the argument.
Added #
- Log persistence.
LogSinkis the shape of a destination andDevtrayExportowns the batching; nothing happens until you add a sink. Entries reach the sinks before the ring buffer evicts, so a long session writes every line even though the page shows the last 1000.FlushPolicyis a choice —immediate()/batched()/manual()— withflushOnPauseto catch backgrounding. See the newdevtray_log_filefor files; a remote uploader is just anotherLogSink. - Saved session browser.
LogsDebugPage(sessionSource: ...)adds a picker for past runs, opened read-only and clearly marked as not live. Loaded sessions are held separately fromDevtrayLogand never re-exported. - Structured context on log entries. Three layers, composing least-specific to most:
DevtrayLog.setContext(ambient),addEnricher(computed per entry), andfields:on the individual call — pluswithContextfor a scope. All land inLogEntry.fields, are searchable and filterable, and are written by the sinks. Costs nothing when unused. JumpToLatestButton,LogFieldsSection,NetworkLogRowandDebugStorageAdapter.noticeare now exported.
Fixed #
- The overlay no longer taxes the host app's frames. Dragging the launcher called
setStateon the widget wrapping your entire app, once per pointer move, and there was noRepaintBoundaryanywhere — so every drag frame repainted the app behind it. Both layers are now behind boundaries and the app sits outside every builder. - The Logs and Network lists stay still while you read them. A scrolled-back reader no longer drifts as entries arrive or are evicted.
- Opening a full buffer is no longer slow. Both lists now declare
itemExtent(rows are a fixed height), so the viewport computes scroll geometry arithmetically instead of laying out every row. Logs: 2197ms → 485ms with 1000 entries. Network frame cost: ~37ms → ~23ms. - Search is debounced, and
LogEntry.searchableis computed once rather than rebuilt per entry per keystroke. - Response bodies are capped (
DevtrayNet.maxBodyChars, default 256KB), the HTML sniff no longer stringifies whole bodies on every rebuild, andprettyJsonis memoised. - State history no longer holds large state objects strongly — non-primitives are
snapshotted at capture time.
DevtrayState.retainStateObjectsopts back in. - The Storage page reads only the selected adapter, refreshes only what was mutated, and
caps sqflite reads at
maxRowswith the truncation surfaced. DevtrayNetandDevtrayStatenow coalesce their change notifications, matchingDevtrayLog.- The dio interceptor detects double-registration (which used to orphan an entry as permanently pending); the Riverpod and bloc observers check the kill switch before doing work that throws-and-catches per provider update in release.
0.1.0 #
The core now has no dependencies beyond Flutter. Every integration moved to its own
package, so an app compiles only what it uses — a Riverpod + http app no longer pulls in
dio, bloc, shared_preferences, device_info_plus, package_info_plus and flutter_html
to get a debug overlay.
The pages all stayed in the core. Only the adapters moved: NetworkDebugPage reads from
a transport-agnostic store, so dio and http still feed the same page.
Breaking #
-
Adapters moved out. Add the package and import it:
Was in devtrayNow in DebugDioInterceptordevtray_dioDebugHttpClientdevtray_httpDebugBlocObserverdevtray_blocSharedPreferencesStorageAdapter,SharedPreferencesMockRuleStoragedevtray_prefsPluginDeviceInfoProviderdevtray_deviceHtmlPreviewDialogdevtray_html -
runDebugApp(persistMockRules:)is gone. Setting a storage backend is the opt-in.Mock rules are session-only until you install one — and they'll stop surviving hot restart silently, which is exactly when you're iterating on an error state. To restore the old behaviour:
// + devtray_prefs DevtrayMocks.instance.storage = SharedPreferencesMockRuleStorage();The flag was a second switch that could only ever disagree with the first:
storagedefaults to in-memory, which is always empty at startup, so restoring from it was already a no-op. -
NetworkDebugPage(enableMocking:)is gone.DevtrayMocks.instance.disable()is the one switch — the page reads it and drops the whole mocking UI along with the interception.Two switches meant they could disagree, and the dangerous direction was silent: hiding the UI while a rule added from code went on faking traffic, with nothing on screen to reveal it. That state is now unrepresentable.
-
The Network page's HTML preview button is hidden unless you supply a previewer.
The core can't render HTML any more, so a button would open nothing:
// + devtray_html NetworkDebugPage(onPreviewHtml: HtmlPreviewDialog.show)onPreviewHtmlis a plainvoid Function(BuildContext, String)— pass your own renderer if you'd rather.
Added #
DebugHtmlPreviewer— the hook type behindNetworkDebugPage.onPreviewHtml.
0.0.1 #
- Initial release.