DEV Community

Yevhen Beshkarov
Yevhen Beshkarov

Posted on Originally published at saymyframe.com on

Flutter Firebase Analytics screen tracking with 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:

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

Enter fullscreen mode Exit fullscreen mode

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 does What the observer logs What 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 tab nothing /home
Opens the Settings tab again nothing /settings
go('/home/details') details /home/details
Goes back to Home /home /home
push('/profile') over the tabs /profile /profile
Goes back from Profile nothing /home
go('/a/b/c') /a, b, c /a/b/c
go('/item/42') item item

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:

/home, /home, /settings, /settings

Enter fullscreen mode Exit fullscreen mode

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:

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,
    );
  });
}

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

The same steps with the listener:

What the user does What 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 Home nothing

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.

Update: Firebase Analytics logs screen views of its own

Added on 25 October 2026. After this article went out, Ahmed ZNOUDA pointed out two more things that skew screen counts in a comment below, and asked about redirects. Thanks, Ahmed.

Turn off automatic screen reporting

Firebase Analytics also logs screen views by itself. They name the activity or the view controller rather than a screen, and a Flutter app has one of each: MainActivity on Android and FlutterViewController on iOS. I ran a test app with the debug logging of Firebase Analytics on an Android 12 emulator and an iOS simulator (firebase_analytics 12.6.1). On both, every cold start logged one of these views with no screen name, right before the listener's home.home. On Android, the app logged another one each time it came back from the background. With the listener in place, turn them off. On Android, inside <application> of android/app/src/main/AndroidManifest.xml:

<meta-data
    android:name="google_analytics_automatic_screen_reporting_enabled"
    android:value="false" />
Enter fullscreen mode Exit fullscreen mode

On iOS, in the top-level dictionary of ios/Runner/Info.plist:

<key>FirebaseAutomaticScreenReportingEnabled</key>
<false/>
Enter fullscreen mode Exit fullscreen mode

With both keys, the same runs logged only the listener's screen view at the start, and no screen view when the app came back from the background.

Bottom sheets and dialogs

showModalBottomSheet and showDialog push a route without a page. The configuration of the router stays the same, so the listener logs nothing when the sheet opens, and nothing for the page below when it closes. FirebaseAnalyticsObserver leaves them out too, because by default it only logs a PageRoute. If a sheet is a screen you want in the reports, such as a paywall or a consent sheet, log it yourself when you show it:

FirebaseAnalytics.instance.logScreenView(screenName: 'paywall_sheet');
await showModalBottomSheet<void>(context: context, builder: buildPaywall);
Enter fullscreen mode Exit fullscreen mode

Redirects

Redirects don't let a screen slip through. go_router runs redirect while it parses a location, before the delegate gets the new configuration. I checked it on go_router 18.1.1: an app that starts at /home with an auth redirect to /login logs only /login. Signing in then logs /home once, and signing out logs /login once.

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.

Top comments (2)

Collapse
 
ahmedznouda profile image
Ahmed ZNOUDA •

The table of what the observer logs versus what it should is the best part. /a, b, c for one screen is the kind of thing nobody notices until a report looks odd.

Two related things that inflate screen counts, in case they're worth a line:

Firebase also logs screen views on its own, per Activity. In a Flutter app that's a MainActivity screen_view on every launch, next to yours. Setting google_analytics_automatic_screen_reporting_enabled to false in the manifest meta-data (and FirebaseAutomaticScreenReportingEnabled to NO in Info.plist) leaves only the listener's views.

And bottom sheets and dialogs: showModalBottomSheet pushes a route without a page, so it isn't in currentConfiguration and the listener won't see it (nor did the observer, with no name). For a paywall or consent sheet, that's often the screen you most want counted. Do you log those by hand with logScreenView, or leave them out on purpose?

One question on redirects: with an auth redirect from /home to /login, does the delegate notify once with the final configuration, or can a /home view slip through first?

Collapse
 
ybeshkarov profile image
Yevhen Beshkarov • • Edited

Thanks, Ahmed, you were right on both.

I checked the automatic screen views on an Android 12 emulator and an iPhone simulator with the debug logging of Firebase Analytics. Every cold start logged a screen_view with the screen class MainActivity (FlutterViewController on iOS) and no screen name, right before the listener's own view. On Android there was one more each time the app came back from the background. With the two keys you mentioned, only the listener's view was left. The apps that SMF generates didn't turn them off either. That's fixed in a PR that goes out with the next release, and it credits your comment.

As for sheets and dialogs, I don't log them by hand yet. The router's configuration doesn't change when one opens or closes, so the listener logs nothing, and FirebaseAnalyticsObserver skips them too because by default it only logs a PageRoute. So in SMF the paywall that the onboarding pushes as a route without a page isn't counted. For a screen like that I'd call logScreenView when it's shown.

And on redirects, /home doesn't slip through. go_router runs the redirect while it parses the location, before the delegate gets the new configuration. I checked it on go_router 18.1.1: starting at /home while signed out, the listener only sees /login. Signing in then gives /home once, and signing out /login once.

I've added an update to the post with all three. Thanks again for the comment!