From 5b078c8b8511bd845280dce5ce2d8fd28aab80d0 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:18:44 +0000 Subject: [PATCH 1/3] fix(replay): honor PostHogConfig.sessionReplay writes after setup `PostHogConfig.sessionReplay` was a plain constructor `var` with no observer. The SDK read it at setup, then again only on a session rotation or a remote config delivery. An app that read its own feature flag and assigned the result kept recording a user the flag excluded, for the rest of the app lifetime. Writing the property now notifies the session replay handler, which re-evaluates recording through `reevaluateRecordingState()`. That is the same decision the remote config path already makes, so the flag, the event triggers, the sampling decision, the manual-start carve-out, and the forced keyframe on resume all keep their existing behavior. A write that does not change the value does nothing. Generated-By: PostHog Desktop Task-Id: 4058c316-29eb-4c85-8406-4e187f0732d9 --- .../session-replay-live-master-switch.md | 6 +++ posthog-android/api/posthog-android.api | 1 + .../replay/PostHogReplayIntegration.kt | 4 ++ .../com/posthog/android/PostHogAndroidTest.kt | 32 +++++++++++ .../replay/PostHogReplayIntegrationTest.kt | 54 +++++++++++++++++++ posthog/api/posthog.api | 4 ++ posthog/src/main/java/com/posthog/PostHog.kt | 9 ++++ .../main/java/com/posthog/PostHogConfig.kt | 32 ++++++++++- .../replay/PostHogSessionReplayHandler.kt | 7 +++ .../PostHogSessionReplayHandlerFake.kt | 6 +++ .../src/test/java/com/posthog/PostHogTest.kt | 36 +++++++++++++ 11 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 .changeset/session-replay-live-master-switch.md diff --git a/.changeset/session-replay-live-master-switch.md b/.changeset/session-replay-live-master-switch.md new file mode 100644 index 000000000..c7e144569 --- /dev/null +++ b/.changeset/session-replay-live-master-switch.md @@ -0,0 +1,6 @@ +--- +"posthog": patch +"posthog-android": patch +--- + +Fix: writing `PostHogConfig.sessionReplay` after setup now takes effect right away. It used to be honored only at setup, and then again at the next session rotation or remote config delivery, so an app that read its own feature flag and assigned the result kept recording a user the flag excluded. Setting it to false stops recording; setting it to true resumes it when the project settings, the linked flag, the event triggers, and sampling also allow it. diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 80883e1e6..bf971475b 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -90,6 +90,7 @@ public final class com/posthog/android/replay/PostHogReplayIntegration : com/pos public fun onEvent (Ljava/lang/String;Ljava/util/Map;)V public fun onRemoteConfig (Z)V public fun onSessionIdChanged ()V + public fun onSessionReplayConfigChanged ()V public fun start (Z)V public fun stop ()V public fun uninstall ()V diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 1ebc1d138..9254d1d14 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -2401,6 +2401,10 @@ public class PostHogReplayIntegration( } } + override fun onSessionReplayConfigChanged() { + reevaluateRecordingState() + } + /** * Returns true if event triggers are configured and the current session has not been activated yet. */ diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt index 1329d838c..f16e7056d 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidTest.kt @@ -14,15 +14,18 @@ import com.posthog.android.internal.PostHogLifecycleObserverIntegration import com.posthog.android.internal.PostHogPushSubscriptionIntegration import com.posthog.android.internal.PostHogSharedPreferences import com.posthog.internal.PostHogLogger +import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogNetworkStatus import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.robolectric.annotation.Config import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame @@ -376,6 +379,35 @@ internal class PostHogAndroidTest { postHog.close() } + @Test + @Config(sdk = [26]) // the replay integration only installs on API >= O + fun `turning sessionReplay off after setup stops recording`() { + val config = + PostHogAndroidConfig(API_KEY).apply { + sessionReplay = true + // keeps this test from installing the logcat capturer, whose installed flag is static + sessionReplayConfig.captureLogcat = false + // PostHogPreferences.SESSION_REPLAY is internal to the core module. An empty map + // means the project records sessions and no linked flag gates them. + cachePreferences = + PostHogMemoryPreferences().apply { + setValue("sessionReplay", emptyMap()) + } + } + + mockContextAppStart(context, tmpDir) + + val postHog = PostHogAndroid.with(context, config) + + assertTrue(postHog.isSessionReplayActive()) + + config.sessionReplay = false + + assertFalse(postHog.isSessionReplayActive()) + + postHog.close() + } + private class TestLogger : PostHogLogger { val messages = mutableListOf() diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index c7c7c0fa9..5a9cf7793 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -933,6 +933,60 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `onSessionReplayConfigChanged stops replay when automatic replay is disabled`() { + val fx = createIntegrationWithRealQueue(flagActive = true, hasFetched = true) + val postHog = mock() + whenever(postHog.getSessionId()).thenReturn(UUID.randomUUID()) + fx.sut.install(postHog) + fx.sut.start(resumeCurrent = true) + try { + fx.config.sessionReplay = false + fx.sut.onSessionReplayConfigChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(fx.sut.isActive()) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `onSessionReplayConfigChanged resumes recording when automatic replay is enabled`() { + val fx = createIntegrationWithRealQueue(flagActive = true, hasFetched = true, sessionReplay = false) + val postHog = mock() + whenever(postHog.getSessionId()).thenReturn(UUID.randomUUID()) + fx.sut.install(postHog) + try { + assertFalse(fx.sut.isActive()) + + fx.config.sessionReplay = true + fx.sut.onSessionReplayConfigChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(fx.sut.isActive()) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `onSessionReplayConfigChanged does not resume recording when the flag is off`() { + val fx = createIntegrationWithRealQueue(flagActive = false, hasFetched = true, sessionReplay = false) + val postHog = mock() + whenever(postHog.getSessionId()).thenReturn(UUID.randomUUID()) + fx.sut.install(postHog) + try { + fx.config.sessionReplay = true + fx.sut.onSessionReplayConfigChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(fx.sut.isActive()) + } finally { + fx.sut.uninstall() + } + } + @Test fun `onRemoteConfig resumes recording when flag turns on and recording inactive`() { val fx = createIntegrationWithRealQueue(flagActive = true, hasFetched = true) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index ec32a74d9..6e137ebba 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -182,6 +182,7 @@ public class com/posthog/PostHogConfig { public final fun getMaxRetries ()I public final fun getNetworkStatus ()Lcom/posthog/internal/PostHogNetworkStatus; public final fun getOnFeatureFlags ()Lcom/posthog/PostHogOnFeatureFlags; + public final fun getOnSessionReplayChanged ()Lkotlin/jvm/functions/Function0; public final fun getOptOut ()Z public final fun getPersonProfiles ()Lcom/posthog/PersonProfiles; public final fun getPreloadFeatureFlags ()Z @@ -233,6 +234,7 @@ public class com/posthog/PostHogConfig { public final fun setMaxRetries (I)V public final fun setNetworkStatus (Lcom/posthog/internal/PostHogNetworkStatus;)V public final fun setOnFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V + public final fun setOnSessionReplayChanged (Lkotlin/jvm/functions/Function0;)V public final fun setOptOut (Z)V public final fun setPersonProfiles (Lcom/posthog/PersonProfiles;)V public final fun setPreloadFeatureFlags (Z)V @@ -1197,12 +1199,14 @@ public abstract interface class com/posthog/internal/replay/PostHogSessionReplay public abstract fun isActive ()Z public abstract fun onEvent (Ljava/lang/String;Ljava/util/Map;)V public abstract fun onSessionIdChanged ()V + public abstract fun onSessionReplayConfigChanged ()V public abstract fun start (Z)V public abstract fun stop ()V } public final class com/posthog/internal/replay/PostHogSessionReplayHandler$DefaultImpls { public static synthetic fun onEvent$default (Lcom/posthog/internal/replay/PostHogSessionReplayHandler;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static fun onSessionReplayConfigChanged (Lcom/posthog/internal/replay/PostHogSessionReplayHandler;)V } public final class com/posthog/internal/replay/RRCustomEvent : com/posthog/internal/replay/RREvent { diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 7b5ecd004..d8bb76302 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -312,6 +312,14 @@ public class PostHog private constructor( isOptedOut() pushSubscriptionManager?.retryPending() + config.onSessionReplayChanged = { + try { + sessionReplayHandler?.onSessionReplayConfigChanged() + } catch (e: Throwable) { + config.logger.log("onSessionReplayChanged listener failed: $e.") + } + } + PostHogSessionManager.setOnSessionIdChangedListener { try { sessionReplayHandler?.onSessionIdChanged() @@ -517,6 +525,7 @@ public class PostHog private constructor( config?.let { config -> apiKeys.remove(config.apiKey) + config.onSessionReplayChanged = null config.integrations.forEach { try { diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index fb9cd492b..07edd2042 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -158,7 +158,7 @@ public open class PostHogConfig( * Requires Record user sessions to be enabled in the PostHog Project Settings * Defaults to false */ - public var sessionReplay: Boolean = false, + sessionReplay: Boolean = false, /** * Hook that allows to sanitize the event properties * The hook is called before the event is cached or sent over the wire @@ -401,6 +401,36 @@ public open class PostHogConfig( @Volatile public var requestHeaders: Map = emptyMap() + /** + * Enable Recording of Session Replays for Android + * Requires Record user sessions to be enabled in the PostHog Project Settings + * Defaults to false + * + * Writes after setup take effect right away. Set it to false to stop recording, for example + * when your own feature flag turns off for this user. Set it to true to start recording, if + * the project settings, the linked flag, the event triggers, and sampling also allow it. + */ + @Volatile + public var sessionReplay: Boolean = sessionReplay + set(value) { + if (field == value) { + return + } + field = value + try { + onSessionReplayChanged?.invoke() + } catch (e: Throwable) { + logger.log("Reacting to a sessionReplay change failed: $e.") + } + } + + /** + * Called when [sessionReplay] is written after setup, so the recording state follows the + * master switch instead of waiting for the next session rotation or remote config delivery. + */ + @PostHogInternal + public var onSessionReplayChanged: (() -> Unit)? = null + /** * The PostHog project API key, trimmed of leading and trailing whitespace. */ diff --git a/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt b/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt index 250710c32..d82fcbd38 100644 --- a/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt +++ b/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt @@ -24,4 +24,11 @@ public interface PostHogSessionReplayHandler { * Used to stop recording if event triggers are configured and the new session hasn't been activated. */ public fun onSessionIdChanged() + + /** + * Called when [com.posthog.PostHogConfig.sessionReplay] is written after setup. + * Used to re-evaluate recording against the master switch right away, instead of waiting + * for the next session rotation or remote config delivery. + */ + public fun onSessionReplayConfigChanged() {} } diff --git a/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt b/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt index 89a7341a8..c8bcc2a47 100644 --- a/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt +++ b/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt @@ -10,6 +10,7 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po public var lastEventName: String? = null public var lastEventProperties: Map? = null public var onSessionIdChangedCalled: Boolean = false + public var onSessionReplayConfigChangedCalled: Boolean = false public fun reset() { stopCalled = false @@ -19,6 +20,7 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po lastEventName = null lastEventProperties = null onSessionIdChangedCalled = false + onSessionReplayConfigChangedCalled = false } override fun start(resumeCurrent: Boolean) { @@ -48,4 +50,8 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po override fun onSessionIdChanged() { onSessionIdChangedCalled = true } + + override fun onSessionReplayConfigChanged() { + onSessionReplayConfigChangedCalled = true + } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 6376d21bf..0c0fcffce 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2617,6 +2617,42 @@ internal class PostHogTest { assertTrue(integration.resumeCurrent == true) } + @Test + fun `writing sessionReplay after setup asks the replay handler to re-evaluate`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(false) + + val sut = getSut(url.toString(), preloadFeatureFlags = false, integration = integration) + + config.sessionReplay = true + + assertTrue(integration.onSessionReplayConfigChangedCalled) + + integration.reset() + config.sessionReplay = false + + assertTrue(integration.onSessionReplayConfigChangedCalled) + + sut.close() + } + + @Test + fun `writing the same sessionReplay value does not ask the replay handler to re-evaluate`() { + val http = mockHttp() + val url = http.url("/") + val integration = PostHogSessionReplayHandlerFake(false) + + val sut = getSut(url.toString(), preloadFeatureFlags = false, integration = integration) + + integration.reset() + config.sessionReplay = false + + assertFalse(integration.onSessionReplayConfigChangedCalled) + + sut.close() + } + @Test fun `send feature flag called when session starts`() { val file = File("src/test/resources/json/basic-flags-recording-bool-linked-enabled.json") From 1dd05203f6c6ac8d6835de35b6e8b37830092c5b Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:47:41 +0000 Subject: [PATCH 2/3] docs(replay): clarify sessionReplay=false is not an unconditional kill switch The new KDoc on PostHogConfig.sessionReplay stated that a false write stops recording, but reevaluateRecordingState() deliberately preserves a recording that started while the switch was already false (the startedWithAutomaticDisabled carve-out shared with the session-rotation and remote-config paths). A manual startSessionReplay() or an event-trigger start while the switch is off therefore survives a later false write. Document that the property governs automatic replay only and point callers at stopSessionReplay() to stop such recordings. Documentation-only; no behavior change. Generated-By: PostHog Desktop Task-Id: 9129ac5d-1e19-4e7e-bac5-0edeb68b7611 --- posthog/src/main/java/com/posthog/PostHogConfig.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index 07edd2042..69d1c3a15 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -406,9 +406,15 @@ public open class PostHogConfig( * Requires Record user sessions to be enabled in the PostHog Project Settings * Defaults to false * - * Writes after setup take effect right away. Set it to false to stop recording, for example - * when your own feature flag turns off for this user. Set it to true to start recording, if - * the project settings, the linked flag, the event triggers, and sampling also allow it. + * Writes after setup take effect right away. Set it to false to stop automatic recording, + * for example when your own feature flag turns off for this user. Set it to true to start + * recording, if the project settings, the linked flag, the event triggers, and sampling + * also allow it. + * + * This governs automatic replay only, so it is not an unconditional kill switch. A + * recording that started while it was already false — through [PostHog.startSessionReplay] + * or an event trigger — is deliberately preserved, so a false write does not stop it. Call + * [PostHog.stopSessionReplay] to stop those recordings. */ @Volatile public var sessionReplay: Boolean = sessionReplay From 407f7256c6aa7e260eb9556c45a1381b3c210db2 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:53:57 +0000 Subject: [PATCH 3/3] fix(replay): re-check recording permission before running a queued resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reevaluateRecordingState() posts the resume to the main thread but the posted task only re-checked !isSessionReplayActive. Because start() never re-reads config.sessionReplay, a write of true followed by a write of false within the same looper turn left a stale resume queued: the false write saw an inactive recorder and posted no stop, then the resume ran and started recording with config.sessionReplay == false. start() also sets startedWithAutomaticDisabled = !config.sessionReplay (true here), so the (!config.sessionReplay && !startedWithAutomaticDisabled) guard then skipped every later stop — inverting the privacy switch this PR exists to deliver. The same window could reactivate the integration after uninstall(). Re-check isRecordingPermittedForCurrentSession() inside the posted task before start(). It re-reads config.sessionReplay, startedWithAutomaticDisabled, the project flag, event triggers, and the sampling decision on the main thread, and returns false once postHog is null (which uninstall() sets), so both races are covered. Adding a precondition can only make resume more conservative, never start a recording that wasn't already going to happen. Adds two regression tests that toggle true->false, and uninstall, before the looper drains; both fail without the guard. Generated-By: PostHog Desktop Task-Id: 9129ac5d-1e19-4e7e-bac5-0edeb68b7611 --- .../replay/PostHogReplayIntegration.kt | 6 ++- .../replay/PostHogReplayIntegrationTest.kt | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 9254d1d14..f32f2a68b 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -2655,7 +2655,11 @@ public class PostHogReplayIntegration( if (!isSessionReplayActive) { config.logger.log("[Session Replay] Remote config enabled recording. Resuming.") mainHandler.handler.post { - if (!isSessionReplayActive) { + // Re-check live permission on the main thread. config.sessionReplay may have + // been written false (or the integration uninstalled) between posting this task + // and running it, and start() never re-reads it — without this recheck a stale + // resume would record an excluded user with no config-driven way to stop. + if (!isSessionReplayActive && isRecordingPermittedForCurrentSession()) { // Force a fresh keyframe for the resumed segment. While stopped, per-view snapshot // state is frozen and can reference a full snapshot that was never delivered (e.g. a // first-config-off opening window that was dropped), so resuming against it would emit diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index 5a9cf7793..4539a8779 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -987,6 +987,51 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `onSessionReplayConfigChanged does not resume when replay is disabled again before the queued resume runs`() { + val fx = createIntegrationWithRealQueue(flagActive = true, hasFetched = true, sessionReplay = false) + val postHog = mock() + whenever(postHog.getSessionId()).thenReturn(UUID.randomUUID()) + fx.sut.install(postHog) + try { + assertFalse(fx.sut.isActive()) + + // Enabling posts a resume to the main thread. Disabling again before the looper drains + // must win: the stale resume must not start recording for the excluded user. + fx.config.sessionReplay = true + fx.sut.onSessionReplayConfigChanged() + fx.config.sessionReplay = false + fx.sut.onSessionReplayConfigChanged() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(fx.sut.isActive()) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `onSessionReplayConfigChanged does not resume when the integration is uninstalled before the queued resume runs`() { + val fx = createIntegrationWithRealQueue(flagActive = true, hasFetched = true, sessionReplay = false) + val postHog = mock() + whenever(postHog.getSessionId()).thenReturn(UUID.randomUUID()) + fx.sut.install(postHog) + try { + assertFalse(fx.sut.isActive()) + + // Enabling posts a resume to the main thread. Uninstalling before the looper drains + // must win: the stale resume must not reactivate a torn-down integration. + fx.config.sessionReplay = true + fx.sut.onSessionReplayConfigChanged() + fx.sut.uninstall() + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(fx.sut.isActive()) + } finally { + fx.sut.uninstall() + } + } + @Test fun `onRemoteConfig resumes recording when flag turns on and recording inactive`() { val fx = createIntegrationWithRealQueue(flagActive = true, hasFetched = true)