Chris Tam · Last updated 22 October 2026
A Flutter report can look correct in an event stream while the person using it still gets stuck. I use session replay to inspect that report interaction alongside its analytics events.
In this tutorial, I extend Flutter product analytics with a report-only recording window, synchronize the two SDKs' identifiers, and block a private note from capture.
Home keeps recording off. An explicit switch allows recording of the report screen. The report starts capture with the current analytics device and session IDs, uses conservative masking, and tears down its capture lifecycle on Back or backgrounding.
Prerequisites
- The Flutter analytics report app, with its one
AppAnalyticsclient, named screen observer and existingReportScreen. Keep the verified authentication adapter and analytics module. - Flutter 3.29.2 or later, Dart 3.7.2 or later, iOS 13+, or Android minSdk 21+ with compileSdk 36. If the app also uses Experiment 1.0.0, retain that page's higher Flutter 3.32/Dart 3.8 floor. Keep compatible generated native toolchain settings.
- The same project API key and region used by analytics. Enable project capture and a nonzero sample rate in Session Replay settings. Configure the required project masking level before capturing.
Sign up for free if you need an Amplitude account. Check your project's session replay entitlement and capture allowance before a production rollout.
1. Install the stable replay package
Use the current stable package from the Flutter replay reference:
Terminal, existing project root
flutter pub add amplitude_session_replay:1.0.1For iOS, retain the existing platform declaration of at least 13.0 and refresh pods. For Android, set the existing app module's minimum and compile SDK values without replacing its other Gradle settings.
ios/Podfile, existing platform declaration
platform :ios, '13.0'Terminal, project root
cd ios
pod install
cd ..android/app/build.gradle.kts, values inside the existing android block
compileSdk = 36
// Inside defaultConfig:
minSdk = 21Standard Flutter apps use automatic plugin registration. For an embedded/add-to-app integration, follow the separate registration instructions before creating platform views.
2. Configure the report capture lifecycle
In Amplitude's project settings, enable session capture, choose a sample rate above zero and set the masking level required by your app. Remote project settings take precedence over local sampling and masking, so set conservative masking in the project as well when that is the required policy.
Create the complete service below. It takes the project key and region directly from analytics, validates active identifiers, and uses SessionReplay.instance rather than constructing a second recorder.
lib/replay_capture.dart
import 'package:amplitude_flutter/constants.dart' as analytics;
import 'package:amplitude_session_replay/amplitude_session_replay.dart'
as replay;
import 'analytics.dart';
class ReportReplay {
ReportReplay(this.analyticsClient);
final AppAnalytics analyticsClient;
bool _initialized = false;
Future<void> _pending = Future<void>.value();
Future<void> _serial(Future<void> Function() work) {
final operation = _pending.then((_) => work());
_pending = operation.catchError((Object _) {});
return operation;
}
Future<void> start({required bool allowed}) => _serial(() async {
if (!allowed) {
await _dispose();
return;
}
final client = analyticsClient.client;
final deviceId = await client.getDeviceId();
final sessionId = await client.getSessionId();
if (deviceId == null ||
deviceId.length < 6 ||
sessionId == null ||
sessionId <= 0) {
await _dispose();
return;
}
if (_initialized) {
await replay.SessionReplay.instance.stop();
await replay.SessionReplay.instance.setDeviceId(deviceId);
await replay.SessionReplay.instance.setSessionId(sessionId);
if (await replay.SessionReplay.instance.deviceId() != deviceId ||
await replay.SessionReplay.instance.sessionId() != sessionId)
return;
} else {
replay.SessionReplay.instance.init(
replay.SessionReplayConfig(
apiKey: client.configuration.apiKey,
deviceId: deviceId,
sessionId: sessionId,
serverZone:
client.configuration.serverZone == analytics.ServerZone.eu
? replay.ServerZone.eu
: replay.ServerZone.us,
privacyConfig: replay.PrivacyConfig.conservative,
),
);
_initialized = true;
}
await replay.SessionReplay.instance.start();
});
// End this report's capture lifecycle before leaving or changing identity.
Future<void> end() => _serial(_dispose);
Future<void> _dispose() async {
if (!_initialized) return;
await replay.SessionReplay.instance.dispose();
_initialized = false;
}
}start requests recording; it does not prove that frames have been captured or uploaded. The local sample rate stays at its default while the project's remote configuration controls selection. A completed method future is not a processed replay receipt.
Why. Analytics can change the device ID at logout and the session ID at a session boundary. Recording must use the current pair. The service stops before updating either ID and reads the values back before restarting. A report that closes ends this capture lifecycle with dispose; the next opening initializes it again with current IDs.
3. Wrap the existing report screen
Add a wrapper that starts after the first report frame, tears down when the app backgrounds, and waits for teardown before a normal Back navigation. It also reads a changed permission value when its parent rebuilds it.
lib/report_replay_window.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'replay_capture.dart';
class ReportReplayWindow extends StatefulWidget {
const ReportReplayWindow({
super.key,
required this.capture,
required this.allowed,
required this.child,
});
final ReportReplay capture;
final bool allowed;
final Widget child;
@override
State<ReportReplayWindow> createState() => _ReportReplayWindowState();
}
class _ReportReplayWindowState extends State<ReportReplayWindow>
with WidgetsBindingObserver {
bool _leaving = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.capture.start(allowed: widget.allowed));
});
}
@override
void didUpdateWidget(covariant ReportReplayWindow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.allowed != widget.allowed) {
unawaited(widget.capture.start(allowed: widget.allowed));
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && !_leaving) {
unawaited(widget.capture.start(allowed: widget.allowed));
} else {
unawaited(widget.capture.end());
}
}
Future<void> _leave(bool didPop, Object? result) async {
if (didPop || _leaving) return;
_leaving = true;
await widget.capture.end();
if (!mounted) return;
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop(result);
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(widget.capture.end());
super.dispose();
}
@override
Widget build(BuildContext context) => PopScope(
canPop: _leaving,
onPopInvokedWithResult: _leave,
child: widget.child,
);
}Add these imports to the existing lib/main.dart. Retain the current analytics imports and any Experiment or engagement imports.
lib/main.dart, added imports
import 'package:amplitude_session_replay/amplitude_session_replay.dart' as replay;
import 'replay_capture.dart';
import 'report_replay_window.dart';The following complete root class demonstrates the switch and route wrapper. Add it beside the existing ReportScreen in the base analytics page. Retain that screen, its one preview event, and all application-specific routes. After existing analytics/auth initialization, change only runApp to use ReplayReportsApp.
lib/main.dart, root invocation after existing initialization
runApp(ReplayReportsApp(analytics: analytics));lib/main.dart, add the root class; retain existing ReportScreen
class ReplayReportsApp extends StatefulWidget {
const ReplayReportsApp({super.key, required this.analytics});
final AppAnalytics analytics;
@override
State<ReplayReportsApp> createState() => _ReplayReportsAppState();
}
class _ReplayReportsAppState extends State<ReplayReportsApp> {
late final capture = ReportReplay(widget.analytics);
bool allowed = false;
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Usage reports',
navigatorObservers: [AmplitudeNavigatorObserver(widget.analytics.client)],
routes: {
'/':
(context) => Scaffold(
appBar: AppBar(title: const Text('Usage reports')),
body: Column(
children: [
SwitchListTile(
title: const Text('Allow recording of the report screen'),
value: allowed,
onChanged: (value) => setState(() => allowed = value),
),
FilledButton(
onPressed: () => Navigator.pushNamed(context, '/report'),
child: const Text('Preview report'),
),
],
),
),
'/report':
(context) => ReportReplayWindow(
capture: capture,
allowed: allowed,
child: Stack(
children: [
ReportScreen(analytics: widget.analytics),
// Explicit blocking also protects sensitive images in this subtree.
const Positioned(
bottom: 24,
left: 24,
child: replay.AmpBlock(child: Text('Private report note')),
),
],
),
),
},
);
}The private note demonstrates AmpBlock. Wrap sensitive images and other sensitive subtrees in your actual report, because images do not receive automatic masking. Conservative text masking and explicit blocking serve different parts of the screen.
If flags are already installed, keep their report-opening decision and wrap the selected report widget in ReportReplayWindow inside the existing route builder. Keep its /report route name, retained variant and save action. Do not replace the flagged report with the base screen or add a second analytics observer.
4. Connect the existing consent and identity boundaries
The switch controls only this sample's replay window. In an application with existing consent, supply the current replay permission to allowed and await capture.end() when permission is revoked. Apply analytics consent to analytics separately. Keep restricted screens outside this report capture window.
Add an identity boundary so capture is torn down before switching accounts:
lib/replay_identity.dart
import 'replay_capture.dart';
// Return to Home and close engagement before calling this shared auth boundary.
Future<void> changeIdentityOutsideReport(
ReportReplay capture,
Future<void> Function() updateIdentity,
) async {
await capture.end();
await updateIdentity();
// The next report opening reads the new analytics IDs before starting capture.
}Return Home and close guides and surveys before invoking this boundary. Pass the application's existing async identity operation. For the analytics-only app, sign-out uses analytics.signedOut; an app with flags passes its coordinated reset/clear/refetch operation instead. The next report opening reads the new device/session pair. Do not call reset on an ordinary anonymous launch.
lib/main.dart or existing auth handler, after returning Home
await changeIdentityOutsideReport(capture, analytics.signedOut);Add import 'replay_identity.dart'; to the file that uses this handler. Keep real authentication and authorization in the app. A call to the analytics helper is not an application logout.
MCP. Use the Amplitude MCP server to inspect the authorized project's report events and identifiers while checking matching. Use replay playback to verify the recording itself.
5. Run and inspect a report recording
Build a fresh native target after adding the plugin. The project key is the same build-time value used by the base analytics app:
Terminal, project root
flutter run --dart-define=AMPLITUDE_API_KEY=your-project-api-keyPass --dart-define=AMPLITUDE_SERVER_ZONE=EU for an EU project. Restart after changing build-time configuration or native plugin dependencies.
Verify it worked
- Open the report with the recording switch off. Confirm the report works without starting a replay lifecycle.
- Return Home, allow recording and reopen the report. Interact with the report, then use Back. Check SDK diagnostics for initialization, the active IDs and upload errors.
- Confirm the analytics report event uses the same top-level device and session IDs as replay. Both SDKs must use the same project and region; a matching user ID alone is insufficient.
- In Amplitude, open the processed session replay associated with that test session. Confirm the report interaction appears. Check text masking and the blocked private note in playback, then verify sensitive image subtrees you added.
- Background the app, return, and open or resume the report. Check the current analytics session ID before capture resumes. Test an actual account switch through the app's auth handler and confirm the new report uses the new device identity.
- Revoke permission through the application's consent flow and confirm recording ends. Verify analytics consent separately if that flow controls both products.
Troubleshooting
The SDK starts but no replay appears
Check the project's capture toggle, sampling, entitlement, current IDs and region. The local default sample rate is zero and remote project settings control selection in this example. Confirm actual upload and processed playback rather than interpreting start() completion as success.
Events and replay do not match
Use the same project key, server zone, device ID and session ID. Await teardown through the auth boundary, then reopen the report. Read IDs again when the app resumes; do not save one device/session pair permanently at startup.
Private text or images remain visible
Remote masking can override the local level. Set the project policy correctly and inspect playback. Images need an explicit AmpBlock; text masking alone does not hide their content.
Hot reload does not attach the plugin
Stop and relaunch a native build after installing native dependencies. Preserve the iOS Podfile and compatible Android SDK settings. Embedded Flutter engines also need the documented plugin registration before platform views are created.
A report never starts recording after startup
The service deliberately declines to start without a device ID of at least six characters and a positive analytics session ID. Let analytics establish the active session and open the report again. Inspect initialization diagnostics if IDs remain unavailable.
FAQ
Does the example record the entire app?
It records only the report window after permission. Home and screens outside that wrapper do not start this capture lifecycle. Extend the boundary deliberately if other screens need recording.
Is a completed flush a replay link?
A flush requests upload of pending data. Inspect SDK errors and wait for processed playback in Amplitude to prove that a recording is usable.
Can I use this from a native host application?
This page uses Flutter-owned init and start. Native-host/add-to-app integrations use the documented attach lifecycle and registration requirements; keep the host as the recording owner in that setup.
Full code reference
Unchanged from Flutter product analytics; keep your versions: lib/analytics.dart, lib/auth.dart, the existing ReportScreen implementation, and native platform configuration. The lib/main.dart excerpt below contains added imports, the changed root invocation and the new root class. Retain the rest of the file and merge any existing flags or engagement additions.
// -- lib/replay_capture.dart --
import 'package:amplitude_flutter/constants.dart' as analytics;
import 'package:amplitude_session_replay/amplitude_session_replay.dart'
as replay;
import 'analytics.dart';
class ReportReplay {
ReportReplay(this.analyticsClient);
final AppAnalytics analyticsClient;
bool _initialized = false;
Future<void> _pending = Future<void>.value();
Future<void> _serial(Future<void> Function() work) {
final operation = _pending.then((_) => work());
_pending = operation.catchError((Object _) {});
return operation;
}
Future<void> start({required bool allowed}) => _serial(() async {
if (!allowed) {
await _dispose();
return;
}
final client = analyticsClient.client;
final deviceId = await client.getDeviceId();
final sessionId = await client.getSessionId();
if (deviceId == null ||
deviceId.length < 6 ||
sessionId == null ||
sessionId <= 0) {
await _dispose();
return;
}
if (_initialized) {
await replay.SessionReplay.instance.stop();
await replay.SessionReplay.instance.setDeviceId(deviceId);
await replay.SessionReplay.instance.setSessionId(sessionId);
if (await replay.SessionReplay.instance.deviceId() != deviceId ||
await replay.SessionReplay.instance.sessionId() != sessionId)
return;
} else {
replay.SessionReplay.instance.init(
replay.SessionReplayConfig(
apiKey: client.configuration.apiKey,
deviceId: deviceId,
sessionId: sessionId,
serverZone:
client.configuration.serverZone == analytics.ServerZone.eu
? replay.ServerZone.eu
: replay.ServerZone.us,
privacyConfig: replay.PrivacyConfig.conservative,
),
);
_initialized = true;
}
await replay.SessionReplay.instance.start();
});
// End this report's capture lifecycle before leaving or changing identity.
Future<void> end() => _serial(_dispose);
Future<void> _dispose() async {
if (!_initialized) return;
await replay.SessionReplay.instance.dispose();
_initialized = false;
}
}
// -- lib/report_replay_window.dart --
import 'dart:async';
import 'package:flutter/material.dart';
import 'replay_capture.dart';
class ReportReplayWindow extends StatefulWidget {
const ReportReplayWindow({
super.key,
required this.capture,
required this.allowed,
required this.child,
});
final ReportReplay capture;
final bool allowed;
final Widget child;
@override
State<ReportReplayWindow> createState() => _ReportReplayWindowState();
}
class _ReportReplayWindowState extends State<ReportReplayWindow>
with WidgetsBindingObserver {
bool _leaving = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.capture.start(allowed: widget.allowed));
});
}
@override
void didUpdateWidget(covariant ReportReplayWindow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.allowed != widget.allowed) {
unawaited(widget.capture.start(allowed: widget.allowed));
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && !_leaving) {
unawaited(widget.capture.start(allowed: widget.allowed));
} else {
unawaited(widget.capture.end());
}
}
Future<void> _leave(bool didPop, Object? result) async {
if (didPop || _leaving) return;
_leaving = true;
await widget.capture.end();
if (!mounted) return;
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop(result);
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(widget.capture.end());
super.dispose();
}
@override
Widget build(BuildContext context) => PopScope(
canPop: _leaving,
onPopInvokedWithResult: _leave,
child: widget.child,
);
}
// -- lib/replay_identity.dart --
import 'replay_capture.dart';
// Return to Home and close engagement before calling this shared auth boundary.
Future<void> changeIdentityOutsideReport(
ReportReplay capture,
Future<void> Function() updateIdentity,
) async {
await capture.end();
await updateIdentity();
// The next report opening reads the new analytics IDs before starting capture.
}
// -- lib/main.dart: add these imports; keep existing imports --
import 'package:amplitude_session_replay/amplitude_session_replay.dart' as replay;
import 'replay_capture.dart';
import 'report_replay_window.dart';
// -- lib/main.dart: use this root after existing analytics/auth initialization --
// runApp(ReplayReportsApp(analytics: analytics));
// -- lib/main.dart: add this root; retain the existing ReportScreen class --
class ReplayReportsApp extends StatefulWidget {
const ReplayReportsApp({super.key, required this.analytics});
final AppAnalytics analytics;
@override
State<ReplayReportsApp> createState() => _ReplayReportsAppState();
}
class _ReplayReportsAppState extends State<ReplayReportsApp> {
late final capture = ReportReplay(widget.analytics);
bool allowed = false;
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Usage reports',
navigatorObservers: [AmplitudeNavigatorObserver(widget.analytics.client)],
routes: {
'/':
(context) => Scaffold(
appBar: AppBar(title: const Text('Usage reports')),
body: Column(
children: [
SwitchListTile(
title: const Text('Allow recording of the report screen'),
value: allowed,
onChanged: (value) => setState(() => allowed = value),
),
FilledButton(
onPressed: () => Navigator.pushNamed(context, '/report'),
child: const Text('Preview report'),
),
],
),
),
'/report':
(context) => ReportReplayWindow(
capture: capture,
allowed: allowed,
child: Stack(
children: [
ReportScreen(analytics: widget.analytics),
// Explicit blocking also protects sensitive images in this subtree.
const Positioned(
bottom: 24,
left: 24,
child: replay.AmpBlock(child: Text('Private report note')),
),
],
),
),
},
);
}Hand it to your agent
Coding-agent prompt
Extend the existing Flutter report app using https://amplitude.com/build/flutter-session-replay.
Use amplitude_session_replay 1.0.1 with Flutter >=3.29.2, Dart >=3.7.2, iOS >=13 or Android minSdk 21/compileSdk 36. Retain higher runtime floors from other installed hub packages. Keep the existing analytics client, auth adapter, report preview event and named screen observer.
Use SessionReplay.instance, initialize synchronously once per capture lifecycle, and call start only after replay permission and valid current analytics IDs. Reuse the analytics project key and region. Keep remote configuration and configure nonzero project sampling plus the required masking policy in Amplitude.
Add ReportReplay and ReportReplayWindow. Capture only the report, with Home initially off. Tear down the report lifecycle on Back/backgrounding and reinitialize with current IDs on the next opening or resume. On an active resync stop, update device/session IDs and verify matching values before restarting.
Use conservative masking and AmpBlock for private subtrees including sensitive images. Inspect actual playback; future completion is not recording or ingestion proof. Apply consent separately to Analytics and Replay.
Preserve the selected flag layout and save action if installed. Wrap the existing report widget rather than replacing it with another hub's screen. Keep route names, observers, imports and every unrelated application route.
At identity changes, return Home, close engagement if installed, end replay capture, then run the existing auth/analytics reset and flag clear/refetch coordinator. The next report reads fresh IDs. Keep actual logout separate from analytics reset.
Verify no recording before permission, both IDs and region, foreground/background and account transitions, masking and processed replay playback. Report all files changed and exactly which checks ran.What this unlocks
Session replay: inspect the interaction behind a report preview.
Product analytics: connect the report event to a recording using the same device and session.
Experimentation: inspect report interactions while retaining the layout selected by the existing flag flow.