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..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 @@ -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. */ @@ -2651,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/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..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 @@ -933,6 +933,105 @@ 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 `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) 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..69d1c3a15 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,42 @@ 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 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 + 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")