diff --git a/sdk/@launchdarkly/observability-android/README.md b/sdk/@launchdarkly/observability-android/README.md index 3d524bda0e..f39bdf18f4 100644 --- a/sdk/@launchdarkly/observability-android/README.md +++ b/sdk/@launchdarkly/observability-android/README.md @@ -525,6 +525,14 @@ LDReplay.start() Call `LDReplay.stop()` to pause recording. +`LDReplay.start()` returns a `SessionReplayStartResult` reporting whether recording began, and why not when it +didn't — `isRunning` collapses it to a boolean. Useful when a replay you expected never shows up in the +dashboard. + +`sampleRate` is evaluated once per enable cycle, so a session it excluded stays excluded until +`LDReplay.stop()` resets the decision. To record regardless — when reproducing a bug, for instance — pass +`LDReplay.start(ignoreSampling = true)`. + Recording can also end on its own: if LaunchDarkly refuses the session — for example when session replay is not available for the environment — the SDK stops capturing for the rest of the launch, and `LDReplay.isEnabled` reports `false`. The next launch withholds screenshots until the backend accepts a diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index 503196c3dc..a9239e4323 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt @@ -393,22 +393,41 @@ class SessionReplayService( set(value) { if (_isEnabled.value == value) return if (value) { - // A launch the backend has refused cannot record again, so enabling must not report itself - // as enabled. The next launch tries again. Checking and enabling under the gate's lock, the - // same one a verdict is applied under, keeps a refusal that lands in between from being - // overwritten by the enable that raced it. - val canStart = withGate { canStartRecording.also { if (it) _isEnabled.value = true } } - if (!canStart) { - logger.info("Session replay cannot start, the backend refused this launch.") - return - } - attemptStart(ignoreSampling = false) + start(ignoreSampling = false) } else { _isEnabled.value = false stopRecording() } } + /** + * Enables capture and reports the outcome. + * + * Deliberately without the `isEnabled` setter's no-change short-circuit: a session that is + * enabled but not recording — most commonly one [ReplayOptions.sampleRate] excluded — is only + * reachable through here, and only with [ignoreSampling] set. + */ + override fun start(ignoreSampling: Boolean): SessionReplayStartResult { + // Nothing is wired up until `initialize()` installs the collectors and exporter, so a start + // would enable a service that cannot record or export. Record the intent anyway — this is + // the state `initialize()` reads to decide whether to begin capture — but report that the + // outcome is not settled yet. + if (!isInstalled) { + _isEnabled.value = true + return SessionReplayStartResult.UNAVAILABLE + } + // A launch the backend has refused cannot record again, so enabling must not report itself + // as enabled. The next launch tries again. Checking and enabling under the gate's lock, the + // same one a verdict is applied under, keeps a refusal that lands in between from being + // overwritten by the enable that raced it. + val canStart = withGate { canStartRecording.also { if (it) _isEnabled.value = true } } + if (!canStart) { + logger.info("Session replay cannot start, the backend refused this launch.") + return SessionReplayStartResult.UNRECOVERABLE_ERROR + } + return attemptStart(ignoreSampling) + } + /** * Whether this session was selected by [ReplayOptions.sampleRate] and is actively recording. * When [isEnabled] is true but sampling excluded this session, this stays false. @@ -416,17 +435,17 @@ class SessionReplayService( internal val isRunning: Boolean get() = _isRunning.value - private fun attemptStart(ignoreSampling: Boolean): Boolean { - if (_isRunning.value) return true + private fun attemptStart(ignoreSampling: Boolean): SessionReplayStartResult { + if (_isRunning.value) return SessionReplayStartResult.ALREADY_STARTED // The exporter stops talking to the backend after an unrecoverable refusal, so starting would only // collect events that can never be pushed. if (!withGate { canStartRecording }) { logger.info("Session replay cannot start, the backend refused this launch.") - return false + return SessionReplayStartResult.UNRECOVERABLE_ERROR } if (!samplingSession.shouldStartCapture(ignoreSampling, sampleRate)) { logger.info("Session replay skipped by sampling.") - return false + return SessionReplayStartResult.SAMPLED_OUT } // Re-checked while holding the gate's lock, the one a verdict is applied under, because a refusal // can land while sampling is being evaluated above: without this, its `stopRecording` would be @@ -434,7 +453,7 @@ class SessionReplayService( val canStart = withGate { canStartRecording.also { if (it) _isRunning.value = true } } if (!canStart) { logger.info("Session replay cannot start, the backend refused this launch.") - return false + return SessionReplayStartResult.UNRECOVERABLE_ERROR } flushPendingIdentify() // Ask the backend up front instead of waiting for the first export batch, so a refusal can stop @@ -442,7 +461,7 @@ class SessionReplayService( // the event queue meanwhile: they buffer there until the queue overflows, and are pushed as soon as // the session is accepted. startInitializationProbe() - return true + return SessionReplayStartResult.STARTED } private fun stopRecording() { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayStartResult.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayStartResult.kt new file mode 100644 index 0000000000..707c5aa2c9 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayStartResult.kt @@ -0,0 +1,32 @@ +package com.launchdarkly.observability.replay + +/** + * Outcome of a [com.launchdarkly.observability.sdk.LDReplay.start] call. + * + * Mirrors `SessionReplayStartResult` in the Swift session replay SDK, so a cross-platform bridge + * can report the same outcome on both platforms. + */ +enum class SessionReplayStartResult { + /** Session replay is not installed, or has not finished registering. */ + UNAVAILABLE, + + /** Session replay is recording because this call started it. */ + STARTED, + + /** Session replay was already recording before this call. */ + ALREADY_STARTED, + + /** Session replay stayed stopped because the session was sampled out. */ + SAMPLED_OUT, + + /** + * Session replay stayed stopped because the backend refused this launch in a way retrying + * cannot fix. Recording is only attempted again on the next launch, so starting again has no + * effect. + */ + UNRECOVERABLE_ERROR; + + /** Whether session replay is recording once this call has been applied. */ + val isRunning: Boolean + get() = this == STARTED || this == ALREADY_STARTED +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt index bfdeb37caa..5d413f2d48 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt @@ -1,6 +1,7 @@ package com.launchdarkly.observability.sdk import android.app.Activity +import com.launchdarkly.observability.replay.SessionReplayStartResult import com.launchdarkly.observability.replay.plugin.SessionReplayHookProxy import io.opentelemetry.api.common.Attributes @@ -9,7 +10,7 @@ import io.opentelemetry.api.common.Attributes * * If Session Replay is not configured, most methods are no-ops. The exceptions are stateful * operations whose data is buffered and replayed onto the live replay service during - * [init]: [isEnabled], [registerActivity], and [afterIdentify]. This way, callers can + * [init]: [isEnabled], [start], [registerActivity], and [afterIdentify]. This way, callers can * configure replay before SDK initialization without losing their preferences. * * All public operations are thread-safe. @@ -40,10 +41,25 @@ object LDReplay { state.setEnabled(value) } - /** Starts session replay capture. */ - fun start() { - isEnabled = true - } + /** + * Starts session replay capture and reports the outcome. + * + * Unlike setting [isEnabled] to `true`, this always attempts a start rather than short-circuiting + * when replay already reports itself enabled. That distinction matters for a session that is + * enabled but not recording — most commonly one that [com.launchdarkly.observability.replay.ReplayOptions.sampleRate] + * excluded — which can only be started by passing [ignoreSampling]. + * + * Calls made before the SDK has wired up the underlying replay service buffer the start (along + * with [ignoreSampling]) and apply it during [init], and report + * [SessionReplayStartResult.UNAVAILABLE] because the outcome is not known yet. + * + * @param ignoreSampling starts recording even when `sampleRate` would exclude this session. + * Intended for debugging: it also overrides a sampling decision already made for the current + * enable cycle, which a plain start cannot do until [stop] resets it. + */ + @JvmOverloads + fun start(ignoreSampling: Boolean = false): SessionReplayStartResult = + state.start(ignoreSampling) /** Pauses session replay capture. */ fun stop() { @@ -121,6 +137,12 @@ internal interface SessionReplayServicing { */ var isEnabled: Boolean + /** + * Enables capture and reports the outcome. Unlike assigning [isEnabled], this always attempts + * a start, so it can recover a session that reports itself enabled but is not recording. + */ + fun start(ignoreSampling: Boolean = false): SessionReplayStartResult + fun flush() fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) fun afterTrack(name: String, metricValue: Double?, attributes: Attributes) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt index 2dc84e8e93..f25ea8b3d5 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt @@ -1,6 +1,7 @@ package com.launchdarkly.observability.sdk import android.app.Activity +import com.launchdarkly.observability.replay.SessionReplayStartResult import com.launchdarkly.observability.util.runOnMainThread /** @@ -12,16 +13,16 @@ import com.launchdarkly.observability.util.runOnMainThread * Buffered state: * - [liveReplayService]: the live replay service once [bind] has run; `null` doubles as the * "pre-init" flag. - * - `pendingEnabled`: latest pre-init `isEnabled` write. `null` means "no buffered value", - * so [bind] won't clobber the replay service's `ReplayOptions.enabled` default. + * - `pendingEnable`: latest pre-init `isEnabled` write or [start]. `null` means "no buffered + * value", so [bind] won't clobber the replay service's `ReplayOptions.enabled` default. * - `pendingActivities`: every pre-init [registerActivity], in submission order. * - `pendingIdentify`: most recent pre-init [afterIdentify] only — older identifies are stale. * * Concurrency: * - [isEnabled] reads hold the private monitor so they observe a consistent snapshot of - * ([liveReplayService], `pendingEnabled`). Reading the two `@Volatile` fields lock-free + * ([liveReplayService], `pendingEnable`). Reading the two `@Volatile` fields lock-free * is unsafe during [bind]: a reader can read `liveReplayService = null` (stale) and then - * `pendingEnabled = null` (cleared by `bind`), reporting `false` even when the buffered + * `pendingEnable = null` (cleared by `bind`), reporting `false` even when the buffered * `true` was already applied to the live service. JMM happens-before flows forward from * the later volatile read, not backward to the earlier one. * - Setters and [bind] hold the same monitor so the setters' "check [liveReplayService], @@ -38,7 +39,7 @@ internal class PreInitReplayBuffer { private set @Volatile - private var pendingEnabled: Boolean? = null + private var pendingEnable: PendingEnable? = null private val pendingActivities = mutableListOf() private var pendingIdentify: PendingIdentify? = null @@ -48,9 +49,9 @@ internal class PreInitReplayBuffer { // Snapshot both fields under the same monitor [bind] uses, otherwise a reader can // observe the dual-field tear documented in the class header and return `false` // mid-bind. The live service's `isEnabled` itself is read outside the lock — - // the lock only needs to protect the (liveReplayService, pendingEnabled) pair. + // the lock only needs to protect the (liveReplayService, pendingEnable) pair. val service = synchronized(this) { - liveReplayService ?: return pendingEnabled ?: false + liveReplayService ?: return pendingEnable?.isEnabled ?: false } return service.isEnabled } @@ -59,7 +60,7 @@ internal class PreInitReplayBuffer { val target: SessionReplayServicing? = synchronized(this) { val current = liveReplayService if (current == null) { - pendingEnabled = value + pendingEnable = if (value) PendingEnable.Start(ignoreSampling = false) else PendingEnable.Stop null } else { current @@ -68,6 +69,24 @@ internal class PreInitReplayBuffer { target?.let { runOnMainThread { it.isEnabled = value } } } + fun start(ignoreSampling: Boolean): SessionReplayStartResult { + val target: SessionReplayServicing = synchronized(this) { + val current = liveReplayService + if (current == null) { + // The start is applied during [bind]; its outcome cannot be known until then. + pendingEnable = PendingEnable.Start(ignoreSampling) + return SessionReplayStartResult.UNAVAILABLE + } + current + } + // Dispatched outside the monitor, like every other forwarding path here: [runOnMainThread] + // blocks until the block completes, so holding the lock across it could deadlock against a + // main thread waiting to enter [bind]. The latch it waits on also publishes `result`. + var result = SessionReplayStartResult.UNAVAILABLE + runOnMainThread { result = target.start(ignoreSampling) } + return result + } + fun registerActivity(activity: Activity) { val target: SessionReplayServicing? = synchronized(this) { val current = liveReplayService @@ -107,7 +126,13 @@ internal class PreInitReplayBuffer { // Drain buffered state into the live replay service in a deterministic order: // enable first (so subsequent operations see the right gate), then activities, // then the latest identify. - pendingEnabled?.let { replayService.isEnabled = it } + when (val pending = pendingEnable) { + // Routed through `start` rather than `isEnabled` so a buffered + // `LDReplay.start(ignoreSampling = true)` still forces recording here. + is PendingEnable.Start -> replayService.start(pending.ignoreSampling) + PendingEnable.Stop -> replayService.isEnabled = false + null -> Unit + } pendingActivities.forEach { replayService.registerActivity(it) } pendingIdentify?.let { replayService.afterIdentify(it.contextKeys, it.canonicalKey, it.completed) } @@ -115,7 +140,7 @@ internal class PreInitReplayBuffer { // cleared buffer is guaranteed to also see the live service (per JMM volatile // ordering). liveReplayService = replayService - pendingEnabled = null + pendingEnable = null pendingActivities.clear() pendingIdentify = null } @@ -124,12 +149,28 @@ internal class PreInitReplayBuffer { fun reset() { synchronized(this) { liveReplayService = null - pendingEnabled = null + pendingEnable = null pendingActivities.clear() pendingIdentify = null } } + /** + * Latest pre-init enable/disable intent. [Start] carries its `ignoreSampling` flag so a + * pre-init forced start is still forced when [bind] applies it. + */ + private sealed interface PendingEnable { + val isEnabled: Boolean + + data class Start(val ignoreSampling: Boolean) : PendingEnable { + override val isEnabled: Boolean get() = true + } + + data object Stop : PendingEnable { + override val isEnabled: Boolean get() = false + } + } + private data class PendingIdentify( val contextKeys: Map, val canonicalKey: String, diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayStartResultTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayStartResultTest.kt new file mode 100644 index 0000000000..3f71234af6 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayStartResultTest.kt @@ -0,0 +1,19 @@ +package com.launchdarkly.observability.replay + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SessionReplayStartResultTest { + + @Test + fun `isRunning is true only for the outcomes that leave replay recording`() { + // Bridges surface this to callers who cannot see the individual cases, and it has to agree + // with the Swift SDK's `SessionReplayStartResult.isRunning`. + val recording = SessionReplayStartResult.entries.filter { it.isRunning } + + assertEquals( + listOf(SessionReplayStartResult.STARTED, SessionReplayStartResult.ALREADY_STARTED), + recording, + ) + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt index 41f2b07f9f..b3766dd90b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt @@ -1,6 +1,7 @@ package com.launchdarkly.observability.sdk import android.app.Activity +import com.launchdarkly.observability.replay.SessionReplayStartResult import com.launchdarkly.observability.testing.ObservabilityMainThreadTestHooks import io.mockk.mockk import org.junit.jupiter.api.AfterEach @@ -12,15 +13,27 @@ import org.junit.jupiter.api.Test class LDReplayTest { - private class TestReplayService(initialIsEnabled: Boolean = false) : SessionReplayServicing { + private class TestReplayService( + initialIsEnabled: Boolean = false, + private val startResult: SessionReplayStartResult = SessionReplayStartResult.STARTED, + ) : SessionReplayServicing { override var isEnabled: Boolean = initialIsEnabled var flushCalls = 0 val registeredActivities = mutableListOf() val identifyCalls = mutableListOf() + /** `ignoreSampling` of every [start] call, in order. */ + val startCalls = mutableListOf() + val registerActivityCalls: Int get() = registeredActivities.size val afterIdentifyCalls: Int get() = identifyCalls.size + override fun start(ignoreSampling: Boolean): SessionReplayStartResult { + startCalls += ignoreSampling + if (startResult.isRunning) isEnabled = true + return startResult + } + override fun flush() { flushCalls++ } @@ -86,6 +99,115 @@ class LDReplayTest { assertTrue(replayService.isEnabled) } + @Test + fun `start reports the outcome from the replay service`() { + val replayService = TestReplayService(startResult = SessionReplayStartResult.ALREADY_STARTED) + LDReplay.init(replayService) + + assertEquals(SessionReplayStartResult.ALREADY_STARTED, LDReplay.start()) + } + + @Test + fun `start reports a sampled-out session without recording`() { + val replayService = TestReplayService(startResult = SessionReplayStartResult.SAMPLED_OUT) + LDReplay.init(replayService) + + val result = LDReplay.start() + + assertEquals(SessionReplayStartResult.SAMPLED_OUT, result) + assertFalse(result.isRunning) + } + + @Test + fun `start defaults to honoring sampling`() { + val replayService = TestReplayService() + LDReplay.init(replayService) + + LDReplay.start() + + assertEquals(listOf(false), replayService.startCalls) + } + + @Test + fun `start forwards ignoreSampling to the replay service`() { + val replayService = TestReplayService() + LDReplay.init(replayService) + + LDReplay.start(ignoreSampling = true) + + assertEquals(listOf(true), replayService.startCalls) + } + + @Test + fun `setting isEnabled to true does not ignore sampling`() { + // The setter is the "respect the configured sample rate" path; only an explicit + // start(ignoreSampling = true) may override a sampling decision. + val replayService = TestReplayService() + LDReplay.init(replayService) + + LDReplay.isEnabled = true + + assertTrue(replayService.isEnabled) + assertTrue(replayService.startCalls.none { it }) + } + + @Test + fun `start before init reports unavailable and is replayed during init`() { + // No live service yet, so the outcome cannot be known — but the intent must survive. + assertEquals(SessionReplayStartResult.UNAVAILABLE, LDReplay.start()) + + val replayService = TestReplayService() + LDReplay.init(replayService) + + assertEquals(listOf(false), replayService.startCalls) + assertTrue(replayService.isEnabled) + } + + @Test + fun `start before init preserves ignoreSampling when replayed during init`() { + // A forced start buffered from e.g. Application.onCreate must still be forced once it is + // applied, otherwise sampling would silently discard it. + LDReplay.start(ignoreSampling = true) + + val replayService = TestReplayService() + LDReplay.init(replayService) + + assertEquals(listOf(true), replayService.startCalls) + } + + @Test + fun `start before init is reflected by the isEnabled getter`() { + LDReplay.start() + + assertTrue(LDReplay.isEnabled) + } + + @Test + fun `stop after a buffered start wins`() { + LDReplay.start(ignoreSampling = true) + LDReplay.stop() + + val replayService = TestReplayService(initialIsEnabled = true) + LDReplay.init(replayService) + + // Only the last pre-init intent is applied, so the buffered start must not resurrect itself. + assertTrue(replayService.startCalls.isEmpty()) + assertFalse(replayService.isEnabled) + assertFalse(LDReplay.isEnabled) + } + + @Test + fun `resetForTest clears a buffered start`() { + LDReplay.start(ignoreSampling = true) + + LDReplay.resetForTest() + + val replayService = TestReplayService() + LDReplay.init(replayService) + + assertTrue(replayService.startCalls.isEmpty()) + } + @Test fun `stop sets isEnabled to false on active replay service`() { val replayService = TestReplayService(initialIsEnabled = true)