From 69e6f9e3c9e5fbbef5ed9021df102921bd23d6c1 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 2 Oct 2026 20:40:08 +0300 Subject: [PATCH 1/2] fix(replay): pare down debug properties on captured events --- .changeset/brave-owls-drift.md | 6 + .../replay/PostHogReplayIntegration.kt | 1 - .../replay/PostHogReplayIntegrationTest.kt | 5 +- .../json/snapshots/batch-request.json | 58 +-- posthog/src/main/java/com/posthog/PostHog.kt | 110 ++++- .../main/java/com/posthog/PostHogStateless.kt | 23 +- .../replay/PostHogSessionReplayHandler.kt | 5 +- .../src/test/java/com/posthog/PostHogTest.kt | 380 ++++++++++++++++-- 8 files changed, 497 insertions(+), 91 deletions(-) create mode 100644 .changeset/brave-owls-drift.md diff --git a/.changeset/brave-owls-drift.md b/.changeset/brave-owls-drift.md new file mode 100644 index 000000000..fdd5fb025 --- /dev/null +++ b/.changeset/brave-owls-drift.md @@ -0,0 +1,6 @@ +--- +'posthog': patch +'posthog-android': patch +--- + +Keep `$recording_status`, the replay trigger statuses and the replay buffer length on every captured event. Add the remaining `$sdk_debug_*` session replay properties and `$sdk_debug_session_start` only to SDK events (names starting with `$`, excluding `$feature_flag_called` and `$snapshot`), at most once every 30 seconds. Remove `$sdk_debug_current_session_duration` and `$sdk_debug_replay_throttle_delay_ms`. 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 af6d4b885..7c4a08681 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 @@ -2606,7 +2606,6 @@ public class PostHogReplayIntegration( val captureMode = if (isScreenshotCapable) "screenshot" else "wireframe" props["\$sdk_debug_replay_capture_mode"] = captureMode - props["\$sdk_debug_replay_throttle_delay_ms"] = config.sessionReplayConfig.throttleDelayMs props["\$sdk_debug_replay_internal_buffer_length"] = if (buffering) replayQueue?.bufferDepth ?: 0 else replayQueue?.size ?: 0 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 d6bab3df6..469af7c55 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 @@ -4385,7 +4385,7 @@ internal class PostHogReplayIntegrationTest { val sut = getSut(config) assertEquals("wireframe", sut.debugProperties()["\$sdk_debug_replay_capture_mode"]) - assertEquals(1000L, sut.debugProperties()["\$sdk_debug_replay_throttle_delay_ms"]) + assertFalse(sut.debugProperties().containsKey("\$sdk_debug_replay_throttle_delay_ms")) } @Test @@ -4519,7 +4519,6 @@ internal class PostHogReplayIntegrationTest { assertEquals("disabled", props["\$recording_status"]) assertFalse(props.containsKey("\$sdk_debug_replay_flush_hold_reason")) assertEquals("wireframe", props["\$sdk_debug_replay_capture_mode"]) - assertEquals(1000L, props["\$sdk_debug_replay_throttle_delay_ms"]) assertEquals(0, props["\$sdk_debug_replay_internal_buffer_length"]) } finally { sut.uninstall() @@ -4578,7 +4577,7 @@ internal class PostHogReplayIntegrationTest { "linked_flag".takeIf { props["\$sdk_debug_replay_linked_flag_trigger_status"] == "trigger_pending" }, ) assertEquals(pending.ifEmpty { null }, props["\$sdk_debug_replay_pending_trigger_conditions"], "pending conditions vs statuses") - assertTrue(props.containsKey("\$sdk_debug_replay_capture_mode") && props.containsKey("\$sdk_debug_replay_throttle_delay_ms")) + assertTrue(props.containsKey("\$sdk_debug_replay_capture_mode")) } assertEquals("disabled", sut.debugProperties()["\$recording_status"]) assertFalse(sut.debugProperties().containsKey("\$sdk_debug_replay_flush_hold_reason")) diff --git a/posthog-android/src/test/resources/json/snapshots/batch-request.json b/posthog-android/src/test/resources/json/snapshots/batch-request.json index 9a9d0d096..c9d3b3997 100644 --- a/posthog-android/src/test/resources/json/snapshots/batch-request.json +++ b/posthog-android/src/test/resources/json/snapshots/batch-request.json @@ -43,8 +43,6 @@ }, "$lib": "posthog-android", "$lib_version": "", - "$is_identified": false, - "$process_person_profile": true, "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "amount": 42.5, "currency": "USD", @@ -54,15 +52,13 @@ "quantity": 2 } ], + "$is_identified": false, + "$process_person_profile": true, + "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_capture_mode": "wireframe", - "$sdk_debug_replay_throttle_delay_ms": 1000, - "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_session_start": 1700000000123, - "$sdk_debug_current_session_duration": 0, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_replay_internal_buffer_length": 0 }, "timestamp": "2023-11-14T22:13:20.123Z", "uuid": "" @@ -100,19 +96,17 @@ }, "$lib": "posthog-android", "$lib_version": "", - "$is_identified": true, - "$process_person_profile": true, "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$anon_distinct_id": "anonymous-user", + "$is_identified": true, + "$process_person_profile": true, + "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", "$sdk_debug_replay_capture_mode": "wireframe", - "$sdk_debug_replay_throttle_delay_ms": 1000, "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_session_start": 1700000000123, - "$sdk_debug_current_session_duration": 0, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_session_start": 1700000000123 }, "timestamp": "2023-11-14T22:13:20.123Z", "uuid": "" @@ -141,8 +135,6 @@ "$network_carrier": "Snapshot Telecom", "$lib": "posthog-android", "$lib_version": "", - "$is_identified": true, - "$process_person_profile": true, "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$group_type": "company", "$group_key": "posthog", @@ -150,15 +142,13 @@ "industry": "analytics", "employees": 100 }, + "$is_identified": true, + "$process_person_profile": true, + "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_capture_mode": "wireframe", - "$sdk_debug_replay_throttle_delay_ms": 1000, - "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_session_start": 1700000000123, - "$sdk_debug_current_session_duration": 0, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_replay_internal_buffer_length": 0 }, "timestamp": "2023-11-14T22:13:20.123Z", "uuid": "" @@ -194,8 +184,6 @@ "$active_feature_flags": [ "snapshot-flag" ], - "$is_identified": true, - "$process_person_profile": true, "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$feature_flag": "snapshot-flag", "$feature_flag_response": "snapshot-variant", @@ -203,15 +191,13 @@ "$feature_flag_id": 123, "$feature_flag_version": 7, "$feature_flag_reason": "", + "$is_identified": true, + "$process_person_profile": true, + "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_capture_mode": "wireframe", - "$sdk_debug_replay_throttle_delay_ms": 1000, - "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_session_start": 1700000000123, - "$sdk_debug_current_session_duration": 0, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_replay_internal_buffer_length": 0 }, "timestamp": "2023-11-14T22:13:20.123Z", "uuid": "" @@ -247,8 +233,6 @@ "$active_feature_flags": [ "snapshot-flag" ], - "$is_identified": true, - "$process_person_profile": true, "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$exception_level": "error", "$exception_list": [ @@ -308,15 +292,13 @@ ], "handled": true, "component": "checkout", + "$is_identified": true, + "$process_person_profile": true, + "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_capture_mode": "wireframe", - "$sdk_debug_replay_throttle_delay_ms": 1000, - "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_session_start": 1700000000123, - "$sdk_debug_current_session_duration": 0, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_replay_internal_buffer_length": 0 }, "timestamp": "2023-11-14T22:13:20.123Z", "uuid": "" diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index b74829757..96354af96 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -49,6 +49,21 @@ import java.util.concurrent.Executors private const val PUSH_NOTIFICATION_OPENED_EVENT = "\$push_notification_opened" private const val MAX_DEBUG_ERROR_LENGTH = 500 +private const val REPLAY_DEBUG_PROPERTIES_INTERVAL_MILLIS = 30_000L + +// Kept on every event because the app reads them per event; the rest of the replay debug bundle is throttled. +private val REQUIRED_REPLAY_DEBUG_PROPERTY_KEYS = + setOf( + "\$recording_status", + "\$sdk_debug_replay_event_trigger_status", + "\$sdk_debug_replay_linked_flag_trigger_status", + "\$sdk_debug_replay_internal_buffer_length", + ) + +private fun isReplayDebugEvent(event: String): Boolean = + event.startsWith("\$") && + event != PostHogEventName.FEATURE_FLAG_CALLED.event && + event != PostHogEventName.SNAPSHOT.event // A duplicate report of one tap arrives within the same launch: milliseconds after a warm tap, seconds // after a cold start while the host's JS/Dart handlers register. Finite so that a re-send carrying no @@ -99,6 +114,11 @@ public class PostHog private constructor( private val featureFlagsCalledLock = Any() private val cachedPersonPropertiesLock = Any() + private val replayDebugPropertiesLock = Any() + + // Guarded by replayDebugPropertiesLock. + private var lastReplayDebugPropertiesAt: Long? = null + private var outstandingReplayDebugClaimAt: Long? = null private var replayQueue: PostHogQueueInterface? = null @@ -570,6 +590,10 @@ public class PostHog private constructor( featureFlagsCalled.clear() lastScreenName = null + synchronized(replayDebugPropertiesLock) { + lastReplayDebugPropertiesAt = null + outstandingReplayDebugClaimAt = null + } PostHogSessionManager.setOnSessionIdChangedListener(null) @@ -669,6 +693,7 @@ public class PostHog private constructor( appendSharedProps: Boolean = true, appendGroups: Boolean = true, timestamp: Date? = null, + claimReplayDebugBundle: () -> Boolean = { false }, ): MutableMap { val props = mutableMapOf() @@ -762,7 +787,7 @@ public class PostHog private constructor( // extend(properties, sdkDebugProperties). After session resolution so the debug snapshot // never precedes a rotation triggered by getActiveSessionId() above. if (appendSharedProps) { - props.putAll(sdkDebugProperties(sessionIdString, timestamp)) + props.putAll(sdkDebugProperties(sessionIdString, timestamp, claimReplayDebugBundle)) } // only Session replay needs distinct_id also in the props @@ -779,6 +804,7 @@ public class PostHog private constructor( private fun sdkDebugProperties( sessionId: String?, timestamp: Date?, + claimReplayDebugBundle: () -> Boolean, ): Map { val props = mutableMapOf() val managerStart = PostHogSessionManager.getSessionStartedAt() @@ -787,17 +813,22 @@ public class PostHog private constructor( if (timestamp != null && managerStart > 0 && timestamp.time < managerStart) { return props } + val claimed = claimReplayDebugBundle() // Guarded separately from the session and queue keys below: those come from the session // manager and the queue, not from replay, so a handler that throws must not suppress them. try { - props.putAll(sessionReplayHandler?.debugProperties() ?: mapOf("\$recording_status" to "disabled")) + val replayProps = sessionReplayHandler?.debugProperties() ?: mapOf("\$recording_status" to "disabled") + if (claimed) { + props.putAll(replayProps) + } else { + REQUIRED_REPLAY_DEBUG_PROPERTY_KEYS.forEach { key -> replayProps[key]?.let { props[key] = it } } + } } catch (e: Throwable) { props["\$sdk_debug_error_capturing_properties"] = e.toString().take(MAX_DEBUG_ERROR_LENGTH) } try { - sessionStart(sessionId, managerStart)?.let { start -> - props["\$sdk_debug_session_start"] = start - props["\$sdk_debug_current_session_duration"] = PostHogSessionManager.currentTimeMillis() - start + if (claimed) { + sessionStart(sessionId, managerStart)?.let { props["\$sdk_debug_session_start"] = it } } queue?.size?.let { props["\$sdk_debug_pending_queue_size"] = it } } catch (e: Throwable) { @@ -806,6 +837,43 @@ public class PostHog private constructor( return props } + private fun currentTimeMillis(): Long = config?.dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() + + private fun isWithinReplayDebugInterval( + since: Long?, + now: Long, + ): Boolean = since != null && now - since in 0 until REPLAY_DEBUG_PROPERTIES_INTERVAL_MILLIS + + /** + * Single outstanding claim, so a capture inside `beforeSend` can't also take it; a claim older + * than the interval counts as leaked. The window starts at commit, on wall clock so a + * future-dated capture can't hold it shut. + */ + private fun claimReplayDebugPropertiesWindow(): Boolean { + synchronized(replayDebugPropertiesLock) { + val now = currentTimeMillis() + if (isWithinReplayDebugInterval(lastReplayDebugPropertiesAt, now) || + isWithinReplayDebugInterval(outstandingReplayDebugClaimAt, now) + ) { + return false + } + outstandingReplayDebugClaimAt = now + return true + } + } + + private fun commitReplayDebugPropertiesWindow() { + synchronized(replayDebugPropertiesLock) { + lastReplayDebugPropertiesAt = currentTimeMillis() + } + } + + private fun releaseReplayDebugPropertiesClaim() { + synchronized(replayDebugPropertiesLock) { + outstandingReplayDebugClaimAt = null + } + } + // The manager's clock for its own session. A caller-supplied id (the RN/Flutter bridges) carries // its start in the UUIDv7 timestamp, the derivation posthog-js applies to a bootstrapped id. private fun sessionStart( @@ -848,6 +916,9 @@ public class PostHog private constructor( groups: Map?, timestamp: Date?, ) { + // Tracked on this call rather than derived from the event, so it survives beforeSend renaming + // or replacing the event. + var claimedReplayDebugBundle = false try { if (!isEnabled()) { return @@ -917,6 +988,9 @@ public class PostHog private constructor( // only append groups if not a group identify event and not a snapshot appendGroups = !groupIdentify, timestamp = timestamp, + claimReplayDebugBundle = { + isReplayDebugEvent(event) && claimReplayDebugPropertiesWindow().also { claimedReplayDebugBundle = it } + }, ) val postHogEvent = buildEvent(event, newDistinctId, mergedProperties, timestamp) @@ -942,15 +1016,19 @@ public class PostHog private constructor( if (isSnapshotEvent) { replayQueue?.add(postHogEvent) } else { - super.captureStateless( - postHogEvent.event, - newDistinctId, - postHogEvent.properties ?: emptyMap(), - userProperties, - userPropertiesSetOnce, - groups, - timestamp, - ) + val queued = + captureStatelessInternal( + postHogEvent.event, + newDistinctId, + postHogEvent.properties ?: emptyMap(), + userProperties, + userPropertiesSetOnce, + groups, + timestamp, + ) + if (queued && claimedReplayDebugBundle) { + commitReplayDebugPropertiesWindow() + } // Notify surveys integration about the event surveysHandler?.onEvent(event, mergedProperties) // Notify session replay handler about the event for event triggers @@ -958,6 +1036,10 @@ public class PostHog private constructor( } } catch (e: Throwable) { config?.logger?.log("Capture failed: $e.") + } finally { + if (claimedReplayDebugBundle) { + releaseReplayDebugPropertiesClaim() + } } } diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index 4eb589176..69f9eb877 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -239,14 +239,26 @@ public open class PostHogStateless protected constructor( groups: Map?, timestamp: Date?, ) { + captureStatelessInternal(event, distinctId, properties, userProperties, userPropertiesSetOnce, groups, timestamp) + } + + internal fun captureStatelessInternal( + event: String, + distinctId: String, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + ): Boolean { try { if (!isEnabled()) { - return + return false } if (config?.optOut == true) { config?.logger?.log("PostHog is in OptOut state.") - return + return false } var groupIdentify = false @@ -286,12 +298,15 @@ public open class PostHogStateless protected constructor( originalMessage } config?.logger?.log(message) - return + return false } - queue?.add(postHogEvent) + val queue = queue ?: return false + queue.add(postHogEvent) + return true } catch (e: Throwable) { config?.logger?.log("Capture failed: $e.") + return false } } 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 c521e80bd..36027641b 100644 --- a/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt +++ b/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt @@ -11,8 +11,9 @@ public interface PostHogSessionReplayHandler { public fun isActive(): Boolean /** - * Snapshot of `$recording_status` and `$sdk_debug_replay_*` properties to attach to captured - * events. Must always include `$recording_status`; the rest are omitted when not applicable. + * Snapshot of `$recording_status` and `$sdk_debug_replay_*` properties. PostHog attaches only the + * required subset to every event and the full map to at most one SDK event per 30s. Must always + * include `$recording_status`; the rest are omitted when not applicable. */ public fun debugProperties(): Map diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 90f52559e..c49f23340 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -34,6 +34,7 @@ import okhttp3.mockwebserver.MockWebServer import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File +import java.util.Date import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -51,6 +52,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +private const val SDK_EVENT = "\$sdk_event" + internal class PostHogTest { @get:Rule val tmpDir = TemporaryFolder() @@ -753,6 +756,8 @@ internal class PostHogTest { assertEquals(true, theEvent.properties!!["\$feature/4535-funnel-bar-viz"]) assertTrue(theEvent.properties!!.containsKey("\$active_feature_flags")) assertEquals("disabled", theEvent.properties!!["\$recording_status"]) + assertFalse(theEvent.properties!!.containsKey("\$sdk_debug_session_start")) + assertNotNull(theEvent.properties!!["\$sdk_debug_pending_queue_size"]) sut.close() } @@ -2807,19 +2812,12 @@ internal class PostHogTest { } @Test - fun `custom event carries debug properties from the replay handler`() { + fun `custom event carries only the required replay debug keys from the replay handler`() { val http = mockHttp() val url = http.url("/") - val integration = - PostHogSessionReplayHandlerFake(false).apply { - debugProperties = - mapOf( - "\$recording_status" to "disabled", - "\$sdk_debug_replay_capture_mode" to "wireframe", - "\$sdk_debug_replay_throttle_delay_ms" to 1000, - ) - } + val integration = replayHandlerWithFullBundle() val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, integration = integration) + sut.startSession() sut.capture(EVENT) @@ -2829,13 +2827,16 @@ internal class PostHogTest { val content = request.body.unGzip() val batch = serializer.deserialize(content.reader()) - val theEvent = batch.batch.first() - assertEquals("disabled", theEvent.properties!!["\$recording_status"]) - assertEquals("wireframe", theEvent.properties!!["\$sdk_debug_replay_capture_mode"]) - assertEquals(1000, theEvent.properties!!["\$sdk_debug_replay_throttle_delay_ms"]) - assertNotNull(theEvent.properties!!["\$sdk_debug_session_start"]) - assertNotNull(theEvent.properties!!["\$sdk_debug_current_session_duration"]) - assertNotNull(theEvent.properties!!["\$sdk_debug_pending_queue_size"]) + val props = batch.batch.first().properties!! + assertEquals("buffering", props["\$recording_status"]) + assertEquals("trigger_pending", props["\$sdk_debug_replay_event_trigger_status"]) + assertEquals("trigger_disabled", props["\$sdk_debug_replay_linked_flag_trigger_status"]) + assertEquals(1, (props["\$sdk_debug_replay_internal_buffer_length"] as Number).toInt()) + assertNotNull(props["\$sdk_debug_pending_queue_size"]) + assertFalse(props.containsKey("\$sdk_debug_replay_capture_mode")) + assertFalse(props.containsKey("\$sdk_debug_replay_flush_hold_reason")) + assertFalse(props.containsKey("\$sdk_debug_replay_pending_trigger_conditions")) + assertFalse(props.containsKey("\$sdk_debug_session_start")) sut.close() } @@ -2850,7 +2851,6 @@ internal class PostHogTest { mapOf( "\$recording_status" to "disabled", "\$sdk_debug_replay_capture_mode" to "wireframe", - "\$sdk_debug_replay_throttle_delay_ms" to 1000, ) } val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, integration = integration) @@ -2867,10 +2867,10 @@ internal class PostHogTest { assertEquals("\$exception", theEvent.event) assertEquals("disabled", theEvent.properties!!["\$recording_status"]) assertEquals("wireframe", theEvent.properties!!["\$sdk_debug_replay_capture_mode"]) - assertEquals(1000, theEvent.properties!!["\$sdk_debug_replay_throttle_delay_ms"]) assertNotNull(theEvent.properties!!["\$sdk_debug_session_start"]) - assertNotNull(theEvent.properties!!["\$sdk_debug_current_session_duration"]) assertNotNull(theEvent.properties!!["\$sdk_debug_pending_queue_size"]) + assertFalse(theEvent.properties!!.containsKey("\$sdk_debug_replay_throttle_delay_ms")) + assertFalse(theEvent.properties!!.containsKey("\$sdk_debug_current_session_duration")) sut.close() } @@ -2910,7 +2910,7 @@ internal class PostHogTest { } val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, integration = integration) - sut.capture(EVENT) + sut.capture(SDK_EVENT) queueExecutor.shutdownAndAwaitTermination() @@ -2991,7 +2991,7 @@ internal class PostHogTest { } val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, integration = integration) - sut.capture(EVENT) + sut.capture(SDK_EVENT) queueExecutor.shutdownAndAwaitTermination() @@ -3000,17 +3000,341 @@ internal class PostHogTest { val batch = serializer.deserialize(content.reader()) val theEvent = batch.batch.first() - assertEquals(EVENT, theEvent.event) + assertEquals(SDK_EVENT, theEvent.event) assertNotNull(theEvent.properties!!["\$sdk_debug_error_capturing_properties"]) assertFalse(theEvent.properties!!.containsKey("\$recording_status")) // The session and queue keys do not come from the replay handler, so they survive its throw. assertNotNull(theEvent.properties!!["\$sdk_debug_session_start"]) - assertNotNull(theEvent.properties!!["\$sdk_debug_current_session_duration"]) assertNotNull(theEvent.properties!!["\$sdk_debug_pending_queue_size"]) sut.close() } + private fun replayHandlerWithFullBundle(): PostHogSessionReplayHandlerFake = + PostHogSessionReplayHandlerFake(false).apply { + debugProperties = + mapOf( + "\$recording_status" to "buffering", + "\$sdk_debug_replay_event_trigger_status" to "trigger_pending", + "\$sdk_debug_replay_linked_flag_trigger_status" to "trigger_disabled", + "\$sdk_debug_replay_internal_buffer_length" to 1, + "\$sdk_debug_replay_flush_hold_reason" to "awaiting_remote_config", + "\$sdk_debug_replay_pending_trigger_conditions" to listOf("event_trigger"), + "\$sdk_debug_replay_capture_mode" to "wireframe", + ) + } + + private fun capturedEvents(http: MockWebServer): List { + queueExecutor.shutdownAndAwaitTermination() + return serializer.deserialize(http.takeRequest().body.unGzip().reader())!!.batch + } + + private val PostHogEvent.carriesReplayDebugBundle: Boolean + get() = properties!!.containsKey("\$sdk_debug_replay_capture_mode") + + private fun replayDebugSut( + http: MockWebServer, + flushAt: Int, + beforeSend: PostHogBeforeSend? = null, + ): PostHogInterface = + getSut( + http.url("/").toString(), + flushAt = flushAt, + preloadFeatureFlags = false, + reloadFeatureFlags = false, + integration = replayHandlerWithFullBundle(), + beforeSend = beforeSend, + ) + + @Test + fun `replay debug bundle is attached at most once every 30 seconds while required keys stay on every event`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 3) + config.dateProvider = clock + + sut.capture(SDK_EVENT) + clock.nowMs += 29_000 + sut.capture(SDK_EVENT) + clock.nowMs += 1_000 + sut.capture(SDK_EVENT) + + val events = capturedEvents(http) + assertEquals(listOf(true, false, true), events.map { it.carriesReplayDebugBundle }) + events.forEach { + assertEquals("buffering", it.properties!!["\$recording_status"]) + assertEquals("trigger_pending", it.properties!!["\$sdk_debug_replay_event_trigger_status"]) + assertEquals("trigger_disabled", it.properties!!["\$sdk_debug_replay_linked_flag_trigger_status"]) + assertNotNull(it.properties!!["\$sdk_debug_replay_internal_buffer_length"]) + assertNotNull(it.properties!!["\$sdk_debug_pending_queue_size"]) + } + + sut.close() + } + + @Test + fun `replay debug bundle follows the wall clock, not a future event timestamp`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 3) + config.dateProvider = clock + + sut.capture(SDK_EVENT, timestamp = Date(clock.nowMs + 60 * 60 * 1000)) + clock.nowMs += 30_000 + sut.capture(SDK_EVENT, timestamp = Date(clock.nowMs)) + clock.nowMs += 1_000 + sut.capture(SDK_EVENT, timestamp = Date(clock.nowMs)) + + assertEquals(listOf(true, true, false), capturedEvents(http).map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `replay debug window reopens when the wall clock moves backwards`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 3) + config.dateProvider = clock + + sut.capture(SDK_EVENT) + clock.nowMs -= 60_000 + sut.capture(SDK_EVENT) + clock.nowMs += 1_000 + sut.capture(SDK_EVENT) + + assertEquals(listOf(true, true, false), capturedEvents(http).map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `feature flag called and custom events carry the required replay keys but never the bundle, and do not arm the window`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 3) + config.dateProvider = clock + + sut.capture(PostHogEventName.FEATURE_FLAG_CALLED.event, properties = mapOf("\$feature_flag" to "flag")) + sut.capture(EVENT) + clock.nowMs += 1_000 + sut.capture(SDK_EVENT) + + val events = capturedEvents(http) + assertEquals(listOf(false, false, true), events.map { it.carriesReplayDebugBundle }) + events.forEach { + assertEquals("buffering", it.properties!!["\$recording_status"]) + assertNotNull(it.properties!!["\$sdk_debug_pending_queue_size"]) + } + + sut.close() + } + + @Test + fun `an event dropped by beforeSend releases its claim without starting the window`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 2, beforeSend = { if (it.event == "\$dropped") null else it }) + config.dateProvider = clock + + sut.capture("\$dropped") + sut.capture(SDK_EVENT) + clock.nowMs += 1_000 + sut.capture(SDK_EVENT) + + val events = capturedEvents(http) + assertEquals(listOf(SDK_EVENT, SDK_EVENT), events.map { it.event }) + assertEquals(listOf(true, false), events.map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `an event without the bundle does not consume the window when the interval elapses mid-capture`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = + replayDebugSut(http, flushAt = 3, beforeSend = { + if (it.event == "\$inside") clock.nowMs += 6_000 + it + }) + config.dateProvider = clock + + sut.capture("\$first") + clock.nowMs += 29_000 + sut.capture("\$inside") + clock.nowMs += 1_000 + sut.capture("\$after") + + assertEquals(listOf(true, false, true), capturedEvents(http).map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `a deduplicated identify set does not arm the window`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 4) + config.dateProvider = clock + + sut.identify("user_dedup") + clock.nowMs += 31_000 + sut.identify("user_dedup", userProperties = mapOf("name" to "John")) + clock.nowMs += 31_000 + sut.identify("user_dedup", userProperties = mapOf("name" to "John")) + sut.capture("\$after") + clock.nowMs += 1_000 + sut.capture("\$throttled") + + val events = capturedEvents(http) + assertEquals(listOf("\$identify", "\$set", "\$after", "\$throttled"), events.map { it.event }) + assertEquals(listOf(true, true, true, false), events.map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `an SDK event captured from inside beforeSend does not also carry the bundle`() { + val http = mockHttp() + lateinit var sut: PostHogInterface + // beforeSend runs twice per stateful capture (PostHog.capture, then captureStateless), so the + // nested capture fires twice. + sut = + replayDebugSut(http, flushAt = 3, beforeSend = { + if (it.event == "\$outer") sut.capture("\$inner") + it + }) + + sut.capture("\$outer") + + val carriers = capturedEvents(http).filter { it.carriesReplayDebugBundle } + assertEquals(listOf("\$outer"), carriers.map { it.event }) + + sut.close() + } + + @Test + fun `an SDK event renamed by beforeSend still carries the bundle and consumes the window`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 2, beforeSend = { if (it.event == "\$renamed") it.copy(event = "custom name") else it }) + config.dateProvider = clock + + sut.capture("\$renamed") + clock.nowMs += 1_000 + sut.capture("\$after") + + val events = capturedEvents(http) + assertEquals(listOf("custom name", "\$after"), events.map { it.event }) + assertEquals(listOf(true, false), events.map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `a custom event renamed to an SDK name by beforeSend carries no bundle and does not consume the window`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 2, beforeSend = { if (it.event == "custom name") it.copy(event = "\$renamed") else it }) + config.dateProvider = clock + + sut.capture("custom name") + clock.nowMs += 1_000 + sut.capture("\$after") + + val events = capturedEvents(http) + assertEquals(listOf("\$renamed", "\$after"), events.map { it.event }) + assertEquals(listOf(false, true), events.map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `the replay debug window starts when the event is accepted, not while beforeSend runs`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = + replayDebugSut(http, flushAt = 2, beforeSend = { + if (it.event == "\$slow") clock.nowMs += 31_000 + it + }) + config.dateProvider = clock + + sut.capture("\$slow") + sut.capture("\$next") + + assertEquals(listOf(true, false), capturedEvents(http).map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `a claimer routed to the replay queue as a snapshot releases its claim`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = + replayDebugSut(http, flushAt = 2, beforeSend = { + if (it.event == "\$becomes_snapshot") it.copy(event = PostHogEventName.SNAPSHOT.event) else it + }) + config.dateProvider = clock + + sut.capture("\$becomes_snapshot") + sut.capture(SDK_EVENT) + clock.nowMs += 1_000 + sut.capture(SDK_EVENT) + + assertEquals(listOf(true, false), capturedEvents(http).map { it.carriesReplayDebugBundle }) + + sut.close() + } + + @Test + fun `no internal replay debug state reaches a queued event`() { + val http = mockHttp() + val sut = replayDebugSut(http, flushAt = 1) + + sut.capture(SDK_EVENT) + + val props = capturedEvents(http).single().properties!! + assertTrue(props.keys.none { it.startsWith("\$__") }, "unexpected internal keys: ${props.keys}") + + sut.close() + } + + @Test + fun `closing the SDK clears the replay debug window`() { + val http = mockHttp() + val clock = TestDateProvider(System.currentTimeMillis()) + val sut = replayDebugSut(http, flushAt = 2) + config.dateProvider = clock + + sut.capture(SDK_EVENT) + sut.capture(SDK_EVENT) + assertEquals(listOf(true, false), capturedEvents(http).map { it.carriesReplayDebugBundle }) + sut.close() + + val carried = CopyOnWriteArrayList() + val newConfig = + PostHogConfig(API_KEY, http.url("/").toString()).apply { + storagePrefix = tmpDir.newFolder().absolutePath + preloadFeatureFlags = false + addIntegration(replayHandlerWithFullBundle()) + dateProvider = clock + addBeforeSend { + carried.add(it.properties!!.containsKey("\$sdk_debug_replay_capture_mode")) + it + } + } + sut.setup(newConfig) + sut.capture(SDK_EVENT) + + assertTrue(carried.isNotEmpty()) + assertTrue(carried.all { it }) + + 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") @@ -5447,13 +5771,12 @@ internal class PostHogTest { val callerSessionId = TimeBasedEpochGenerator.generate() TimeBasedEpochGenerator.setDateProvider(com.posthog.internal.PostHogDeviceDateProvider()) - sut.capture(EVENT, DISTINCT_ID, properties = mapOf("\$session_id" to callerSessionId.toString())) + sut.capture(SDK_EVENT, DISTINCT_ID, properties = mapOf("\$session_id" to callerSessionId.toString())) queueExecutor.shutdownAndAwaitTermination() val props = serializer.deserialize(http.takeRequest().body.unGzip().reader()).batch.first().properties!! assertEquals(callerSessionId.toString(), props["\$session_id"]) assertEquals(managerStart - oneHourMs, (props["\$sdk_debug_session_start"] as Number).toLong()) - assertTrue((props["\$sdk_debug_current_session_duration"] as Number).toLong() >= oneHourMs) sut.close() } @@ -5465,13 +5788,12 @@ internal class PostHogTest { val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false) sut.startSession() - sut.capture(EVENT, DISTINCT_ID, properties = mapOf("\$session_id" to java.util.UUID.randomUUID().toString())) + sut.capture(SDK_EVENT, DISTINCT_ID, properties = mapOf("\$session_id" to java.util.UUID.randomUUID().toString())) queueExecutor.shutdownAndAwaitTermination() val props = serializer.deserialize(http.takeRequest().body.unGzip().reader()).batch.first().properties!! assertEquals("disabled", props["\$recording_status"]) assertFalse(props.containsKey("\$sdk_debug_session_start")) - assertFalse(props.containsKey("\$sdk_debug_current_session_duration")) sut.close() } @@ -5513,7 +5835,7 @@ internal class PostHogTest { sut.startSession() val start = PostHogSessionManager.getSessionStartedAt() - sut.capture(EVENT, DISTINCT_ID, timestamp = java.util.Date(start + 1)) + sut.capture(SDK_EVENT, DISTINCT_ID, timestamp = java.util.Date(start + 1)) queueExecutor.shutdownAndAwaitTermination() val props = serializer.deserialize(http.takeRequest().body.unGzip().reader()).batch.first().properties!! From cf9a86a218b1e9b438d9eb79bdb54477792e302b Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 2 Oct 2026 20:43:42 +0300 Subject: [PATCH 2/2] fix(replay): keep the replay debug window shut when the clock moves backwards --- posthog/src/main/java/com/posthog/PostHog.kt | 2 +- posthog/src/test/java/com/posthog/PostHogTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 96354af96..80fa17205 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -842,7 +842,7 @@ public class PostHog private constructor( private fun isWithinReplayDebugInterval( since: Long?, now: Long, - ): Boolean = since != null && now - since in 0 until REPLAY_DEBUG_PROPERTIES_INTERVAL_MILLIS + ): Boolean = since != null && now - since < REPLAY_DEBUG_PROPERTIES_INTERVAL_MILLIS /** * Single outstanding claim, so a capture inside `beforeSend` can't also take it; a claim older diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index c49f23340..8b0134cd4 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -3091,7 +3091,7 @@ internal class PostHogTest { } @Test - fun `replay debug window reopens when the wall clock moves backwards`() { + fun `replay debug window stays shut when the wall clock moves backwards until it catches up`() { val http = mockHttp() val clock = TestDateProvider(System.currentTimeMillis()) val sut = replayDebugSut(http, flushAt = 3) @@ -3100,10 +3100,10 @@ internal class PostHogTest { sut.capture(SDK_EVENT) clock.nowMs -= 60_000 sut.capture(SDK_EVENT) - clock.nowMs += 1_000 + clock.nowMs += 90_000 sut.capture(SDK_EVENT) - assertEquals(listOf(true, true, false), capturedEvents(http).map { it.carriesReplayDebugBundle }) + assertEquals(listOf(true, false, true), capturedEvents(http).map { it.carriesReplayDebugBundle }) sut.close() }