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
18 changes: 18 additions & 0 deletions .github/workflows/turbo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,36 @@ jobs:
- name: Install js dependencies
run: yarn install

- name: Detect JS monorepo changes
uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
id: changes
with:
filters: |
yarn-test:
- '**'
- '!sdk/@launchdarkly/observability-android/**'
- '!sdk/@launchdarkly/mobile-dotnet/**'
- '!e2e/android/**'
- '!.github/workflows/android-observability.yml'
- '!.github/workflows/android-e2e.yml'

- name: Check yarn for duplicate deps
run: yarn dedupe --check

- name: Check formatting
run: yarn format-check

- name: Build & test (in a fork without doppler)
if: steps.changes.outputs.yarn-test == 'true' || github.event_name == 'workflow_dispatch'
run: yarn test
env:
NEXT_PUBLIC_HIGHLIGHT_PROJECT_ID: 1jdkoe52
REACT_APP_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Skipped yarn test (Android/MAUI-only change)
if: steps.changes.outputs.yarn-test != 'true' && github.event_name != 'workflow_dispatch'
run: echo "Skipping yarn test — only Android observability and/or MAUI (.NET) paths changed."

# Guard against shipping unresolvable types: install the freshly
# built package into a clean consumer and type-check it with
# skipLibCheck:false. Runs before publish so a broken .d.ts blocks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ package com.launchdarkly.observability.replay
* @property privacyProfile privacy profile that controls masking behavior
* @property frameRate target capture rate in frames per second
* @property scale optional replay scale override. When null, no additional scaling is applied. Usually from 1-4. 1 = 160DPI
* @property sampleRate probability from `0.0` to `1.0` that session replay starts when [enabled] is
* true. Values less than or equal to zero never start; values greater than or equal to one always
* start. The decision is made once per enable cycle and reset when replay is stopped.
* @property enabled controls whether session replay starts capturing immediately on initialization
* @property compression compression strategy for frame export
*/
data class ReplayOptions(
val enabled: Boolean = true,
val debug: Boolean = false,
val privacyProfile: PrivacyProfile = PrivacyProfile(),
val sampleRate: Double = 1.0,
val frameRate: Double = 1.0,
/** Optional replay scale. Null disables scaling override. */
val scale: Float? = 1.0f,
Expand Down Expand Up @@ -62,6 +66,7 @@ data class ReplayOptions(
fun enabled(enabled: Boolean) = apply { options = options.copy(enabled = enabled) }
fun debug(debug: Boolean) = apply { options = options.copy(debug = debug) }
fun privacyProfile(privacyProfile: PrivacyProfile) = apply { options = options.copy(privacyProfile = privacyProfile) }
fun sampleRate(sampleRate: Double) = apply { options = options.copy(sampleRate = sampleRate) }
fun frameRate(frameRate: Double) = apply { options = options.copy(frameRate = frameRate) }
fun scale(scale: Float?) = apply { options = options.copy(scale = scale) }
fun compression(compression: CompressionMethod) = apply { options = options.copy(compression = compression) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.launchdarkly.observability.replay

import kotlin.random.Random

internal object SessionReplaySampling {
fun shouldSample(
sampleRate: Double,
randomValue: () -> Double = { Random.nextDouble() },
): Boolean {
if (sampleRate <= 0.0) return false
if (sampleRate >= 1.0) return true
return randomValue() < sampleRate
}
}

/**
* Tracks whether sampling has been decided for the current enable cycle.
* Mirrors `SessionReplaySamplingSession` in the Swift session replay SDK.
*/
internal class SessionReplaySamplingSession {
private var decisionMade = false

@Synchronized
fun shouldStartCapture(
ignoreSampling: Boolean,
sampleRate: Double,
randomValue: () -> Double = { Random.nextDouble() },
): Boolean {
if (ignoreSampling) return true
if (decisionMade) return false
decisionMade = true
return SessionReplaySampling.shouldSample(sampleRate, randomValue)
}

@Synchronized
fun reset() {
decisionMade = false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ class SessionReplayService(
private var captureJob: Job? = null
private val shouldCapture = MutableStateFlow(false)
private val _isEnabled = MutableStateFlow(options.enabled)
private val _isRunning = MutableStateFlow(false)
private val sampleRate = options.sampleRate
private val samplingSession = SessionReplaySamplingSession()
private var processLifecycleObserver: DefaultLifecycleObserver? = null
private var isInstalled: Boolean = false
private var exporter: SessionReplayExporter? = null
Expand Down Expand Up @@ -162,6 +165,10 @@ class SessionReplayService(
startCaptureStateObserver()
startProcessLifecycleObserver()

if (_isEnabled.value) {
attemptStart(ignoreSampling = false)
}

isInstalled = true
return true
}
Expand All @@ -170,7 +177,7 @@ class SessionReplayService(
// Images collector
instrumentationScope.launch {
captureManager?.captureFlow?.collect { capture ->
if (!_isEnabled.value) return@collect
if (!_isRunning.value) return@collect
eventQueue.send(ImageItemPayload(capture))
}
}
Expand All @@ -185,7 +192,7 @@ class SessionReplayService(
// Interactions collector
instrumentationScope.launch {
interactionSource?.captureFlow?.collect { interaction ->
if (!_isEnabled.value) return@collect
if (!_isRunning.value) return@collect
eventQueue.send(InteractionItemPayload(interaction))
}
}
Expand All @@ -194,7 +201,7 @@ class SessionReplayService(
// event on the active recording.
instrumentationScope.launch {
observabilityContext.screenViewFlow?.collect { screenView ->
if (!_isEnabled.value) return@collect
if (!_isRunning.value) return@collect
eventQueue.send(
NavigateItemPayload(
name = screenView.name,
Expand All @@ -218,7 +225,7 @@ class SessionReplayService(
// an rrweb `Foreground` / `Background` breadcrumb on the active recording.
instrumentationScope.launch {
observabilityContext.appLifecycleFlow?.collect { signal ->
if (!_isEnabled.value) return@collect
if (!_isRunning.value) return@collect
val tag = when (signal.kind) {
AppLifecycleSignal.Kind.FOREGROUND -> RRWebCustomDataTag.APP_FOREGROUND
AppLifecycleSignal.Kind.BACKGROUND -> RRWebCustomDataTag.APP_BACKGROUND
Expand Down Expand Up @@ -253,7 +260,7 @@ class SessionReplayService(
*/
private fun startCaptureStateObserver() {
instrumentationScope.launch {
combine(shouldCapture, _isEnabled) { shouldRun, enabled -> shouldRun && enabled }
combine(shouldCapture, _isRunning) { shouldRun, running -> shouldRun && running }
.collect { shouldRun ->
val running = captureJob?.isActive == true
if (shouldRun == running) return@collect
Expand Down Expand Up @@ -317,17 +324,44 @@ class SessionReplayService(
}

/**
* Whether replay capture is enabled. Setting to `true` flushes any identify payload that
* was cached while disabled (mirroring the previous `start()` behaviour); setting to
* `false` simply pauses event production (mirroring `stop()`).
* Whether replay capture is enabled. Setting to `true` evaluates [ReplayOptions.sampleRate]
* once per enable cycle and starts recording when selected; setting to `false` pauses event
* production and resets the sampling decision (mirroring `stop()` on iOS).
*/
override var isEnabled: Boolean
get() = _isEnabled.value
set(value) {
if (_isEnabled.value == value) return
_isEnabled.value = value
if (value) flushPendingIdentify()
if (value) {
attemptStart(ignoreSampling = false)
} else {
stopRecording()
}
}

/**
* 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
if (!samplingSession.shouldStartCapture(ignoreSampling, sampleRate)) {
logger.info("Session replay skipped by sampling.")
return false
}
_isRunning.value = true
flushPendingIdentify()
return true
}

private fun stopRecording() {
samplingSession.reset()
_isRunning.value = false
}

override fun flush() {
batchWorker.flush()
Expand Down Expand Up @@ -446,6 +480,11 @@ class SessionReplayService(
return
}

// Sampled-out sessions are enabled but not recording; skip identify like tracks/collectors.
if (!_isRunning.value) {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sampled-out replay drops identify

Medium Severity

When replay is enabled but excluded by sampleRate, identifySession returns immediately without caching the payload. That differs from the disabled path, which stores pending identify for the next start. After a later stop/start that wins sampling, recording can begin without the user context that was already identified.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9e399f7. Configure here.


synchronized(pendingIdentifyLock) {
pendingIdentify = null
}
Expand Down Expand Up @@ -482,8 +521,8 @@ class SessionReplayService(
logger.warn("track received before SessionReplayService was installed; skipping.")
return
}
// Track events are timeline indicators on an active recording; skip when replay is disabled.
if (!_isEnabled.value) return
Comment thread
cursor[bot] marked this conversation as resolved.
// Track events are timeline indicators on an active recording; skip when not running.
if (!_isRunning.value) return

val event = TrackItemPayload.from(
eventKey = name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,14 @@ class ReplayOptionsTest {
fun `frameRate can be configured`() {
assertEquals(2.0, ReplayOptions(frameRate = 2.0).frameRate)
}

@Test
fun `sampleRate defaults to always sample`() {
assertEquals(1.0, ReplayOptions().sampleRate)
}

@Test
fun `sampleRate can be configured`() {
assertEquals(0.25, ReplayOptions(sampleRate = 0.25).sampleRate)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.launchdarkly.observability.replay

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class SessionReplaySamplingTest {

@Test
fun `sampleRate defaults to always sample`() {
assertEquals(1.0, ReplayOptions().sampleRate)
assertTrue(SessionReplaySampling.shouldSample(sampleRate = 1.0) { 0.99 })
}

@Test
fun `sampleRate zero disables session replay`() {
assertFalse(SessionReplaySampling.shouldSample(sampleRate = 0.0) { 0.0 })
}

@Test
fun `sampleRate samples when random value is below rate`() {
assertTrue(SessionReplaySampling.shouldSample(sampleRate = 0.5) { 0.49 })
assertFalse(SessionReplaySampling.shouldSample(sampleRate = 0.5) { 0.5 })
}

@Test
fun `sampling decision is not re-evaluated after sampled out`() {
val session = SessionReplaySamplingSession()
assertFalse(
session.shouldStartCapture(
ignoreSampling = false,
sampleRate = 0.25,
randomValue = { 0.99 },
)
)
assertFalse(
session.shouldStartCapture(
ignoreSampling = false,
sampleRate = 0.25,
randomValue = { 0.0 },
)
)
session.reset()
assertTrue(
session.shouldStartCapture(
ignoreSampling = false,
sampleRate = 0.25,
randomValue = { 0.0 },
)
)
}

@Test
fun `ignoreSampling bypasses persisted sampled-out decision`() {
val session = SessionReplaySamplingSession()
assertFalse(
session.shouldStartCapture(
ignoreSampling = false,
sampleRate = 0.25,
randomValue = { 0.99 },
)
)
assertTrue(
session.shouldStartCapture(
ignoreSampling = true,
sampleRate = 0.25,
randomValue = { 0.99 },
)
)
}
}
Loading