Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand Down Expand Up @@ -88,6 +91,27 @@ internal class SessionReplayClientAdapter private constructor() {
}
}

fun afterIdentify(contextKeys: ReadableMap, canonicalKey: String, completed: Boolean) {
val keys = mutableMapOf<String, String>()
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)
Comment thread
beekld marked this conversation as resolved.
}
} catch (e: Exception) {
logger.error("afterIdentify: threw {0}: {1}", e::class.simpleName, e.message)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

fun stop(completion: () -> Unit) {
logger.debug("stop")
// Post to the main thread so that stop() queues behind any in-progress start().
Expand Down Expand Up @@ -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) {
Expand All @@ -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<String, String>?): 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<Application>(relaxed = true)) { s, e ->
adapter.start(mockk<Application>(relaxed = true), null) { s, e ->
success = s
errorMessage = e
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
beekld marked this conversation as resolved.

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export interface Spec extends TurboModule {
configure(mobileKey: string, options?: Object): Promise<void>;
startSessionReplay(): Promise<void>;
stopSessionReplay(): Promise<void>;
afterIdentify(
contextKeys: Object,
canonicalKey: string,
completed: boolean
): Promise<void>;
}

export default TurboModuleRegistry.getEnforcing<Spec>(
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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(
Expand Down
Loading
Loading