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/opt-in-native-replay-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog_flutter': patch
---

Fix session replay after `optIn()` continuing the session that was active while opted out, and raise the minimum PostHog iOS SDK to 3.92.3.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions posthog_flutter/darwin/posthog_flutter.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ Postog flutter plugin
s.ios.dependency 'Flutter'
s.osx.dependency 'FlutterMacOS'

# ~> Version 3.80.0 up to, but not including, 4.0.0
s.dependency 'PostHog', '>= 3.84.0', '< 4.0.0'
s.dependency 'PostHog', '>= 3.92.3', '< 4.0.0'

s.ios.deployment_target = '13.0'
# PH iOS SDK 3.0.0 requires >= 10.15
Expand Down
2 changes: 1 addition & 1 deletion posthog_flutter/darwin/posthog_flutter/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
],
dependencies: [
.package(name: "FlutterFramework", path: "../FlutterFramework"),
.package(url: "https://github.com/PostHog/posthog-ios", "3.84.0" ..< "4.0.0"),
.package(url: "https://github.com/PostHog/posthog-ios", "3.92.3" ..< "4.0.0"),
],
targets: [
.target(
Expand Down
19 changes: 8 additions & 11 deletions posthog_flutter/lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ 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.
/// it to re-arm Flutter capture for the recording native opt-in restarts,
/// and a replay the caller already stopped is left stopped.
bool _restartSessionReplayOnEnable = false;

/// Returns the singleton PostHog client instance.
Expand Down Expand Up @@ -399,19 +399,17 @@ class Posthog {
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();
}

await Future.wait([if (stopReplay != null) stopReplay, _posthog.disable()]);
// Native opt-out stops replay itself. A native stopSessionRecording() here
// would record that the app stopped replay, so native opt-in would not
// restart it.
await _posthog.disable();
}

/// Opts the current user back in to data collection.
Expand All @@ -430,9 +428,8 @@ class Posthog {
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);
PostHogInternalEvents.requestReplaySessionReset();
PostHogInternalEvents.sessionRecordingActive.value = true;
}
}

Expand Down
43 changes: 26 additions & 17 deletions posthog_flutter/test/opt_out_session_replay_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,41 +73,50 @@ void main() {
expect(methodsOf(recordedCalls), isNot(contains('sendFullSnapshot')));
});

test('optOut stops replay before opting out, optIn starts a new recording',
() async {
test('optOut and optIn leave the replay restart to native', () 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(methodsOf(recordedCalls), contains('disable'));
expect(
disabled.indexOf('stopSessionRecording'),
lessThan(disabled.indexOf('disable')),
reason: 'native stop is a no-op once the SDK is already opted out',
methodsOf(recordedCalls),
isNot(contains('stopSessionRecording')),
reason: 'a native stop while opted in keeps replay off after opt-in',
);
expect(PostHogInternalEvents.sessionRecordingActive.value, isFalse);

recordedCalls.clear();
final resetsBefore = PostHogInternalEvents.forceReplaySessionReset.value;
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',
PostHogInternalEvents.forceReplaySessionReset.value,
greaterThan(resetsBefore),
);
expect(methodsOf(recordedCalls), contains('enable'));
expect(
methodsOf(recordedCalls),
isNot(contains('startSessionRecording')),
reason: 'native opt-in already starts a new session',
);
final start = recordedCalls
.firstWhere((call) => call.method == 'startSessionRecording');
expect(start.arguments, isFalse);
expect(PostHogInternalEvents.sessionRecordingActive.value, isTrue);
});

test('optIn leaves Flutter replay off when the app stopped it first',
() async {
PosthogFlutterPlatformInterface.instance = PosthogFlutterIO();
await Posthog().setup(replayConfig());
await Posthog().stopSessionRecording();

await Posthog().optOut();
await Posthog().optIn();

expect(PostHogInternalEvents.sessionRecordingActive.value, isFalse);
});

test('capture after an un-awaited optOut reaches native after the opt-out',
() async {
PosthogFlutterPlatformInterface.instance = PosthogFlutterIO();
Expand Down
Loading