flutter_inspector_kit 2.9.0
flutter_inspector_kit: ^2.9.0 copied to clipboard
A multi-inspector tool integration for Flutter, bundling debugging and inspection utilities behind a single unified API.
π Flutter Inspector #
Language / θͺθ¨: English | ηΉι«δΈζ
In-app, multi-inspector debugging overlay for Flutter apps β logs, network, navigation, and database, all behind one unified API.
π¦ Features #
| Feature | Description | Use Case Example |
|---|---|---|
| πͺ΅ Console | Capture logs across five severity levels (verbose / debug / info / warning / error), with optional structured data and stack traces; stack traces are shown in their concise form by default, with the raw trace one toggle away |
QA says "tapping checkout does nothing" β open Console, spot a red error entry in the timeline; tap in to see the structured error detail, response body, and the stack trace with framework noise collapsed, so the frames from your own code are what you read first |
| π Timeline Bookmark | Long-press any timeline entry to bookmark it with a visual indicator, then quickly filter the view to show only bookmarked items | Found a suspicious request while scrolling through hundreds of logs? Bookmark it, then toggle the "Bookmarks" chip to isolate it alongside other key events without losing track of your place in the timeline |
| π§΅ Merged Timeline | Console tab interleaves logs, network, navigation, and database events on one timestamp-sorted timeline, with per-source filter chips; error logs and failed network calls carry a faint red row tint so they stand out while scrolling | Continuing the checkout case β switch the source chip to "All" and scroll back along the timeline to inspect the request that got 401: check what token was in the Authorization header, what params were sent, and compare with backend expectations to pinpoint why the server rejected it β all without switching tabs or manually comparing timestamps |
| π‘ Network | Intercept HTTP traffic via Dio; inspect structured request/response details; search/filter by URL, method, or status; share as cURL | A page shows up completely blank β open the Network tab to find the API returned an error, so there's no data to display; tap in to inspect request params and response body, then copy as a runnable cURL command and paste it into a bug ticket for the backend team to reproduce |
| π³ JSON Tree Viewer | Network request/response bodies and structured log data render as a collapsible tree β container sizes inline, first two levels expanded, long-press any row to copy its path: value; a search field appears for large payloads, and a raw/tree toggle restores the selectable plain text |
A 200-line response body hides the one field you need β fold away the branches that don't matter, or type the key into the search field and read the match with its parents forced open, instead of scrolling a wall of pretty-printed JSON |
| π Network Replay | Resend a captured request using the original Dio instance (same headers, base URL, interceptors); replayed entries are auto-labeled, and the result shows the status code (Resent β 500) or the failure type (Resend failed: connectionError) on the spot |
Re-trigger a failed API call on-device to verify a server-side hotfix without restarting the app or rebuilding the user flow |
| π’ Slow Request Indicator | Network requests that exceed a configurable time threshold are flagged with a π’ SLOW visual marker in the Network and Console tabs β including requests still waiting for a response, which show no response Β· 12s |
Easily spot endpoints that are slowing down your app, and track down performance bottlenecks without digging through logs manually |
| π¨ Structured Network Errors | Failed requests show an Exception Details section distinguishing transport-layer failures (device offline / DNS / timeout) from server-side errors (4xx/5xx), with copyable stack traces | Instantly tell whether "Failed" means the device lost connectivity or the server returned 500 β no more guessing during QA |
| π Error Aggregation Summary | Network tab shows a collapsible banner that groups failed/errored requests by status code (or error type for transport failures), with per-group counts and time range; tap a group to filter the call list down to just that error | A page is flooded with dozens of failed calls β glance at the summary banner to see "12Γ 401", "3Γ timeout", tap the 401 group to isolate exactly those calls instead of scrolling through the full list |
| π©Ί Diagnostic Report | Export a single Markdown report β device/app header, current route stack, and the logs / network / navigation / database sections β filtered by time window (5m / 1h / all), source, and an optional errors-only toggle; straight to the share sheet, nothing written to disk | QA reproduces a bug and, instead of screenshotting four tabs and hand-typing the OS version, taps Export once and pastes a complete report into the Jira ticket |
| π WebView Inline Debugging | Bridge a WebView's own console.*, window.onerror/unhandledrejection, and fetch/XMLHttpRequest activity into the same Console and Network tabs used for native logs and Dio traffic β dependency-free, wire your own webview_flutter or flutter_inappwebview to WebViewBridgeAdapter |
A hybrid screen misbehaves and you can't tell if the bug is in Flutter or the embedded web page β see the WebView's JS errors and its fetch/XHR calls inline in Console/Network, tagged by origin, so you know instantly which side to fix without attaching Chrome DevTools to the WebView |
| π‘οΈ Sensitive-Data Redaction | Secure by default β sensitive headers (Authorization, Cookie, Set-Cookie, X-Api-Key) and sensitive routing parameters (token, password, pin, apiKey, β¦) are masked in every share/export path |
Safely share network logs with teammates or attach them to Jira tickets without leaking tokens or session cookies |
| π§ Navigator | Track route pushes, pops, and replacements automatically; toggle between Event History (raw log) and Active Stack (live route-stack visualization). Each event records its URL query parameters and route arguments, shown as Params / Args; tap a navigation row in the Console to inspect them as a JSON tree. The dashboard's own routes are filtered out, so investigating never pollutes the history | Verify deep-link routing, confirm back-stack correctness, or diagnose "why did the user land on this screen?" during a QA walkthrough β read which id a detail page was opened with instead of guessing from the route name β and since opening detail views doesn't write into the history, the stack you read after ten minutes of digging is still your app's, not a log of your own investigation |
| ποΈ Database | Record insert / update / delete / query operations with affected-row counts and payloads; browse real tables via pluggable DatabaseBrowserSource (SQLite / ObjectBox adapters provided) |
Verify that a "Save" action actually wrote the expected rows; browse local SQLite tables on-device without pulling the .db file |
| π Storage | Browse, edit, delete and clear key-value stores via pluggable KeyValueBrowserSource (SharedPreferences / SecureStorage adapter examples provided); every write is confirmed, and successful writes are logged |
Check whether a stale token or a stuck feature flag is behind the bug β and clear it on-device, without an adb shell |
| π§΅ Readable Stack Traces | Framework-internal frames collapse into a [... N frames of framework internals] marker (boundary frames preserved) and async suspensions render as <-- async gap -->; a raw/concise toggle switches the view, and the share menu exports either form explicitly |
An uncaught error produces 40 lines of package:flutter/β¦ around the two lines of your own code that matter β the concise view puts your frames back on screen without scrolling, and the raw form is one tap away when you need the full trace |
| π§ Log Route Context | Every log entry records the top-most page at the moment it was written, shown in detail views and exports | The same "token refresh failed" error means different things from the checkout screen and from a background refresh β the recorded route tells the two apart when reading the timeline later |
| π€ Agent Handoff Prompt | Copy any log or network entry as a Markdown prompt for a coding agent β the event's own facts, its stack trace, and what else happened earlier on the same route, bounded by the visit rather than a fixed time window | You find the failing call but the fix is in code you don't know β instead of retyping the error and hunting the timeline for context, copy the prompt and paste it into your agent; it arrives with the preceding same-route events already assembled, and with an explicit note that the cause is unknown so the agent doesn't treat the failing line as the faulty one |
| π Uncaught Error Capture (opt-in) | Automatically turn uncaught errors into error-level Console logs via three Flutter hooks (build/layout/paint, async, ErrorWidget); chains existing handlers β never swallows errors. RenderFlex overflows get a badge with the overflow size and direction, and the detail view names the error-causing widget and its source location |
An unawaited Future throws deep inside a third-party package β no try/catch anywhere near it. Uncaught error capture logs it automatically with a full stack trace, so it shows up in Console without any manual instrumentation |
| β±οΈ App Lifecycle Markers (opt-in) | Record every resumed / inactive / paused / detached transition as an info Console log, each naming the top-most page at that moment, interleaved into the merged Timeline |
A batch of requests fails with timeouts that nobody can reproduce at a desk β read the Timeline and an App lifecycle: paused Β· CheckoutPage marker sits right before them, so the OS froze the network while the user switched away; the backend was never at fault. Equally useful in reverse: confirming a "refresh on resume" actually fires, and on which page |
| π§ Frozen Frame Capture (opt-in) | Record every frame that takes 700ms or longer to render (Android vitals' frozen-frame definition) as a warning Console log with its wait / build / raster breakdown; consecutive frozen frames merge into one entry |
The app "hangs for a second" after opening the order list β the Timeline shows Frozen frame (1240ms) Β· OrderListPage right after a 2 MB JSON response, so the stall is the parse on the UI thread, not the network |
| π Live Notification (opt-in) | A system notification summarising the latest API call and the running total; tap to jump straight to the Network tab | Monitor API traffic in real-time while navigating the app β no need to keep the dashboard open; also useful for verifying whether the number of API calls per operation is reasonable (e.g., a single page load triggering dozens of calls hints at redundant requests) |
| π Layout Boundary Overlay (debug only) | A grid button in the dashboard app bar toggles Flutter's layout boundary overlay (debugPaintSizeEnabled) and repaints the app immediately; hidden outside debug builds |
A row overflows on a small device β flip the overlay on from the dashboard and see every widget's box and padding on the real screen, without editing code or opening DevTools |
| π― Tap-to-Inspect Layout | Tap any widget to read its size, constraints and screen position, step up through its parents, and copy or share the whole chain as text; for small targets, press and drag to aim with a magnifier and crosshair above your finger (below it near the top edge); tap the ruler icon to measure distances and gaps to another widget with dual dp and px badges; works in release builds | A button is squashed on a QA device β tap it to see constraints: 0.0 β€ h β€ 20.0, step up to the parent that imposes the 20px limit, and paste the exact numbers into the bug ticket instead of a screenshot; a 16Γ16 icon hidden under your fingertip is reached by dragging the crosshair onto it; tap the ruler icon to verify whether the margin between an avatar and user name matches the 12dp design spec |
| π Magical Tap & Floating Button | Open the dashboard with a hidden multi-tap gesture or a draggable in-app FAB | Embed in a release build as a hidden diagnostic entry point β when QA or users hit an issue, trigger the inspector on the spot for initial error triage without rebuilding a debug version or tracing through source code |
| π§© Custom Tab | Inject your own widget as a 5th dashboard tab via FlutterInspector(customTab: ..., customTabTitle: ...) β sits alongside Console / Network / Navigator / Database |
Surface app-specific debug tooling right next to the built-in inspectors β a feature-flag toggle panel, the current auth/session state, or a "clear cache" button β so your team's ad-hoc diagnostics live in one place instead of a separate debug screen |
π± Screenshots #
| Home | Console | Network |
|---|---|---|
![]() |
![]() |
![]() |
| Network Detail | Navigator | Uncaught Error |
|---|---|---|
![]() |
![]() |
![]() |
| Database Browse | Storage | Diagnostic Report |
|---|---|---|
![]() |
![]() |
![]() |
| Info Sharing | Touch Loupe (Drag-to-aim) | Gap & Distance Ruler |
|---|---|---|
![]() |
![]() |
![]() |
πͺ Usage #
Add to pubspec.yaml #
dependencies:
flutter_inspector_kit: ^2.9.0
Then run flutter pub get.
Initialize #
Create a single shared FlutterInspector instance and wire it into your app. A navigatorKey is required β the inspector routes to the dashboard through it β so create one, pass it to the inspector, and pass the same key to your MaterialApp. Then register the navigator observer to track routes, and wrap your app in FlutterInspectorMagicalTap so a hidden gesture can open the dashboard from anywhere.
import 'package:flutter/material.dart';
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
// 1. One key, shared by the inspector and the MaterialApp.
final navigatorKey = GlobalKey<NavigatorState>();
final inspector = FlutterInspector(
// Required: the dashboard is opened through this key.
navigatorKey: navigatorKey,
// Optional: configure threshold for marking requests as "π’ SLOW" (defaults to 2s)
slowRequestThreshold: const Duration(seconds: 3),
);
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// 2. The same key β without this the dashboard has no context to open in.
navigatorKey: navigatorKey,
// 3. Track navigation events
navigatorObservers: [inspector.navigatorObserver],
// 4. A hidden gesture opens the dashboard from anywhere
builder: (context, child) {
return FlutterInspectorMagicalTap(
onTap: () => inspector.openDashboard(),
child: child ?? const SizedBox.shrink(),
);
},
home: const MyHomePage(),
);
}
}
That's it? Yes, that's it.
Both halves are required.
navigatorKeyis a compile-time requirement on the constructor, but passing it toMaterialAppis not something the compiler can check. Miss that second step and the key never gets a mounted context, soopenDashboard()silently does nothing β via magical tap, floating button, or notification tap alike.
Floating button #
Prefer a visible trigger? Attach the inspector once the first frame is built to show a draggable floating button that opens the dashboard.
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
inspector.attach(context: context);
});
}
Remove it again with inspector.detach().
Inspect widget size and constraints #
Tap the Inspect layout button in the dashboard app bar. The dashboard closes and the next tap on your app selects the widget under the finger: it is outlined on screen, and a card shows its widget and render object type, size, constraints and screen position. Use the up/down arrows to walk through its parents β the constraint that squashes a widget usually comes from an ancestor β and copy or share the whole chain as plain text for a bug ticket. Tap elsewhere to select another widget, or close the card to return to the app.
For small targets such as a 16Γ16 icon, press and drag instead of tapping. Once your finger moves past the gesture slop, a magnifier above it β or below it near the top of the screen β shows the area underneath with a crosshair on the exact point being probed, and the outline and card follow the widget under the crosshair as you move. Release to select it; the result is the same as tapping that point. With a mouse, a click that drifts a couple of pixels briefly shows the magnifier, with the same result as a click. The magnifier is not shown on web; dragging still aims there.
To measure relative distances and gaps between widgets, tap the Measure distance ruler button on the inspection card. This locks the selected widget as reference A (blue border). Tapping any other widget selects target B (orange border), immediately drawing real-time ruler guidelines with numeric badges showing dual units: logical pixels (dp) and physical device pixels (px). You can still use the up/down arrows on target B to inspect spacing against its parent containers, and copying or sharing includes the complete measurement breakdown. Tap the ruler button again to exit measurement mode.
It needs no setup and works in every build mode, including release. Values are a snapshot taken when you tap or release; if the layout changes, select again. Nothing is written to the Timeline. In profile and release builds the card notes that type names may be obfuscated (when the app is built with --obfuscate).
Add a custom tab #
Inject your own widget as a 5th dashboard tab, sitting alongside the built-in Console / Network / Navigator / Database tabs. Use it to surface app-specific diagnostics β a feature-flag panel, the current auth state, a "clear cache" button, etc.
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
customTab: const MyDebugPanel(),
customTabTitle: 'Flags', // defaults to 'Custom'
);
The widget is built lazily when its tab is first shown and can be any Flutter widget β including stateful ones with their own controllers.
Log network requests #
With Dio
Add the interceptor to your Dio instance and every request/response is captured automatically. Pass the sourceDio instance to enable the Resend (Replay) feature in the Network detail view.
final dio = Dio();
dio.interceptors.add(FlutterInspectorDioInterceptor(inspector, sourceDio: dio));
Multiple Dio Instances
If your app uses multiple Dio instances (e.g., authDio for authenticated API calls, publicDio for public assets), register the interceptor on each instance and make sure to pass the respective instance as sourceDio:
// Authenticated API client
final authDio = Dio();
authDio.interceptors.add(FlutterInspectorDioInterceptor(inspector, sourceDio: authDio));
// Public API client
final publicDio = Dio();
publicDio.interceptors.add(FlutterInspectorDioInterceptor(inspector, sourceDio: publicDio));
This guarantees that replaying a request in the Network detail view uses the exact same Dio instance, maintaining the correct baseUrl, interceptors, and authentication state.
With other HTTP clients
Build a NetworkEntry yourself and pass it to inspector.logNetwork:
inspector.logNetwork(entry);
To display an in-flight request that later resolves, log a pending entry first, then log the completed entry with replaces so it updates in place instead of duplicating:
final pending = inspector.logNetwork(NetworkEntry(method: 'GET', url: url));
// ...after the response arrives:
inspector.logNetwork(completedEntry, replaces: pending);
With gRPC (ClientInterceptor)
For gRPC services using package:grpc, extend ClientInterceptor to record unary RPC invocations into the Network tab (streaming calls pass through via the base class):
import 'dart:convert';
// Requires `grpc` and `protobuf` in your app's pubspec.yaml.
import 'package:grpc/grpc.dart';
import 'package:protobuf/protobuf.dart';
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
class FlutterInspectorGrpcInterceptor extends ClientInterceptor {
FlutterInspectorGrpcInterceptor(this.inspector);
final FlutterInspector inspector;
@override
ResponseFuture<R> interceptUnary<Q, R>(
ClientMethod<Q, R> method,
Q request,
CallOptions options,
ClientUnaryInvoker<Q, R> invoker,
) {
// 1. Log pending invocation
final pending = inspector.logNetwork(
NetworkEntry(
method: 'gRPC',
url: method.path,
requestHeaders: options.metadata,
requestBody: _encode(request),
),
);
final ResponseFuture<R> responseFuture;
try {
responseFuture = invoker(method, request, options);
} on Exception catch (e, st) {
// A downstream interceptor may throw synchronously: record it, rethrow
_logFailure(pending, e, st);
rethrow;
}
// 2. Settle the pending entry; the caller still gets the original future
_settle(responseFuture, pending);
return responseFuture;
}
Future<void> _settle<R>(ResponseFuture<R> future, NetworkEntry pending) async {
try {
final response = await future;
// copyWith keeps pending.timestamp (request start time)
inspector.logNetwork(
pending.copyWith(
duration: DateTime.now().difference(pending.timestamp),
isComplete: true,
responseBody: _encode(response),
),
replaces: pending,
);
} on Exception catch (e, st) {
_logFailure(pending, e, st);
}
}
void _logFailure(NetworkEntry pending, Exception e, StackTrace st) {
// gRPC status goes into headers, never into statusCode (see Gotchas)
inspector.logNetwork(
pending.copyWith(
duration: DateTime.now().difference(pending.timestamp),
isComplete: true,
error: e is GrpcError ? '[${e.codeName}] ${e.message ?? e}' : '$e',
errorStackTrace: st.toString(),
responseHeaders: e is GrpcError ? {'grpc-status': '${e.code}'} : null,
),
replaces: pending,
);
}
// Proto3 JSON renders as a tree in the Network detail view;
// protobuf's toString() is text format, not JSON.
String _encode(Object? message) => message is GeneratedMessage
? jsonEncode(message.toProto3Json())
: '$message';
}
Attach it when creating your generated gRPC client stub:
final client = GreeterClient(channel, interceptors: [
FlutterInspectorGrpcInterceptor(inspector),
]);
With WebSocket
WebSocket connections are persistent, bidirectional streams rather than discrete request-response round-trips. The recommended recipe is to log connection lifecycle events to the Console tab via inspector.log:
// Requires `web_socket_channel` in your app's pubspec.yaml.
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
final channel = WebSocketChannel.connect(Uri.parse(wsUrl));
// Connection established: only once the handshake has actually completed
try {
await channel.ready;
inspector.log('WebSocket connected: $wsUrl');
} on Exception catch (e) {
inspector.log('WebSocket connect failed: $e', level: LogLevel.error);
return;
}
// Reuse your existing listener: a channel stream is single-subscription,
// so do not add a second one just for logging.
channel.stream.listen(
onMessage,
onError: (Object e) =>
inspector.log('WebSocket error: $e', level: LogLevel.error),
// closeCode / closeReason come from the server's close frame; they are null
// when the client closed the connection itself or it dropped abruptly.
onDone: () => inspector.log(
channel.closeCode == null
? 'WebSocket disconnected (no close frame from server)'
: 'WebSocket disconnected: code=${channel.closeCode} '
'reason=${channel.closeReason}',
),
);
Gotchas: Protocol Status Codes & NetworkOrigin
When integrating non-HTTP or non-Dio protocols manually, beware of two critical traps:
-
Never put protocol status codes into
NetworkEntry.statusCode:NetworkEntry.isFaileddetermines failure via:bool get isFailed => (statusCode ?? 0) >= 400 || error != null || errorType != null;- gRPC status codes range from
0(OK) to16(UNAUTHENTICATED). Because all gRPC codes are< 400, settingstatusCode: error.codewill never triggerisFailed(a cancelled or unauthenticated call would be classified as successful!). - WebSocket close codes use
1000for normal closure (1000..4999). If placed intostatusCode,1000 >= 400evaluates totrue, erroneously flagging healthy disconnections as failures! - Furthermore, the Network tab filter chips (
2xx,3xx,4xx,5xx) expect standard HTTP status codes. - Solution: Leave
statusCodeasnullfor non-HTTP protocols. Pass error descriptions toerror:(which setsisFailed = true), and record protocol codes insideresponseHeaders(e.g.{'grpc-status': '${error.code}'}) or the body.
- gRPC status codes range from
-
Never add or expect custom
NetworkOriginenum values:NetworkOriginis a public enum (dio,webview). Host applications frequently use exhaustiveswitch (entry.origin)expressions. Adding new values likegrpcorwebsocketwould break exhaustive matching at compile time (Never break userspace).originhas no "unspecified" value: it defaults toNetworkOrigin.dio, so custom entries will showdioin the detail view's Origin row. Treat this as a known limitation of the current API (do not setwebvieweither, as that would mislabel the entry).
Inside the Network tab #
- Search & filter: filter the call list by URL, method, or status code (case-insensitive); method and status (
2xx/3xx/4xx/5xx/Failed) chips narrow it further. - Error summary banner: a collapsible banner above the call list groups failed/errored requests by status code (or error type for transport failures β offline/timeout/DNS), showing a per-group count and first/last-seen time range. Tap a group card to filter the list down to just that error; tap again to clear the filter.
- Call details: tap any call for a structured view β General (method, URL, status with color coding, duration, request/response sizes), Query Parameters, Headers, and JSON bodies rendered as a collapsible tree (with search for large payloads and a Show raw toggle for the plain text). A body that is not valid JSON β truncated, or mislabelled by its
Content-Typeβ falls back to plain text. Truncated bodies are clearly marked. Binary payloads (Uint8List, e.g. file downloads) are recorded as a[binary N bytes]placeholder instead of being encoded byte by byte, and a request with a binary body cannot be resent. Failed requests display an Exception Details section distinguishing between transport-layer failures and server-side errors, with a copyable stack trace. - Sharing: copy the call as a runnable
cURLcommand, copy the full details as text, or open the system share sheet (native viashare_plus, web via the browser Web Share API β falls back to the clipboard when unavailable). - Replay / Resend: for requests captured with a
sourceDioprovided to the interceptor, you can trigger a "Resend" action in the detail view to replay the request locally using the same Dio client (carrying the same headers, base URL, and interceptors). Replayed requests automatically show up as new entries with a dedicated "Replay" label, and the confirmation shows the outcome directly βResent β 200/Resent β 500when a response arrived, orResend failed: <type>(e.g.connectionError,receiveTimeout) when it did not. - Slow requests: calls at or above
slowRequestThresholdcarry aπ’ SLOWbadge here and in the Console, and the summary banner counts them. A request still waiting for a response is measured from the moment it was sent β its row readsno response Β· 12sβ so a call that never returns gets flagged too. The badge is evaluated when the list rebuilds; it does not tick on its own timer.
Redact sensitive headers #
Every share/export path in the Network detail view β copy as cURL, copy as text, and the system share sheet β masks sensitive headers by default, so secrets never leak to the clipboard, a share sheet, or a screenshot. The masked keys are Authorization, Cookie, Set-Cookie, and X-Api-Key (matched case-insensitively); their values are replaced with β’β’β’β’.
This is controlled by the redactSensitiveData constructor flag, which defaults to true. Pick one of the two configurations below.
Secure by default β sensitive headers are masked in shared/exported output:
final inspector = FlutterInspector(navigatorKey: navigatorKey);
Or opt out (e.g. internal builds where you need the raw values):
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
redactSensitiveData: false,
);
The flag only affects shared/exported text β the headers shown live inside the dashboard are always the real values.
WebView inline debugging #
Bridge a WebView's own console.*, window.onerror/unhandledrejection, and fetch/XMLHttpRequest activity into the same Console and Network tabs used for native logs and Dio traffic β one more event source, not a second system. The package stays dependency-free: you bring your own WebView package (webview_flutter or flutter_inappwebview) and wire it to WebViewBridgeAdapter yourself.
Every integration follows the same three steps:
- Create
final adapter = WebViewBridgeAdapter(inspector);β a translator that turns bridge messages into existingLogEntry/NetworkEntryobjects viainspector.log/inspector.logNetwork. It holds no buffer and does no redaction of its own. - Wire your WebView package's channel/handler (named via the
kWebViewBridgeChannelNameconstant, i.e.'FlutterInspectorBridge') to calladapter.handleMessage(rawMessageString). - Inject the
inspectorWebViewBridgeJspayload into the page β it hooksconsole.*,window.onerror,unhandledrejection,fetch, andXMLHttpRequest, and posts one JSON envelope per event back through the channel.
With webview_flutter
final adapter = WebViewBridgeAdapter(inspector);
controller
..addJavaScriptChannel(
kWebViewBridgeChannelName, // 'FlutterInspectorBridge'
onMessageReceived: (m) => adapter.handleMessage(m.message),
)
..setNavigationDelegate(NavigationDelegate(
onPageStarted: (_) => controller.runJavaScript(inspectorWebViewBridgeJs),
));
Working demo: example/lib/demos/webview_demo.dart.
With flutter_inappwebview
final adapter = WebViewBridgeAdapter(inspector);
// AT_DOCUMENT_START injection catches even the page's earliest logs.
controller.addUserScript(userScript: UserScript(
source: inspectorWebViewBridgeJs,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
));
controller.addJavaScriptHandler(
handlerName: kWebViewBridgeChannelName,
callback: (args) {
// The page can call this handler directly β validate before forwarding.
final raw = args.isNotEmpty ? args.first : null;
if (raw is String) adapter.handleMessage(raw);
},
);
Working demo: example/lib/demos/inappwebview_demo.dart β its page fires a log during document load, proving the document-start injection catches activity that webview_flutter's wiring misses.
Provenance: telling WebView traffic apart from native
Every bridged request is tagged first-class: NetworkEntry.origin is NetworkOrigin.webview (native Dio traffic defaults to NetworkOrigin.dio) and NetworkEntry.pageUrl carries the issuing page's location.href. Both show up as Origin / Page URL rows in the Network detail view. Bridged log entries carry the same marking in LogEntry.data (origin / pageUrl), visible in the log detail view's Data section.
Injection timing: know what each package can and can't catch
The two packages inject at genuinely different points, and that changes what you see:
flutter_inappwebview'sUserScriptwithAT_DOCUMENT_STARTruns before the page's own scripts, so it catches logs/errors/requests fired during the page's earliest initialization.webview_flutter'srunJavaScriptonly runs fromonPageStarted, which fires after navigation has already begun β anyconsole.*/ error / fetch activity before that point is missed. This is a real gap, not a rounding error; if you need the page's earliest activity, useflutter_inappwebview.
Limitations
- Main frame only β iframes and Service Workers are not bridged; only the top-level page's JS is hooked.
setOnConsoleMessageis a console-only fallback β it doesn't cover errors or network activity, so it isn't the primary path here; the injected bridge JS is what drives all four event types.- Replay is unavailable for WebView network entries β the Network detail view's "Resend" action requires a
sourceDioinstance. WebView traffic isn't captured through Dio, sosourceDiois alwaysnulland Replay correctly stays disabled β the same degradation already used for any entry without asourceDio.
Redaction
WebView network entries are ordinary NetworkEntry objects flowing through the same buffer as Dio-captured traffic, so they're masked by the exact same redactSensitiveData rules described above β same masked keys, same opt-out flag, same code path. There is no separate redaction step for WebView traffic to configure or forget.
Live notification (opt-in) #
A continuously-updated system notification can summarise the latest call and the running total. It is disabled by default β enable it explicitly:
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
showNetworkNotification: true,
);
Once enabled, the inspector requests notification permission for you when it initialises β the host app does not need to add any permission-handling code.
Notification behaviour:
- Android: appears as a silent heads-up banner (no sound or vibration) when a new API call arrives. The banner animates in and dismisses automatically. Subsequent calls within a 2-second window silently update the notification content without re-alerting. After 2 seconds, the next call triggers another heads-up alert.
- iOS / macOS: displays a foreground banner when a new API call arrives, throttled the same way as Android. This requires one line of setup in your
AppDelegateβ see Required iOS / macOS setup below. Without it, iOS silently suppresses the foreground banner (the entry is still delivered to Notification Center). - The notification uses a dedicated high-priority Android channel (
flutter_inspector_network_v2) β if you upgrade from an earlier version, the old notification channel is automatically deleted and will not appear in system settings.
Tapping the notification opens the dashboard on the Network tab β this uses the same required navigatorKey you already wired up in Initialize, so there is nothing extra to configure:
final inspector = FlutterInspector(
navigatorKey: navigatorKey, // required β already set up during Initialize
showNetworkNotification: true,
);
As with any dashboard entry point, the tap only routes if that same key was also passed to your MaterialApp.
Android setup (required)
flutter_local_notifications relies on Java 8+ APIs, so your app's Gradle module must enable core library desugaring β this is needed whether or not notifications are enabled, otherwise the app will not build. In android/app/build.gradle.kts:
android {
defaultConfig {
multiDexEnabled = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
Also ensure a notification icon exists at @mipmap/ic_launcher (default Flutter apps already have it). On Android 13+ the POST_NOTIFICATIONS runtime permission is requested automatically when the inspector initialises.
Required iOS / macOS setup
On iOS / macOS, the user is prompted for notification permission when the inspector initialises. The permission alone is not enough to show a banner while your app is in the foreground: the system only presents a foreground notification when a UNUserNotificationCenterDelegate returns it from willPresentNotification.
iOS Setup
FlutterAppDelegate on iOS already implements that forwarding and conforms to UNUserNotificationCenterDelegate, so your host app only needs to assign it in AppDelegate.swift:
import UserNotifications // add this import
// ...inside application(_:didFinishLaunchingWithOptions:), before `return super...`:
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
macOS Setup
Unlike iOS, FlutterAppDelegate on macOS does not conform to UNUserNotificationCenterDelegate. You must explicitly declare compliance and implement the callback in macos/Runner/AppDelegate.swift:
import UserNotifications // add this import
@main
class AppDelegate: FlutterAppDelegate, UNUserNotificationCenterDelegate {
override func applicationDidFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
super.applicationDidFinishLaunching(notification)
}
// Handle foreground notifications on macOS
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.alert, .sound])
}
}
See example/ios/Runner/AppDelegate.swift and example/macos/Runner/AppDelegate.swift for working references. Without this setup no foreground banner appears on iOS / macOS β the notification is still delivered silently to Notification Center, and tapping it still works.
If permission is denied or the platform isn't supported, the notifier degrades silently to a no-op β it never crashes your app.
Log messages #
inspector.log('User signed in', level: LogLevel.info);
inspector.log(
'Payment failed',
level: LogLevel.error,
data: {'orderId': 'A123', 'amount': 4200},
stackTrace: stackTrace.toString(),
);
Available levels: verbose, debug, info, warning, error.
Bridge existing loggers #
Already logging through logger, talker or logging? Leave every call site alone. Wire one synchronous hook in main(), right after building the inspector, and each record becomes exactly one entry on the Console timeline, interleaved with network, navigation and database events.
Nothing is added to this package: the forwarding code lives in your app, so flutter_inspector_kit never depends on these libraries. Each recipe below is a file to copy as is, plus one wiring line. The same files are compiled and tested in example/lib/bridges/ (verified with logger 2.8.0, talker 5.1.20 and logging 1.3.0).
Each hook sits after that library's own gate, so records it drops never reach the timeline: Logger.level or your LogFilter for logger, the TalkerFilter and the enabled flag for talker, Logger.root.level for logging. talker's log level is the exception (see its notes below).
package:logger
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
import 'package:logger/logger.dart';
/// Forwards one `logger` event to [inspector] as exactly one log entry.
///
/// Reads the structured [OutputEvent.origin] rather than the printer's
/// `lines`, so no ANSI colours or box borders reach the Console and the
/// stack trace lands in its own tappable field.
void forwardLoggerEvent(FlutterInspector inspector, OutputEvent event) {
final origin = event.origin;
final raw = origin.message;
final message = '${raw is Function ? raw() : raw}';
inspector.log(
[message, origin.error].nonNulls.join('\n'),
level: _toInspectorLevel(origin.level),
stackTrace: origin.stackTrace?.toString(),
);
}
// Thresholds, not a switch: deprecated levels (verbose, wtf) fall into the
// right band without being named, which would trip deprecated_member_use.
LogLevel _toInspectorLevel(Level level) {
if (level >= Level.error) return LogLevel.error;
if (level >= Level.warning) return LogLevel.warning;
if (level >= Level.info) return LogLevel.info;
if (level >= Level.debug) return LogLevel.debug;
return LogLevel.verbose;
}
Logger.addOutputListener((event) => forwardLoggerEvent(inspector, event));
- The listener runs after your filter and printer. The default
DevelopmentFilterdrops everything in release builds, so nothing is forwarded there either. - One event, one entry: the text comes from the structured
LogEvent, not the printer's output lines, so ANSI colours and box borders never reach the Console. Extras a custom printer adds (prefixes, class names) are not forwarded. - The listener list is static and process-wide, so register it once. Registering it twice forwards every record twice.
- A
Functionmessage is evaluated by the bridge as well as by the printer (PrettyPrinter, the default, andSimplePrinterboth call it), so it can run twice per event: keep lazy messages free of side effects. If the bridge's call throws,loggercatches it and skips the rest of that event's output.
package:talker
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart' as kit;
import 'package:talker/talker.dart';
/// Forwards one talker record to [inspector] as exactly one log entry.
///
/// Shared by all three [TalkerObserver] callbacks: [TalkerError] and
/// [TalkerException] are [TalkerData] too, and carry their own level.
void forwardTalkerData(kit.FlutterInspector inspector, TalkerData data) {
final message = [
data.message,
data.exception,
data.error,
].nonNulls.where((p) => '$p'.isNotEmpty).join('\n');
inspector.log(
message,
level: _toInspectorLevel(data.logLevel),
stackTrace: data.stackTrace?.toString(),
);
}
// Exhaustive switch: a level talker adds later fails to compile here instead
// of being filed silently. A record without a level is debug, as in talker.
kit.LogLevel _toInspectorLevel(LogLevel? level) => switch (level) {
LogLevel.error || LogLevel.critical => kit.LogLevel.error,
LogLevel.warning => kit.LogLevel.warning,
LogLevel.info => kit.LogLevel.info,
LogLevel.debug || null => kit.LogLevel.debug,
LogLevel.verbose => kit.LogLevel.verbose,
};
/// Routes every talker callback into [forwardTalkerData].
///
/// talker has a single observer slot: if you already use one, call
/// [forwardTalkerData] from it instead of replacing it.
class InspectorTalkerObserver extends TalkerObserver {
const InspectorTalkerObserver(this._inspector);
final kit.FlutterInspector _inspector;
@override
void onLog(TalkerData log) => forwardTalkerData(_inspector, log);
@override
void onError(TalkerError err) => forwardTalkerData(_inspector, err);
@override
void onException(TalkerException err) => forwardTalkerData(_inspector, err);
}
final talker = Talker(observer: InspectorTalkerObserver(inspector));
- talker exports its own
LogLevel, which clashes with this package's. That's why the import above usesas kit. - talker's log level (
TalkerLoggerSettings.level) only governs console output and history. It is applied after observers run, so it does not keep records off the timeline. To exclude levels, filter by key before observers, e.g.Talker(observer: InspectorTalkerObserver(inspector), filter: TalkerFilter(disabledKeys: [TalkerKey.verbose, TalkerKey.debug])). ATalkerFilteralso removes those levels from talker's own console output and history. To keep them there, checkdata.logLevelin your own observer before callingforwardTalkerDatainstead. - talker has a single observer slot (
Talker(observer:)/talker.configure(observer:)). If you already use one (to report to Crashlytics, say), keep it and callforwardTalkerData(inspector, data)from itsonLog,onErrorandonException. - Don't bridge through
talker.stream: it delivers on a later microtask, after other timeline events have already been stamped, so entries would land out of order.
package:logging
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
import 'package:logging/logging.dart';
/// Forwards one `logging` record to [inspector] as exactly one log entry,
/// prefixed with the logger name (`[Auth] token expired`) so the source
/// module is visible and searchable on the timeline row.
void forwardLogRecord(FlutterInspector inspector, LogRecord record) {
final name = record.loggerName;
final message = name.isEmpty ? record.message : '[$name] ${record.message}';
inspector.log(
[message, record.error].nonNulls.join('\n'),
level: _toInspectorLevel(record.level),
stackTrace: record.stackTrace?.toString(),
);
}
// Thresholds: Level is an open class (hosts may define their own), so only a
// range check covers every possible value.
LogLevel _toInspectorLevel(Level level) {
if (level >= Level.SEVERE) return LogLevel.error;
if (level >= Level.WARNING) return LogLevel.warning;
if (level >= Level.INFO) return LogLevel.info;
if (level >= Level.FINE) return LogLevel.debug;
return LogLevel.verbose;
}
Logger.root.onRecord.listen((r) => forwardLogRecord(inspector, r));
Logger.rootdefaults toLevel.INFO, soCONFIG,FINE,FINERandFINESTrecords are never emitted until you lower it (Logger.root.level = Level.ALL;).- With
hierarchicalLoggingEnabledat its default (false), the root receives every named logger's records, including those from third-party packages that log throughlogging. If you enable it, records still propagate up to the root, but each logger's ownlevelbecomes the gate instead ofLogger.root.level.
Notes for all three
| Inspector | logger |
talker |
logging |
|---|---|---|---|
error |
error, fatal |
error, critical |
SEVERE, SHOUT |
warning |
warning |
warning |
WARNING |
info |
info |
info |
INFO |
debug |
debug |
debug, no level |
FINE, CONFIG |
verbose |
trace |
verbose |
FINER, FINEST |
fatal,criticalandSHOUTfold intoerror, the top level here. They still get a red row and still matchβ‘ Errors only.loggerandloggingboth exportLoggerandLevel. In a file that imports both, prefix one (import 'package:logging/logging.dart' as logging;), asexample/lib/main.dartdoes.- Bridged records share the 500-entry log buffer with your other logs. Leaving
traceorFINESTon can push older context out, so keep your app's level at what you actually need (for talker, use the filter above). - With
captureUncaughtErrors: true, an error that your ownFlutterError.onErroralso passes totalker.handle()orlogger.e()is recorded twice. Route it through one or the other.
Read the merged timeline #
The Console tab already interleaves logs, network, navigation, and database events on a single timeline (newest first), with a filter chip per source. Error-level logs and failed network calls are given a faint red row background so they're spottable while scrolling a long timeline β warnings keep their orange text but stay un-tinted, so a warning-heavy app doesn't wash the whole list out.
To narrow a long timeline, the tab also offers:
- Search β a case-insensitive keyword matched against each entry's readable fields: log messages and stack traces, network URLs, methods and status codes, route names, and database tables and operations.
- Level chips β
Verbose,Debug,Info,Warning. These constrain log entries only, so picking one narrows your logs without hiding network, navigation, or database events. There is deliberately noErrorlevel chip: it would have hidden failed network calls thatβ‘ Errors onlykeeps, leaving two chips that both claimed to isolate failures but disagreed on what counts as one. β‘ Errors onlyβ isolates failures across types:warning/errorlogs together with failed network calls.- Tap to jump back β while a filter is active, tapping any row clears every filter and scrolls the full timeline to that same entry, so a row you found by searching can be read back in its surrounding context. Long-press still bookmarks the row, filtered or not.
You can also read that same merged view programmatically:
// All sources, newest first.
final entries = inspector.mergedTimeline();
// Filter to specific sources only.
final networkAndLogs = inspector.mergedTimeline(
sources: {TimelineSource.network, TimelineSource.log},
);
for (final entry in entries) {
// displayTime is a shared HH:mm:ss.mmm helper on every TimestampedEntry.
print('${entry.displayTime} $entry');
}
mergedTimeline returns List<TimestampedEntry> sorted by timestamp descending. Available sources: TimelineSource.log, .network, .nav, .db (defaults to all). The entries are the live buffer objects, so a pending network call that later completes is reflected on the next read.
Readable stack traces #
A Flutter stack trace is mostly framework plumbing. Log detail views therefore show a concise stack trace by default: each run of consecutive package:flutter/β¦ or dart:β¦ frames collapses into a single marker, and asynchronous suspensions are labelled explicitly.
#0 CheckoutPage._submit (package:my_app/checkout/checkout_page.dart:88:5)
#1 _rootRunUnary (dart:async/zone.dart:1407:47)
[... 14 frames of framework internals]
#16 _WidgetsFlutterBinding.handleEvent (package:flutter/src/gestures/binding.dart:475:19)
<-- async gap -->
#17 CheckoutPage.build.<anonymous closure> (package:my_app/checkout/checkout_page.dart:120:9)
The first and last frame of every collapsed run are kept on purpose β that boundary is where control crossed between your code and the framework, which is usually the part worth reading.
A Show raw / Show concise button in the detail view switches between the two forms. Exporting is a separate, explicit choice: the share menu offers Copy concise, Copy raw, Share concise and Share raw, and each exports the form its menu item names regardless of what is currently on screen β so you can read the concise trace while pasting the raw one into an issue tracker. The stored trace is never modified β normalization is a presentation-layer projection only.
The same helpers are available programmatically:
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
// Collapse framework noise in any stack trace string.
final concise = normalizeStackTrace(rawStackTrace);
// Full plain-text export of a log entry; concise by default.
final text = buildLogPlainText(entry);
final verbatim = buildLogPlainText(entry, isConcise: false);
Where a log came from #
Every LogEntry records the top-most page at the moment it was written, in its activeRoute field β populated automatically, with no wiring on your side. It shows up in the log detail view and in plain-text exports.
inspector.log('Token refresh failed', level: LogLevel.error);
// entry.activeRoute == 'CheckoutPage'
The same error logged from a checkout screen and from a background refresh are usually different bugs; the route is what tells them apart after the fact.
Hand an entry to a coding agent #
Log and network detail views carry a Copy prompt for AI agent action in the share menu. It renders the entry you are viewing as a Markdown prompt you can paste straight into a coding agent.
The prompt carries four things:
- an explicit note that this is an execution-time observation and not a diagnosis, so the failing frame is not mistaken for the faulty one;
- the entry's own facts;
- its stack trace, in the concise form, when it has one;
- what else happened earlier on the same route β the part that is tedious to gather by hand.
That last section is bounded by the route the user was actually on: the walk back stops where they entered the page. A page open for four seconds and one open for four minutes both yield exactly the events belonging to that visit, which a fixed Β±n seconds window cannot do.
Requests, database operations and logs all record the page they happened on, so the trace-back works across sources:
final inspector = FlutterInspector(
// A fuse for long-lived pages, not a routine limit β the walk normally
// stops at the route's entry point well before this. Defaults to 50.
maxTraceBackEntries: 50,
);
When the cap or the end of the buffer is reached, the prompt says so in the output β a truncated history is never handed over as a complete one. Redaction follows your redactSensitiveData setting, so a prompt masks exactly what any other share path masks.
The prompt deliberately suggests no cause. The package holds runtime facts but not your codebase; a confidently wrong cause costs more than none, so naming one is left to the agent that can actually read the source.
The captured data is marked as untrusted inside the prompt. A response body or a log line can contain text shaped like an instruction, and the prompt is pasted directly into an agent.
Uncaught error capture (opt-in) #
By default you have to log errors yourself. Enable uncaught error capture to have the inspector automatically turn uncaught errors into error-level Console logs β no manual try/catch needed.
It is disabled by default so the package never touches your error handling unless you ask. Enable it on the constructor:
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
captureUncaughtErrors: true,
);
This wires three standard Flutter hooks β FlutterError.onError (build/layout/paint errors), PlatformDispatcher.instance.onError (uncaught async errors, including unawaited Future errors), and ErrorWidget.builder (which widget failed to build). Together they cover framework, asynchronous and build-time errors without wrapping runApp in a custom zone, so there is no Zone mismatch to manage.
Errors are never swallowed. Every hook chains/wraps your existing handler rather than replacing it: the inspector records the error and then forwards it downstream (your handler, or Flutter's default presentation β debug red screen / release grey screen unchanged). The capture is purely additive.
Captured errors appear as red logs in the Console tab. Tap any log that carries a stack trace or structured data to open a detail view with a copyable stack trace and the structured payload, plus copy/share actions.
RenderFlex overflows are recognised from the error text: the Console row carries a badge with the overflow size and direction (e.g. Overflow: 200px right), and the log detail view shows the Error Widget and Error Location (file:line:column) that Flutter names in the error, so the offending Row/Column is found without reading the full report. The parsed values are also stored in the log's data (overflowPixels, overflowDirection, errorCausingWidget, errorLocation).
Crash notifications (opt-in)
Captured errors can also raise a system notification, so a crash while the app is backgrounded β or while you are looking at another screen β is noticed immediately instead of on the next trip to the dashboard:
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
captureUncaughtErrors: true, // required β see below
showCrashNotification: true,
);
captureUncaughtErrors: trueis required. Without the error hooks attached there is no event to notify about, soshowCrashNotificationalone does nothing. It is deliberately not made to enable capture on its own β installing global error handlers is your decision, not the package's.
Crash notifications are independent of the network notification: they use their own notification id and their own throttler, so a crash alert never replaces the network summary and a burst of network activity cannot suppress a crash alert. On Android they also use a separate channel (flutter_inspector_crash), so each category can be silenced independently in system settings.
Alerts are throttled, not one-per-error. Every captured error updates the notification's content, but the system only re-alerts (heads-up banner) at most once per 2-second window β an error storm produces one alert, not fifty. All errors are still recorded in full in the Console tab.
Unlike the ongoing network summary, a crash alert is a discrete event: it can be swiped away. Tapping it opens the dashboard on the Console tab, where the error and its stack trace landed.
Native platforms only. Like the network notification, this relies on
flutter_local_notifications, which the web build deliberately excludes to stay WASM-compatible. On webshowCrashNotificationis accepted but never shows anything β errors are still captured to the Console as usual.Known limitation β startup gap. Notification initialisation is an asynchronous platform call, while the error hooks attach synchronously. A crash in that window (tens of milliseconds after construction) is still recorded to the Console, but no notification is shown. This is accepted rather than fixed; the log and timeline capture it either way.
App lifecycle markers (opt-in) #
Enable lifecycle capture to record every foreground/background transition as an info-level Console log, so a crash or a stalled request can be read against whether the app was in the foreground at that moment:
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
captureLifecycleEvents: true,
);
It is disabled by default, and detach() removes the observer again. Each transition (resumed / inactive / paused / detached, plus hidden on Flutter 3.13+) becomes one entry, which the merged Timeline interleaves with network, navigation and database events automatically.
Each entry also names the current top-most page, so repeated home/back switches stay distinguishable in the Console without jumping to the Navigator tab:
App lifecycle: resumed Β· HomePage (/home)
App lifecycle: paused Β· CheckoutPage
The page is a best-effort replay of the navigation history; when it cannot be resolved (empty history), the suffix is simply omitted.
The observer is registered with
WidgetsBinding.instance.addObserver, which appends to a list β your app's ownWidgetsBindingObservers keep receiving their callbacks untouched.
The same flag also records memory pressure, as a warning-level entry:
Memory pressure Β· CheckoutPage
This is the only OOM/LMK precursor visible from Dart β the actual RSS figure needs a platform channel β and it lands on the merged Timeline like any other event, so the pressure can be read against the network responses, route pushes and database queries that preceded it. That is the question Play Console cannot answer: not how bad the memory usage is, but what happened right before it. It is a warning rather than an info precisely so it surfaces through the Console's error/warning filters instead of sinking into the info stream.
It shares captureLifecycleEvents rather than having its own flag because both are callbacks on the same WidgetsBindingObserver, with one attach/detach lifecycle.
Platform coverage: Android delivers it via
onTrimMemory, iOS viadidReceiveMemoryWarning. On Web it effectively never fires β no entry appearing there is the platform's behavior, not a fault in the package.Only while the app is visible. Pressure reported while the app is
hidden,pausedordetachedis dropped: Android signals memory pressure routinely every time the app goes to the background (TRIM_MEMORY_UI_HIDDEN), which would otherwise turn each home-button press into a false OOM warning.inactiveis still recorded, since the app is on screen.
Frozen frame capture (opt-in) #
Enable performance capture to record every frozen frame β one that takes 700ms or longer to render, the Android vitals definition β as a warning-level Console log:
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
capturePerformance: true,
// Optional: defaults to 700ms; must be greater than zero.
frozenFrameThreshold: const Duration(milliseconds: 700),
);
It is disabled by default, and detach() removes the timings callback again. Each entry names the top-most page, and a run of consecutive frozen frames is merged into one entry:
Frozen frame (920ms) Β· OrderListPage
Frozen frame (2140ms, 3 frames) Β· CheckoutPage
The entry's data carries totalSpanMs, plus vsyncOverheadMs, buildDurationMs and rasterDurationMs to tell a UI thread blocked before the frame (a synchronous onPressed handler) apart from a slow build or a slow raster; merged entries add frameCount. Like every other entry it lands on the merged Timeline, so the freeze can be read against the response that was parsed or the route that was pushed just before it.
The 700ms threshold is deliberate: debug builds render every frame several times slower than release, and a 16ms or 32ms threshold would flood the Timeline with false jank, while 700ms is only reached by a real stall. There is no FPS counter or live meter β only these discrete events.
Construct the inspector after the binding exists. With
capturePerformance: truethe callback is registered withSchedulerBinding.instanceat construction time. A top-levelfinal inspector = FlutterInspector(...)is fine β Dart initialises it on first use, afterrunAppβ but if you create it eagerly inmain, callWidgetsFlutterBinding.ensureInitialized()first.Pauses the inspector causes itself, such as toggling the layout boundary overlay (which reassembles the app), are not recorded.
Track navigation #
Nothing to do here β routes are tracked automatically once you register inspector.navigatorObserver in navigatorObservers (see Initialize). Pushes, pops, and replacements all show up in the Navigator tab.
The Navigator tab offers two views, toggled via chips at the top:
- Event History: the raw log of push/pop/replace/remove events (the original behavior).
- Active Stack: the current route stack, derived live from the event history and rendered top-first as vertical cards β the topmost card (the current screen) is highlighted. Shows "Empty stack history" when no routes have been recorded yet.
Each event also records the parameters the route was opened with, kept in two separate fields so you can tell which channel a value came through:
| Field | Source | Shown as |
|---|---|---|
NavigatorEntry.queryParams |
Query string of the route name (/order?id=42&from=push) |
Params: β¦ |
NavigatorEntry.argParams |
Route arguments β a Map's scalar entries, or the query of a Uri / String argument |
Args: β¦ |
Values are bounded (strings truncated at 100 characters, at most 20 entries per event), and nested values in a Map argument appear as <complex>. Timeline rows show the route name next to the widget type (OrderPage (/order)), and tapping a navigation row in the Console opens a detail view that renders the params and the full arguments as JSON trees, so the nested values are still inspectable.
With redactSensitiveData: true (the default), keys that look sensitive β token, password, secret, auth, apiKey, pin, key, β¦ β are masked in both maps and in the stored arguments, and the query string is stripped from routeName itself, so a token in a deep link never reaches an export. Set redactSensitiveData: false to keep everything verbatim.
The dashboard's own routes β log/network detail views, table and cell details, the export sheet β are excluded from this history, so investigating a bug never shows up as navigation in your app's route stack. Your app's routes are recorded exactly as before, including unnamed ones.
Track database operations #
Record database operations so you can review them in the dashboard.
inspector.database(
DatabaseOperation.update,
'users',
affectedRows: 1,
data: {'query': 'UPDATE users SET name = ? WHERE id = ?'},
);
Available operations: insert, update, delete, query.
Browse database tables #
You can browse tables and rows directly from the Database tab. By default, operations logged via inspector.database(...) are grouped into virtual tables.
To browse real databases (e.g. SQLite, ObjectBox), implement DatabaseBrowserSource and register it.
SQLite Adapter Example
Here is a complete, copy-pasteable implementation of DatabaseBrowserSource for sqflite:
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
import 'package:sqflite/sqflite.dart';
class SqfliteBrowserSource implements DatabaseBrowserSource {
SqfliteBrowserSource(this._db, {this.name = 'SQLite database'});
final Database _db;
@override
final String name;
@override
Future<List<DatabaseTableInfo>> listTables() async {
final List<Map<String, Object?>> tables = await _db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
);
final List<DatabaseTableInfo> result = [];
for (final table in tables) {
final name = table['name'] as String;
final countResult = await _db.rawQuery('SELECT COUNT(*) as count FROM "$name"');
final rowCount = Sqflite.firstIntValue(countResult);
result.add(DatabaseTableInfo(name: name, rowCount: rowCount));
}
return result;
}
@override
Future<DatabaseTablePage> fetchRows(
String tableName, {
int limit = 200,
int offset = 0,
}) async {
final countResult = await _db.rawQuery('SELECT COUNT(*) as count FROM "$tableName"');
final totalRows = Sqflite.firstIntValue(countResult) ?? 0;
final List<Map<String, Object?>> queryResult = await _db.rawQuery(
'SELECT * FROM "$tableName" LIMIT ? OFFSET ?',
[limit, offset],
);
if (queryResult.isEmpty) {
final tableInfo = await _db.rawQuery('PRAGMA table_info("$tableName")');
final columns = tableInfo.map((info) => info['name'] as String).toList();
return DatabaseTablePage(
columns: columns,
rows: const [],
totalRows: totalRows,
);
}
final columns = queryResult.first.keys.toList();
final rows = queryResult.map((map) {
return columns.map((col) => map[col]).toList();
}).toList();
return DatabaseTablePage(
columns: columns,
rows: rows,
totalRows: totalRows,
);
}
}
ObjectBox Adapter Example
For ObjectBox, since Box/Entity represents a table and reflection is not available at runtime to convert entities to map, you can register entities manually:
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
import 'package:objectbox/objectbox.dart';
class ObjectBoxEntityInfo<T> {
ObjectBoxEntityInfo({
required this.name,
required this.box,
required this.toMap,
});
final String name;
final Box<T> box;
final Map<String, dynamic> Function(T) toMap;
}
class ObjectBoxBrowserSource implements DatabaseBrowserSource {
ObjectBoxBrowserSource({
required this.entities,
this.name = 'ObjectBox database',
});
final List<ObjectBoxEntityInfo> entities;
@override
final String name;
@override
Future<List<DatabaseTableInfo>> listTables() async {
return entities.map((e) {
return DatabaseTableInfo(
name: e.name,
rowCount: e.box.count(),
);
}).toList();
}
@override
Future<DatabaseTablePage> fetchRows(
String tableName, {
int limit = 200,
int offset = 0,
}) async {
final entityInfo = entities.firstWhere((e) => e.name == tableName);
final totalRows = entityInfo.box.count();
// Query with offset and limit
final query = entityInfo.box.query().build();
query.limit = limit;
query.offset = offset;
final items = query.find();
query.close();
if (items.isEmpty) {
return DatabaseTablePage(
columns: [],
rows: const [],
totalRows: totalRows,
);
}
final maps = items.map((item) => entityInfo.toMap(item)).toList();
final columns = maps.first.keys.toList();
final rows = maps.map((map) => columns.map((col) => map[col]).toList()).toList();
return DatabaseTablePage(
columns: columns,
rows: rows,
totalRows: totalRows,
);
}
}
Registration
You can register these sources when initializing FlutterInspector or dynamically at runtime:
// At initialization
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
databaseSources: [SqfliteBrowserSource(db)],
);
// Or dynamically
inspector.registerDatabaseSource(SqfliteBrowserSource(db));
π Browse key-value storage #
SharedPreferences, FlutterSecureStorage and friends hold the state behind
most "stuck on a screen" reports β a stale token, a feature flag left on, a
cache key that never got cleared. The Storage tab lists those keys and lets
you edit, delete or wipe them on-device.
The tab only appears once you register at least one KeyValueBrowserSource.
As with DatabaseBrowserSource, the package ships no implementation and takes
no dependency on any storage plugin β you inject the adapter.
Every write requires an explicit confirmation β edits add a validation step
before it, so a mistyped value is caught before the confirmation appears β and
is recorded as an info log in the Console timeline once it succeeds, so a
change made while debugging never becomes a mystery later. A cancelled or
failed write leaves no log β the trail only ever claims what actually landed. Values in that log are masked unless
the host sets redactSensitiveData: false, since the log is shareable and a
key-value source may hold tokens.
SharedPreferences Adapter Example
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SharedPrefsBrowserSource implements KeyValueBrowserSource {
SharedPrefsBrowserSource(this._prefs, {this.name = 'SharedPreferences'});
final SharedPreferences _prefs;
// Shown in the source selector. Pass a distinct name when registering more
// than one store, or they are indistinguishable there.
@override
final String name;
@override
Future<List<KeyValueEntry>> listAll() async {
return _prefs.getKeys().map((key) {
final value = _prefs.get(key);
return KeyValueEntry(key: key, value: value, type: _typeOf(value));
}).toList();
}
@override
Future<void> setValue(String key, Object? value) async {
// The value already arrives parsed into the entry's original type.
final ok = switch (value) {
final String v => await _prefs.setString(key, v),
final int v => await _prefs.setInt(key, v),
final double v => await _prefs.setDouble(key, v),
final bool v => await _prefs.setBool(key, v),
final List<String> v => await _prefs.setStringList(key, v),
_ => false,
};
_check(ok, 'write $key');
}
@override
Future<void> remove(String key) async {
_check(await _prefs.remove(key), 'remove $key');
}
@override
Future<void> clear() async {
_check(await _prefs.clear(), 'clear');
}
// SharedPreferences signals failure by returning false, not by throwing.
// Swallowing it would let the audit log claim a write that never landed.
void _check(bool ok, String what) {
if (!ok) throw StateError('SharedPreferences failed to $what');
}
KeyValueType _typeOf(Object? value) => switch (value) {
int() => KeyValueType.int,
double() => KeyValueType.double,
bool() => KeyValueType.bool,
List<String>() => KeyValueType.stringList,
_ => KeyValueType.string,
};
}
FlutterSecureStorage Adapter Example
Secure storage only holds strings, so every entry is KeyValueType.string.
readAll()is not supported on every platform. Let the failure propagate β the Storage tab renders it as a retryable error instead of an empty list, so "cannot enumerate" is never mistaken for "nothing stored".
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStorageBrowserSource implements KeyValueBrowserSource {
SecureStorageBrowserSource(this._storage, {this.name = 'SecureStorage'});
final FlutterSecureStorage _storage;
@override
final String name;
@override
Future<List<KeyValueEntry>> listAll() async {
final all = await _storage.readAll();
return all.entries
.map(
(e) => KeyValueEntry(
key: e.key,
value: e.value,
type: KeyValueType.string,
),
)
.toList();
}
@override
Future<void> setValue(String key, Object? value) =>
_storage.write(key: key, value: '$value');
@override
Future<void> remove(String key) => _storage.delete(key: key);
@override
Future<void> clear() => _storage.deleteAll();
}
Registration
// At initialization
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
keyValueSources: [SharedPrefsBrowserSource(prefs)],
);
// Or dynamically
inspector.registerKeyValueSource(SecureStorageBrowserSource(storage));
Export a diagnostic report #
Open the dashboard and tap the share icon in the app bar. Pick which sources to include, a time window (last 5m / last 1h / all), and optionally "errors & warnings only", then hit Share report β a Markdown report goes straight to the system share sheet. Nothing is written to disk.
The report leads with a single chronological Timeline that interleaves the log, network, navigation, and database entries you selected by timestamp (newest first), so cross-layer causality reads at a glance; the per-source Network / Navigation / Database detail sections follow below it. The "errors & warnings only" toggle filters this whole Timeline down to error signals (log errors/warnings plus failed network calls), leaving those detail sections untouched.
The report inherits your redactSensitiveData setting, and its header states
Redaction: enabled / disabled so whoever receives it knows what was masked.
Populating the device / app header (optional)
This package depends on no device-info plugin (and never touches dart:io, so it
stays WASM-compatible). Without a source, the header degrades to N/A and the report
is still produced in full.
To fill it in, add package_info_plus and device_info_plus to your own app, then
implement DiagnosticInfoSource:
import 'dart:io' show Platform;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_inspector_kit/flutter_inspector_kit.dart';
import 'package:package_info_plus/package_info_plus.dart';
// Note: This example is mobile-only due to its dart:io Platform usage.
// It will not compile on Web/WASM.
class AppDiagnosticInfoSource implements DiagnosticInfoSource {
@override
Future<DiagnosticInfo> collect() async {
final pkg = await PackageInfo.fromPlatform();
final deviceInfo = DeviceInfoPlugin();
String? deviceModel;
String? osVersion;
if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
deviceModel = ios.utsname.machine;
osVersion = 'iOS ${ios.systemVersion}';
} else if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
deviceModel = '${android.manufacturer} ${android.model}';
osVersion = 'Android ${android.version.release}';
}
return DiagnosticInfo(
appVersion: '${pkg.version}+${pkg.buildNumber}',
deviceModel: deviceModel,
osVersion: osVersion,
);
}
}
Then pass it in:
final inspector = FlutterInspector(
navigatorKey: navigatorKey,
diagnosticInfoSource: AppDiagnosticInfoSource(),
);
πΉοΈ Example #
A complete, runnable integration lives in the example/ directory:
cd example
flutter run
π License #
This project is licensed under the terms described in the LICENSE file.











