From be399c8d88f5178da0685f7589ef838c48d9fb49 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 11:19:41 -0700 Subject: [PATCH 01/10] refactor: add an afterIdentify hook that passes through the context --- .../SessionReplayClientAdapter.kt | 10 +++ .../SessionReplayReactNativeModule.kt | 14 ++++ .../src/NativeSessionReplayReactNative.ts | 5 ++ .../src/__tests__/index.test.tsx | 78 ++++++++++++++++++- .../src/index.tsx | 62 +++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) 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 7456eeb0db..53101da058 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 @@ -88,6 +88,16 @@ internal class SessionReplayClientAdapter private constructor() { } } + fun afterIdentify(contextKeys: ReadableMap, canonicalKey: String, completed: Boolean) { + val keys = mutableMapOf() + val iterator = contextKeys.keySetIterator() + while (iterator.hasNextKey()) { + val kind = iterator.nextKey() + contextKeys.getString(kind)?.let { keys[kind] = it } + } + LDReplay.hookProxy?.afterIdentify(keys, canonicalKey, completed) + } + fun stop(completion: () -> Unit) { logger.debug("stop") // Post to the main thread so that stop() queues behind any in-progress start(). 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 9d8fdd88ba..76213a1bf8 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 @@ -55,6 +55,20 @@ class SessionReplayReactNativeModule(reactContext: ReactApplicationContext) : } } + override fun afterIdentify( + contextKeys: ReadableMap, + canonicalKey: String, + completed: Boolean, + promise: Promise + ) { + try { + SessionReplayClientAdapter.shared.afterIdentify(contextKeys, canonicalKey, completed) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("after_identify_failed", e.message, e) + } + } + companion object { const val NAME = "SessionReplayReactNative" } 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 4a15b89ebf..2b5898fbe0 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts @@ -17,6 +17,11 @@ export interface Spec extends TurboModule { configure(mobileKey: string, options?: Object): Promise; startSessionReplay(): Promise; stopSessionReplay(): Promise; + afterIdentify( + contextKeys: Object, + canonicalKey: string, + completed: boolean + ): Promise; } export default TurboModuleRegistry.getEnforcing( 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 a4d25c07a7..85848fc0f9 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 @@ -1,10 +1,15 @@ import NativeSessionReplayReactNative from '../NativeSessionReplayReactNative'; -import { configureSessionReplay, createSessionReplayPlugin } from '../index'; +import { + afterIdentify, + configureSessionReplay, + createSessionReplayPlugin, +} from '../index'; jest.mock('../NativeSessionReplayReactNative', () => ({ configure: jest.fn().mockResolvedValue(undefined), startSessionReplay: jest.fn().mockResolvedValue(undefined), stopSessionReplay: jest.fn().mockResolvedValue(undefined), + afterIdentify: jest.fn().mockResolvedValue(undefined), })); describe('configureSessionReplay', () => { @@ -17,6 +22,77 @@ describe('configureSessionReplay', () => { }); }); +describe('afterIdentify', () => { + it('passes kind and key for a single-kind context', async () => { + await afterIdentify({ kind: 'user', key: 'abc' }, true); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { user: 'abc' }, + 'abc', + true + ); + }); + + it('uses kind:key canonical key for non-user single-kind context', async () => { + await afterIdentify({ kind: 'org', key: 'acme' }, true); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { org: 'acme' }, + 'org:acme', + true + ); + }); + + it('escapes colons and percent signs in keys', async () => { + await afterIdentify({ kind: 'org', key: 'a:b%c' }, true); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { org: 'a:b%c' }, + 'org:a%3Ab%25c', + true + ); + }); + + it('passes all sub-context kind/key pairs for a multi-kind context', async () => { + await afterIdentify( + { kind: 'multi', org: { key: 'acme' }, user: { key: 'abc' } }, + true + ); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { org: 'acme', user: 'abc' }, + 'org:acme:user:abc', + true + ); + }); + + it('sorts sub-contexts by kind for the canonical key', async () => { + await afterIdentify( + { kind: 'multi', user: { key: 'abc' }, org: { key: 'acme' } }, + true + ); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { user: 'abc', org: 'acme' }, + 'org:acme:user:abc', + true + ); + }); + + it('handles legacy LDUser with implicit user kind', async () => { + await afterIdentify({ key: 'legacy-user' }, true); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { user: 'legacy-user' }, + 'legacy-user', + true + ); + }); + + it('passes completed=false through', async () => { + await afterIdentify({ kind: 'user', key: 'abc' }, false); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { user: 'abc' }, + 'abc', + false + ); + }); +}); + describe('SessionReplayPluginAdapter', () => { it('calls configure and startSessionReplay on register', async () => { const plugin = createSessionReplayPlugin(); 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 586f3b5c39..00317ccf2f 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx @@ -5,6 +5,7 @@ import type { LDClientMin, } from '@launchdarkly/observability-react-native'; import type { + LDContext, LDPluginEnvironmentMetadata, LDPluginMetadata, } from '@launchdarkly/js-sdk-common'; @@ -12,6 +13,67 @@ import type { const MOBILE_KEY_REQUIRED_MESSAGE = 'Session replay requires a non-empty mobile key. Provide metadata.sdkKey or metadata.mobileKey when initializing the LaunchDarkly client.'; +// Mirrors escapeKey() in LDObserveContext.kt (observability-android) +function escapeContextKey(key: string): string { + return key.replace(/%/g, '%25').replace(/:/g, '%3A'); +} + +// Mirrors SessionReplayHook.afterIdentify() in SessionReplayHook.kt (observability-android) +function contextKeysFromContext(context: LDContext): Record { + const keys: Record = {}; + if (!('kind' in context)) { + // Legacy LDUser — no 'kind' field, implicitly 'user' + keys['user'] = context.key; + return keys; + } + if (context.kind === 'multi') { + for (const [kindName, value] of Object.entries(context)) { + if (kindName !== 'kind' && typeof value === 'object' && value !== null) { + keys[kindName] = (value as { key: string }).key; + } + } + return keys; + } + keys[context.kind] = context.key; + return keys; +} + +// Mirrors LDObserveContext.fullyQualifiedKey in LDObserveContext.kt (observability-android) +function canonicalKeyFromContext(context: LDContext): string { + if (!('kind' in context)) { + // Legacy LDUser + return context.key; + } + if (context.kind === 'multi') { + const parts: string[] = []; + for (const [kindName, value] of Object.entries(context)) { + if (kindName !== 'kind' && typeof value === 'object' && value !== null) { + parts.push( + `${kindName}:${escapeContextKey((value as { key: string }).key)}` + ); + } + } + return parts.sort().join(':'); + } + if (context.kind === 'user') { + return context.key; + } + return `${context.kind}:${escapeContextKey(context.key)}`; +} + +export function afterIdentify( + context: LDContext, + completed: boolean +): Promise { + const contextKeys = contextKeysFromContext(context); + const canonicalKey = canonicalKeyFromContext(context); + return SessionReplayReactNative.afterIdentify( + contextKeys, + canonicalKey, + completed + ); +} + export function configureSessionReplay( mobileKey: string, options: SessionReplayOptions = {} From d1efd9e6757455ca545c0624c60adfd6ef5035cf Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 11:22:19 -0700 Subject: [PATCH 02/10] fix: fix the ios build --- .../ios/SessionReplayReactNative.mm | 9 +++++++++ 1 file changed, 9 insertions(+) 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 8c4392b722..dccb05cbe9 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayReactNative.mm +++ b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayReactNative.mm @@ -53,6 +53,15 @@ - (void)stopSessionReplay:(RCTPromiseResolveBlock)resolve } } +- (void)afterIdentify:(NSDictionary *)contextKeys + canonicalKey:(NSString *)canonicalKey + completed:(BOOL)completed + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + resolve(nil); +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { From d2df6f2e72ae2bd88060c19f8f2c977a553751c6 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 11:28:39 -0700 Subject: [PATCH 03/10] refactor: hook up the RN JS afterIdentify hook to the turbo module hook --- .../src/__tests__/index.test.tsx | 48 +++++++++++++++++++ .../src/index.tsx | 33 +++++++++++++ 2 files changed, 81 insertions(+) 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 85848fc0f9..6a134357ba 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 @@ -94,6 +94,54 @@ describe('afterIdentify', () => { }); describe('SessionReplayPluginAdapter', () => { + it('returns a hook from getHooks', () => { + const plugin = createSessionReplayPlugin(); + const hooks = plugin.getHooks!({ + sdk: { name: 'test', version: '0.0.0' }, + mobileKey: 'mob-key-123', + }); + expect(hooks).toHaveLength(1); + expect(hooks[0]!.getMetadata().name).toBe('session-replay-react-native'); + }); + + it('hook afterIdentify calls native afterIdentify with context', async () => { + const plugin = createSessionReplayPlugin(); + const [hook] = plugin.getHooks!({ + sdk: { name: 'test', version: '0.0.0' }, + mobileKey: 'mob-key-123', + }); + hook!.afterIdentify!( + { context: { kind: 'user', key: 'abc' } }, + {}, + { status: 'completed' } + ); + await new Promise(process.nextTick); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { user: 'abc' }, + 'abc', + true + ); + }); + + it('hook afterIdentify passes completed=false for shed status', async () => { + const plugin = createSessionReplayPlugin(); + const [hook] = plugin.getHooks!({ + sdk: { name: 'test', version: '0.0.0' }, + mobileKey: 'mob-key-123', + }); + hook!.afterIdentify!( + { context: { kind: 'user', key: 'abc' } }, + {}, + { status: 'shed' } + ); + await new Promise(process.nextTick); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { user: 'abc' }, + 'abc', + false + ); + }); + it('calls configure and startSessionReplay on register', async () => { const plugin = createSessionReplayPlugin(); plugin.register( 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 00317ccf2f..074c6755ba 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx @@ -9,6 +9,13 @@ import type { LDPluginEnvironmentMetadata, LDPluginMetadata, } from '@launchdarkly/js-sdk-common'; +import type { + Hook, + HookMetadata, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, +} from '@launchdarkly/react-native-client-sdk'; const MOBILE_KEY_REQUIRED_MESSAGE = 'Session replay requires a non-empty mobile key. Provide metadata.sdkKey or metadata.mobileKey when initializing the LaunchDarkly client.'; @@ -93,6 +100,28 @@ export function stopSessionReplay(): Promise { return SessionReplayReactNative.stopSessionReplay(); } +class SessionReplayHook implements Hook { + getMetadata(): HookMetadata { + return { name: 'session-replay-react-native' }; + } + + afterIdentify( + hookContext: IdentifySeriesContext, + data: IdentifySeriesData, + result: IdentifySeriesResult + ): IdentifySeriesData { + afterIdentify(hookContext.context, result.status === 'completed').catch( + (error) => { + console.error( + '[SessionReplay] Failed to forward identify context:', + error + ); + } + ); + return data; + } +} + class SessionReplayPluginAdapter implements LDPlugin { private options: SessionReplayOptions; @@ -106,6 +135,10 @@ class SessionReplayPluginAdapter implements LDPlugin { }; } + getHooks(_metadata: LDPluginEnvironmentMetadata): Hook[] { + return [new SessionReplayHook()]; + } + register(_client: LDClientMin, metadata: LDPluginEnvironmentMetadata): void { const sdkKey = metadata.sdkKey || metadata.mobileKey || ''; const key = typeof sdkKey === 'string' ? sdkKey.trim() : ''; From b09bc696df2b314fb5b2bf91ba02b3663cd29870 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 11:50:47 -0700 Subject: [PATCH 04/10] docs: update a comment --- .../observability/replay/plugin/SessionReplayHookProxy.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHookProxy.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHookProxy.kt index b4f24e8351..12f060b73d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHookProxy.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHookProxy.kt @@ -3,11 +3,12 @@ package com.launchdarkly.observability.replay.plugin import com.launchdarkly.observability.sdk.SessionReplayServicing /** - * JVM adapter for the C# / MAUI bridge. + * JVM adapter for cross-platform bridges (C# / MAUI, React Native, etc.). * * Accepts simple JVM types (String, Map) and delegates * to [SessionReplayServicing] so the replay logic is written once. * The C# NativeHookProxy delegates here via the Xamarin.Android binding. + * The React Native turbo module delegates here via LDReplay.hookProxy. */ class SessionReplayHookProxy internal constructor( private val sessionReplayService: SessionReplayServicing From 13efbf9343cb4b80d783203bbaf68da6b73cd854 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 11:51:14 -0700 Subject: [PATCH 05/10] docs: update a comment --- .../main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt index b829d9a329..d927998ffa 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt @@ -13,7 +13,7 @@ object LDReplay { internal var client: SessionReplayServicing? = null /** - * Hook proxy for the C# / MAUI bridge. + * Hook proxy for cross-platform bridges (C# / MAUI, React Native, etc.). */ val hookProxy: SessionReplayHookProxy? get() = client?.let { SessionReplayHookProxy(it) } From b261b74cc896fee26f850f52d56ed39458ef688f Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 12:24:43 -0700 Subject: [PATCH 06/10] fix: keep track of context even if it arrives before SR is started --- .../SessionReplayClientAdapter.kt | 40 ++++++++++++++----- .../SessionReplayClientAdapterTest.kt | 32 ++++++++++++++- 2 files changed, 61 insertions(+), 11 deletions(-) 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 53101da058..5bf4c96588 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 @@ -27,6 +27,8 @@ internal class SessionReplayClientAdapter private constructor() { private var replayOptions: ReplayOptions? = null // Only accessed from the main thread (all reads/writes are inside Handler(mainLooper).post blocks). private var initialized = false + // The most recently identified context. Used on init if one arrived before LDClient.init() ran. + private var cachedContextKeys: Map? = null private val logger = LDLogger.withAdapter(LDAndroidLogging.adapter(), TAG) fun setMobileKey(mobileKey: String, options: ReadableMap?) { @@ -95,7 +97,14 @@ internal class SessionReplayClientAdapter private constructor() { val kind = iterator.nextKey() contextKeys.getString(kind)?.let { keys[kind] = it } } - LDReplay.hookProxy?.afterIdentify(keys, canonicalKey, completed) + Handler(Looper.getMainLooper()).post { + if (completed) { + cachedContextKeys = keys + } + if (initialized) { + LDReplay.hookProxy?.afterIdentify(keys, canonicalKey, completed) + } + } } fun stop(completion: () -> Unit) { @@ -135,17 +144,14 @@ internal class SessionReplayClientAdapter private constructor() { ) .build() - // The context key is a placeholder. The LDClient is offline and never sends it to - // LaunchDarkly servers, but SessionReplay does use it locally to attribute sessions. - // - // TODO: Pass the actual initial context here once the LaunchDarkly React Native SDK - // supports providing a context at initialization time. Currently, context is only - // available after an explicit client.identify() call — getContext() always returns - // undefined when register() runs during the LDClient constructor. - val placeholderContext = LDContext.builder(ContextKind.DEFAULT, "placeholder").build() + // Use a real context if one arrived via afterIdentify() before init; otherwise fall back + // to a placeholder. The LDClient is offline and never sends the context to LaunchDarkly + // servers, but SessionReplay uses it locally to attribute sessions. + val initialContext = buildContextFromKeys(cachedContextKeys) + ?: LDContext.builder(ContextKind.DEFAULT, "placeholder").build() // timeout=0: return immediately without blocking the main thread waiting for flags. // onPluginsReady() fires synchronously during init() before it returns. - LDClient.init(application, config, placeholderContext, 0) + LDClient.init(application, config, initialContext, 0) } private fun applyEnabled(enabled: Boolean) { @@ -156,6 +162,20 @@ internal class SessionReplayClientAdapter private constructor() { } } + // 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? { + if (contextKeys == null) return null + if (contextKeys.size == 1) { + val (kind, key) = contextKeys.entries.first() + return LDContext.builder(ContextKind.of(kind), key).build() + } + val contexts = contextKeys.map { (kind, key) -> + LDContext.builder(ContextKind.of(kind), key).build() + } + return LDContext.createMulti(*contexts.toTypedArray()) + } + internal fun replayOptionsFrom(map: ReadableMap?): ReplayOptions { if (map == null) { return ReplayOptions( 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 31f6127155..c1bde27784 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 @@ -2,10 +2,13 @@ package com.sessionreplayreactnative import android.app.Application import com.facebook.react.bridge.ReadableMap +import com.launchdarkly.sdk.ContextKind import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -46,13 +49,40 @@ class SessionReplayClientAdapterTest { assertTrue(options.privacyProfile.maskText) } + @Test + fun `buildContextFromKeys returns null for null input`() { + val adapter = newAdapter() + assertNull(adapter.buildContextFromKeys(null)) + } + + @Test + fun `buildContextFromKeys returns single-kind context`() { + val adapter = newAdapter() + val context = adapter.buildContextFromKeys(mapOf("user" to "abc123")) + assertNotNull(context) + assertEquals("abc123", context!!.key) + assertEquals(ContextKind.of("user"), context.kind) + } + + @Test + fun `buildContextFromKeys returns multi-kind context`() { + val adapter = newAdapter() + val context = adapter.buildContextFromKeys(mapOf("user" to "abc", "org" to "acme")) + assertNotNull(context) + assertTrue(context!!.isMultiple) + assertNotNull(context.getIndividualContext(ContextKind.of("user"))) + assertEquals("abc", context.getIndividualContext(ContextKind.of("user"))!!.key) + assertNotNull(context.getIndividualContext(ContextKind.of("org"))) + assertEquals("acme", context.getIndividualContext(ContextKind.of("org"))!!.key) + } + @Test fun `start before setMobileKey calls completion with failure`() { val adapter = newAdapter() var success: Boolean? = null var errorMessage: String? = null - adapter.start(mockk(relaxed = true)) { s, e -> + adapter.start(mockk(relaxed = true), null) { s, e -> success = s errorMessage = e } From 2710858483511defd2610006d9978640d2c91d50 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 12:35:19 -0700 Subject: [PATCH 07/10] fix: use an anonymous context by default --- .../SessionReplayClientAdapter.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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 5bf4c96588..f4ad981f22 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 @@ -27,8 +27,9 @@ internal class SessionReplayClientAdapter private constructor() { private var replayOptions: ReplayOptions? = null // Only accessed from the main thread (all reads/writes are inside Handler(mainLooper).post blocks). private var initialized = false - // The most recently identified context. Used on init if one arrived before LDClient.init() ran. - private var cachedContextKeys: Map? = null + // The most recently identified context. Defaults to anonymous; updated on each successful identify. + private var cachedContext: LDContext = + LDContext.builder(ContextKind.DEFAULT, "anonymous").anonymous(true).build() private val logger = LDLogger.withAdapter(LDAndroidLogging.adapter(), TAG) fun setMobileKey(mobileKey: String, options: ReadableMap?) { @@ -99,7 +100,7 @@ internal class SessionReplayClientAdapter private constructor() { } Handler(Looper.getMainLooper()).post { if (completed) { - cachedContextKeys = keys + buildContextFromKeys(keys)?.let { cachedContext = it } } if (initialized) { LDReplay.hookProxy?.afterIdentify(keys, canonicalKey, completed) @@ -144,14 +145,9 @@ internal class SessionReplayClientAdapter private constructor() { ) .build() - // Use a real context if one arrived via afterIdentify() before init; otherwise fall back - // to a placeholder. The LDClient is offline and never sends the context to LaunchDarkly - // servers, but SessionReplay uses it locally to attribute sessions. - val initialContext = buildContextFromKeys(cachedContextKeys) - ?: LDContext.builder(ContextKind.DEFAULT, "placeholder").build() // timeout=0: return immediately without blocking the main thread waiting for flags. // onPluginsReady() fires synchronously during init() before it returns. - LDClient.init(application, config, initialContext, 0) + LDClient.init(application, config, cachedContext, 0) } private fun applyEnabled(enabled: Boolean) { From bc93e1830780e767ba108dcfe01328268a15ef6d Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 15:17:53 -0700 Subject: [PATCH 08/10] fix: fix a minor edge case --- .../SessionReplayClientAdapter.kt | 16 ++++++++++------ .../SessionReplayClientAdapterTest.kt | 6 ++++++ 2 files changed, 16 insertions(+), 6 deletions(-) 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 f4ad981f22..c0484b215d 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 @@ -99,11 +99,15 @@ internal class SessionReplayClientAdapter private constructor() { contextKeys.getString(kind)?.let { keys[kind] = it } } Handler(Looper.getMainLooper()).post { - if (completed) { - buildContextFromKeys(keys)?.let { cachedContext = it } - } - if (initialized) { - LDReplay.hookProxy?.afterIdentify(keys, canonicalKey, completed) + try { + if (completed) { + buildContextFromKeys(keys)?.let { cachedContext = it } + } + if (initialized) { + LDReplay.hookProxy?.afterIdentify(keys, canonicalKey, completed) + } + } catch (e: Exception) { + logger.error("afterIdentify: threw {0}: {1}", e::class.simpleName, e.message) } } } @@ -161,7 +165,7 @@ internal class SessionReplayClientAdapter private constructor() { // 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? { - if (contextKeys == null) return null + if (contextKeys.isNullOrEmpty()) return null if (contextKeys.size == 1) { val (kind, key) = contextKeys.entries.first() return LDContext.builder(ContextKind.of(kind), key).build() 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 c1bde27784..2c28ca9c2b 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 @@ -55,6 +55,12 @@ class SessionReplayClientAdapterTest { assertNull(adapter.buildContextFromKeys(null)) } + @Test + fun `buildContextFromKeys returns null for empty map`() { + val adapter = newAdapter() + assertNull(adapter.buildContextFromKeys(emptyMap())) + } + @Test fun `buildContextFromKeys returns single-kind context`() { val adapter = newAdapter() From 358b9a04cc9ac7fdb269db66b5ac5c3c36c3f5d3 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 15:22:50 -0700 Subject: [PATCH 09/10] fix: fix a lint error --- .../react-native-ld-session-replay/src/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 074c6755ba..ee8f130c41 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx @@ -41,7 +41,7 @@ function contextKeysFromContext(context: LDContext): Record { } return keys; } - keys[context.kind] = context.key; + keys[context.kind] = context.key as string; return keys; } @@ -65,7 +65,7 @@ function canonicalKeyFromContext(context: LDContext): string { if (context.kind === 'user') { return context.key; } - return `${context.kind}:${escapeContextKey(context.key)}`; + return `${context.kind}:${escapeContextKey(context.key as string)}`; } export function afterIdentify( From 8e58d17513ef6e7fbd6e34e48c0844d82ea02a9f Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 23 Apr 2026 15:32:40 -0700 Subject: [PATCH 10/10] fix: fix a minor bug --- .../src/__tests__/index.test.tsx | 14 ++++++++++++++ .../react-native-ld-session-replay/src/index.tsx | 10 +++++----- 2 files changed, 19 insertions(+), 5 deletions(-) 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 6a134357ba..5aac97afaa 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 @@ -74,6 +74,20 @@ describe('afterIdentify', () => { ); }); + it('sorts by kind name, not by kind:key string', async () => { + // "org-team" sorts before "org" when sorting full "kind:key" strings because + // '-' (45) < ':' (58). Sorting by kind name only keeps "org" first. + await afterIdentify( + { 'kind': 'multi', 'org-team': { key: 'eng' }, 'org': { key: 'acme' } }, + true + ); + expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( + { 'org-team': 'eng', 'org': 'acme' }, + 'org:acme:org-team:eng', + true + ); + }); + it('handles legacy LDUser with implicit user kind', async () => { await afterIdentify({ key: 'legacy-user' }, true); expect(NativeSessionReplayReactNative.afterIdentify).toHaveBeenCalledWith( 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 ee8f130c41..212db0dd38 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx @@ -52,15 +52,15 @@ function canonicalKeyFromContext(context: LDContext): string { return context.key; } if (context.kind === 'multi') { - const parts: string[] = []; + const entries: Array<[string, string]> = []; for (const [kindName, value] of Object.entries(context)) { if (kindName !== 'kind' && typeof value === 'object' && value !== null) { - parts.push( - `${kindName}:${escapeContextKey((value as { key: string }).key)}` - ); + entries.push([kindName, (value as { key: string }).key]); } } - return parts.sort().join(':'); + // Sort by kind name only, matching LDObserveContext.fullyQualifiedKey in (observability-android) + entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return entries.map(([k, v]) => `${k}:${escapeContextKey(v)}`).join(':'); } if (context.kind === 'user') { return context.key;