Skip to content
All articles

5 min read

Flutter Firebase Analytics screen tracking with go_router

FirebaseAnalyticsObserver misses tab switches in go_router apps. Flutter Firebase Analytics screen tracking that logs one screen_view for each screen.

By Yevhen Beshkarov

  • firebase
  • go_router

The usual advice for Flutter Firebase Analytics screen tracking is to give FirebaseAnalyticsObserver to GoRouter. In an app with tabs, it misses screens. Going back to a tab the user has already opened logs nothing, and neither does closing a page that was opened over the tabs. A go() to a nested route logs every parent page as well, under names like b and c. I checked this on go_router 18.1.1 and firebase_analytics 13.0.0, with Flutter 3.44.2 and 3.47.4. A listener on the router delegate logs one screen view for each screen the user sees, in about 30 lines.

What FirebaseAnalyticsObserver logs with go_router tabs

The usual setup:

Dart
final router = GoRouter(
  observers: [FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance)],
  routes: [...],
);

My test app has two tabs in a StatefulShellRoute: /home, which has a child route details, and /settings. It also has a /profile page that opens over the tabs on the root navigator, a nested route /a/b/c, and /item/:id, which is named item. A fake FirebaseAnalytics recorded every call to logScreenView:

What the user doesWhat the observer logsWhat it should log
The app starts on Home/home/home
Opens the Settings tab for the first time/settings/settings
Goes back to the Home tabnothing/home
Opens the Settings tab againnothing/settings
go('/home/details')details/home/details
Goes back to Home/home/home
push('/profile') over the tabs/profile/profile
Goes back from Profilenothing/home
go('/a/b/c')/a, b, c/a/b/c
go('/item/42')itemitem

Users move between tabs all the time, so the reports undercount the screens behind the tabs. They also count parent pages that nobody saw.

Why FirebaseAnalyticsObserver misses tab switches

FirebaseAnalyticsObserver is a NavigatorObserver. It logs a screen view when a navigator pushes, replaces or pops a route, and at no other time. Each branch of a StatefulShellRoute has a navigator of its own, and that navigator stays alive in an IndexedStack while the user is on another tab. The first visit to a tab builds its navigator, which pushes the first page, so that visit is logged. Going back to the tab only changes which navigator is visible. No navigator pushes or pops anything.

Closing /profile pops it from the root navigator, and the observer then logs the route below it. That route is the page of the shell route. go_router names a page after the name of its route, or after its path when there is no name. A shell route has neither, so the observer gets null as the name and logs nothing.

The same goes for a page that your own pageBuilder returns. Tabs often use NoTransitionPage(child: ...), and that page has no name unless you pass one. With such a page on the Home tab, the observer never logged Home, not even when the app started. Pass name: state.name ?? state.path to the page, and it is logged like the others.

go('/a/b/c') builds the whole chain. go_router puts a page on the navigator for each of /a, b and c, so that back works, and the navigator reports a push for each of them. The names come from path, and the path of a child route is its own segment, not the full location. That is also why details lost its /home/.

Don't add an observer to each branch

Before go_router 17.0.0, the observers of GoRouter didn't hear the navigators of shell routes, so guides from that time give each ShellRoute or StatefulShellBranch observers of its own. Since 17.0.0, shell routes pass their navigation to the observers of GoRouter by default, through notifyRootObserver. With one observer on GoRouter and another on each branch, every screen in a tab is logged twice. Opening the app on Home and then the Settings tab logged this:

Text
/home, /home, /settings, /settings

Keep only the observer on GoRouter, or pass notifyRootObserver: false to the shell route.

Log screen views from the go_router delegate

The router knows what the user sees, whichever navigator it is in. GoRouter.routerDelegate notifies its listeners each time its configuration changes, and switching tabs counts. Its currentConfiguration ends with the page on top. This listener logs that page:

lib/screen_tracking.dart
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';

/// Logs a screen view each time the user sees another screen: another
/// page on top of any navigator of the router, or the same page at
/// another path.
void trackScreenViews(GoRouter router, FirebaseAnalytics analytics) {
  (LocalKey, String)? last;
  router.routerDelegate.addListener(() {
    final pages = router.routerDelegate.currentConfiguration;
    final top = pages.lastOrNull;
    final shown = switch (top) {
      ImperativeRouteMatch(:final matches) => matches, // a pushed page
      _ => pages,
    };
    if (top == null || shown.isError) {
      // The error screen. Its location can hold anything, such as a token.
      last = null;
      return;
    }
    final screen = (top.pageKey, shown.uri.path);
    if (screen == last) return;
    last = screen;
    final name = top.route.name ?? shown.fullPath;
    analytics.logScreenView(screenName: name).catchError(
      (Object error) => debugPrint('Screen view not logged: $error'),
      test: (error) => error is PlatformException,
    );
  });
}

Call it once, right after you create the router, and leave out observers:

Dart
final router = GoRouter(routes: [...]);
trackScreenViews(router, FirebaseAnalytics.instance);

The same steps with the listener:

What the user doesWhat the listener logs
The app starts on Home/home
Opens the Settings tab, then Home, then Settings/settings, /home, /settings
go('/home/details'), then goes back/home/details, /home
push('/profile'), then goes back/profile, /home
go('/a/b/c')/a/b/c
go('/item/42'), then go('/item/43')item, item
go('/home?tab=2') while on Homenothing

Screen names, duplicates and the error screen

The listener compares the page on top and its path with the last ones it logged. A pushed page gets a page key of its own, so pushing the same location twice logs it twice: the user saw it twice. /item/42 and /item/43 share a page key in go_router, and the path tells them apart. A change of the query alone, such as a filter, logs nothing.

A screen is named after its route, or after the full path pattern, such as /item/:id, when the route has no name. The location itself would make every item a screen of its own in the reports, and a location can hold an email address or a token. Since the listener reads routes rather than pages, a page from a pageBuilder needs no name.

When no route matches, the configuration is an error, and the location that failed can hold anything. The listener logs nothing for the error screen and forgets the last screen, so the screen the user goes back to is logged again.

Nothing waits for logScreenView, so a PlatformException would go to the handler of uncaught errors. The listener prints it instead, as FirebaseAnalyticsObserver does.

How SMF does Flutter Firebase Analytics screen tracking

I ran into this while building SMF, a free, open-source Flutter project generator. In every app with go_router and the Firebase Analytics module, the router tells listeners about each screen the user sees, and the module logs a screen view for each one, with no code in the app. Tests that run in the generated apps check that listeners hear about each screen once, tab switches included.

Firebase has a trap on iOS too, in the build phase that uploads symbols to Crashlytics: how to fix the flutterfire upload-crashlytics-symbols error.

Back to all articles

Release notes by email

Get an email when SMF or its modules have a new release.

We use Resend. You can unsubscribe at any time.