How to add product analytics to a Flutter app

Track named screen navigation and report previews, then connect those events to the app's signed-in user. Allow about 20 minutes.

  • Flutter 3.29+
  • iOS and Android
  • Flutter SDK 4
  • 20 min

Chris Tam · Last updated 21 October 2026

Flutter builds mobile screens from widgets, so navigation events need the names of Flutter routes. I use product analytics to connect those screens with the actions people take inside the app, including opening a report.

In this tutorial, I initialize one analytics client, attach the screen observer, record a report preview, and connect the app's existing authenticated identity without replacing its login system.

An iOS or Android app with Home and Report screens. Named navigation produces [Amplitude] Screen Viewed, and opening the report records Report Previewed with report_type, days, and framework. Signed-in events use the same user ID as the backend.

Prerequisites

  • A Flutter mobile project on Flutter 3.29 or later and Dart 3.7 or later, with an iOS simulator/device or Android emulator/device configured. This page targets amplitude_flutter 4.7.3. The three Dart files below are complete; merge shared initialization and route changes into an existing app.
  • A working native toolchain. iOS needs Xcode and CocoaPods; Android needs the Android SDK and the compatible Gradle/Kotlin versions in the SDK compatibility reference. Set the iOS deployment target to at least 13.0.
  • An Amplitude project API key: Settings > Projects > your project > General, then Manage next to API Key. Use the key for the region where the project lives.

No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. Sign up for free to get your project API key.

1. Install the Flutter SDK package

Use the current Flutter SDK 4 package from the installation reference. These examples pin the package version so the source and app agree:

Terminal, existing project root

Terminal, existing project root
flutter pub add amplitude_flutter:4.7.3

For iOS, keep the generated Podfile and set its existing platform declaration to:

ios/Podfile, platform declaration

ios/Podfile, platform declaration
platform :ios, '13.0'

Then update the native dependencies:

Terminal, existing project root

Terminal, existing project root
cd ios
pod install
cd ..

Keep the rest of the generated platform configuration. This guide uses the native iOS/Android SDK path; Flutter Web has a separate Browser SDK setup.

2. Create the analytics and identity modules

Keep the application user model separate from analytics. The default session adapter returns null, so the sample runs anonymously until connected to existing verified authentication.

lib/auth.dart

lib/auth.dart
class AppUser {
  const AppUser({required this.id, required this.plan});
  final String id;
  final String plan;
}

Future<AppUser?> restoreSignedInUser() async {
  // Connect the existing verified session here. No session means anonymous.
  return null;
}

Create one analytics service. It waits for SDK initialization before exposing the client, uses the region passed from the app, and enables screen capture explicitly.

lib/analytics.dart

lib/analytics.dart
// Keep additions from later Flutter flags, replay, and guides pages.
import 'package:amplitude_flutter/amplitude.dart';
import 'package:amplitude_flutter/autocapture/autocapture.dart';
import 'package:amplitude_flutter/configuration.dart';
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/events/base_event.dart';
import 'package:amplitude_flutter/events/identify.dart';
import 'package:flutter/foundation.dart';

import 'auth.dart';

class AppAnalytics {
  AppAnalytics._(this.client);
  final Amplitude client;

  static Future<AppAnalytics> create({
    required String apiKey,
    ServerZone serverZone = ServerZone.us,
  }) async {
    if (apiKey.trim().isEmpty) {
      throw ArgumentError('Set AMPLITUDE_API_KEY with --dart-define.');
    }
    final client = Amplitude(
      Configuration(
        apiKey: apiKey,
        serverZone: serverZone,
        autocapture: const AutocaptureOptions(
          sessions: true,
          appLifecycles: true,
          screenViews: true,
        ),
      ),
    );
    if (!await client.isBuilt) {
      throw StateError('Amplitude initialization did not complete.');
    }
    return AppAnalytics._(client);
  }

  Future<void> signedIn(AppUser user) async {
    if (user.id.length < 5) {
      throw ArgumentError('Use the same stable ID of at least 5 characters.');
    }
    await client.setUserId(user.id);
    await client.identify(Identify()..set('plan', user.plan));
  }

  Future<void> signedOut() async {
    // Later hub pages extend this with their cleanup before reset/refetch.
    await client.reset();
  }

  Future<void> reportPreviewed() async {
    try {
      await client.track(
        BaseEvent(
          'Report Previewed',
          eventProperties: {
            'report_type': 'usage',
            'days': 7,
            'framework': 'flutter',
          },
        ),
      );
    } catch (_) {
      // An analytics failure should not block the report UI.
      debugPrint('The report analytics event could not be queued.');
    }
  }

  Future<void> flush() => client.flush();
}

Call signedIn after the application confirms a login, with its canonical user ID. Keep that ID identical to backend instrumentation; prefix short internal IDs consistently on both sides. Identify sets the plan before later events so it can be used in charts.

After the application's real sign-out completes, await signedOut. The SDK clears the user ID and generates a fresh device ID. Do not reset on every anonymous startup, because the existing device identity connects anonymous visits.

Why. The service is passed to screens instead of created during widget builds. Rebuilding a widget should not initialize a second analytics client or change identity. Authentication remains the application's job; these methods only synchronize analytics.

3. Connect named routes to the same client

Initialize Flutter bindings first, create analytics once, restore any verified signed-in account, and then run the app. Keep the root app, routes, observers, and authentication wiring from other pages when merging this code.

lib/main.dart

lib/main.dart
// Keep initialization, routes, and observers added by later Flutter pages.
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/observers/amplitude_navigator_observer.dart';
import 'package:flutter/material.dart';

import 'analytics.dart';
import 'auth.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  const apiKey = String.fromEnvironment('AMPLITUDE_API_KEY');
  const zone = String.fromEnvironment(
    'AMPLITUDE_SERVER_ZONE',
    defaultValue: 'US',
  );
  final analytics = await AppAnalytics.create(
    apiKey: apiKey,
    serverZone: zone == 'EU' ? ServerZone.eu : ServerZone.us,
  );
  final user = await restoreSignedInUser();
  if (user != null) await analytics.signedIn(user);
  runApp(ReportsApp(analytics: analytics));
}

class ReportsApp extends StatelessWidget {
  const ReportsApp({super.key, required this.analytics});
  final AppAnalytics analytics;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Usage reports',
      navigatorObservers: [AmplitudeNavigatorObserver(analytics.client)],
      routes: {
        '/':
            (context) => Scaffold(
              appBar: AppBar(title: const Text('Usage reports')),
              body: Center(
                child: FilledButton(
                  onPressed: () => Navigator.pushNamed(context, '/report'),
                  child: const Text('Preview report'),
                ),
              ),
            ),
        '/report': (context) => ReportScreen(analytics: analytics),
      },
    );
  }
}

class ReportScreen extends StatefulWidget {
  const ReportScreen({super.key, required this.analytics});
  final AppAnalytics analytics;

  @override
  State<ReportScreen> createState() => _ReportScreenState();
}

class _ReportScreenState extends State<ReportScreen> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) widget.analytics.reportPreviewed();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Seven-day usage')),
      body: const Center(child: Text('Your usage report is ready.')),
    );
  }
}

Both screenViews: true and AmplitudeNavigatorObserver are needed. The observer reads RouteSettings.name; these named routes provide / and /report. It records route navigation, including Back, without an extra manual screen event.

The report event runs once after that Report screen's first frame. Rebuilding the same widget does not record another preview; leaving and reopening the route is a new preview. In an existing app, place the event after the real report is ready rather than tracking it from every build() call.

MCP. With existing project access, use the Amplitude MCP server to inspect the event taxonomy before naming report events. Reuse Report Previewed if it is already the app's event rather than adding a near-duplicate name.

4. Run the app with the project key

List the available mobile targets, then start the app with a build-time configuration value. Select the configured iOS or Android target if Flutter prompts.

Terminal, project root

Terminal, project root
flutter devices
flutter run --dart-define=AMPLITUDE_API_KEY=your-project-api-key

For an EU project, pass both the EU project key and the region:

Terminal, EU project

Terminal, EU project
flutter run --dart-define=AMPLITUDE_API_KEY=your-eu-project-api-key --dart-define=AMPLITUDE_SERVER_ZONE=EU

Restart the app after changing build-time defines. The Dart code deliberately rejects an empty key rather than initializing an unusable client.

The SDK batches events. For a deliberate development upload, await the service's flush() from a debugger or development action after opening the report. A completed platform method is not an ingestion receipt, and force-closing an app can interrupt pending work.

Verify it worked

  1. Open Home, tap Preview report, then go Back. The app should show the report and return to Home without another client initialization. Repeat the navigation to create a second report preview.
  2. On the device, check the SDK's delivery/network diagnostics. Allow the normal batching interval, or request a development flush. Separate a queued Dart call from an actual native upload; a UI response alone does not prove delivery.
  3. In Amplitude's Ingestion Debugger, check successful requests, events, Identify counts, and errors. Use Users > User Profiles to find the same user or device and inspect the event stream.
  4. Confirm [Amplitude] Screen Viewed has screen names /, /report, and / in sequence. Confirm Report Previewed contains report_type: usage, days: 7, and framework: flutter. One opening produces one custom report event.
  5. Connect the session adapter to the app's verified auth, sign in, and open another report. Confirm the canonical user ID and plan on subsequent events. Complete the app's sign-out, call signedOut, and confirm later activity is anonymous on a new device identity.

Troubleshooting

The app reports a missing project key

Pass AMPLITUDE_API_KEY with --dart-define and restart. A shell variable alone does not become a String.fromEnvironment value unless it is passed into the Flutter build.

Initialization returns false

Wait for isBuilt and inspect the native plugin/toolchain error. Stop and relaunch after adding a plugin; hot reload does not install its native components. A simulator/device build is needed to validate native integration.

Enable screenViews, attach the observer to the active Navigator, and give routes names. Anonymous routes are skipped. If your application uses a different router, connect its observer integration and preserve its route names rather than replacing it with this sample's routes.

Report previews are counted more than once

Keep tracking out of build(). This sample records after the route's first frame. Do not add another identical event in the navigation button and the destination screen.

Signed-out events still use the previous account

Call reset() through signedOut() after the application's sign-out and before allowing another person's activity. Setting a shared user ID like anonymous does not anonymize users.

Identify shows on a profile but not an earlier event

Identify affects later events. Set the plan before tracking the next report preview, then inspect that new event rather than expecting prior events to change.

FAQ

Does this start session recording?

This page initializes the analytics SDK. Session replay uses the separate Flutter replay package and setup; adding this analytics service does not start recording.

Does the default auth adapter sign anyone in?

It returns no user until connected to the application's verified session. The sample still records anonymous product events. Do not use an email typed into a form as proof of authentication.

Do I need to track screen views manually too?

Use the observer for the named routes shown here. Adding a second manual event for the same navigation duplicates screen measurement. A custom event such as Report Previewed measures a different action.

Full code reference

These are the same three files used above. Keep the existing generated platform configuration, app-specific authentication, and route additions from other Flutter pages.

Full code reference
// -- lib/auth.dart --
class AppUser {
  const AppUser({required this.id, required this.plan});
  final String id;
  final String plan;
}

Future<AppUser?> restoreSignedInUser() async {
  // Connect the existing verified session here. No session means anonymous.
  return null;
}

// -- lib/analytics.dart --
// Keep additions from later Flutter flags, replay, and guides pages.
import 'package:amplitude_flutter/amplitude.dart';
import 'package:amplitude_flutter/autocapture/autocapture.dart';
import 'package:amplitude_flutter/configuration.dart';
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/events/base_event.dart';
import 'package:amplitude_flutter/events/identify.dart';
import 'package:flutter/foundation.dart';

import 'auth.dart';

class AppAnalytics {
  AppAnalytics._(this.client);
  final Amplitude client;

  static Future<AppAnalytics> create({
    required String apiKey,
    ServerZone serverZone = ServerZone.us,
  }) async {
    if (apiKey.trim().isEmpty) {
      throw ArgumentError('Set AMPLITUDE_API_KEY with --dart-define.');
    }
    final client = Amplitude(
      Configuration(
        apiKey: apiKey,
        serverZone: serverZone,
        autocapture: const AutocaptureOptions(
          sessions: true,
          appLifecycles: true,
          screenViews: true,
        ),
      ),
    );
    if (!await client.isBuilt) {
      throw StateError('Amplitude initialization did not complete.');
    }
    return AppAnalytics._(client);
  }

  Future<void> signedIn(AppUser user) async {
    if (user.id.length < 5) {
      throw ArgumentError('Use the same stable ID of at least 5 characters.');
    }
    await client.setUserId(user.id);
    await client.identify(Identify()..set('plan', user.plan));
  }

  Future<void> signedOut() async {
    // Later hub pages extend this with their cleanup before reset/refetch.
    await client.reset();
  }

  Future<void> reportPreviewed() async {
    try {
      await client.track(
        BaseEvent(
          'Report Previewed',
          eventProperties: {
            'report_type': 'usage',
            'days': 7,
            'framework': 'flutter',
          },
        ),
      );
    } catch (_) {
      // An analytics failure should not block the report UI.
      debugPrint('The report analytics event could not be queued.');
    }
  }

  Future<void> flush() => client.flush();
}

// -- lib/main.dart --
// Keep initialization, routes, and observers added by later Flutter pages.
import 'package:amplitude_flutter/constants.dart';
import 'package:amplitude_flutter/observers/amplitude_navigator_observer.dart';
import 'package:flutter/material.dart';

import 'analytics.dart';
import 'auth.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  const apiKey = String.fromEnvironment('AMPLITUDE_API_KEY');
  const zone = String.fromEnvironment(
    'AMPLITUDE_SERVER_ZONE',
    defaultValue: 'US',
  );
  final analytics = await AppAnalytics.create(
    apiKey: apiKey,
    serverZone: zone == 'EU' ? ServerZone.eu : ServerZone.us,
  );
  final user = await restoreSignedInUser();
  if (user != null) await analytics.signedIn(user);
  runApp(ReportsApp(analytics: analytics));
}

class ReportsApp extends StatelessWidget {
  const ReportsApp({super.key, required this.analytics});
  final AppAnalytics analytics;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Usage reports',
      navigatorObservers: [AmplitudeNavigatorObserver(analytics.client)],
      routes: {
        '/':
            (context) => Scaffold(
              appBar: AppBar(title: const Text('Usage reports')),
              body: Center(
                child: FilledButton(
                  onPressed: () => Navigator.pushNamed(context, '/report'),
                  child: const Text('Preview report'),
                ),
              ),
            ),
        '/report': (context) => ReportScreen(analytics: analytics),
      },
    );
  }
}

class ReportScreen extends StatefulWidget {
  const ReportScreen({super.key, required this.analytics});
  final AppAnalytics analytics;

  @override
  State<ReportScreen> createState() => _ReportScreenState();
}

class _ReportScreenState extends State<ReportScreen> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) widget.analytics.reportPreviewed();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Seven-day usage')),
      body: const Center(child: Text('Your usage report is ready.')),
    );
  }
}

Hand it to your agent

Coding-agent prompt

Coding-agent prompt
Add Amplitude product analytics to this Flutter iOS/Android app using https://amplitude.com/build/flutter-product-analytics.
Target amplitude_flutter 4.7.3. Merge initialization/routes with the existing app instead of replacing app-specific code. Keep future flags, replay, and guides additions in shared files.
Call WidgetsFlutterBinding.ensureInitialized, create one Amplitude client from Configuration using AMPLITUDE_API_KEY passed via --dart-define, and await isBuilt. Check false initialization explicitly. Honor the project region through ServerZone.
Enable sessions, appLifecycles and screenViews through AutocaptureOptions. Attach AmplitudeNavigatorObserver to the active Navigator and preserve route names. Do not manually duplicate its screen events.
Keep the existing verified auth layer. Set the same canonical user ID as the backend after sign-in, reject IDs shorter than five characters, and Identify plan before later events. On anonymous startup keep the existing device ID. After actual sign-out call reset before another user's activity; merge later hub cleanup in the documented order.
Track Report Previewed only when the report is ready, once per opening, with report_type=usage, days=7 and framework=flutter. Do not track from every widget build.
Preserve native platform settings, iOS 13+ and compatible Android tools. Verify native plugin integration and remote ingestion on a device separately from widget/method-channel tests. Report every file changed.

What this unlocks

Product analytics: compare report previews by plan and follow the named screen journey.

Feature flags: add a later report variant while reusing the same user identity and preview event.

Session replay: connect recordings in a later setup using shared analytics device and session IDs.

Next steps

Read the report journey

Open a report, inspect its events, and compare how signed-in and anonymous users move through the app.