From bfc31ace6d13fd9bab01869ecc47cfd8af04a487 Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Mon, 28 Sep 2026 11:47:09 +0400 Subject: [PATCH 1/3] fix(replay): stop session replay when the user opts out disable() left the Flutter screenshot loop running after opt-out. Stop it before the platform opt-out, and start a new recording on opt-in only if a recording was actually stopped. --- .changeset/stop-replay-on-opt-out.md | 5 + posthog_flutter/lib/src/posthog.dart | 33 ++++- .../test/opt_out_session_replay_test.dart | 125 ++++++++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 .changeset/stop-replay-on-opt-out.md create mode 100644 posthog_flutter/test/opt_out_session_replay_test.dart diff --git a/.changeset/stop-replay-on-opt-out.md b/.changeset/stop-replay-on-opt-out.md new file mode 100644 index 00000000..0ce3f6f8 --- /dev/null +++ b/.changeset/stop-replay-on-opt-out.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Stop Flutter session replay when `optOut()` opts the user out, and start a new recording on `optIn()` if a recording was actually stopped. diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 8abd1018..71166b97 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -27,6 +27,11 @@ class Posthog { PostHogConfig? _config; + /// Set when [optOut] stops an in-progress Flutter replay. [optIn] consumes + /// it so opt-in starts a new recording, and a replay the caller already + /// stopped is left stopped. + bool _restartSessionReplayOnEnable = false; + /// Returns the singleton PostHog client instance. factory Posthog() { return _instance; @@ -71,6 +76,7 @@ class Posthog { } _config = config; // Store the config + _restartSessionReplayOnEnable = false; // The mask controller singleton may predate this setup() (or a previous // setup() built it with different masking flags); without a refresh the @@ -378,24 +384,42 @@ class Posthog { /// Opts the current user out of data collection. /// + /// Stops Flutter session replay before the platform opt-out. Native + /// `stopSessionRecording` is a no-op once the SDK is already opted out, so + /// the stop has to happen first. Error autocapture is uninstalled as well. + /// /// Returns a [Future] that completes when the opt-out request has been queued. - Future optOut() { + Future optOut() async { // Uninstall Flutter-specific integrations when opting out _uninstallFlutterIntegrations(); + if (PostHogInternalEvents.sessionRecordingActive.value) { + _restartSessionReplayOnEnable = true; + await stopSessionRecording(); + } - return _posthog.disable(); + await _posthog.disable(); } /// Opts the current user back in to data collection. /// + /// Reinstalls Flutter error autocapture. If [optOut] stopped an active + /// session replay, this starts a new recording after the platform opt-in. + /// Starting first would no-op, because the native SDKs ignore + /// `startSessionRecording` while still opted out. + /// /// Returns a [Future] that completes when the opt-in request has been queued. - Future optIn() { + Future optIn() async { final config = _config; if (config != null) { _installFlutterIntegrations(config); } - return _posthog.enable(); + await _posthog.enable(); + final restartReplay = _restartSessionReplayOnEnable; + _restartSessionReplayOnEnable = false; + if (restartReplay) { + await startSessionRecording(resumeCurrent: false); + } } /// Opts the current user out of data collection. @@ -882,6 +906,7 @@ class Posthog { Future close() { _config = null; _currentScreen = null; + _restartSessionReplayOnEnable = false; PostHogInternalEvents.sessionRecordingActive.value = false; // Forced rather than keyed on observing a new session id, because the // platforms disagree on whether close() rotates the session at all — the diff --git a/posthog_flutter/test/opt_out_session_replay_test.dart b/posthog_flutter/test/opt_out_session_replay_test.dart new file mode 100644 index 00000000..6814f626 --- /dev/null +++ b/posthog_flutter/test/opt_out_session_replay_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_flutter_io.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +import 'package:posthog_flutter/src/posthog_internal_events.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; +import 'replay_capture_settle.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('posthog_flutter'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + final recordedCalls = []; + + PostHogConfig replayConfig() { + return PostHogConfig('test_project_token') + ..sessionReplay = true + ..sessionReplayConfig.throttleDelay = const Duration(milliseconds: 100) + ..sessionReplayConfig.captureNativeScreens = false; + } + + setUp(() { + recordedCalls.clear(); + messenger.setMockMethodCallHandler(channel, (call) async { + recordedCalls.add(call); + if (call.method == 'getSessionReplayState') { + return {'isActive': true, 'sessionId': 'session-a'}; + } + return null; + }); + }); + + tearDown(() async { + await Posthog().close(); + messenger.setMockMethodCallHandler(channel, null); + }); + + List methodsOf(List calls) => + calls.map((c) => c.method).toList(); + + testWidgets( + 'disable stops Flutter replay, so a later frame is not snapshotted', + (tester) async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); + await Posthog().setup(replayConfig()); + expect(PostHogInternalEvents.sessionRecordingActive.value, isTrue); + + await tester.pumpWidget( + PostHogWidget(child: Container(color: const Color(0xFF00FF00))), + ); + await settleUntil( + tester, + () => recordedCalls.any((c) => c.method == 'sendFullSnapshot'), + ); + expect(methodsOf(recordedCalls), contains('sendFullSnapshot')); + recordedCalls.clear(); + + await Posthog().optOut(); + expect(PostHogInternalEvents.sessionRecordingActive.value, isFalse); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpWidget( + PostHogWidget(child: Container(color: const Color(0xFF0000FF))), + ); + await settleCapture(tester); + + expect(methodsOf(recordedCalls), isNot(contains('sendFullSnapshot'))); + }); + + test('disable stops replay before opting out, enable starts a new recording', + () async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); + await Posthog().setup(replayConfig()); + + recordedCalls.clear(); + await Posthog().optOut(); + + final disabled = methodsOf(recordedCalls); + expect(disabled, contains('stopSessionRecording')); + expect(disabled, contains('disable')); + expect( + disabled.indexOf('stopSessionRecording'), + lessThan(disabled.indexOf('disable')), + reason: 'native stop is a no-op once the SDK is already opted out', + ); + expect(PostHogInternalEvents.sessionRecordingActive.value, isFalse); + + recordedCalls.clear(); + await Posthog().optIn(); + + final enabled = methodsOf(recordedCalls); + expect(enabled, contains('enable')); + expect(enabled, contains('startSessionRecording')); + expect( + enabled.indexOf('enable'), + lessThan(enabled.indexOf('startSessionRecording')), + reason: 'startSessionRecording returns immediately while opted out', + ); + final start = recordedCalls + .firstWhere((call) => call.method == 'startSessionRecording'); + expect(start.arguments, isFalse); + expect(PostHogInternalEvents.sessionRecordingActive.value, isTrue); + }); + + test('disable does not stop replay when it was never started', () async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); + await Posthog().setup(PostHogConfig('test_project_token')); + + recordedCalls.clear(); + await Posthog().optOut(); + await Posthog().optIn(); + + expect(methodsOf(recordedCalls), contains('disable')); + expect(methodsOf(recordedCalls), contains('enable')); + expect(methodsOf(recordedCalls), isNot(contains('stopSessionRecording'))); + expect(methodsOf(recordedCalls), isNot(contains('startSessionRecording'))); + expect(PostHogInternalEvents.sessionRecordingActive.value, isFalse); + }); +} From 8f1f62345998af157a0e44589fd5e0920841c42b Mon Sep 17 00:00:00 2001 From: Omid Mirzaei Date: Tue, 29 Sep 2026 01:32:34 +0400 Subject: [PATCH 2/3] docs(replay): keep opt-out ordering out of the public docs Callers do not need the native stop and start constraints. Those notes now sit next to the calls. --- posthog_flutter/lib/src/posthog.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 71166b97..e52a8852 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -384,9 +384,7 @@ class Posthog { /// Opts the current user out of data collection. /// - /// Stops Flutter session replay before the platform opt-out. Native - /// `stopSessionRecording` is a no-op once the SDK is already opted out, so - /// the stop has to happen first. Error autocapture is uninstalled as well. + /// Also stops session replay and uninstalls Flutter error autocapture. /// /// Returns a [Future] that completes when the opt-out request has been queued. Future optOut() async { @@ -394,6 +392,8 @@ class Posthog { _uninstallFlutterIntegrations(); if (PostHogInternalEvents.sessionRecordingActive.value) { _restartSessionReplayOnEnable = true; + // Native stopSessionRecording is a no-op once the SDK is already opted + // out, so this has to run before the platform disable. await stopSessionRecording(); } @@ -402,10 +402,8 @@ class Posthog { /// Opts the current user back in to data collection. /// - /// Reinstalls Flutter error autocapture. If [optOut] stopped an active - /// session replay, this starts a new recording after the platform opt-in. - /// Starting first would no-op, because the native SDKs ignore - /// `startSessionRecording` while still opted out. + /// Reinstalls Flutter error autocapture. If [optOut] stopped session replay, + /// this starts a new recording. /// /// Returns a [Future] that completes when the opt-in request has been queued. Future optIn() async { @@ -418,6 +416,8 @@ class Posthog { final restartReplay = _restartSessionReplayOnEnable; _restartSessionReplayOnEnable = false; if (restartReplay) { + // startSessionRecording is ignored while the SDK is still opted out, so + // the platform opt-in has to finish first. await startSessionRecording(resumeCurrent: false); } } From d5726ddb63c5b14a122acf22b149411e1a68fa33 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 14:29:37 -0400 Subject: [PATCH 3/3] fix(replay): address review on opt-out replay stop Send the native stop and disable back to back instead of awaiting the stop first, so a capture() after an un-awaited optOut() still reaches native after the opt-out. Skip the stop and restart on web, where posthog-js already drops replay while opted out and a restart would override a replay disabled in posthog.init. Name iOS as the platform that ignores startSessionRecording while opted out. Co-authored-by: Omid Mirzaei Co-Authored-By: Claude Opus 5.5 (1M context) --- posthog_flutter/lib/src/posthog.dart | 17 ++++++++----- .../test/opt_out_session_replay_test.dart | 25 ++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index e52a8852..5546bd0c 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -390,14 +390,19 @@ class Posthog { Future optOut() async { // Uninstall Flutter-specific integrations when opting out _uninstallFlutterIntegrations(); - if (PostHogInternalEvents.sessionRecordingActive.value) { + Future? stopReplay; + // Skipped on web: posthog-js drops replay while opted out, and restarting + // it on optIn would override a replay disabled in posthog.init. + if (!kIsWeb && PostHogInternalEvents.sessionRecordingActive.value) { _restartSessionReplayOnEnable = true; + PostHogInternalEvents.sessionRecordingActive.value = false; // Native stopSessionRecording is a no-op once the SDK is already opted - // out, so this has to run before the platform disable. - await stopSessionRecording(); + // out, so it is sent first. Not awaited before disable, so a capture() + // right after an un-awaited optOut() still reaches native after it. + stopReplay = _posthog.stopSessionRecording(); } - await _posthog.disable(); + await Future.wait([if (stopReplay != null) stopReplay, _posthog.disable()]); } /// Opts the current user back in to data collection. @@ -416,8 +421,8 @@ class Posthog { final restartReplay = _restartSessionReplayOnEnable; _restartSessionReplayOnEnable = false; if (restartReplay) { - // startSessionRecording is ignored while the SDK is still opted out, so - // the platform opt-in has to finish first. + // On iOS, startSessionRecording is ignored while the SDK is still opted + // out, so the platform opt-in has to finish first. await startSessionRecording(resumeCurrent: false); } } diff --git a/posthog_flutter/test/opt_out_session_replay_test.dart b/posthog_flutter/test/opt_out_session_replay_test.dart index 6814f626..1138f13e 100644 --- a/posthog_flutter/test/opt_out_session_replay_test.dart +++ b/posthog_flutter/test/opt_out_session_replay_test.dart @@ -45,7 +45,7 @@ void main() { calls.map((c) => c.method).toList(); testWidgets( - 'disable stops Flutter replay, so a later frame is not snapshotted', + 'optOut stops Flutter replay, so a later frame is not snapshotted', (tester) async { PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); await Posthog().setup(replayConfig()); @@ -73,7 +73,7 @@ void main() { expect(methodsOf(recordedCalls), isNot(contains('sendFullSnapshot'))); }); - test('disable stops replay before opting out, enable starts a new recording', + test('optOut stops replay before opting out, optIn starts a new recording', () async { PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); await Posthog().setup(replayConfig()); @@ -108,7 +108,26 @@ void main() { expect(PostHogInternalEvents.sessionRecordingActive.value, isTrue); }); - test('disable does not stop replay when it was never started', () async { + test('capture after an un-awaited optOut reaches native after the opt-out', + () async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); + await Posthog().setup(replayConfig()); + messenger.setMockMethodCallHandler(channel, (call) async { + recordedCalls.add(call); + await Future.delayed(Duration.zero); + return null; + }); + + recordedCalls.clear(); + final optOut = Posthog().optOut(); + await Posthog().capture(eventName: 'after_opt_out'); + await optOut; + + final methods = methodsOf(recordedCalls); + expect(methods.indexOf('disable'), lessThan(methods.indexOf('capture'))); + }); + + test('optOut does not stop replay when it was never started', () async { PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); await Posthog().setup(PostHogConfig('test_project_token'));