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..5546bd0c 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,47 @@ class Posthog { /// Opts the current user out of data collection. /// + /// Also stops session replay and uninstalls Flutter error autocapture. + /// /// 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(); + 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 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(); + } - return _posthog.disable(); + await Future.wait([if (stopReplay != null) stopReplay, _posthog.disable()]); } /// Opts the current user back in to data collection. /// + /// 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() { + Future optIn() async { final config = _config; if (config != null) { _installFlutterIntegrations(config); } - return _posthog.enable(); + await _posthog.enable(); + final restartReplay = _restartSessionReplayOnEnable; + _restartSessionReplayOnEnable = false; + if (restartReplay) { + // 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); + } } /// Opts the current user out of data collection. @@ -882,6 +911,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..1138f13e --- /dev/null +++ b/posthog_flutter/test/opt_out_session_replay_test.dart @@ -0,0 +1,144 @@ +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( + 'optOut 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('optOut stops replay before opting out, optIn 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('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')); + + 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); + }); +}