AppInspect

A network inspector for Flutter that QC can use on their own.

Tap the floating bubble and AppInspect shows the API calls of the screen you're on: the URL, status in plain words, time, size, headers and the raw response. Copy any call as cURL for the backend team, export it as HAR or Postman, or hold the bubble to send a complete bug report. No cable, no debug console, no "sit beside me".

Calls grouped by screen, with failed, slow and repeated calls flagged A parse error that names the exact field the app couldn't read Logs console tagged by screen

What it does

  • Calls grouped by screen. Every request is tagged with the screen that made it. The inspector opens on "This screen", with "All" one tap away.
  • A bubble that turns red. It counts the calls on the current screen and turns red when something fails, even when the app shows nothing.
  • Plain-language status. 500 Server error, 404 Not found, Timeout, No connection.
  • Parse errors linked to the response. When the server answers 200 but the app can't read it, AppInspect says so, names the field and highlights it: "The server answered 201 Created, but the app failed at total: expected int, got double. In CartResult.fromJson (cart.dart:42)."
  • Insights. Server errors, rejected requests, slow calls and calls repeated in a row (often an API call inside build()).
  • Request and response details. The final URL, the headers after every interceptor, the request body, and the raw response before your app parses it.
  • JSON viewer. Fold and unfold nodes. Tap a value to copy it or its path, such as data.items[0].price.
  • Logs console. debugPrint, print, Flutter errors, uncaught errors, failed calls and parse errors, each tagged with its screen and linked to its call.
  • One-tap bug report. Hold the bubble: a screenshot of the app without AppInspect on top, this screen's calls with cURL for the failures, its errors and warnings, and device info, as Markdown with a HAR file attached.
  • Exports. cURL, JSON, Markdown, HAR (Chrome DevTools, Charles, Proxyman) and Postman collections.
  • Masking. Authorization headers, cookies, passwords and tokens are masked on screen and in every export. A developer can reveal one header when needed.
  • Safe for production. On in debug builds. In profile and release builds it's on only with --dart-define=APP_INSPECT=true, so production builds don't contain it.
  • PIN for UAT builds. Lock the inspector in builds you hand to people outside the team.
  • Easy on the eyes. A light, touch-first inspector: tap a stat tile to filter, haptics on every tap, smooth transitions and a frosted action bar. Drag the bubble anywhere and it springs to the nearest edge. Dark theme in Settings.

Setup

dependencies:
  app_inspect: ^0.2.0
  app_inspect_dio: ^0.2.0   # if you use Dio
  app_inspect_http: ^0.2.0  # if you use package:http
  1. Wrap your app where you call runApp:

    void main() => runApp(AppInspect.wrap(const MyApp()));
    

    To capture print in the logs too, run the app in AppInspect's zone, first thing in main:

    void main() => AppInspect.runZoned(() => runApp(AppInspect.wrap(const MyApp())));
    
  2. Add the observer, so calls are tagged with their screen:

    MaterialApp(
      navigatorObservers: [AppInspect.navigatorObserver],
      // ...
    );
    

    With go_router, pass it to GoRouter(observers: [AppInspect.navigatorObserver]).

  3. Capture your client's calls:

    // Dio: add it last, so it sees the headers other interceptors add.
    dio.interceptors.add(AppInspectDioInterceptor());
    
    // package:http
    final client = AppInspectHttpClient(); // or AppInspectHttpClient(yourClient)
    

That's it. Run the app in debug mode and the bubble appears.

Parse errors

Parse through AppInspect and a failure names the exact field:

// Dio
final cart = AppInspectDio.parse(response, Cart.fromJson);
final items = AppInspectDio.parseList(response, Item.fromJson);

// Anything else: pass the request's URL so AppInspect can find the call.
final cart = AppInspect.parse(json, Cart.fromJson, uri: response.request!.url);

They return what fromJson returns and rethrow what it throws, so your error handling doesn't change. In builds without AppInspect they just call fromJson.

With Retrofit, plug AppInspect into its parse-error logger instead:

class AppInspectParseErrorLogger implements ParseErrorLogger {
  @override
  void logError(Object error, StackTrace stackTrace, RequestOptions options, {Response<dynamic>? response}) =>
      AppInspectDio.reportParseError(options, error, stackTrace);
}

final client = RestClient(dio, errorLogger: AppInspectParseErrorLogger());

Retrofit parses the response itself, so AppInspect can't see which field it read last. It lists the likely fields instead: values of the type the app got, such as every number with decimals when the app expected an int.

Bug reports and sharing

Without any setup, Share and "Copy report" copy text to the clipboard. To send files and screenshots, connect your share sheet or tracker:

AppInspect.wrap(
  const MyApp(),
  config: AppInspectConfig(
    // Extra lines for every bug report.
    reportInfo: () => {'App': '2.3.1 (231)', 'Flavor': 'staging', 'User': currentUserId},

    // Markdown, or a file's contents when item.fileName is set (such as
    // ProductScreen.har). Hand it to share_plus or your own share sheet.
    onShare: (item) async { /* ... */ },

    // report.markdown, report.screenshot (PNG), report.har and
    // report.logsText. Send them to Jira, Slack or a share sheet.
    onReport: (report) async { /* ... */ },
  ),
);

Logs

The Logs tab shows, newest first and tagged with the screen:

  • debugPrint output, and print output when the app runs in AppInspect.runZoned
  • Flutter errors and uncaught errors, with stack traces
  • failed calls and parse errors, linked to their call
  • your own lines: AppInspect.log('Checkout started', level: AppInspectLogLevel.info)

AppInspect chains to the error handlers that are already set when you call AppInspect.wrap. If you set FlutterError.onError later (for example for Crashlytics), set it before AppInspect.wrap so both run.

QA builds

QC usually tests release or profile builds. Turn AppInspect on for those with a compile-time flag:

flutter build apk --release --dart-define=APP_INSPECT=true

Builds without the flag don't contain AppInspect. AppInspect.isEnabled is a constant, so the compiler drops every code path behind it. Turn it off in debug with --dart-define=APP_INSPECT=false.

To lock the inspector in a build you share with clients:

AppInspect.wrap(const MyApp(), config: const AppInspectConfig(pin: '2468'));

Screens that aren't routes

Bottom tabs and PageView pages don't push routes. Wrap them so their calls get their own name:

IndexedStack(
  index: tab,
  children: const [
    AppInspectScreen(name: 'ShopTab', child: ShopTab()),
    AppInspectScreen(name: 'AccountTab', child: AccountTab()),
  ],
);

Or call AppInspect.markScreen('CartTab') yourself.

Screens pushed without a route name are named after their widget, such as ProductDetailsScreen. AppInspect looks for the first widget on the route whose type ends in Screen, Page or View.

Settings

AppInspectConfig(
  maxCalls: 300,                         // oldest calls are dropped first
  maxLogs: 1000,                         // oldest log entries are dropped first
  maxBodyChars: 500000,                  // longer bodies are cut
  slowThreshold: Duration(seconds: 1),   // slower calls are flagged
  maskedHeaders: {'authorization', 'cookie', 'x-session'},
  maskedBodyKeys: {'password', 'token', 'otp'},
  showBubble: true,                      // false: open with AppInspect.open()
  captureLogs: true,                     // debugPrint, Flutter and uncaught errors
  darkTheme: false,                      // true: start in the dark theme
);

AppInspect has no plugin dependencies, so it can't cause version conflicts in your app.

Performance

  • Capturing appends to a fixed-size list. Bodies are stored as they arrive and formatted only when someone opens a call.
  • Large bodies are decoded on another isolate. The JSON viewer builds only the lines on screen.
  • The UI updates at most once per frame, however many calls or log lines arrive. While the inspector is closed, only the bubble rebuilds.
  • The http adapter copies the response as it streams to your code, up to maxBodyChars, so large downloads aren't held in memory twice.
  • AppInspect.parse tracks which field fromJson reads, only in builds with AppInspect.

What it captures

Source How
Dio AppInspectDioInterceptor from app_inspect_dio
package:http AppInspectHttpClient from app_inspect_http
Anything else Call AppInspect.store.startCall, then completeCall or failCall

Not captured: traffic from native SDKs (Firebase, payment SDKs) and clients that don't go through Dio or package:http.

Known limits

  • On Android, if your app opts in to predictive back (android:enableOnBackInvokedCallback="true"), a back gesture while the inspector is open can also pop your app's screen. The classic back button is handled correctly.
  • AppInspect.navigatorObserver tracks one navigator: give it to your root navigator.
  • On Android, the OS line in bug reports comes from the kernel version. Add the Android version with reportInfo if you need it.

Coming next

  • Session timeline and GraphQL operation names.
  • Fault injection, slow network and offline mode, edit and resend, compare two calls.
  • Environment switcher, WebSocket frames, analytics events, and viewing a phone's session on a laptop.

Libraries

app_inspect
AppInspect: a QC-first network inspector for Flutter.