Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/session-replay-live-master-switch.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2401,6 +2401,10 @@ public class PostHogReplayIntegration(
}
}

override fun onSessionReplayConfigChanged() {
reevaluateRecordingState()
Comment on lines +2404 to +2405

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.

An event trigger can restart replay after the master switch turns off

must_fix

Why we think it's a valid issue
  • Checked: the full onEvent body at posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt:2311-2341, start() at 2254-2280, shouldWaitForEventTriggers() at 2411-2426, install() at 525-556, every read and write of startedWithAutomaticDisabled, and the dispatch site in the core.
  • Found: the chain has no master-switch gate at any link. PostHog.kt:875 dispatches sessionReplayHandler?.onEvent(...) unconditionally. install() (line 525) has no config.sessionReplay check, so the integration is live even when the switch is off. onEvent reads only the session id, the trigger list, and triggerActivatedSessionId before it calls start(resumeCurrent = true) at line 2339.
  • Found: the one guard inside start() is already satisfied by the time it runs. onEvent assigns triggerActivatedSessionId = currentSessionId at line 2335, and shouldWaitForEventTriggers() returns activatedSession != currentSessionId (line 2425), so it returns false and start() proceeds.
  • Found: the resulting recording is immune to the switch, not merely started by mistake. start() sets startedWithAutomaticDisabled = !config.sessionReplay at line 2267, which is true here. The guard (!config.sessionReplay && !startedWithAutomaticDisabled) then evaluates to false in reevaluateRecordingState (line 2638), in isRecordingPermittedForCurrentSession (line 2613), and on session rotation (line 2387). Only stop() (line 2292) or uninstall() (line 582) clears the flag, so nothing short of an explicit stopSessionReplay() call recovers.
  • Found: a clean sequence reaches it with no manual API use. The project has event triggers configured. The app sets sessionReplay = true. Recording waits for a trigger, so it is inactive. The app writes false, and stopIfActive at line 2640 finds nothing to stop. A matching event then arrives and starts recording with the switch off.
  • Found: it also reaches the state after a correct stop. A trigger starts recording in session A while the switch is on, the app writes false and recording stops, the session rotates, and the next matching event in session B starts recording again through the same path.
  • Found: onEvent itself is untouched by this PR, so the hole predates it. What is new is the documented contract at posthog/src/main/java/com/posthog/PostHogConfig.kt:409-411, "Set it to false to stop recording, for example when your own feature flag turns off for this user", which this path defeats within the same session.
  • Impact: an app that excludes a user through the switch keeps recording that user as soon as a project-side trigger event fires, and no later config write, remote-config delivery, or session rotation turns it off. This is the exact privacy failure the PR sets out to close, reached through the SDK's own trigger path rather than through anything the app did.
Issue description

Even when no manual start exists, a matching event can reactivate replay after a false write. onEvent() does not check config.sessionReplay. It calls start(), which marks the recording as manually started. This records a user whom the app excluded.

Suggested fix

Track explicit manual intent separately from an event trigger. In onEvent(), return when config.sessionReplay is false unless a manual start is pending. Add a true-to-false-to-trigger regression test.

Prompt to fix with AI (copy-paste)
## Context
@posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt#L2311-2339
@posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt#L2404-2405

<issue_description>
Even when no manual start exists, a matching event can reactivate replay after a false write. `onEvent()` does not check `config.sessionReplay`. It calls `start()`, which marks the recording as manually started. This records a user whom the app excluded.
</issue_description>

<issue_validation>
- **Checked:** the full `onEvent` body at posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt:2311-2341, `start()` at 2254-2280, `shouldWaitForEventTriggers()` at 2411-2426, `install()` at 525-556, every read and write of `startedWithAutomaticDisabled`, and the dispatch site in the core.
- **Found:** the chain has no master-switch gate at any link. `PostHog.kt:875` dispatches `sessionReplayHandler?.onEvent(...)` unconditionally. `install()` (line 525) has no `config.sessionReplay` check, so the integration is live even when the switch is off. `onEvent` reads only the session id, the trigger list, and `triggerActivatedSessionId` before it calls `start(resumeCurrent = true)` at line 2339.
- **Found:** the one guard inside `start()` is already satisfied by the time it runs. `onEvent` assigns `triggerActivatedSessionId = currentSessionId` at line 2335, and `shouldWaitForEventTriggers()` returns `activatedSession != currentSessionId` (line 2425), so it returns false and `start()` proceeds.
- **Found:** the resulting recording is immune to the switch, not merely started by mistake. `start()` sets `startedWithAutomaticDisabled = !config.sessionReplay` at line 2267, which is `true` here. The guard `(!config.sessionReplay && !startedWithAutomaticDisabled)` then evaluates to false in `reevaluateRecordingState` (line 2638), in `isRecordingPermittedForCurrentSession` (line 2613), and on session rotation (line 2387). Only `stop()` (line 2292) or `uninstall()` (line 582) clears the flag, so nothing short of an explicit `stopSessionReplay()` call recovers.
- **Found:** a clean sequence reaches it with no manual API use. The project has event triggers configured. The app sets `sessionReplay = true`. Recording waits for a trigger, so it is inactive. The app writes `false`, and `stopIfActive` at line 2640 finds nothing to stop. A matching event then arrives and starts recording with the switch off.
- **Found:** it also reaches the state after a correct stop. A trigger starts recording in session A while the switch is on, the app writes `false` and recording stops, the session rotates, and the next matching event in session B starts recording again through the same path.
- **Found:** `onEvent` itself is untouched by this PR, so the hole predates it. What is new is the documented contract at posthog/src/main/java/com/posthog/PostHogConfig.kt:409-411, "Set it to false to stop recording, for example when your own feature flag turns off for this user", which this path defeats within the same session.
- **Impact:** an app that excludes a user through the switch keeps recording that user as soon as a project-side trigger event fires, and no later config write, remote-config delivery, or session rotation turns it off. This is the exact privacy failure the PR sets out to close, reached through the SDK's own trigger path rather than through anything the app did.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Track explicit manual intent separately from an event trigger. In `onEvent()`, return when `config.sessionReplay` is false unless a manual start is pending. Add a true-to-false-to-trigger regression test.
</potential_solution>

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.

Confirmed and escalating for a human design decision rather than fixing unattended β€” this one needs a policy call I shouldn't make on my own. The bug is real: onEvent starts recording on a matching trigger without checking config.sessionReplay, and start() stamps the recording as startedWithAutomaticDisabled, so once a trigger fires with the switch off the recording is immune to the switch, to remote-config deliveries, and to session rotation β€” only an explicit stopSessionReplay() clears it. That defeats the false-stops-recording contract this PR documents.

Why not a quick fix: the safe-looking change β€” return from onEvent when config.sessionReplay is false β€” would break a supported flow. startSessionReplay() does not set config.sessionReplay, and when event triggers are configured it defers (start() early-returns until a trigger matches), so onEvent is the only path that ever begins recording under triggers β€” for automatic and manual replay. Gating it on the master switch would silently disable manual startSessionReplay() for any project that also uses event triggers with the switch off, which is exactly the manual carve-out this PR is preserving.

Doing it right means introducing explicit manual-intent state (there is none today) and deciding the intended semantics: should an automatic event trigger respect the master switch while an explicit manual start survives it, and how should manual intent be carried through the trigger-deferral path? That plus the fact that onEvent predates this PR (is this in scope here or a follow-up?) is the decision I'd like a maintainer to make before I implement.

}
Comment thread
posthog[bot] marked this conversation as resolved.

/**
* Returns true if event triggers are configured and the current session has not been activated yet.
*/
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, Any>())
}
}

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<String>()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostHogInterface>()
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<PostHogInterface>()
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<PostHogInterface>()
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<PostHogInterface>()
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<PostHogInterface>()
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)
Expand Down
4 changes: 4 additions & 0 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -517,6 +525,7 @@ public class PostHog private constructor(

config?.let { config ->
apiKeys.remove(config.apiKey)
config.onSessionReplayChanged = null

config.integrations.forEach {
try {
Expand Down
38 changes: 37 additions & 1 deletion posthog/src/main/java/com/posthog/PostHogConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -401,6 +401,42 @@ public open class PostHogConfig(
@Volatile
public var requestHeaders: Map<String, String> = 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po
public var lastEventName: String? = null
public var lastEventProperties: Map<String, Any>? = null
public var onSessionIdChangedCalled: Boolean = false
public var onSessionReplayConfigChangedCalled: Boolean = false

public fun reset() {
stopCalled = false
Expand All @@ -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) {
Expand Down Expand Up @@ -48,4 +50,8 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po
override fun onSessionIdChanged() {
onSessionIdChangedCalled = true
}

override fun onSessionReplayConfigChanged() {
onSessionReplayConfigChangedCalled = true
}
}
36 changes: 36 additions & 0 deletions posthog/src/test/java/com/posthog/PostHogTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading