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 b4f24e835..12f060b73 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 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 b829d9a32..d927998ff 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) } 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 7456eeb0d..c0484b215 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,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. 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?) { @@ -88,6 +91,27 @@ 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 } + } + Handler(Looper.getMainLooper()).post { + 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) + } + } + } + fun stop(completion: () -> Unit) { logger.debug("stop") // Post to the main thread so that stop() queues behind any in-progress start(). @@ -125,17 +149,9 @@ 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() // 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, cachedContext, 0) } private fun applyEnabled(enabled: Boolean) { @@ -146,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.isNullOrEmpty()) 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/main/java/com/sessionreplayreactnative/SessionReplayReactNativeModule.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayReactNativeModule.kt index 9d8fdd88b..76213a1bf 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/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 31f612715..2c28ca9c2 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,46 @@ 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 null for empty map`() { + val adapter = newAdapter() + assertNull(adapter.buildContextFromKeys(emptyMap())) + } + + @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 } 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 8c4392b72..dccb05cbe 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 { 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 4a15b89eb..2b5898fbe 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 a4d25c07a..5aac97afa 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,7 +22,140 @@ 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('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( + { 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('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 586f3b5c3..212db0dd3 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx @@ -5,13 +5,82 @@ import type { LDClientMin, } from '@launchdarkly/observability-react-native'; import type { + LDContext, 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.'; +// 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 as string; + 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 entries: Array<[string, string]> = []; + for (const [kindName, value] of Object.entries(context)) { + if (kindName !== 'kind' && typeof value === 'object' && value !== null) { + entries.push([kindName, (value as { key: string }).key]); + } + } + // 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; + } + return `${context.kind}:${escapeContextKey(context.key as string)}`; +} + +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 = {} @@ -31,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; @@ -44,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() : '';