diff --git a/.github/workflows/turbo.yml b/.github/workflows/turbo.yml index a5cc725a00..8326ef8ead 100644 --- a/.github/workflows/turbo.yml +++ b/.github/workflows/turbo.yml @@ -63,6 +63,19 @@ 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 @@ -70,11 +83,16 @@ jobs: 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 diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt index 4113ded984..c48986c31e 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt @@ -7,6 +7,9 @@ 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 */ @@ -14,6 +17,7 @@ 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, @@ -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) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplaySampling.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplaySampling.kt new file mode 100644 index 0000000000..d9ce65766e --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplaySampling.kt @@ -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 + } +} 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 276639cf12..78f56b2fd3 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 @@ -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 @@ -162,6 +165,10 @@ class SessionReplayService( startCaptureStateObserver() startProcessLifecycleObserver() + if (_isEnabled.value) { + attemptStart(ignoreSampling = false) + } + isInstalled = true return true } @@ -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)) } } @@ -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)) } } @@ -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, @@ -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 @@ -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 @@ -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() @@ -446,6 +480,11 @@ class SessionReplayService( return } + // Sampled-out sessions are enabled but not recording; skip identify like tracks/collectors. + if (!_isRunning.value) { + return + } + synchronized(pendingIdentifyLock) { pendingIdentify = null } @@ -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 + // Track events are timeline indicators on an active recording; skip when not running. + if (!_isRunning.value) return val event = TrackItemPayload.from( eventKey = name, diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplayOptionsTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplayOptionsTest.kt index 7855cc3ab9..0f95e4f082 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplayOptionsTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplayOptionsTest.kt @@ -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) + } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplaySamplingTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplaySamplingTest.kt new file mode 100644 index 0000000000..c047c6a1f3 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplaySamplingTest.kt @@ -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 }, + ) + ) + } +}