Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stop-replay-on-opt-out.md
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.
38 changes: 34 additions & 4 deletions posthog_flutter/lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,6 +76,7 @@ class Posthog {
}

_config = config; // Store the config
_restartSessionReplayOnEnable = false;

Copy link
Copy Markdown
Contributor

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 sets sessionRecordingActive to true a few lines down, so after a relaunch the replay timer runs again until disable() 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?

Copy link
Copy Markdown
Contributor Author

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 whenever sessionReplay is set, and checking saved consent means waiting on native setup the way the React Native change does.


// The mask controller singleton may predate this setup() (or a previous
// setup() built it with different masking flags); without a refresh the
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On web this lands in posthog-js startSessionRecording(), which sets disable_session_recording: false, so an app that turned replay off in its posthog.init but shares sessionReplay = true in Dart starts recording after enable(). posthog-js already drops replay while opted out ($snapshot goes through capture(), which bails), and PostHogWidget doesn't record on web, so I think we should skip this on web. Gating the disable() side is enough, since the restart flag then never gets set:

if (!kIsWeb && PostHogInternalEvents.sessionRecordingActive.value) {

(kIsWeb is already in scope via foundation.dart.)

}
}

/// Opts the current user out of data collection.
Expand Down Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions posthog_flutter/test/opt_out_session_replay_test.dart
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);
});
}
Loading