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
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ open class BaseApplication : Application() {
),
maskXMLViewIds = listOf("smoothieTitle")
),
sampleRate = 1.0,
frameRate = 1.0
)
)
Expand Down
28 changes: 27 additions & 1 deletion sdk/@launchdarkly/react-native-ld-session-replay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<LDUnmask>`, 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**.
Expand All @@ -95,6 +120,7 @@ createSessionReplayPlugin({
maskLabels: false, // when true, masks every <Text>
maskImages: false, // when true, masks every <Image>
maskWebViews: false, // when true, masks every <WebView>
minimumAlpha: 0.02, // mask views below this opacity (0.0–1.0); default 0.02
});
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,32 @@ 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,
maskText = maskText,
maskImageViews = maskImages,
maskXMLViewIds = maskTestIDs,
unmaskXMLViewIds = unmaskTestIDs,
minimumAlpha = minimumAlpha,
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -44,13 +47,69 @@ 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)

assertTrue(options.privacyProfile.maskText)
}

@Test
fun `replayOptionsFrom maps frameRate scale sampleRate and minimumAlpha`() {
val adapter = newAdapter()
val map = mockk<ReadableMap> {
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<ReadableMap> {
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} : {}),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
)
}

Expand Down Expand Up @@ -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)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -173,22 +191,27 @@ 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' }
);

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
Expand Down
Loading