From a07cb73e33f7ccbdedf5c2db7466a63e3429010f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 30 Jun 2026 11:35:52 -0700 Subject: [PATCH 1/5] sampeRate --- .../androidobservability/BaseApplication.kt | 1 + .../observability/replay/ReplayOptions.kt | 5 ++ .../replay/SessionReplaySampling.kt | 39 ++++++++++ .../replay/SessionReplayService.kt | 56 ++++++++++++--- .../observability/replay/ReplayOptionsTest.kt | 10 +++ .../replay/SessionReplaySamplingTest.kt | 72 +++++++++++++++++++ .../SessionReplayClientAdapter.kt | 17 +++++ .../SessionReplayClientAdapterTest.kt | 59 +++++++++++++++ .../ios/SessionReplayClientAdapter.swift | 26 ++++++- .../src/NativeSessionReplayReactNative.ts | 23 +++++- .../src/__tests__/index.test.tsx | 33 +++++++-- 11 files changed, 321 insertions(+), 20 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplaySampling.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplaySamplingTest.kt diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt index 7aef09840d..695d548730 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt @@ -66,6 +66,7 @@ open class BaseApplication : Application() { ), maskXMLViewIds = listOf("smoothieTitle") ), + sampleRate = 1.0, frameRate = 1.0 ) ) 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..9a11dad4a2 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,18 +324,45 @@ 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() } @@ -482,8 +516,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 }, + ) + ) + } +} diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt index 840c725443..397c6f3dad 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt @@ -260,8 +260,24 @@ internal class SessionReplayClientAdapter private constructor() { val maskTestIDs = stringListFromMap(map, "maskTestIDs") val unmaskTestIDs = stringListFromMap(map, "unmaskTestIDs") + val frameRate = if (map.hasKey("frameRate")) map.getDouble("frameRate") else 1.0 + val replayScale = if (map.hasKey("scale")) { + map.getDouble("scale").takeIf { it > 0 }?.toFloat() ?: 1.0f + } else { + 1.0f + } + val minimumAlpha = if (map.hasKey("minimumAlpha")) { + map.getDouble("minimumAlpha").toFloat() + } else { + PrivacyProfile.DEFAULT_MINIMUM_ALPHA + } + val sampleRate = if (map.hasKey("sampleRate")) map.getDouble("sampleRate") else 1.0 + return ReplayOptions( enabled = isEnabled, + sampleRate = sampleRate, + frameRate = frameRate, + scale = replayScale, privacyProfile = PrivacyProfile( maskTextInputs = maskTextInputs, maskWebViews = maskWebViews, @@ -269,6 +285,7 @@ internal class SessionReplayClientAdapter private constructor() { maskImageViews = maskImages, maskXMLViewIds = maskTestIDs, unmaskXMLViewIds = unmaskTestIDs, + minimumAlpha = minimumAlpha, ) ) } diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt index bf04967a07..b3d54d14a2 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt @@ -26,6 +26,9 @@ class SessionReplayClientAdapterTest { val options = adapter.replayOptionsFrom(null) assertTrue(options.enabled) + assertEquals(1.0, options.frameRate) + assertEquals(1.0f, options.scale) + assertEquals(1.0, options.sampleRate) assertTrue(options.privacyProfile.maskTextInputs) assertFalse(options.privacyProfile.maskWebViews) assertFalse(options.privacyProfile.maskText) @@ -44,6 +47,10 @@ class SessionReplayClientAdapterTest { every { hasKey("maskImages") } returns false every { hasKey("maskTestIDs") } returns false every { hasKey("unmaskTestIDs") } returns false + every { hasKey("frameRate") } returns false + every { hasKey("scale") } returns false + every { hasKey("minimumAlpha") } returns false + every { hasKey("sampleRate") } returns false } val options = adapter.replayOptionsFrom(map) @@ -51,6 +58,58 @@ class SessionReplayClientAdapterTest { assertTrue(options.privacyProfile.maskText) } + @Test + fun `replayOptionsFrom maps frameRate scale sampleRate and minimumAlpha`() { + val adapter = newAdapter() + val map = mockk { + every { hasKey("isEnabled") } returns false + every { hasKey("maskTextInputs") } returns false + every { hasKey("maskWebViews") } returns false + every { hasKey("maskLabels") } returns false + every { hasKey("maskImages") } returns false + every { hasKey("maskTestIDs") } returns false + every { hasKey("unmaskTestIDs") } returns false + every { hasKey("frameRate") } returns true + every { getDouble("frameRate") } returns 2.0 + every { hasKey("scale") } returns true + every { getDouble("scale") } returns 2.5 + every { hasKey("minimumAlpha") } returns true + every { getDouble("minimumAlpha") } returns 0.05 + every { hasKey("sampleRate") } returns true + every { getDouble("sampleRate") } returns 0.25 + } + + val options = adapter.replayOptionsFrom(map) + + assertEquals(2.0, options.frameRate) + assertEquals(2.5f, options.scale) + assertEquals(0.05f, options.privacyProfile.minimumAlpha) + assertEquals(0.25, options.sampleRate) + } + + @Test + fun `replayOptionsFrom treats non-positive scale as default`() { + val adapter = newAdapter() + val map = mockk { + every { hasKey("isEnabled") } returns false + every { hasKey("maskTextInputs") } returns false + every { hasKey("maskWebViews") } returns false + every { hasKey("maskLabels") } returns false + every { hasKey("maskImages") } returns false + every { hasKey("maskTestIDs") } returns false + every { hasKey("unmaskTestIDs") } returns false + every { hasKey("frameRate") } returns false + every { hasKey("scale") } returns true + every { getDouble("scale") } returns 0.0 + every { hasKey("minimumAlpha") } returns false + every { hasKey("sampleRate") } returns false + } + + val options = adapter.replayOptionsFrom(map) + + assertEquals(1.0f, options.scale) + } + @Test fun `buildContextFromKeys returns null for null input`() { val adapter = newAdapter() diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift index b470356474..5bae58f388 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift +++ b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift @@ -226,6 +226,22 @@ public class SessionReplayClientAdapter: NSObject { } extension SessionReplayClientAdapter { + private func doubleOption( + _ dictionary: NSDictionary, + key: String, + default defaultValue: Double + ) -> Double { + guard let number = dictionary[key] as? NSNumber else { return defaultValue } + return number.doubleValue + } + + /// Mirrors Android `replayOptionsFrom`: non-positive scale falls back to `1.0`. + private func scaleOption(_ dictionary: NSDictionary, default defaultValue: CGFloat = 1.0) -> CGFloat { + guard let number = dictionary["scale"] as? NSNumber else { return defaultValue } + let value = number.doubleValue + return value > 0 ? CGFloat(value) : defaultValue + } + private func sessionReplayOptionsFrom(dictionary: NSDictionary?) -> SessionReplayOptions { // Handle nil dictionary by using all default values guard let dictionary = dictionary else { @@ -244,8 +260,11 @@ extension SessionReplayClientAdapter { ) return .init( isEnabled: true, + sampleRate: 1.0, serviceName: "sessionreplay-react-native", - privacy: privacy + privacy: privacy, + frameRate: 1.0, + scale: 1.0 ) } @@ -277,8 +296,11 @@ extension SessionReplayClientAdapter { return .init( isEnabled: dictionary["isEnabled"] as? Bool ?? true, + sampleRate: doubleOption(dictionary, key: "sampleRate", default: 1.0), serviceName: dictionary["serviceName"] as? String ?? "sessionreplay-react-native", - privacy: privacy + privacy: privacy, + frameRate: doubleOption(dictionary, key: "frameRate", default: 1.0), + scale: scaleOption(dictionary) ) } } diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts index e9d222afb7..e93cd59bb2 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts @@ -48,11 +48,30 @@ export type SessionReplayOptions = { unmaskTestIDs?: string[]; /** - * iOS only. Mask views whose effective opacity is below this threshold (0.0–1.0). - * Defaults to `0.02`. Has no effect on Android. + * Target capture rate in frames per second. Applied on iOS and Android. Defaults to `1.0`. + */ + frameRate?: number; + + /** + * Replay capture scale. Controls the resolution frames are captured and + * exported at: `1.0` = 1x (160 DPI), `2.0` = 2x, etc. Applied on iOS and + * Android. Non-positive values are treated as `1.0`. Defaults to `1.0`. + */ + scale?: number; + + /** + * Mask views whose effective opacity is below this threshold (0.0–1.0). + * Defaults to `0.02`. */ minimumAlpha?: number; + /** + * Probability from `0.0` to `1.0` that session replay starts when `isEnabled` is true. + * `0.0` never records; `1.0` always records. Applied on iOS and Android. The decision is + * made once per enable cycle and reset when replay is stopped. Defaults to `1.0`. + */ + sampleRate?: number; + /** * Session id to adopt for the native session replay / observability instance, * so its spans (e.g. `click`) share the same `session.id` as the JS diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/src/__tests__/index.test.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/src/__tests__/index.test.tsx index b32e26b26f..104b33dd4b 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/__tests__/index.test.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/__tests__/index.test.tsx @@ -21,6 +21,24 @@ describe('configureSessionReplay', () => { await expect(configureSessionReplay(' ')).rejects.toThrow(); }); + it('forwards frameRate, scale, and sampleRate to native configure', async () => { + await configureSessionReplay('mob-key-123', { + frameRate: 2, + scale: 2.5, + sampleRate: 0.25, + }); + expect(NativeSessionReplayReactNative.configure).toHaveBeenCalledWith( + 'mob-key-123', + expect.objectContaining({ + frameRate: 2, + scale: 2.5, + sampleRate: 0.25, + maskTestIDs: ['__LD_INTERNAL_MASK__'], + unmaskTestIDs: ['__LD_INTERNAL_UNMASK__'], + }) + ); + }); + it('prepends LDMask / LDUnmask sentinels to the user lists', async () => { // configure with user-supplied testID lists await configureSessionReplay('mob-key-123', { @@ -173,7 +191,11 @@ describe('SessionReplayPluginAdapter', () => { }); it('calls configure and startSessionReplay on register', async () => { - const plugin = createSessionReplayPlugin(); + const plugin = createSessionReplayPlugin({ + frameRate: 4, + scale: 2, + sampleRate: 0.5, + }); plugin.register( {}, { sdk: { name: 'test', version: '0.0.0' }, mobileKey: 'mob-key-123' } @@ -181,14 +203,15 @@ describe('SessionReplayPluginAdapter', () => { await new Promise(process.nextTick); - // configure receives the user options plus the LDMask / LDUnmask sentinel testIDs - // that withInternalSentinels prepends. expect(NativeSessionReplayReactNative.configure).toHaveBeenCalledWith( 'mob-key-123', - { + expect.objectContaining({ + frameRate: 4, + scale: 2, + sampleRate: 0.5, maskTestIDs: ['__LD_INTERNAL_MASK__'], unmaskTestIDs: ['__LD_INTERNAL_UNMASK__'], - } + }) ); expect( NativeSessionReplayReactNative.startSessionReplay From 93e7e9f20dffa9a40c42a38ae2bcb5661342e33b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 30 Jun 2026 11:40:12 -0700 Subject: [PATCH 2/5] Skip identify export when session replay is sampled out. Gate identifySession on isRunning so sampled-out sessions do not send replay identify payloads, matching tracks and collectors. Co-authored-by: Cursor --- .../observability/replay/SessionReplayService.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 9a11dad4a2..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 @@ -480,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 } From 0cf83afb088e09ed0954e20ed7a95bef83f114cc Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 30 Jun 2026 11:56:59 -0700 Subject: [PATCH 3/5] readme update --- .../react-native-ld-session-replay/README.md | 28 ++++++++++++++++++- .../example-legacy/src/App.tsx | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/README.md b/sdk/@launchdarkly/react-native-ld-session-replay/README.md index 9dc47054e1..69e997f7a8 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/README.md +++ b/sdk/@launchdarkly/react-native-ld-session-replay/README.md @@ -70,6 +70,31 @@ options, so setting `backendUrl` routes both observability and session replay to the same environment. Either option falls back to its production default when unset. Both are applied on iOS and Android. +## Capture and sampling options + +These options control how often frames are captured, at what resolution, and whether +this session is selected for recording. All are forwarded to the native session replay +SDK on **iOS and Android**. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `frameRate` | `number` | `1.0` | Target capture rate in frames per second. | +| `scale` | `number` | `1.0` | Capture/export resolution multiplier (`1.0` = 1x / 160 DPI, `2.0` = 2x, etc.). Non-positive values are treated as `1.0`. | +| `sampleRate` | `number` | `1.0` | Probability from `0.0` to `1.0` that replay starts when `isEnabled` is `true`. `0.0` never records; `1.0` always records. Evaluated once per enable cycle and reset when replay is stopped. | + +```js +const plugin = createSessionReplayPlugin({ + isEnabled: true, + frameRate: 2.0, + scale: 1.0, + sampleRate: 0.25, // record roughly 25% of sessions +}); +``` + +When a session is sampled out, `isEnabled` may stay `true` but no frames, interactions, +or identify payloads are exported for that enable cycle — matching native iOS and Android +behavior. + ## Masking sensitive content ### How the SDK decides what to mask @@ -80,7 +105,7 @@ For each view, the SDK evaluates these rules in order and stops at the first tha - **Yes**: the view is **masked**. This overrides everything below. 2. **Explicit unmasking**: is this view — or any of its ancestors — explicitly unmasked (wrapped in ``, or `testID` matched by `unmaskTestIDs`)? - **Yes**: the view is **unmasked**. -3. **Global configuration**: does the global privacy config (`maskTextInputs`, `maskLabels`, `maskImages`, `maskWebViews`) apply to this view? +3. **Global configuration**: does the global privacy config (`maskTextInputs`, `maskLabels`, `maskImages`, `maskWebViews`, `minimumAlpha`) apply to this view? - **Yes**: the view follows the global config. When two rules conflict at the same level, **masking wins over unmasking**. @@ -95,6 +120,7 @@ createSessionReplayPlugin({ maskLabels: false, // when true, masks every maskImages: false, // when true, masks every maskWebViews: false, // when true, masks every + minimumAlpha: 0.02, // mask views below this opacity (0.0–1.0); default 0.02 }); ``` diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx index 2fccf51988..29bd7fae17 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx @@ -43,6 +43,7 @@ const plugin = createSessionReplayPlugin({ maskTestIDs: ['password', 'ssn'], unmaskTestIDs: ['safe'], minimumAlpha: 0.05, + sampleRate: 0.99, ...(OTLP_ENDPOINT ? {otlpEndpoint: OTLP_ENDPOINT} : {}), ...(BACKEND_URL ? {backendUrl: BACKEND_URL} : {}), }); From ce21f699fa3106dd1df901da3c268193dc0bed93 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 30 Jun 2026 12:01:54 -0700 Subject: [PATCH 4/5] set to 1 --- .../react-native-ld-session-replay/example-legacy/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx index 29bd7fae17..096e0eac51 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx @@ -43,7 +43,7 @@ const plugin = createSessionReplayPlugin({ maskTestIDs: ['password', 'ssn'], unmaskTestIDs: ['safe'], minimumAlpha: 0.05, - sampleRate: 0.99, + sampleRate: 1.0, ...(OTLP_ENDPOINT ? {otlpEndpoint: OTLP_ENDPOINT} : {}), ...(BACKEND_URL ? {backendUrl: BACKEND_URL} : {}), }); From d5c28d793918c22c4bbc686850074e76b403c3f8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 30 Jun 2026 13:33:22 -0700 Subject: [PATCH 5/5] 0.60.0 Android --- .../react-native-ld-session-replay/android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle index 7bb9def90e..4d9aa946c6 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle @@ -94,7 +94,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.launchdarkly:launchdarkly-observability-android:0.59.0" + implementation "com.launchdarkly:launchdarkly-observability-android:0.60.0" implementation "com.launchdarkly:launchdarkly-android-client-sdk:5.13.1" // compileOnly: OTel Attributes appears in ObservabilityOptions parameter types; provided at // runtime transitively through launchdarkly-observability-android.