diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/README.md b/sdk/@launchdarkly/react-native-ld-session-replay/README.md index fa599ecdee..9dca84b0e4 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/README.md +++ b/sdk/@launchdarkly/react-native-ld-session-replay/README.md @@ -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 @@ -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({ diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec b/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec index 39d291c809..c9130d3d58 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec +++ b/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec @@ -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 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 bb56f8e350..d0f3f305d1 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.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. 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 5dcdf2a0c3..9d24631520 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 @@ -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 @@ -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? @@ -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." + ) + } + } } } @@ -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?): LDContext? { diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayReactNativeModule.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayReactNativeModule.kt index 84220739d4..fbe4472b67 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayReactNativeModule.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayReactNativeModule.kt @@ -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 { 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 5b422db98b..c4a3028157 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 @@ -155,7 +155,24 @@ class SessionReplayClientAdapterTest { var success: Boolean? = null var errorMessage: String? = null - adapter.start(mockk(relaxed = true), null) { s, e -> + adapter.start(mockk(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(relaxed = true), null, forceEnable = false) { s, e -> success = s errorMessage = e } diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx index bbfe3a9f19..cd6d5a5d7a 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx @@ -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, @@ -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 = { isEnabled: true, // Forwarded to the native observability + session replay instances so their // spans report the same service.name / service.version as the JS observability @@ -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 @@ -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 @@ -181,6 +202,19 @@ export default function App() { JS load: {JS_LOAD_ID} + { + beginSessionReplay().catch((e: unknown) => + console.warn('[deferred-start] failed to start', e) + ); + }} + > + + Start replay + + 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 { @@ -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) } } diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayReactNative.mm b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayReactNative.mm index 8686b42bdb..f2094f8210 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayReactNative.mm +++ b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayReactNative.mm @@ -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 { 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 d183e04c23..2bcef7a239 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts @@ -1,6 +1,13 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; export type SessionReplayOptions = { + /** + * Whether recording starts as soon as session replay initializes. Defaults to `true`. + * + * `false` still initializes the native session replay / observability instance — so + * native-emitted signals such as `click` spans keep flowing — it only leaves recording + * off. Call `startSessionReplay()` to begin recording later. + */ isEnabled?: boolean; /** * The OpenTelemetry `service.name` reported by the native session replay / @@ -95,6 +102,21 @@ export type SessionReplayOptions = { export interface Spec extends TurboModule { configure(mobileKey: string, options?: Object): Promise; + + /** + * Initializes the native session replay / observability instance, and starts recording + * only when the configured `isEnabled` is true. This is the auto-start path used by + * `createSessionReplayPlugin`; call `startSessionReplay()` to begin recording later. + */ + initializeSessionReplay(): Promise; + + /** + * Initializes the native instance if it is not initialized yet, then starts recording + * regardless of the configured `isEnabled`. + * + * Subject to `sampleRate` for the current enable cycle: a session that sampling already + * excluded stays excluded until `stopSessionReplay()` resets the decision. + */ startSessionReplay(): Promise; stopSessionReplay(): Promise; afterIdentify( 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 79b8571b10..792f6fbca3 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 @@ -4,15 +4,21 @@ import { configureSessionReplay, createSessionReplayPlugin, LDClick, + startSessionReplay, } from '../index'; jest.mock('../NativeSessionReplayReactNative', () => ({ configure: jest.fn().mockResolvedValue(undefined), + initializeSessionReplay: jest.fn().mockResolvedValue(undefined), startSessionReplay: jest.fn().mockResolvedValue(undefined), stopSessionReplay: jest.fn().mockResolvedValue(undefined), afterIdentify: jest.fn().mockResolvedValue(undefined), })); +// register() resolves the shared session id asynchronously before touching native, so tests +// have to cross a macrotask boundary before asserting. +const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 0)); + describe('configureSessionReplay', () => { it('rejects if key is empty', async () => { await expect(configureSessionReplay('')).rejects.toThrow(); @@ -205,7 +211,7 @@ describe('SessionReplayPluginAdapter', () => { ); }); - it('calls configure and startSessionReplay on register', async () => { + it('calls configure and initializeSessionReplay on register', async () => { const plugin = createSessionReplayPlugin({ frameRate: 4, scale: 2, @@ -217,10 +223,7 @@ describe('SessionReplayPluginAdapter', () => { { sdk: { name: 'test', version: '0.0.0' }, mobileKey: 'mob-key-123' } ); - // register() resolves the shared session id asynchronously before calling - // native configure/start, so flush all microtasks (a macrotask boundary) - // before asserting. - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAsync(); expect(NativeSessionReplayReactNative.configure).toHaveBeenCalledWith( 'mob-key-123', @@ -233,8 +236,74 @@ describe('SessionReplayPluginAdapter', () => { unmaskTestIDs: ['__LD_INTERNAL_UNMASK__'], }) ); + // The auto-start must go through the init path, which honors the configured isEnabled. + // Using startSessionReplay here would force recording on for `isEnabled: false` users. expect( - NativeSessionReplayReactNative.startSessionReplay + NativeSessionReplayReactNative.initializeSessionReplay ).toHaveBeenCalled(); + expect( + NativeSessionReplayReactNative.startSessionReplay + ).not.toHaveBeenCalled(); + }); + + it('does not force recording on when registered with isEnabled false', async () => { + const plugin = createSessionReplayPlugin({ isEnabled: false }); + plugin.register( + {}, + { sdk: { name: 'test', version: '0.0.0' }, mobileKey: 'mob-key-123' } + ); + + await flushAsync(); + + expect(NativeSessionReplayReactNative.configure).toHaveBeenCalledWith( + 'mob-key-123', + expect.objectContaining({ isEnabled: false }) + ); + expect( + NativeSessionReplayReactNative.startSessionReplay + ).not.toHaveBeenCalled(); + }); + + it('falls back to startSessionReplay when native has no initializeSessionReplay', async () => { + // A JS-only (OTA) update can run against an older native binary. Losing the init path + // entirely would leave replay uninitialized, so register() degrades to the old call. + const native = NativeSessionReplayReactNative as unknown as Record< + string, + unknown + >; + const initialize = native.initializeSessionReplay; + delete native.initializeSessionReplay; + try { + const plugin = createSessionReplayPlugin({}); + plugin.register( + {}, + { sdk: { name: 'test', version: '0.0.0' }, mobileKey: 'mob-key-123' } + ); + + await flushAsync(); + + expect( + NativeSessionReplayReactNative.startSessionReplay + ).toHaveBeenCalled(); + } finally { + native.initializeSessionReplay = initialize; + } + }); +}); + +describe('startSessionReplay', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('starts without re-sending configure', async () => { + // The whole point of the fix: a deferred start needs no configure round-trip, so callers + // cannot accidentally clobber the options the plugin already applied. + await startSessionReplay(); + + expect( + NativeSessionReplayReactNative.startSessionReplay + ).toHaveBeenCalledTimes(1); + expect(NativeSessionReplayReactNative.configure).not.toHaveBeenCalled(); }); }); diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx index 32f555a222..345d307a8c 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx @@ -116,6 +116,28 @@ export function configureSessionReplay( ); } +/** + * Initializes native session replay, starting recording only when the configured + * `isEnabled` is true. + * + * `createSessionReplayPlugin` calls this for you, so reach for it only when driving session + * replay by hand and you want the configured `isEnabled` respected. To begin recording + * regardless of it, use {@link startSessionReplay}. + */ +export function initializeSessionReplay(): Promise { + return SessionReplayReactNative.initializeSessionReplay(); +} + +/** + * Starts recording, initializing native session replay first if it is not initialized yet. + * + * Starts regardless of the configured `isEnabled`, which is what makes deferred recording work: + * configure with `isEnabled: false`, then call this after login or wherever recording should + * begin. Recording stays on until {@link stopSessionReplay}. + * + * `sampleRate` still applies. It is evaluated once per enable cycle, so a session that sampling + * already excluded stays excluded until `stopSessionReplay()` resets the decision. + */ export function startSessionReplay(): Promise { return SessionReplayReactNative.startSessionReplay(); } @@ -259,7 +281,17 @@ class SessionReplayPluginAdapter implements LDPlugin { try { await configureSessionReplay(key, options); - await startSessionReplay(); + // Feature-detected because a JS-only (OTA) update can run this bundle against an older + // native binary that has no `initializeSessionReplay`. Since this is the plugin's only + // init path, falling back keeps replay working there — honoring `isEnabled` on the first + // start is the old binary's behavior anyway — instead of leaving it uninitialized. + if ( + typeof SessionReplayReactNative.initializeSessionReplay === 'function' + ) { + await initializeSessionReplay(); + } else { + await startSessionReplay(); + } } catch (error) { console.error( '[SessionReplay] Failed to initialize session replay:',