Skip to content
Merged
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
8 changes: 8 additions & 0 deletions sdk/@launchdarkly/observability-android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,56 +393,75 @@ 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.
*/
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
// undone here and the collectors would keep running for a launch reporting itself disabled.
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
// (or keep withholding) screenshots at the very start of the launch. Input events keep flowing into
// 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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<String, String>, canonicalKey: String, completed: Boolean)
fun afterTrack(name: String, metricValue: Double?, attributes: Attributes)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -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],
Expand All @@ -38,7 +39,7 @@ internal class PreInitReplayBuffer {
private set

@Volatile
private var pendingEnabled: Boolean? = null
private var pendingEnable: PendingEnable? = null

private val pendingActivities = mutableListOf<Activity>()
private var pendingIdentify: PendingIdentify? = null
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -107,15 +126,21 @@ 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) }

// Publish [liveReplayService] before clearing buffers so a reader observing a
// cleared buffer is guaranteed to also see the live service (per JMM volatile
// ordering).
liveReplayService = replayService
pendingEnabled = null
pendingEnable = null
pendingActivities.clear()
pendingIdentify = null
}
Expand All @@ -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<String, String>,
val canonicalKey: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Loading
Loading