binnacle 0.1.3 copy "binnacle: ^0.1.3" to clipboard
binnacle: ^0.1.3 copied to clipboard

Pull-based flight recorder for Flutter apps: sessions, navigation and HTTP captured locally, uploaded in batches on demand.

binnacle

Records what happens inside a Flutter app — sessions, navigation and HTTP traffic — into a database on the device, and hands the recording over when it is asked for it.

It answers the question a crash reporter cannot: what was this person doing, and what did the server answer, in the minutes before it went wrong.

What it records #

  • Sessions. Every launch is a session. Coming back after long enough in the background starts a new one; a session left open by a process that was killed is closed at its last event on the next launch.
  • Navigation. Screens, dialogs and sheets, with where they came from and what was on the stack.
  • HTTP. Requests paired with their answers: status, duration, size, error type, and which screen the call was made from.
  • Signing in and out, and who a session belongs to, without ever storing a credential.
  • Custom events, deep links and errors the app decides are worth keeping.

Every event carries wall-clock time to the millisecond and a monotonic elapsed time, so a recording stays in order even if the device clock moves.

What it never records #

  • Bodies of calls to any host other than the app's own API, decided by an allowlist and closed by default.
  • Values under keys that look like a secret — passwords, tokens, cards, CVVs, one-time codes — replaced before they reach the disk.
  • Anything at all beyond the visit itself on screens the app declares sensitive: no body, no query, no route parameter, whatever the fields happen to be called.
  • Credentials. An Authorization header is recorded as a yes or a no.
  • Keystrokes, screen contents, location and contacts. There is no such code.

Installing #

flutter pub add binnacle

To work on the package and an app side by side, point the app at a local checkout with a pubspec_overrides.yaml next to its pubspec.yaml — pub reads it automatically and it is git-ignored, so it never reaches a commit:

dependency_overrides:
  binnacle:
    path: ../binnacle

Getting started #

import 'package:binnacle/binnacle.dart';

final recorder = Binnacle.instance;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await recorder.init(
    const BinnacleConfig(
      apiBaseUrl: 'https://api.example.com',
      environment: 'production',
      sensitiveRoutes: {'/sign-in', '/sign-up', '/password', '/card'},
    ),
  );

  runApp(const MyApp());
}

init neither throws nor waits for the disk: recording starts before storage is open, so the first screens of a launch are kept while the database is still opening.

Leaving the app is what ends a session and what forces everything queued to disk, so the recorder has to hear about it:

class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    recorder.notifyLifecycle(state);
  }
}

Recording navigation #

Three ways, depending on who knows where the app went.

The framework knows. Attach an observer and nothing else changes:

MaterialApp(
  navigatorObservers: [recorder.newNavigatorObserver()],
  // ...
);

A router knows. An observer watches one navigator only, so an app with nested navigators needs one each — a shell route has a navigator of its own, and the screens inside it are invisible to an observer attached above it:

GoRouter(
  observers: [recorder.newNavigatorObserver()],
  routes: [
    ShellRoute(
      observers: [recorder.newNavigatorObserver()],
      // ...
    ),
  ],
);

Routes that carry no name of their own can be named by hand, so a recording reads as the screens a person visited rather than a list of widget classes:

recorder.newNavigatorObserver(
  nameExtractor: (route) => route.settings.name ?? '${route.runtimeType}',
);

The app knows. An app that already funnels every navigation through code of its own can report it directly, with everything it knows and an observer cannot see:

recorder.trackNav(
  NavCapture(
    action: NavAction.go,
    route: '/orders/:id',
    pathParams: {'id': orderId},
    stack: navigationStack,
    navType: 'push',
  ),
);

Then add an observer in overlays-only mode, so dialogs and sheets are still recorded without every screen landing twice:

MaterialApp(
  navigatorObservers: [recorder.newNavigatorObserver(overlaysOnly: true)],
  // ...
);

Recording HTTP #

Two ways, depending on whether an interceptor can reach the client.

A shared client. Add the interceptor:

dio.interceptors.add(recorder.dioInterceptor);

It only watches: it never changes a request, and it never swallows an error. Which credential was attached cannot be told from the request alone, so an app that cares can say so:

dio.get<void>(
  '/orders',
  options: Options(extra: {authKindExtra: AuthKind.userToken}),
);

A client no interceptor reaches — one built per call, or a stack that is not dio at all. Open a capture and close it with whichever outcome the call has:

final capture = recorder.startHttpCapture(
  HttpCaptureStart(
    source: HttpSource.primary,
    method: 'GET',
    url: '$baseUrl/orders',
    query: query,
    authPresent: token != null,
    authKind: AuthKind.userToken,
  ),
);

try {
  final response = await client.get<dynamic>('/orders');
  capture.success(response.statusCode, response.data);
} on DioException catch (error) {
  capture.failure(error);
  rethrow;
}

A capture that is never closed stays on the timeline as a request that never got an answer, which is itself worth seeing.

Telling it who the user is #

recorder.onLoginSuccess(userToken: token); // the token is never stored
recorder.onLoginFailed(attemptedLogin: login);
recorder.onIdentity(userKey: userId); // once the app knows who it is
recorder.onSessionRestored(success: true);
recorder.onLogout();

A sign-out that follows a rejected request is recorded as forced rather than deliberate, which is the difference between someone leaving and a session expiring under them.

Sessions before anyone signs in are recorded too, tied to the install rather than to a person.

Anything else worth keeping #

recorder.trackCustom('order_placed', {'method': 'card'});
recorder.trackDeepLink(uri, source: 'notification');
recorder.trackError(
  ErrorCapture.deserialization(message: '$error', url: url, status: 200),
);

Reading a recording #

final sessions = await recorder.listSessions();
final batch = await recorder.exportBatch(); // JSON, the wire format
await recorder.forget(); // drops everything on the device

forget() is what an app hands to someone who asks for their recording to be deleted. Whatever happens afterwards is recorded as usual.

The example app carries an inspector — a button floating over every screen that opens the recording in tabs — which is the quickest way to see what is being captured while integrating:

cd example && flutter run

On the web #

There is no database on the web, so Binnacle.instance there is a recorder that records nothing and every call is a harmless no-op. An app that builds for both needs no kIsWeb checks of its own.

How much it keeps #

Recordings are pruned to fit: bodies stop being written first, then events, and whole sessions are dropped oldest first — never a slice out of the middle of one, because half a session cannot be read back as what someone did. The session being recorded is never dropped. If storage refuses writes often enough, recording stops for the rest of the run, and the app never hears about it.

Status #

Capture, storage, sessions, sanitization and the batch format are done and tested. What is not here yet is the uploader — the protocol that asks a backend whether it wants the recording and sends it. Until then a recording is read with exportBatch().

1
likes
160
points
224
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Pull-based flight recorder for Flutter apps: sessions, navigation and HTTP captured locally, uploaded in batches on demand.

Repository (GitHub)
View/report issues

Topics

#analytics #logging #monitoring #navigation #http

License

MIT (license)

Dependencies

crypto, device_info_plus, dio, flutter, flutter_secure_storage, package_info_plus, sqflite, uuid

More

Packages that depend on binnacle