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/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/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. 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/example-legacy/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/App.tsx index 2fccf51988..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,6 +43,7 @@ const plugin = createSessionReplayPlugin({ maskTestIDs: ['password', 'ssn'], unmaskTestIDs: ['safe'], minimumAlpha: 0.05, + sampleRate: 1.0, ...(OTLP_ENDPOINT ? {otlpEndpoint: OTLP_ENDPOINT} : {}), ...(BACKEND_URL ? {backendUrl: BACKEND_URL} : {}), }); 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