skip to content

In a Flutter app using firebase_analytics, why are screen views missing, and how do FirebaseAnalyticsObserver and logScreenView fix it?

level: middleimportance: should knowfreq 38%

answer

  1. one native screen hosts every route
  2. observer reads RouteSettings.name
  3. null name: nothing is sent
  4. default filter: PageRoute only
  5. manual logScreenView for tabs

basics

~10 s

Automatic screen tracking sees only the one native screen hosting Flutter, so routes need FirebaseAnalyticsObserver, which calls logScreenView with each PageRoute's RouteSettings.name, or manual logScreenView calls for tabs and unnamed routes.

solid answer

~40 s

Analytics' automatic screen tracking works at the native level, and a Flutter app draws every route inside one native activity or view controller, so route changes are invisible to it. `FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance)` is a `RouteObserver` you add to `navigatorObservers`, or to `GoRouter(observers: ...)`. On push and replace it calls `logScreenView(screenName: ...)` with the name from `nameExtractor`, which defaults to `RouteSettings.name`. On pop it reports the route you return to. A route with a `null` name sends nothing, and the default `routeFilter` only accepts `PageRoute`, so dialogs and bottom sheets are skipped. Tabs, `PageView` pages and nested navigators need a manual `logScreenView(screenName: 'lesson_quiz')` or their own observer. Platform errors go to `onError`, or to `debugPrint` if you omit it.

code

dart · 33 lines
dart
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';

final analytics = FirebaseAnalytics.instance;

String? screenNameFor(RouteSettings settings) {
  final name = settings.name;
  if (name == null) return null;
  // Strip IDs so /lesson/es-a1-07 reports as lesson_player.
  return name.startsWith('/lesson/') ? 'lesson_player' : name;
}

class LinguaApp extends StatelessWidget {
  const LinguaApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorObservers: [
        FirebaseAnalyticsObserver(
          analytics: analytics,
          nameExtractor: screenNameFor,
        ),
      ],
      home: const Scaffold(),
    );
  }
}

void onPracticeTabChanged(int index) {
  // Tabs push no route, so the observer never sees them.
  analytics.logScreenView(screenName: index == 0 ? 'practice_cards' : 'practice_quiz');
}

go deeper

for a junior

Remember to add FirebaseAnalyticsObserver to navigatorObservers, and that routes need a name for a screen view to be sent.

for a middle

Explain why native automatic tracking sees one screen, and how the observer's nameExtractor, PageRoute-only routeFilter and didPop behaviour decide what is logged.

for a senior

Show how you cover nested navigators, tabs and ID-bearing paths, and how you verify screen names before release so reports and crash breadcrumbs stay meaningful.

for a principal

Define a screen-naming convention across teams and routers so screen reports, funnels and crash breadcrumbs stay comparable as navigation is refactored.

## Why automatic tracking falls short Google Analytics can log `screen_view` automatically by watching native screens. A Flutter app renders all its UI itself, inside **one** native activity on Android or view controller on iOS. From the native SDK's point of view, the user never leaves that one screen. The lesson list, the lesson player and the results page are all Flutter routes it cannot see. So a Flutter app reports screens from Dart, through **firebase_analytics**. ## Two tools | Tool | What it does | Best for | |---|---|---| | `FirebaseAnalyticsObserver` | a `RouteObserver<ModalRoute<dynamic>>` that logs on navigator changes | every page pushed on a `Navigator` | | `logScreenView(screenName:, screenClass:, parameters:)` | logs one `screen_view` event | tabs, `PageView`, sub-views and anything the observer misses | `logScreenView` sends the standard `screen_view` event with `screen_name` and `screen_class` parameters. ## How the observer decides what to send `FirebaseAnalyticsObserver({required analytics, nameExtractor, routeFilter, onError})`: 1. **`didPush`**: if `routeFilter(route)` passes, it logs the pushed route. 2. **`didReplace`**: logs the new route if it passes the filter. 3. **`didPop`**: if both the popped route and the route underneath pass the filter, it logs the route underneath, the one now visible. 4. **Name**: `nameExtractor(route.settings)`, by default `settings.name`. A `null` name means **no event at all**. 5. **Filter**: `defaultRouteFilter` returns `true` only for `PageRoute`. Dialogs and modal bottom sheets are popup routes, so they are not logged, and popping one does not re-log the page under it. 6. **Errors**: a `PlatformException` from logging goes to `onError`, or to `debugPrint` when you did not pass one. ## Common reasons screens are missing - **Unnamed routes.** `Navigator.push(MaterialPageRoute(builder: ...))` without `settings: RouteSettings(name: 'lesson_player')` has a `null` name, so nothing is logged. Either name your routes or pass a `nameExtractor` that derives a name. - **The router's observer list.** With go_router, the observer goes in `GoRouter(observers: [...])`. Pages go_router builds from `builder:` are named after the route's `name`, or its path when it has none. Pages you build yourself in `pageBuilder` carry whatever `name` you give them. Nested navigators, such as a shell route, have their own observer lists. - **One observer instance, one navigator.** A `RouteObserver` tracks one navigator's routes, so create a separate observer for each nested navigator you want tracked. - **Tabs don't navigate.** Switching a `TabBar`, a bottom navigation index or a `PageView` page pushes no route. Call `logScreenView` in the tab-change callback. ## Naming screens well - Prefer stable, human-readable names, such as `lesson_player`, over paths that contain IDs, such as `/lesson/es-a1-07`. IDs explode the number of distinct screen names in reports. - Put the ID in a parameter instead, for example through `logScreenView(screenName: 'lesson_player', parameters: {'lesson_id': id})`. - Keep screen names in constants, just like event names. ## Screen views and crash breadcrumbs Crashlytics builds its breadcrumb logs from Analytics events, including `screen_view`. Accurate screen tracking therefore also tells you which screens a user visited before a crash. That is a concrete reason to fix missing screen views even if nobody reads the screen reports.

  • A user opens a dialog over the lesson screen and closes it. Does FirebaseAnalyticsObserver log the lesson screen again?
    No. The default `routeFilter` accepts only `PageRoute`, and `didPop` logs the route underneath only when both routes pass the filter. A dialog is a popup route, so neither the dialog nor the return to the lesson screen is logged. Pass a custom `routeFilter` if you do want dialogs tracked.
  • Why not call logScreenView inside each screen's build method?
    `build` can run many times per visit: on every `setState`, theme change or parent rebuild. Each run would log another `screen_view` and inflate counts. Log from navigation events (the observer), from lifecycle points that run once per visit, or from explicit tab-change callbacks.

saying these in an interview costs you the question

  • Analytics tracks every Flutter route automatically, like native screens.
  • FirebaseAnalyticsObserver logs routes that have no name under a default label.
  • Dialogs and bottom sheets are logged as screens by default.
  • Switching tabs triggers the observer because the UI changes.
  • Logging screen_view from build() is the simplest correct place.