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
29 changes: 28 additions & 1 deletion sdk/@launchdarkly/react-native-ld-session-replay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@ await startSessionReplay();
// later: await stopSessionReplay();
```

## Delaying recording

`isEnabled` controls whether recording starts as soon as session replay initializes. Set it to
`false` to initialize without recording — native observability still starts, so signals such as
`click` spans keep flowing — and call `startSessionReplay()` when recording should begin, for
example after login:

```js
const plugin = createSessionReplayPlugin({ isEnabled: false });

// later, wherever recording should begin:
await startSessionReplay();
```

`startSessionReplay()` starts recording regardless of the configured `isEnabled`, and needs no
`configureSessionReplay()` call first. `stopSessionReplay()` is the only thing that stops it, and a
`startSessionReplay()` afterwards resumes.

Two things to know:

- **`sampleRate` still applies.** It is evaluated once per enable cycle, so a session that sampling
already excluded stays excluded until `stopSessionReplay()` resets the decision.
- **Recording outlives a JS reload.** The native instance lives in the host process, so a soft/OTA
reload that re-runs your plugin setup will not stop a recording that an earlier
`startSessionReplay()` began. Call `stopSessionReplay()` if you need it off.

## Custom endpoints

By default the native session replay and observability instances report to
Expand Down Expand Up @@ -89,10 +115,11 @@ the native session replay SDK on **iOS and Android**.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `isEnabled` | `boolean` | `true` | Whether recording starts as soon as session replay initializes. `false` still initializes native session replay and observability; call `startSessionReplay()` to begin recording later. See [Delaying recording](#delaying-recording). |
| `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`. |
| `imageQuality` | `number` | `0.3` | JPEG encoding quality of exported frames (`0.0` = lowest quality / smallest payload, `1.0` = highest quality / largest payload). Values outside that range are clamped by the native SDK. |
| `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. |
| `sampleRate` | `number` | `1.0` | Probability from `0.0` to `1.0` that replay starts when recording is turned on, whether by `isEnabled` or by `startSessionReplay()`. `0.0` never records; `1.0` always records. Evaluated once per enable cycle and reset when replay is stopped. |

```js
const plugin = createSessionReplayPlugin({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Pod::Spec.new do |s|
"DEFINES_MODULE" => "YES"
}

s.dependency 'LaunchDarklySessionReplay', '~> 0.53.1'
s.dependency 'LaunchDarklySessionReplay', '~> 0.56.0'

install_modules_dependencies(s)
end
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.66.1"
implementation "com.launchdarkly:launchdarkly-observability-android:0.69.0"
implementation "com.launchdarkly:launchdarkly-android-client-sdk:5.14.0"
// 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 @@ -11,6 +11,7 @@ import com.launchdarkly.observability.context.LDObserveLogging
import com.launchdarkly.observability.plugin.Observability
import com.launchdarkly.observability.replay.PrivacyProfile
import com.launchdarkly.observability.replay.ReplayOptions
import com.launchdarkly.observability.replay.SessionReplayStartResult
import com.launchdarkly.observability.replay.plugin.SessionReplay
import com.launchdarkly.observability.sdk.LDReplay
import com.launchdarkly.sdk.ContextKind
Expand Down Expand Up @@ -77,7 +78,20 @@ internal class SessionReplayClientAdapter private constructor() {
}
}

fun start(application: Application, activity: Activity?, completion: (Boolean, String?) -> Unit) {
/**
* Initializes session replay if needed and enables recording.
*
* @param forceEnable `true` for an explicit `startSessionReplay()`, which records regardless
* of the configured `enabled`. `false` for the plugin's auto-start, which honors it. Either
* way this never *disables* recording — that is [stop]'s job — so a deferred start is not
* undone by a later re-init.
*/
fun start(
application: Application,
activity: Activity?,
forceEnable: Boolean,
completion: (Boolean, String?) -> Unit
) {
val localMobileKey: String?
val localServiceName: String
val localServiceVersion: String?
Expand Down Expand Up @@ -129,16 +143,48 @@ internal class SessionReplayClientAdapter private constructor() {
)
}
} else {
logger.info("$LOG_PREFIX start: already initialized, re-applying enabled=${localReplayOptions.enabled}")
logger.info("$LOG_PREFIX start: already initialized, forceEnable=$forceEnable")
}
try {
applyEnabled(localReplayOptions.enabled)
// Only ever enables. On the first start the SessionReplay plugin has already applied
// the configured `enabled`, so this covers an explicit start and a start after stop().
// Calling LDReplay.stop() here instead would make startSessionReplay() a no-op for a
// deferred start, and would silently stop a recording an earlier start had begun.
if (!forceEnable && !localReplayOptions.enabled) {
logger.info("$LOG_PREFIX start: leaving recording off (enabled=false)")
completion(true, null)
return@post
}
// `start` over `isEnabled = true` so the outcome is reported: the setter's no-change
// guard would also swallow a start on a session that is already enabled but not
// recording. Runs inline here rather than hopping threads, since we are on the main
// thread already.
val result = try {
LDReplay.start(ignoreSampling = false)
} catch (e: Exception) {
logger.error("$LOG_PREFIX start: applyEnabled threw {0}: {1}", e::class.simpleName, e.message)
logger.error("$LOG_PREFIX start: LDReplay.start threw {0}: {1}", e::class.simpleName, e.message)
completion(false, "Session replay failed to start.")
return@post
}
completion(true, null)
when (result) {
SessionReplayStartResult.STARTED,
SessionReplayStartResult.ALREADY_STARTED -> completion(true, null)
SessionReplayStartResult.SAMPLED_OUT -> {
// A legitimate outcome of honoring sampleRate, not a failure.
logger.info("$LOG_PREFIX start: not recording, the session was sampled out")
completion(true, null)
}
SessionReplayStartResult.UNAVAILABLE -> {
logger.error("$LOG_PREFIX start: session replay is unavailable — the plugin did not register")
completion(false, "Session replay is unavailable; the native plugin did not register.")
}
SessionReplayStartResult.UNRECOVERABLE_ERROR -> {
logger.error("$LOG_PREFIX start: LaunchDarkly refused session replay for this launch")
completion(
false,
"LaunchDarkly refused session replay for this launch; it is retried on the next launch."
)
}
}
}
}

Expand Down Expand Up @@ -237,14 +283,6 @@ internal class SessionReplayClientAdapter private constructor() {
LDClient.init(application, config, cachedContext, 0)
}

private fun applyEnabled(enabled: Boolean) {
if (enabled) {
LDReplay.start()
} else {
LDReplay.stop()
}
}

// Analogous to buildObserveContext() in SessionReplayService.kt (observability-android),
// but builds an LDContext (for LDClient.init()) instead of an LDObserveContext.
internal fun buildContextFromKeys(contextKeys: Map<String, String>?): LDContext? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,28 @@ class SessionReplayReactNativeModule(reactContext: ReactApplicationContext) :
}
}

/** Initializes and honors the configured `isEnabled` (the plugin's auto-start). */
override fun initializeSessionReplay(promise: Promise) {
start(forceEnable = false, promise = promise)
}

/** Starts recording regardless of the configured `isEnabled`. */
override fun startSessionReplay(promise: Promise) {
start(forceEnable = true, promise = promise)
}

private fun start(forceEnable: Boolean, promise: Promise) {
val application = reactApplicationContext.applicationContext as? Application
if (application == null) {
promise.reject("start_failed", "Could not obtain application context.", null)
return
}
try {
SessionReplayClientAdapter.shared.start(application, reactApplicationContext.getCurrentActivity()) { success, errorMessage ->
SessionReplayClientAdapter.shared.start(
application,
reactApplicationContext.getCurrentActivity(),
forceEnable
) { success, errorMessage ->
if (success) {
promise.resolve(null)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,24 @@ class SessionReplayClientAdapterTest {
var success: Boolean? = null
var errorMessage: String? = null

adapter.start(mockk<Application>(relaxed = true), null) { s, e ->
adapter.start(mockk<Application>(relaxed = true), null, forceEnable = true) { s, e ->
success = s
errorMessage = e
}

assertEquals(false, success)
assertTrue(errorMessage!!.contains("mobile key"))
}

@Test
fun `initialize before setMobileKey calls completion with failure`() {
// Both entry points must reject a start that configure() never set up, not just the
// explicit one.
val adapter = newAdapter()
var success: Boolean? = null
var errorMessage: String? = null

adapter.start(mockk<Application>(relaxed = true), null, forceEnable = false) { s, e ->
success = s
errorMessage = e
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
AutoEnvAttributes,
} from '@launchdarkly/react-native-client-sdk';
import { useEffect, useState } from 'react';
import { createSessionReplayPlugin } from '@launchdarkly/session-replay-react-native';
import {
createSessionReplayPlugin,
startSessionReplay,
type SessionReplayOptions,
} from '@launchdarkly/session-replay-react-native';
import {
LDObserve,
Observability,
Expand Down Expand Up @@ -49,7 +53,10 @@ const { env: LD_ENV, endpoints } = resolveLDEnvironment(LAUNCHDARKLY_ENV, {
const JS_LOAD_ID = Math.random().toString(36).slice(2, 8);
console.log(`[soft-reload] JS_LOAD_ID=${JS_LOAD_ID} (new value each JS load)`);

const plugin = createSessionReplayPlugin({
// Replay is deferred so the example exercises the isEnabled: false path: the plugin
// initializes native replay and observability with recording off, and the "Start
// replay" button turns it on later.
const replayOptions: SessionReplayOptions = {
Comment thread
abelonogov-ld marked this conversation as resolved.
Resolved
isEnabled: true,
// Forwarded to the native observability + session replay instances so their
// spans report the same service.name / service.version as the JS observability
Expand All @@ -65,7 +72,9 @@ const plugin = createSessionReplayPlugin({
minimumAlpha: 0.05,
otlpEndpoint: endpoints.otlpEndpoint,
backendUrl: endpoints.backendUrl,
});
};

const plugin = createSessionReplayPlugin(replayOptions);

// The observability plugin powers the distributed tracing examples on the
// "Tracing" tab. `tracingOrigins` opts the demo API hosts into W3C
Expand Down Expand Up @@ -113,6 +122,18 @@ const client = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, {
});
const context = { kind: 'user', key: 'user-key-123abc' };

// The plugin above initialized native replay with recording off (isEnabled: false).
// startSessionReplay() turns it on regardless of that setting, with no configure
// round-trip, so the options and session id the plugin already applied stay intact.
async function beginSessionReplay() {
await startSessionReplay();
console.log(
`[deferred-start] session replay started, session.id=${
LDObserve.getSessionInfo()?.sessionId
}`
);
}

// Emulates an OTA "soft reload": restarts only the JS runtime, leaving the native
// process (and the SR singleton) alive. Sampling is intentionally NOT re-rolled —
// the native enable cycle never resets, so a sampled-out session stays out and a
Expand Down Expand Up @@ -181,6 +202,19 @@ export default function App() {
<Text testID="safe" style={styles.actionLabel}>
JS load: {JS_LOAD_ID}
</Text>
<TouchableOpacity
testID="safe"
style={styles.actionButton}
onPress={() => {
beginSessionReplay().catch((e: unknown) =>
console.warn('[deferred-start] failed to start', e)
);
}}
>
<Text testID="safe" style={styles.actionButtonText}>
Start replay
</Text>
</TouchableOpacity>
<TouchableOpacity
testID="safe"
style={styles.actionButton}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ public class SessionReplayClientAdapter: NSObject {
return context
}

@objc public func start(completion: @escaping (Bool, String?) -> Void) {
/// Initializes session replay if needed and enables recording.
///
/// - Parameter forceEnable: `true` for an explicit `startSessionReplay()`, which records
/// regardless of the configured `isEnabled`. `false` for the plugin's auto-start, which
/// honors it. Either way this never *disables* recording — that is `stop()`'s job — so a
/// deferred start is not undone by a later re-init.
@objc public func start(forceEnable: Bool, completion: @escaping (Bool, String?) -> Void) {
lock.lock()
defer { lock.unlock() }
guard let mobileKey = mobileKey, let sessionReplayOptions = sessionReplayOptions else {
Expand Down Expand Up @@ -212,10 +218,33 @@ public class SessionReplayClientAdapter: NSObject {
}
self.initialized = true
} else {
NSLog("%@ start: already initialized, re-applying isEnabled=%@", Self.logPrefix, sessionReplayOptions.isEnabled ? "true" : "false")
NSLog("%@ start: already initialized, forceEnable=%@", Self.logPrefix, forceEnable ? "true" : "false")
}
// Only ever enables. On the first start the SessionReplay plugin has already applied the
// configured `isEnabled`, so this covers an explicit start and a start after stop(). Writing
// `false` here instead would make `startSessionReplay()` a no-op for a deferred start, and
// would silently stop a recording that an earlier explicit start had begun.
guard forceEnable || sessionReplayOptions.isEnabled else {
NSLog("%@ start: leaving recording off (isEnabled=false)", Self.logPrefix)
completion(true, nil)
return
}
// `start` over `isEnabled = true` so the outcome is reported: the setter's no-change guard
// would also swallow a start on a session that is already enabled but not recording.
switch LDReplay.shared.start(ignoreSampling: false) {
case .started, .alreadyStarted:
completion(true, nil)
case .sampledOut:
// A legitimate outcome of honoring sampleRate, not a failure.
NSLog("%@ start: not recording, the session was sampled out", Self.logPrefix)
completion(true, nil)
case .unavailable:
NSLog("%@ start: session replay is unavailable — the plugin did not register", Self.logPrefix)
completion(false, "Session replay is unavailable; the native plugin did not register.")
case .unrecoverableError:
NSLog("%@ start: LaunchDarkly refused session replay for this launch", Self.logPrefix)
completion(false, "LaunchDarkly refused session replay for this launch; it is retried on the next launch.")
}
LDReplay.shared.isEnabled = sessionReplayOptions.isEnabled
completion(true, nil)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,31 @@ + (BOOL)requiresMainQueueSetup
}
}

// Initializes and honors the configured isEnabled (the plugin's auto-start).
RCT_EXPORT_METHOD(initializeSessionReplay:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
@try {
[[SessionReplayClientAdapter shared] startWithForceEnable:NO completion:^(BOOL success, NSString * _Nullable errorMessage) {
if (success) {
resolve(nil);
} else {
NSString *error = errorMessage ?: @"Session replay failed to initialize";
reject(@"start_failed", error, nil);
}
}];
} @catch(NSException *exception) {
NSLog(@"⚠️ initializeSessionReplay crash: %@", exception);
reject(@"start_failed", exception.reason, nil);
}
}

// Starts recording regardless of the configured isEnabled.
RCT_EXPORT_METHOD(startSessionReplay:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
@try {
[[SessionReplayClientAdapter shared] startWithCompletion:^(BOOL success, NSString * _Nullable errorMessage) {
[[SessionReplayClientAdapter shared] startWithForceEnable:YES completion:^(BOOL success, NSString * _Nullable errorMessage) {
if (success) {
resolve(nil);
} else {
Expand Down
Loading
Loading