Repository navigation
fix(replay): stop session replay when the user opts out #606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<void> optOut() { | ||
| Future<void> optOut() async { | ||
| // Uninstall Flutter-specific integrations when opting out | ||
| _uninstallFlutterIntegrations(); | ||
| Future<void>? 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<void> optIn() { | ||
| Future<void> 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On web this lands in posthog-js if (!kIsWeb && PostHogInternalEvents.sessionRecordingActive.value) {( |
||
| } | ||
| } | ||
|
|
||
| /// Opts the current user out of data collection. | ||
|
|
@@ -882,6 +911,7 @@ class Posthog { | |
| Future<void> 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = <MethodCall>[]; | ||
|
|
||
| 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<String> methodsOf(List<MethodCall> 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<void>.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); | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the user opted out in an earlier launch,
setup()still setssessionRecordingActiveto true a few lines down, so after a relaunch the replay timer runs again untildisable()is called. That's fine as a follow-up rather than something for this PR. The RN fix for the same issue (PostHog/posthog-js#5124) checks saved consent at startup. Would you like to take that on here too?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will leave the persisted opt-out for a follow-up.
setup()still turns the Flutter timer on wheneversessionReplayis set, and checking saved consent means waiting on native setup the way the React Native change does.