Skip to content

fix(replay): stop session replay when the user opts out - #606

Merged
dustinbyrne merged 3 commits into
PostHog:mainfrom
o-mid:fix/opt-out-stops-session-replay
Oct 7, 2026
Merged

dustinbyrne merged 3 commits into
PostHog:mainfrom
o-mid:fix/opt-out-stops-session-replay

Conversation

@o-mid

@o-mid o-mid commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Posthog().disable() forwards opt-out and uninstalls Flutter error autocapture, but it left sessionRecordingActive true, so PostHogWidget kept its capture timer running.

On Android that timer still took screenshots. Native capture returns immediately while opted out (PostHog is in OptOut state.), so those frames never reached /s/. On iOS, optOut() uninstalls the replay integration, getSessionReplayState comes back inactive, and the capturer skips the screenshot. The timer still called getSessionReplayState on every interval.

stopSessionRecording has to run before the platform opt-out: posthog-ios 3.84 and newer ignore that call once the SDK is already opted out. enable() starts a new recording only if this disable() was what stopped one.

This is the Flutter side of #427. It does not close the Android gap in PostHog/posthog-android#242 — optOut() there still does not uninstall other native integrations. This change only stops the Flutter replay loop through the existing channel.

How I tested

  • posthog_flutter/test/opt_out_session_replay_test.dart failed on main (sessionRecordingActive stayed true, and the channel only received disable), then passed after the change.
  • flutter test in posthog_flutter (708 tests).
  • dart analyze on the touched files, and scripts/check-api-dart.sh (public API snapshot unchanged).

Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.
  • Ran pnpm changeset to generate a changeset file

@o-mid
o-mid requested a review from a team as a code owner September 28, 2026 07:47
@ioannisj

Copy link
Copy Markdown
Contributor

Hey @o-mid Thank you for the contribution here. I ran a repro on Pixel 8 against a local mock server and the fix holds up. A couple of things in the description tat don't match what actually happened though, if you mind validating as well.

After disable() the widget did keep screenshotting on Android, but every one dropped natively with "PostHog is in OptOut state", so nothing reached /s/ endpoint

On iOS, optOut() uninstalls the replay integration completely, so the capturer skips screenshots. However, there's still a wasted getSessionReplayState call per interval

Mind updating the PR description when you validate the above and also sign your commits before this can be merged?

@o-mid

o-mid commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Updated the description.

On Android the timer did keep capturing after disable(), and capture returned inside the SDK on the opt-out check, before anything was sent to /s/. On iOS optOut() uninstalls the replay integration, so the capturer bails once getSessionReplayState is inactive. That call was still happening on every interval because the Flutter timer stayed running.

@o-mid
o-mid force-pushed the fix/opt-out-stops-session-replay branch from 60531d1 to 054afc9 Compare September 28, 2026 10:57
}

_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.

Comment thread posthog_flutter/lib/src/posthog.dart Outdated
@turnipdabeets
turnipdabeets requested a review from a team September 28, 2026 20:44
Comment thread posthog_flutter/lib/src/posthog.dart Outdated
Comment on lines +388 to +395
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();
}

return _posthog.disable();
await _posthog.disable();

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.

Awaiting the stop delays sending disable by a platform round trip, so an un-awaited Posthog().disable(); Posthog().capture(...) now reaches native as [stopSessionRecording, capture, disable] (on main it's [disable, capture]) — the event goes out after the user opted out. Confirmed with a mock channel that replies one event-loop hop later. I think we need to send both without awaiting in between; invokeMethod dispatches synchronously, so the order holds (your tests still pass with this):

Suggested change
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();
}
return _posthog.disable();
await _posthog.disable();
Future<void>? stopReplay;
if (PostHogInternalEvents.sessionRecordingActive.value) {
_restartSessionReplayOnEnable = true;
PostHogInternalEvents.sessionRecordingActive.value = false;
// Sent back to back, not awaited in between, so a capture() right after
// an un-awaited disable() still reaches native after the opt-out.
stopReplay = _posthog.stopSessionRecording();
}
await Future.wait([if (stopReplay != null) stopReplay, _posthog.disable()]);

A test with an un-awaited disable() followed by capture() would lock it in.

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);

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.)

Comment thread posthog_flutter/lib/src/posthog.dart Outdated
Comment on lines +414 to +415
// startSessionRecording is ignored while the SDK is still opted out, so
// the platform opt-in has to finish first.

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.

[nit] This holds on iOS, but Android's startSessionReplay doesn't check opt-out, so naming the platform keeps the next reader from treating the ordering as a cross-platform rule:

Suggested change
// 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.

@turnipdabeets
turnipdabeets requested a review from a team September 29, 2026 17:25
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@github-actions github-actions Bot added the stale label Oct 7, 2026
o-mid and others added 3 commits October 7, 2026 14:29
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.
Callers do not need the native stop and start constraints. Those notes
now sit next to the calls.
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 <omidomirzaei@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@turnipdabeets
turnipdabeets force-pushed the fix/opt-out-stops-session-replay branch from a4b121c to d5726dd Compare October 7, 2026 18:29
@turnipdabeets

Copy link
Copy Markdown
Contributor

Thanks @o-mid! I rebased this onto main and pushed a follow-up commit to address the review.

@PostHog/team-client-libraries mind giving this a quick look before merging?

@turnipdabeets turnipdabeets self-assigned this Oct 7, 2026
@dustinbyrne
dustinbyrne merged commit 6ec313e into PostHog:main Oct 7, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants