From 26ee040942ddd80a38580484bbd164361b802731 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 29 Sep 2026 17:04:54 -0700 Subject: [PATCH 1/4] chore(react-native-session-replay): bump LaunchDarklySessionReplay to 0.56.0 Picks up LaunchDarkly iOS Client SDK 11.6.2. Changes since 0.53.1 are additive apart from ObservabilityOptions.crashReporting now defaulting to .disabled, which the adapter already sets explicitly. Co-authored-by: Cursor --- .../SessionReplayReactNative.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7f1e4e023d91376bfaa3677fd025434544ddb69a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 29 Sep 2026 17:18:41 -0700 Subject: [PATCH 2/4] feat(session-replay-react-native): honor isEnabled: false and add initializeSessionReplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startSessionReplay() re-applied the configured isEnabled, so the documented deferred-start pattern (isEnabled: false, then startSessionReplay() later) never recorded — the call wrote false back on iOS and called LDReplay.stop() on Android. startSessionReplay() now always enables recording, and the plugin's auto-start moved to a new initializeSessionReplay() that honors isEnabled. Neither adapter writes false any more, so a re-init (a JS soft/OTA reload, say) no longer silently stops a recording an earlier start began. The plugin feature-detects the new method so an OTA bundle running against an older native binary still initializes. On iOS the outcome is mapped from LDReplay.start(ignoreSampling:), so an unavailable plugin or an unrecoverable error rejects the promise instead of resolving as success. Android keeps plain LDReplay.start() until the launchdarkly-observability-android dependency exposes the same result API. Co-authored-by: Cursor --- .../react-native-ld-session-replay/README.md | 29 ++++++- .../SessionReplayClientAdapter.kt | 41 +++++++--- .../SessionReplayReactNativeModule.kt | 16 +++- .../SessionReplayClientAdapterTest.kt | 19 ++++- .../example/src/App.tsx | 42 +++++++++- .../ios/SessionReplayClientAdapter.swift | 37 ++++++++- .../ios/SessionReplayReactNative.mm | 22 ++++- .../src/NativeSessionReplayReactNative.ts | 22 +++++ .../src/__tests__/index.test.tsx | 81 +++++++++++++++++-- .../src/index.tsx | 34 +++++++- 10 files changed, 312 insertions(+), 31 deletions(-) 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/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..6e81871f62 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 @@ -77,7 +77,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,12 +142,24 @@ 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") + } + // 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 } try { - applyEnabled(localReplayOptions.enabled) + // TODO: report the outcome (and support ignoreSampling) once the + // launchdarkly-observability-android dependency exposes + // LDReplay.start(ignoreSampling): SessionReplayStartResult, matching iOS. + LDReplay.start() } 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 } @@ -237,14 +262,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..f013381ace 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,8 +53,11 @@ 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({ - isEnabled: true, +// 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: false, // Forwarded to the native observability + session replay instances so their // spans report the same service.name / service.version as the JS observability // plugin below. serviceVersion only affects observability-emitted signals. @@ -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:', From a1dc103afdde01c799d1924e4e3f149591b73c1c Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 29 Sep 2026 18:56:53 -0700 Subject: [PATCH 3/4] feat(session-replay-react-native): report Android start outcomes via observability-android 0.69.0 Bumps launchdarkly-observability-android 0.66.1 -> 0.69.0 to pick up LDReplay.start(ignoreSampling): SessionReplayStartResult, and maps the result the same way iOS already does: started/alreadyStarted and sampledOut resolve, while unavailable and unrecoverableError reject with the same messages. This clears the TODO left in the previous commit, so both platforms now report why a start did not record instead of always resolving as success. The call runs inline on the main thread rather than hopping, because the adapter is already inside a main-looper post and the core executor runs the block directly when it is already on the main thread. The mapping sits after Handler.post, which the plain JVM unit tests cannot reach; the enum-to-behavior mapping is covered by the core SDK's own tests. Co-authored-by: Cursor --- .../android/build.gradle | 2 +- .../SessionReplayClientAdapter.kt | 33 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) 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 6e81871f62..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 @@ -153,17 +154,37 @@ internal class SessionReplayClientAdapter private constructor() { completion(true, null) return@post } - try { - // TODO: report the outcome (and support ignoreSampling) once the - // launchdarkly-observability-android dependency exposes - // LDReplay.start(ignoreSampling): SessionReplayStartResult, matching iOS. - LDReplay.start() + // `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: 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." + ) + } + } } } From 885e000d965a595b2e1f3c4fae1811664ce6db19 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 30 Sep 2026 14:22:03 -0700 Subject: [PATCH 4/4] fix start --- .../react-native-ld-session-replay/example/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f013381ace..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 @@ -57,7 +57,7 @@ console.log(`[soft-reload] JS_LOAD_ID=${JS_LOAD_ID} (new value each JS load)`); // initializes native replay and observability with recording off, and the "Start // replay" button turns it on later. const replayOptions: SessionReplayOptions = { - isEnabled: false, + isEnabled: true, // Forwarded to the native observability + session replay instances so their // spans report the same service.name / service.version as the JS observability // plugin below. serviceVersion only affects observability-emitted signals.