router_observer

pub package License: MIT

A complete navigation observability toolkit for Flutter. One observer, every navigation insight: structured events, readable console logs, crash-report breadcrumbs, analytics screen tracking with time-on-screen, live stack inspection, and an in-app debug overlay.

Works with anything that accepts a NavigatorObserver: MaterialApp, CupertinoApp, go_router, auto_route, nested navigators.

Why this package

Flutter's NavigatorObserver gives you seven raw callbacks and leaves the rest to you. router_observer turns them into a production-grade pipeline:

Feature What you get
Structured events Sealed NavEvent hierarchy (NavPush, NavPop, NavRemove, NavReplace, NavTopChange, NavGestureStart, NavGestureEnd) with exhaustive switch support, JSON serialization, timestamps, sequence numbers, and stack snapshots
Smart route names No more <anonymous>: resolves settings.name, Page.name/Page.key, and labels dialogs [dialog], sheets [bottom-sheet], popups [popup]
Console logging One-line, color-capable, human-readable logs or single-line JSON
Breadcrumbs Ring buffer of recent navigation, ready to attach to Sentry/Crashlytics reports
Screen tracking Analytics-grade screen views with time-on-screen measurement, popup-aware
Debug overlay Draggable in-app bubble that expands into a live navigation timeline + current stacks
Stack inspection observer.currentStack, currentRoute, stackDepth, and a broadcast events stream
PII safety Argument capture is opt-in and always redacted (tokens, passwords, emails masked by default)
Crash-proof Listener exceptions are reported via FlutterError.reportError and never break navigation
Zero dependencies Only the Flutter SDK

Quick start

dependencies:
  router_observer: ^1.0.0
import 'package:flutter/material.dart';
import 'package:router_observer/router_observer.dart';

void main() {
  runApp(
    MaterialApp(
      navigatorObservers: [
        RouterObserver(listeners: [ConsoleNavLogger()]),
      ],
      home: const HomePage(),
    ),
  );
}

Console output:

[root] -> push         /details (from /home) depth=2
[root] <- pop          /details (to /home) depth=1
[root] <> replace      /signup (was /login) depth=1

The event model

Every transition becomes an immutable NavEvent you can switch over exhaustively:

NavCallbackListener(onEvent: (event) {
  switch (event) {
    case NavPush(:final route):
      analytics.track('push', {'screen': route.name});
    case NavPop(:final previousRoute):
      debugPrint('back to ${previousRoute?.name}');
    case NavRemove() || NavReplace():
      // stack surgery
    case NavTopChange() || NavGestureStart() || NavGestureEnd():
      break;
  }
})

Each event carries a RouteInfo snapshot (name, type, id, isFirst, isPopup, redacted arguments, pageKey), the previous route when relevant, a timestamp, a monotonic sequence, the navigatorLabel, and a stackSnapshot. event.toJson() produces a JSON-encodable map.

Configuration

RouterObserver(
  navigatorLabel: 'root',
  listeners: [ConsoleNavLogger(), NavBreadcrumbs()],
  settings: RouterObserverSettings(
    enabled: true,
    captureArguments: false,     // opt-in; redacted when enabled
    includePopupRoutes: true,    // dialogs, sheets, menus
    includeGestureEvents: false, // iOS back-swipe start/end
    includeTopChanges: true,     // didChangeTop events
    routeFilter: (route) => !route.name.startsWith('/debug'),
    eventFilter: (event) => true,
    nameResolver: defaultNameResolver,
    redactor: defaultRedactor,
    maskKeys: defaultMaskKeys,   // token, password, secret, email, ...
    maxChars: 200,
    clock: DateTime.now,         // injectable for tests
  ),
)

Recipes

Crash-report breadcrumbs (Sentry, Crashlytics, ...)

final breadcrumbs = NavBreadcrumbs(
  capacity: 50,
  // Optional: forward each event immediately to your crash SDK.
  onCrumb: (event) => Sentry.addBreadcrumb(
    Breadcrumb(category: 'navigation', message: event.toString()),
  ),
);

RouterObserver(listeners: [breadcrumbs]);

// Or attach the whole trail when a crash happens:
crashReporter.attach('navigation', breadcrumbs.describe());

Screen-view analytics with time-on-screen (Firebase, Mixpanel, ...)

RouterObserver(listeners: [
  ScreenTracker(onScreenView: (view) {
    FirebaseAnalytics.instance.logScreenView(screenName: view.name);
    if (view.timeOnPreviousScreen != null) {
      analytics.track('screen_time', {
        'screen': view.previousName,
        'seconds': view.timeOnPreviousScreen!.inSeconds,
      });
    }
  }),
]);

ScreenTracker collapses noisy raw events (pushes, top changes, popup traffic) into clean "the visible screen changed" callbacks.

go_router

GoRouter(
  observers: [RouterObserver(listeners: [ConsoleNavLogger()])],
  routes: [...],
);

Nested navigators

A NavigatorObserver can only watch one navigator, so create one RouterObserver per navigator and share the listener instances. Events carry the navigator label:

final logger = ConsoleNavLogger();
final rootObserver = RouterObserver(listeners: [logger]);
final tabObserver = RouterObserver(
  navigatorLabel: 'tab:feed',
  listeners: [logger],
);

In-app debug overlay

MaterialApp(
  navigatorObservers: [observer],
  builder: (context, child) => NavDebugOverlay(
    observers: [rootObserver, tabObserver],
    child: child!,
  ),
)

A draggable bubble floats over your app; tap it for a live event timeline and the current route stack of every observer. Enabled only in debug mode by default, so it is safe to leave in place for release builds.

Sampling and rate limiting

eventFilter is the general-purpose hook:

// Sample 10% of events.
final random = Random();
RouterObserverSettings(eventFilter: (_) => random.nextDouble() < 0.1);

// Rate-limit to 120 events per minute.
var windowStart = DateTime.now();
var count = 0;
RouterObserverSettings(eventFilter: (event) {
  if (event.timestamp.difference(windowStart) > const Duration(minutes: 1)) {
    windowStart = event.timestamp;
    count = 0;
  }
  return ++count <= 120;
});

Stripping in release builds

MaterialApp(
  navigatorObservers: [
    if (!kReleaseMode) RouterObserver(listeners: [ConsoleNavLogger()]),
  ],
)

Or keep the observer and disable it: RouterObserverSettings(enabled: kDebugMode).

Custom listener

class TracingListener implements NavListener {
  @override
  void onNavEvent(NavEvent event) =>
      tracer.span('nav.${event.typeName}', event.toJson());

  @override
  void close() => tracer.flush();
}

PII redaction

Argument capture is off by default. When enabled, values pass through the configured Redactor first. defaultRedactor masks any map key containing token, password, secret, email, authorization, apikey, or api_key (case-insensitive), recurses through nested maps/lists with caps, truncates long strings, and never throws.

Testing your navigation

The package ships test utilities:

testWidgets('opens details', (tester) async {
  final recorder = RecordingNavListener();
  await tester.pumpWidget(MaterialApp(
    navigatorObservers: [
      RouterObserver(
        listeners: [recorder],
        settings: RouterObserverSettings(clock: () => DateTime.utc(2026)),
      ),
    ],
    // ...
  ));

  // ...drive the app...

  expect(recorder.pushes.last.route.name, '/details');
});

License

MIT - see LICENSE.

Libraries

router_observer
A complete navigation observability toolkit for Flutter.