From 153987dcc9427f640885ba1fd06bd7839c78e5ee Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 4 May 2026 16:34:27 -0700 Subject: [PATCH 01/13] remove protobuf (cherry picked from commit a9880a08f5b69621141d4d214076dbba1b4b562d) --- .../xcshareddata/swiftpm/Package.resolved | 11 +---------- sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6c19af08ce..59f06947da 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "726fca91e63fda85d05c9db34511dddd0b0a9cc2ff82f5198bcd32b98d738c87", + "originHash" : "1df544a53c9bf1925935fe959013b9781128f4ab5ca2940de10bda775478286c", "pins" : [ { "identity" : "ios-client-sdk", @@ -45,15 +45,6 @@ "revision" : "57051701c58a93603ffa2051f8e9cf0c8cff7814", "version" : "3.3.0" } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", - "version" : "1.33.3" - } } ], "version" : 3 diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs b/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs index d6a41eb2e7..93e6995f85 100644 --- a/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs +++ b/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs @@ -115,7 +115,7 @@ public static MauiApp CreateMauiApp() ).Build(); var context = LaunchDarkly.Sdk.Context.New("maui-user-key"); - var client = LdClient.Init(ldConfig, context, TimeSpan.FromSeconds(10)); + var client = LdClient.Init(ldConfig, context, TimeSpan.FromSeconds(0)); var feature1 = client.BoolVariation("feature1", false); Console.WriteLine($"feature1 sync value ={feature1}"); From e62383168c8b86e660b35a2f9a1f415c35148d47 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 4 May 2026 17:10:50 -0700 Subject: [PATCH 02/13] bump version (cherry picked from commit 88ca5c0ef41390d0964bb5218ea8fafc66271e7c) --- .../mobile-dotnet/observability/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index a18304a5b0..602f91305a 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props @@ -1,7 +1,7 @@ LaunchDarkly.SessionReplay - 0.9.17 + 0.10.0 false LaunchDarkly LaunchDarkly From 90a69a66d0bd5de85b6ef12ee169ee32f823eb28 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 7 May 2026 19:51:19 -0700 Subject: [PATCH 03/13] LDObserve, LDReplay thread-safety --- .../androidobservability/BaseApplication.kt | 54 +++-- .../observability/Directory.Build.props | 2 +- .../mobile-dotnet/sample/MauiProgram.cs | 4 +- .../client/ObservabilityService.kt | 3 + .../observability/plugin/Observability.kt | 103 ++++----- .../replay/SessionReplayService.kt | 32 +-- .../replay/plugin/SessionReplay.kt | 27 ++- ...playImpl.kt => SessionReplayPluginImpl.kt} | 26 ++- .../observability/sdk/LDObserve.kt | 45 ++-- .../observability/sdk/LDReplay.kt | 109 ++++++--- .../observability/sdk/PreInitReplayBuffer.kt | 144 ++++++++++++ .../observability/util/MainThread.kt | 84 +++++++ .../observability/replay/SessionReplayTest.kt | 54 ++--- .../observability/sdk/LDReplayTest.kt | 208 +++++++++++++++--- 14 files changed, 682 insertions(+), 213 deletions(-) rename sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/{SessionReplayImpl.kt => SessionReplayPluginImpl.kt} (55%) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt index 9430a1642c..16686054f5 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt @@ -58,7 +58,7 @@ open class BaseApplication : Application() { var testUrl: String? = null // example on creating OBS/SR with flagging sdk - open fun realInit() { + open fun realInitLD() { val observabilityPlugin = Observability( application = this@BaseApplication, mobileKey = LAUNCHDARKLY_MOBILE_KEY, @@ -87,18 +87,20 @@ open class BaseApplication : Application() { .anonymous(true) .build() - LDClient.init(this@BaseApplication, ldConfig, context, 1) + Thread { + LDClient.init(this@BaseApplication, ldConfig, context, 0) - if (testUrl == null) { - // intervenes in E2E tests by trigger spans - flagEvaluation() - } + if (testUrl == null) { + // intervenes in E2E tests by trigger spans + flagEvaluation() + } - LDReplay.start() + LDReplay.start() + }.start() } // example on creating OBS/SR without flagging - open fun realIndependentInit() { + open fun realInit() { val effectiveOptions = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions @@ -107,25 +109,29 @@ open class BaseApplication : Application() { .anonymous(true) .build() - LDObserve.init( - application = this@BaseApplication, - mobileKey = LAUNCHDARKLY_MOBILE_KEY, - ldContext = context, - options = effectiveOptions, - replayOptions = ReplayOptions( - enabled = false, - privacyProfile = PrivacyProfile( - maskText = false, - maskWebViews = true, - maskViews = listOf( - view(ImageView::class.java), - ), - maskXMLViewIds = listOf("smoothieTitle") + Thread { + LDObserve.init( + application = this@BaseApplication, + mobileKey = LAUNCHDARKLY_MOBILE_KEY, + ldContext = context, + options = effectiveOptions, + replayOptions = ReplayOptions( + enabled = false, + privacyProfile = PrivacyProfile( + maskText = false, + maskWebViews = true, + maskViews = listOf( + view(ImageView::class.java), + ), + maskXMLViewIds = listOf("smoothieTitle") + ) ) ) - ) - LDReplay.start() + LDReplay.start() + + }.start() + } fun flagEvaluation() { diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index 602f91305a..1ef27afec7 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props @@ -1,7 +1,7 @@ LaunchDarkly.SessionReplay - 0.10.0 + 0.10.1 false LaunchDarkly LaunchDarkly diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs b/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs index 93e6995f85..cd6cf3b667 100644 --- a/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs +++ b/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs @@ -115,7 +115,9 @@ public static MauiApp CreateMauiApp() ).Build(); var context = LaunchDarkly.Sdk.Context.New("maui-user-key"); - var client = LdClient.Init(ldConfig, context, TimeSpan.FromSeconds(0)); + var client = Task.Run(() => LdClient.InitAsync(ldConfig, context)).GetAwaiter().GetResult(); + + //var client = LdClient.Init(ldConfig, context, TimeSpan.FromSeconds(0)); var feature1 = client.BoolVariation("feature1", false); Console.WriteLine($"feature1 sync value ={feature1}"); diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index 4ebf7bcebb..eba94ee7fc 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -23,6 +23,7 @@ import com.launchdarkly.observability.sampling.SamplingConfig import com.launchdarkly.observability.sampling.SamplingLogProcessor import com.launchdarkly.observability.traces.EventSpanProcessor import com.launchdarkly.observability.traces.OtlpTraceExporter +import com.launchdarkly.observability.util.requireMainThread import io.opentelemetry.android.OpenTelemetryRum import io.opentelemetry.android.OpenTelemetryRumBuilder import io.opentelemetry.android.config.OtelRumConfig @@ -108,6 +109,8 @@ class ObservabilityService( private val scope = CoroutineScope(DispatcherProviderHolder.current.io + SupervisorJob()) init { + requireMainThread { "ObservabilityService must be initialized on the main thread" } + registerOtlpExporters() val otelRumConfig = createOtelRumConfig() diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt index e726268c2a..07e9781686 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt @@ -77,16 +77,16 @@ class Observability( override fun register(client: LDClient, metadata: EnvironmentMetadata?) { this.client = client val sdkKey = metadata?.credential ?: "" - if (mobileKey == sdkKey) { - LDObserve.context = ObservabilityContext( - sdkKey = sdkKey, - options = options, - application = application, - logger = logger - ) - } else { + if (mobileKey != sdkKey) { logger.warn("ObservabilityContext could not be initialized for sdkKey: $sdkKey") + return } + LDObserve.context = ObservabilityContext( + sdkKey = sdkKey, + options = options, + application = application, + logger = logger + ) } override fun getHooks(metadata: EnvironmentMetadata?): MutableList { @@ -96,50 +96,53 @@ class Observability( override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { val sdkKey = metadata?.credential ?: "" - client?.let { lDClient -> - if (mobileKey == sdkKey) { - val attributes = Attributes.builder() - Resource.getDefault().attributes.forEach { key, value -> - if (key.key != "service.name") { - @Suppress("UNCHECKED_CAST") - attributes.put(key as AttributeKey, value) - } - } - attributes.put("highlight.project_id", sdkKey) - distroAttributes.forEach { (key, value) -> - attributes.put(AttributeKey.stringKey(key), value) - } - attributes.putAll(options.resourceAttributes) - - metadata?.applicationInfo?.applicationId?.let { - attributes.put("launchdarkly.application.id", it) - } - - metadata?.applicationInfo?.applicationVersion?.let { - attributes.put("launchdarkly.application.version", it) - } - - metadata?.sdkMetadata?.name?.let { sdkName -> - metadata.sdkMetadata?.version?.let { sdkVersion -> - attributes.put("launchdarkly.sdk.version", "$sdkName/$sdkVersion") - } - } - - val builtResource = Resource.create(attributes.build()) - LDObserve.context?.resourceAttributes = builtResource.attributes - - val client = ObservabilityService( - application, sdkKey, builtResource, logger, options, - ) - observabilityClient = client - LDObserve.context?.sessionManager = client.sessionManager - LDObserve.init(client) - - observabilityHook.delegate = client.hookExporter - } else { - logger.warn("Observability could not be initialized for sdkKey: $sdkKey") + if (client == null) { + logger.error("Observability could not be initialized: LDClient is null in onPluginsReady") + return + } + if (mobileKey != sdkKey) { + logger.warn("Observability could not be initialized for sdkKey: $sdkKey") + return + } + + val attributes = Attributes.builder() + Resource.getDefault().attributes.forEach { key, value -> + if (key.key != "service.name") { + @Suppress("UNCHECKED_CAST") + attributes.put(key as AttributeKey, value) + } + } + attributes.put("highlight.project_id", sdkKey) + distroAttributes.forEach { (key, value) -> + attributes.put(AttributeKey.stringKey(key), value) + } + attributes.putAll(options.resourceAttributes) + + metadata?.applicationInfo?.applicationId?.let { + attributes.put("launchdarkly.application.id", it) + } + + metadata?.applicationInfo?.applicationVersion?.let { + attributes.put("launchdarkly.application.version", it) + } + + metadata?.sdkMetadata?.name?.let { sdkName -> + metadata.sdkMetadata?.version?.let { sdkVersion -> + attributes.put("launchdarkly.sdk.version", "$sdkName/$sdkVersion") } } + + val builtResource = Resource.create(attributes.build()) + LDObserve.context?.resourceAttributes = builtResource.attributes + + val client = ObservabilityService( + application, sdkKey, builtResource, logger, options, + ) + observabilityClient = client + LDObserve.context?.sessionManager = client.sessionManager + LDObserve.init(client) + + observabilityHook.delegate = client.hookExporter } fun getTelemetryInspector(): TelemetryInspector? { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index 0018029602..33597d9fa9 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt @@ -17,6 +17,7 @@ import com.launchdarkly.observability.replay.transport.BatchWorker import com.launchdarkly.observability.replay.transport.EventQueue import com.launchdarkly.observability.context.LDObserveContext import com.launchdarkly.observability.sdk.SessionReplayServicing +import com.launchdarkly.observability.util.requireMainThread import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -72,7 +73,7 @@ class SessionReplayService( private val instrumentationScope = CoroutineScope(DispatcherProviderHolder.current.default + SupervisorJob()) private var captureJob: Job? = null private val shouldCapture = MutableStateFlow(false) - private val isEnabled = MutableStateFlow(options.enabled) + private val enabledState = MutableStateFlow(options.enabled) private var processLifecycleObserver: DefaultLifecycleObserver? = null private var isInstalled: Boolean = false private var exporter: SessionReplayExporter? = null @@ -80,6 +81,8 @@ class SessionReplayService( private var pendingIdentify: IdentifyItemPayload? = null fun initialize() { + requireMainThread { "SessionReplayService must be initialized on the main thread" } + if (isInstalled) return val sm = observabilityContext.sessionManager ?: run { @@ -132,7 +135,7 @@ class SessionReplayService( // Images collector instrumentationScope.launch { captureManager?.captureFlow?.collect { capture -> - if (!isEnabled.value) return@collect + if (!enabledState.value) return@collect eventQueue.send(ImageItemPayload(capture)) } } @@ -140,7 +143,7 @@ class SessionReplayService( // Interactions collector instrumentationScope.launch { interactionSource?.captureFlow?.collect { interaction -> - if (!isEnabled.value) return@collect + if (!enabledState.value) return@collect eventQueue.send(InteractionItemPayload(interaction)) } } @@ -152,7 +155,7 @@ class SessionReplayService( */ private fun startCaptureStateObserver() { instrumentationScope.launch { - combine(shouldCapture, isEnabled) { shouldRun, enabled -> shouldRun && enabled } + combine(shouldCapture, enabledState) { shouldRun, enabled -> shouldRun && enabled } .collect { shouldRun -> val running = captureJob?.isActive == true if (shouldRun == running) return@collect @@ -196,14 +199,17 @@ class SessionReplayService( shouldCapture.value = false } - override fun start() { - isEnabled.value = true - flushPendingIdentify() - } - - override fun stop() { - isEnabled.value = false - } + /** + * Whether replay capture is enabled. Setting to `true` flushes any identify payload that + * was cached while disabled (mirroring the previous `start()` behaviour); setting to + * `false` simply pauses event production (mirroring `stop()`). + */ + override var isEnabled: Boolean + get() = enabledState.value + set(value) { + enabledState.value = value + if (value) flushPendingIdentify() + } override fun flush() { batchWorker.flush() @@ -298,7 +304,7 @@ class SessionReplayService( ) // When replay is disabled, cache the identify payload for later session init without sending it now. - if (!isEnabled.value) { + if (!enabledState.value) { synchronized(pendingIdentifyLock) { pendingIdentify = event } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt index 1989e33311..1944c4d7e2 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt @@ -2,6 +2,7 @@ package com.launchdarkly.observability.replay.plugin import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.replay.ReplayOptions +import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata import com.launchdarkly.sdk.android.integrations.Hook @@ -9,31 +10,45 @@ import com.launchdarkly.sdk.android.integrations.Plugin import com.launchdarkly.sdk.android.integrations.PluginMetadata import com.launchdarkly.sdk.android.integrations.RegistrationCompleteResult import java.util.Collections +import java.util.logging.Logger /** * LDClient plugin adapter for Session Replay. * - * Wraps [SessionReplayImpl] so it can be registered as a [Plugin] with the LaunchDarkly + * Wraps [SessionReplayPluginImpl] so it can be registered as a [Plugin] with the LaunchDarkly * Android Client SDK. Only loaded when using the LDClient integration path. + * + * This adapter is the only place that resolves the [com.launchdarkly.observability.client.ObservabilityContext] + * from the global [LDObserve.context]. The LDClient plugin lifecycle constructs plugins eagerly + * and only hands them dependencies at [register], so we can't constructor-inject the context here. + * Once we have it, we forward it to [SessionReplayPluginImpl] explicitly — keeping the global lookup + * confined to this boundary. */ class SessionReplay( options: ReplayOptions = ReplayOptions(), ) : Plugin() { - private val impl = SessionReplayImpl(options) + private val impl = SessionReplayPluginImpl(options) private val sessionReplayHook = SessionReplayHook() val sessionReplayService get() = impl.sessionReplayService override fun getMetadata(): PluginMetadata { return object : PluginMetadata() { - override fun getName(): String = SessionReplayImpl.PLUGIN_NAME + override fun getName(): String = SessionReplayPluginImpl.PLUGIN_NAME override fun getVersion(): String = BuildConfig.OBSERVABILITY_SDK_VERSION } } override fun register(client: LDClient, metadata: EnvironmentMetadata?) { - impl.register() + val context = LDObserve.context ?: run { + logger.warning( + "Observability is not initialized; skipping SessionReplay registration. " + + "Ensure the Observability plugin is registered before SessionReplay." + ) + return + } + impl.register(context) sessionReplayHook.delegate = impl.sessionReplayService } @@ -44,4 +59,8 @@ class SessionReplay( override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { impl.initialize() } + + private companion object { + private val logger = Logger.getLogger("SessionReplay") + } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayImpl.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt similarity index 55% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayImpl.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt index 5d9d82f037..69bb84e0ef 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayImpl.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt @@ -1,5 +1,6 @@ package com.launchdarkly.observability.replay.plugin +import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.SessionReplayService import com.launchdarkly.observability.sdk.LDObserve @@ -7,29 +8,34 @@ import com.launchdarkly.observability.sdk.LDReplay import java.util.logging.Logger /** - * Standalone Session Replay entry point. + * Shared Session Replay registration logic used by both initialization paths. * * Use this directly via [LDObserve.init] for the standalone path (no LDClient). - * For the LDClient plugin path, use [SessionReplay] instead. + * For the LDClient plugin path, use [SessionReplay] (which delegates to this class) instead. */ -class SessionReplayImpl( +class SessionReplayPluginImpl( private val options: ReplayOptions = ReplayOptions(), ) { @Volatile var sessionReplayService: SessionReplayService? = null - fun register() { - val context = LDObserve.context ?: run { - logger.warning("Observability is not initialized; skipping SessionReplay registration.") - return - } - + /** + * Registers a [SessionReplayService] backed by [observabilityContext] and wires it into + * [LDReplay] as the active replay service. + * + * Dependencies are passed in explicitly rather than resolved from `LDObserve.context` so + * this class has no hidden coupling to global state — callers are responsible for ensuring + * observability has been initialized and providing its context. + * + * No-ops if Session Replay has already been registered globally on [LDReplay]. + */ + fun register(observabilityContext: ObservabilityContext) { if (LDReplay.client != null) { logger.warning("Session Replay instance already exists; skipping.") return } - val service = SessionReplayService(options, context) + val service = SessionReplayService(options, observabilityContext) LDReplay.init(service) sessionReplayService = service } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt index 5d559e036b..eceff7e5f3 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt @@ -11,7 +11,8 @@ import com.launchdarkly.observability.client.ObservabilityService import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import com.launchdarkly.observability.replay.ReplayOptions -import com.launchdarkly.observability.replay.plugin.SessionReplayImpl +import com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl +import com.launchdarkly.observability.util.postOnMainThread import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity @@ -104,7 +105,7 @@ class LDObserve(private val client: Observe) : Observe { } @Volatile - private var sessionReplayPlugin: SessionReplayImpl? = null + private var sessionReplayPlugin: SessionReplayPluginImpl? = null /** * Standalone initialization that sets up observability (and optionally session replay) @@ -140,20 +141,32 @@ class LDObserve(private val client: Observe) : Observe { val resource = buildResource(mobileKey, options) obsContext.resourceAttributes = resource.attributes - val service = ObservabilityService( - application, mobileKey, resource, logger, options, - ) - obsContext.sessionManager = service.sessionManager - init(service) - - if (replayOptions != null) { - val plugin = SessionReplayImpl(replayOptions) - sessionReplayPlugin = plugin - plugin.register() - plugin.sessionReplayService?.initialize() - plugin.sessionReplayService?.let { replayService -> - CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { - replayService.identifySession(ldContext) + // ObservabilityService and SessionReplayService both require main-thread initialization + // (they install OpenTelemetry instrumentations that touch UI/lifecycle state). Post + // the work fire-and-forget rather than waiting for it: blocking here can deadlock + // bridge callers (e.g. .NET MAUI startup) that hold the main thread waiting on the + // background thread that's currently executing this method, and even on main-thread + // callers it would freeze the UI for the duration of OTel RUM construction. + // + // Trade-off: SDK calls made on the same caller frame as [init] (e.g. a `recordError` + // immediately after) may hit the no-op delegate until the next looper tick. The + // delegate publication via `@Volatile` makes the swap visible as soon as it lands. + postOnMainThread { + val service = ObservabilityService( + application, mobileKey, resource, logger, options, + ) + obsContext.sessionManager = service.sessionManager + init(service) + + if (replayOptions != null) { + val plugin = SessionReplayPluginImpl(replayOptions) + sessionReplayPlugin = plugin + plugin.register(obsContext) + plugin.sessionReplayService?.initialize() + plugin.sessionReplayService?.let { replayService -> + CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { + replayService.identifySession(ldContext) + } } } } 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 d927998ffa..869e1a50ac 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 @@ -6,53 +6,55 @@ import com.launchdarkly.observability.replay.plugin.SessionReplayHookProxy /** * LDReplay is the singleton entry point for controlling Session Replay capture. * - * If Session Replay is not configured, these methods are no-ops. + * If Session Replay is not configured, most methods are no-ops. The exceptions are stateful + * operations whose data is buffered and replayed onto the live replay service during + * [init]: [isEnabled], [registerActivity], and [afterIdentify]. This way, callers can + * configure replay before SDK initialization without losing their preferences. + * + * All public operations are thread-safe. */ object LDReplay { - @Volatile - internal var client: SessionReplayServicing? = null + private val state = PreInitReplayBuffer() /** * Hook proxy for cross-platform bridges (C# / MAUI, React Native, etc.). */ val hookProxy: SessionReplayHookProxy? - get() = client?.let { SessionReplayHookProxy(it) } - - @Volatile - private var delegate: SessionReplayServicing = object : SessionReplayServicing { - override fun start() {} - override fun stop() {} - override fun flush() {} - override fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) {} - } + get() = state.client?.let { SessionReplayHookProxy(it) } /** - * Wires LDReplay to the active Session Replay controller. + * Whether session replay capture is currently enabled. + * + * Setting this before the SDK has wired up the underlying replay service buffers the + * value and applies it during [init], so the user's preference survives the no-op → live + * transition. After [init] runs, writes are dispatched to the live replay service on the + * main thread. + * + * Reads return the live replay service's value when available, otherwise the buffered + * value, otherwise `false`. */ - internal fun init(controller: SessionReplayServicing) { - delegate = controller - client = controller - } + var isEnabled: Boolean + get() = state.isEnabled + set(value) { + state.setEnabled(value) + } - /** - * Starts session replay capture - */ + /** Convenience for `isEnabled = true`. Starts session replay capture. */ fun start() { - delegate.start() + isEnabled = true } - /** - * Stops session replay capture - */ + /** Convenience for `isEnabled = false`. Pauses session replay capture. */ fun stop() { - delegate.stop() + isEnabled = false } /** - * Flushes any queued replay events immediately. + * Flushes any queued replay events immediately. No-op before the SDK is initialized + * because there are no queued events without a live replay service. */ fun flush() { - delegate.flush() + state.flush() } /** @@ -61,15 +63,62 @@ object LDReplay { * You do not normally need to call this. It is only necessary when the SDK is initialized * after the activity has already started (e.g. in React Native, where the host activity * is already running before the SDK initializes). + * + * Calls made before [init] are buffered and replayed onto the live replay service during init. */ fun registerActivity(activity: Activity) { - delegate.registerActivity(activity) + state.registerActivity(activity) + } + + /** + * Records the result of a LaunchDarkly identify so the live replay service can pivot + * to the new context. Calls made before [init] are buffered; only the most recent identify + * is replayed during init (older identifies are stale by definition). + * + * Internal-only: invoked by SDK-internal hooks ([com.launchdarkly.observability.replay.plugin.SessionReplayHookProxy], + * [com.launchdarkly.observability.replay.plugin.SessionReplayHook]). Not part of the public surface. + */ + internal fun afterIdentify( + contextKeys: Map, + canonicalKey: String, + completed: Boolean + ) { + state.afterIdentify(contextKeys, canonicalKey, completed) + } + + /** + * Read-only access to the live replay service. Used by SDK-internal call sites such as + * [com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl] to detect duplicate + * registrations. Tests should use [init] / [resetForTest] rather than mutating this directly. + */ + internal val client: SessionReplayServicing? + get() = state.client + + /** + * Wires LDReplay to the active Session Replay service. + * + * Forwards all buffered state — [isEnabled], registered activities, and the most recent + * identify — to [replayService] so the user's pre-init configuration is preserved across + * the swap. Buffers are cleared as part of binding. + */ + internal fun init(replayService: SessionReplayServicing) { + state.bind(replayService) + } + + /** Test-only: clears all internal state, including all buffers and the live replay service. */ + internal fun resetForTest() { + state.reset() } } internal interface SessionReplayServicing { - fun start() - fun stop() + /** + * Single source of truth for whether replay capture is active. Replaces the previous + * `start()` / `stop()` pair so callers like [LDReplay] can buffer a pre-init value and + * forward it to a live replay service during initialization. + */ + var isEnabled: Boolean + fun flush() fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) fun registerActivity(activity: Activity) {} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt new file mode 100644 index 0000000000..6592d8d726 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt @@ -0,0 +1,144 @@ +package com.launchdarkly.observability.sdk + +import android.app.Activity +import com.launchdarkly.observability.util.runOnMainThread + +/** + * Buffers caller intent that arrives before [LDReplay] has been wired up to a live replay + * service, then drains the buffer onto the replay service during [bind] without losing + * writes to the init race. + * + * The buffering is the whole reason this class exists: callers may set [LDReplay.isEnabled], + * register activities, or record identify completions before the SDK has finished initializing + * (e.g. `Application.onCreate` fires `LDReplay.start()` while `LDObserve.init(...)` is still + * dispatching the replay service setup to the main looper). Without this buffer, those calls + * would silently no-op against the placeholder; here, they're captured and replayed during + * [bind]. + * + * The class also routes post-init calls to the live replay service, but that's plumbing — + * the distinguishing trait is the safe pre-init capture. + * + * Buffered state: + * - [client]: the wired live replay service, or `null` before [bind] is called. Doubles as + * the "are we past init?" flag. + * - `pendingEnabled`: latest pre-init [LDReplay.isEnabled] write. `null` means "no value + * buffered" — distinct from `false`, which lets [bind] avoid clobbering the replay + * service's `ReplayOptions.enabled`-derived default. + * - `pendingActivities`: every pre-init [registerActivity] call, in submission order. All + * are replayed because each activity is independently meaningful. + * - `pendingIdentify`: most recent pre-init [afterIdentify] call. Older identifies are stale + * by definition, so we keep "latest wins" semantics rather than a queue. + * + * Concurrency contract (the part that actually keeps writes from being lost during init): + * - Reads of [isEnabled] are wait-free; the field is `@Volatile` and the read does not + * take the lock. Other buffers are not exposed for unlocked reading. + * - All mutations ([setEnabled], [registerActivity], [afterIdentify], [bind], [reset]) hold + * a private monitor lock so the "check `client`, then write buffer" pattern in the setters + * cannot interleave with [bind] publishing a replay service and clearing buffers (the + * lost-write race). + * - [bind]'s write order — apply → publish `client` → clear buffers — guarantees that any + * reader observing a cleared `pendingEnabled` is also observing the published `client` + * (per the JMM's volatile happens-before / synchronization-order rule: a volatile read + * sees the last write in synchronization order, and synchronization order is consistent + * with program order). + * - Setter [runOnMainThread] dispatches happen *outside* the lock so we don't hold the + * monitor while blocking on the main looper. + * + * Visibility: `internal` because [LDReplay] (in this same package) needs to instantiate it, + * but no consumer outside the SDK module should depend on it. Tests within the module can + * exercise it directly if needed; production code should go through [LDReplay]. + */ +internal class PreInitReplayBuffer { + @Volatile + var client: SessionReplayServicing? = null + private set + + @Volatile + private var pendingEnabled: Boolean? = null + + private val pendingActivities = mutableListOf() + private var pendingIdentify: PendingIdentify? = null + + val isEnabled: Boolean + get() = client?.isEnabled ?: pendingEnabled ?: false + + fun setEnabled(value: Boolean) { + val replayServiceToForward: SessionReplayServicing? = synchronized(this) { + val current = client + if (current == null) { + pendingEnabled = value + null + } else { + current + } + } + replayServiceToForward?.let { runOnMainThread { it.isEnabled = value } } + } + + fun registerActivity(activity: Activity) { + val replayServiceToForward: SessionReplayServicing? = synchronized(this) { + val current = client + if (current == null) { + pendingActivities.add(activity) + null + } else { + current + } + } + replayServiceToForward?.let { runOnMainThread { it.registerActivity(activity) } } + } + + fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) { + val replayServiceToForward: SessionReplayServicing? = synchronized(this) { + val current = client + if (current == null) { + // Defensive copy of [contextKeys] in case the caller mutates the map afterwards. + pendingIdentify = PendingIdentify(contextKeys.toMap(), canonicalKey, completed) + null + } else { + current + } + } + replayServiceToForward?.let { + runOnMainThread { it.afterIdentify(contextKeys, canonicalKey, completed) } + } + } + + fun flush() { + val current = client ?: return + runOnMainThread { current.flush() } + } + + fun bind(replayService: SessionReplayServicing) { + synchronized(this) { + // Drain buffered state into the live replay service in a deterministic order: + // enable first (so subsequent operations see the right gate), then activities, + // then the latest identify. + pendingEnabled?.let { replayService.isEnabled = it } + pendingActivities.forEach { replayService.registerActivity(it) } + pendingIdentify?.let { replayService.afterIdentify(it.contextKeys, it.canonicalKey, it.completed) } + + // Publish `client` before clearing buffers so a reader observing a cleared + // buffer is guaranteed to also see the live client (per JMM volatile ordering). + client = replayService + pendingEnabled = null + pendingActivities.clear() + pendingIdentify = null + } + } + + fun reset() { + synchronized(this) { + client = null + pendingEnabled = null + pendingActivities.clear() + pendingIdentify = null + } + } + + private data class PendingIdentify( + val contextKeys: Map, + val canonicalKey: String, + val completed: Boolean, + ) +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt new file mode 100644 index 0000000000..4199561b19 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt @@ -0,0 +1,84 @@ +package com.launchdarkly.observability.util + +import android.os.Handler +import android.os.Looper +import java.util.concurrent.CountDownLatch + +/** + * Main-thread helpers shared by the SDK. + * + * Several pieces of OpenTelemetry / Android instrumentation can only be installed on the UI thread + * (e.g. lifecycle observers, view-tree listeners). These helpers centralise the looper check so we + * don't repeat `Looper.myLooper() == Looper.getMainLooper()` everywhere. + */ + +/** Returns `true` when the calling thread is the Android main (UI) looper thread. */ +internal fun isMainThread(): Boolean = Looper.myLooper() == Looper.getMainLooper() + +/** + * Throws [IllegalStateException] (via [check]) when invoked from a non-main thread. + * + * Use this at the top of methods that *must* run on the main thread but cannot reasonably + * dispatch themselves there (e.g. constructors / `init` blocks). + * + * @param lazyMessage Builds the exception message. Only invoked on the failure path, so it's + * safe to compose context-rich messages here. + */ +internal inline fun requireMainThread( + lazyMessage: () -> Any = { "Must be called on the main thread" } +) { + check(isMainThread(), lazyMessage) +} + +/** + * Executes [block] on the Android main thread. + * + * - If the caller is already on the main thread, [block] runs synchronously, in-place. + * - Otherwise [block] is posted to the main [Looper] and the calling thread blocks on a + * [CountDownLatch] until it completes, preserving the caller's synchronous expectations. + * + * Any exception thrown by [block] is captured and re-raised on the calling thread, so failures + * surface to the original caller rather than being silently swallowed by the main looper. + * + * Caveat: if the calling thread holds a lock the main thread is waiting on, this will deadlock. + * Callers that may run from arbitrary background threads should ensure they don't hold UI-thread + * dependencies while invoking this. If you don't actually need to wait for [block] to finish + * (the typical case for "kick off main-thread initialization"), use [postOnMainThread] instead + * to sidestep the deadlock hazard entirely. + */ +internal fun runOnMainThread(block: () -> Unit) { + if (isMainThread()) { + block() + return + } + val latch = CountDownLatch(1) + var thrown: Throwable? = null + Handler(Looper.getMainLooper()).post { + try { + block() + } catch (t: Throwable) { + thrown = t + } finally { + latch.countDown() + } + } + latch.await() + thrown?.let { throw it } +} + +/** + * Schedules [block] to run on the Android main thread without blocking the caller. + * + * Always posts via [Handler] — even when invoked from the main thread — so the caller's frame + * returns immediately and [block] runs on a subsequent looper turn. Use this for fire-and-forget + * main-thread work where the caller doesn't need to observe completion (e.g. SDK initialization + * dispatched from a constructor / bridge entry point). + * + * Unlike [runOnMainThread] this can never deadlock, because the caller never waits. + * + * Exceptions thrown by [block] propagate on the main looper just like any other posted runnable + * (i.e. they crash the process). Wrap [block] internally if you need to swallow or report them. + */ +internal fun postOnMainThread(block: () -> Unit) { + Handler(Looper.getMainLooper()).post(block) +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt index 7b573ca86d..663799e816 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -3,8 +3,7 @@ package com.launchdarkly.observability.replay import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityContext -import com.launchdarkly.observability.replay.plugin.SessionReplayImpl -import com.launchdarkly.observability.sdk.LDObserve +import com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl import com.launchdarkly.observability.sdk.LDReplay import io.mockk.mockk import io.mockk.unmockkAll @@ -17,30 +16,30 @@ import org.junit.jupiter.api.Test class SessionReplayTest { + private fun newContext(): ObservabilityContext = ObservabilityContext( + sdkKey = "test-sdk-key", + options = ObservabilityOptions(), + application = mockk(), + logger = mockk(relaxed = true), + ) + @BeforeEach fun setUp() { - LDObserve.context = null - LDReplay.client = null + // LDReplay is the global entry point this class wires up; reset it between tests. + LDReplay.resetForTest() } @AfterEach fun tearDown() { - LDObserve.context = null - LDReplay.client = null + LDReplay.resetForTest() unmockkAll() } @Test - fun `register creates service and wires up when observability is initialized`() { - LDObserve.context = ObservabilityContext( - sdkKey = "test-sdk-key", - options = ObservabilityOptions(), - application = mockk(), - logger = mockk(relaxed = true), - ) - val sessionReplay = SessionReplayImpl() + fun `register creates service and wires up LDReplay`() { + val sessionReplay = SessionReplayPluginImpl() - sessionReplay.register() + sessionReplay.register(newContext()) assertNotNull(sessionReplay.sessionReplayService) assertNotNull(LDReplay.client) @@ -48,28 +47,13 @@ class SessionReplayTest { } @Test - fun `register does nothing when observability is not initialized`() { - val sessionReplay = SessionReplayImpl() - sessionReplay.register() + fun `register no-ops when LDReplay already has a client`() { + // Use the real wiring API to install a client; tests no longer poke fields directly. + LDReplay.init(mockk(relaxed = true)) + val sessionReplay = SessionReplayPluginImpl() - assertNull(sessionReplay.sessionReplayService) - assertNull(LDReplay.client) - } - - @Test - fun `register does nothing when session replay already exists`() { - LDObserve.context = ObservabilityContext( - sdkKey = "test-sdk-key", - options = ObservabilityOptions(), - application = mockk(), - logger = mockk(relaxed = true), - ) - LDReplay.client = mockk(relaxed = true) - val sessionReplay = SessionReplayImpl() - - sessionReplay.register() + sessionReplay.register(newContext()) assertNull(sessionReplay.sessionReplayService) } - } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt index a8d2958aae..b225038cfe 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt @@ -2,73 +2,223 @@ package com.launchdarkly.observability.sdk import android.app.Activity import io.mockk.mockk +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class LDReplayTest { - private class TestControl : SessionReplayServicing { - var startCalls = 0 - var stopCalls = 0 + private class TestReplayService(initialIsEnabled: Boolean = false) : SessionReplayServicing { + override var isEnabled: Boolean = initialIsEnabled var flushCalls = 0 - var registerActivityCalls = 0 + val registeredActivities = mutableListOf() + val identifyCalls = mutableListOf() - override fun start() { - startCalls++ - } - - override fun stop() { - stopCalls++ - } + val registerActivityCalls: Int get() = registeredActivities.size + val afterIdentifyCalls: Int get() = identifyCalls.size override fun flush() { flushCalls++ } - override fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) {} + override fun afterIdentify( + contextKeys: Map, + canonicalKey: String, + completed: Boolean + ) { + identifyCalls += IdentifyCall(contextKeys, canonicalKey, completed) + } override fun registerActivity(activity: Activity) { - registerActivityCalls++ + registeredActivities += activity } + + data class IdentifyCall( + val contextKeys: Map, + val canonicalKey: String, + val completed: Boolean, + ) + } + + @BeforeEach + fun setUp() { + // LDReplay is a global singleton; reset every piece of state between tests. + LDReplay.resetForTest() + } + + @AfterEach + fun tearDown() { + LDReplay.resetForTest() } @Test - fun `start delegates to replay control`() { - val control = TestControl() - LDReplay.init(control) + fun `setting isEnabled forwards to active replay service`() { + val replayService = TestReplayService() + LDReplay.init(replayService) + + LDReplay.isEnabled = true + + assertTrue(replayService.isEnabled) + } + + @Test + fun `start sets isEnabled to true on active replay service`() { + val replayService = TestReplayService() + LDReplay.init(replayService) LDReplay.start() - assertEquals(1, control.startCalls) + assertTrue(replayService.isEnabled) } @Test - fun `stop delegates to replay control`() { - val control = TestControl() - LDReplay.init(control) + fun `stop sets isEnabled to false on active replay service`() { + val replayService = TestReplayService(initialIsEnabled = true) + LDReplay.init(replayService) LDReplay.stop() - assertEquals(1, control.stopCalls) + assertFalse(replayService.isEnabled) } @Test - fun `flush delegates to replay control`() { - val control = TestControl() - LDReplay.init(control) + fun `isEnabled set before init is forwarded to replay service during init`() { + // Simulate a caller flipping the switch before SDK initialization wires up a replay service. + LDReplay.isEnabled = true + + val replayService = TestReplayService(initialIsEnabled = false) + LDReplay.init(replayService) + + assertTrue(replayService.isEnabled) + // After init, reads should reflect the live replay service's state. + assertTrue(LDReplay.isEnabled) + } + + @Test + fun `isEnabled never set before init preserves replay service options-based default`() { + // No pre-init write: init must not clobber the replay service's `options.enabled`-derived state. + val replayService = TestReplayService(initialIsEnabled = true) + LDReplay.init(replayService) + + assertTrue(replayService.isEnabled) + } + + @Test + fun `isEnabled getter falls back to buffered value before init`() { + LDReplay.isEnabled = true + + // Without a wired replay service, the getter should reflect what the user just set. + assertTrue(LDReplay.isEnabled) + } + + @Test + fun `isEnabled getter defaults to false when no replay service and no pre-init value`() { + assertFalse(LDReplay.isEnabled) + } + + @Test + fun `flush delegates to replay service`() { + val replayService = TestReplayService() + LDReplay.init(replayService) + + LDReplay.flush() + + assertEquals(1, replayService.flushCalls) + } + @Test + fun `flush is a no-op before init`() { + // Should not throw or otherwise misbehave. LDReplay.flush() + } + + @Test + fun `registerActivity delegates to replay service`() { + val replayService = TestReplayService() + LDReplay.init(replayService) + + LDReplay.registerActivity(mockk()) + + assertEquals(1, replayService.registerActivityCalls) + } + + @Test + fun `registerActivity before init is replayed to replay service during init`() { + val activity = mockk() + LDReplay.registerActivity(activity) + + val replayService = TestReplayService() + LDReplay.init(replayService) + + assertEquals(1, replayService.registerActivityCalls) + assertEquals(activity, replayService.registeredActivities.single()) + } + + @Test + fun `multiple registerActivity calls before init are all replayed in order`() { + val activityA = mockk() + val activityB = mockk() + LDReplay.registerActivity(activityA) + LDReplay.registerActivity(activityB) + + val replayService = TestReplayService() + LDReplay.init(replayService) - assertEquals(1, control.flushCalls) + assertEquals(listOf(activityA, activityB), replayService.registeredActivities) } @Test - fun `registerActivity delegates to replay control`() { - val control = TestControl() - LDReplay.init(control) + fun `afterIdentify before init is replayed to replay service during init`() { + LDReplay.afterIdentify(mapOf("user" to "abc"), "user:abc", true) + val replayService = TestReplayService() + LDReplay.init(replayService) + + assertEquals(1, replayService.afterIdentifyCalls) + val replayed = replayService.identifyCalls.single() + assertEquals(mapOf("user" to "abc"), replayed.contextKeys) + assertEquals("user:abc", replayed.canonicalKey) + assertTrue(replayed.completed) + } + + @Test + fun `latest afterIdentify before init wins over older ones`() { + LDReplay.afterIdentify(mapOf("user" to "old"), "user:old", true) + LDReplay.afterIdentify(mapOf("user" to "new"), "user:new", true) + + val replayService = TestReplayService() + LDReplay.init(replayService) + + // Older identifies are stale; only the most recent one is replayed. + assertEquals(1, replayService.afterIdentifyCalls) + assertEquals("user:new", replayService.identifyCalls.single().canonicalKey) + } + + @Test + fun `afterIdentify after init forwards directly without buffering`() { + val replayService = TestReplayService() + LDReplay.init(replayService) + + LDReplay.afterIdentify(mapOf("user" to "x"), "user:x", true) + + assertEquals(1, replayService.afterIdentifyCalls) + } + + @Test + fun `resetForTest clears buffered registerActivity and afterIdentify`() { LDReplay.registerActivity(mockk()) + LDReplay.afterIdentify(mapOf("user" to "x"), "user:x", true) + + LDReplay.resetForTest() + + val replayService = TestReplayService() + LDReplay.init(replayService) - assertEquals(1, control.registerActivityCalls) + // Nothing should have been replayed; reset wiped the buffers. + assertEquals(0, replayService.registerActivityCalls) + assertEquals(0, replayService.afterIdentifyCalls) } } From e4c2236415914879aa81490c5b02601dda866f80 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 7 May 2026 21:12:11 -0700 Subject: [PATCH 04/13] read1 --- .../androidobservability/BaseApplication.kt | 14 ++-- .../replay/SessionReplayService.kt | 2 +- .../replay/plugin/SessionReplayPluginImpl.kt | 8 +- .../observability/sdk/LDObserve.kt | 82 ++++++++++++------- .../observability/replay/SessionReplayTest.kt | 12 ++- 5 files changed, 75 insertions(+), 43 deletions(-) diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt index 16686054f5..ef1e38784b 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt @@ -87,16 +87,14 @@ open class BaseApplication : Application() { .anonymous(true) .build() - Thread { - LDClient.init(this@BaseApplication, ldConfig, context, 0) + LDClient.init(this@BaseApplication, ldConfig, context, 0) - if (testUrl == null) { - // intervenes in E2E tests by trigger spans - flagEvaluation() - } + if (testUrl == null) { + // intervenes in E2E tests by trigger spans + flagEvaluation() + } - LDReplay.start() - }.start() + LDReplay.start() } // example on creating OBS/SR without flagging diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index 33597d9fa9..b98519c650 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt @@ -86,7 +86,7 @@ class SessionReplayService( if (isInstalled) return val sm = observabilityContext.sessionManager ?: run { - logger.warn("SessionReplayService.initialize() called before sessionManager is available; skipping.") + logger.error("SessionReplayService.initialize() called before sessionManager is available; skipping.") return } sessionManager = sm diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt index 69bb84e0ef..1fdbb908ad 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt @@ -30,21 +30,19 @@ class SessionReplayPluginImpl( * No-ops if Session Replay has already been registered globally on [LDReplay]. */ fun register(observabilityContext: ObservabilityContext) { - if (LDReplay.client != null) { + if (sessionReplayService != null || LDReplay.client != null) { logger.warning("Session Replay instance already exists; skipping.") return } val service = SessionReplayService(options, observabilityContext) - LDReplay.init(service) sessionReplayService = service } fun initialize() { - val service = checkNotNull(sessionReplayService) { - "SessionReplayService is not registered; call register() before initialize()." - } + val service = sessionReplayService ?: return service.initialize() + LDReplay.init(service) } companion object { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt index eceff7e5f3..86eb07fe92 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt @@ -12,7 +12,7 @@ import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl -import com.launchdarkly.observability.util.postOnMainThread +import com.launchdarkly.observability.util.runOnMainThread import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity @@ -136,38 +136,64 @@ class LDObserve(private val client: Observe) : Observe { application = application, logger = logger ) - context = obsContext val resource = buildResource(mobileKey, options) obsContext.resourceAttributes = resource.attributes - // ObservabilityService and SessionReplayService both require main-thread initialization - // (they install OpenTelemetry instrumentations that touch UI/lifecycle state). Post - // the work fire-and-forget rather than waiting for it: blocking here can deadlock - // bridge callers (e.g. .NET MAUI startup) that hold the main thread waiting on the - // background thread that's currently executing this method, and even on main-thread - // callers it would freeze the UI for the duration of OTel RUM construction. - // - // Trade-off: SDK calls made on the same caller frame as [init] (e.g. a `recordError` - // immediately after) may hit the no-op delegate until the next looper tick. The - // delegate publication via `@Volatile` makes the swap visible as soon as it lands. - postOnMainThread { - val service = ObservabilityService( - application, mobileKey, resource, logger, options, - ) - obsContext.sessionManager = service.sessionManager - init(service) - + // ObservabilityService and SessionReplayService install OpenTelemetry instrumentations + // that touch UI / lifecycle state, so their construction must run on the main thread. + // runOnMainThread blocks the caller until the work completes (via CountDownLatch), so + // the SDK is ready as soon as init returns regardless of which thread called it. + // NOTE: the calling thread must not hold any lock the main thread is waiting on, or + // this will deadlock — see runOnMainThread KDoc. + runOnMainThread { + installObservability(application, mobileKey, resource, logger, options, obsContext) if (replayOptions != null) { - val plugin = SessionReplayPluginImpl(replayOptions) - sessionReplayPlugin = plugin - plugin.register(obsContext) - plugin.sessionReplayService?.initialize() - plugin.sessionReplayService?.let { replayService -> - CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { - replayService.identifySession(ldContext) - } - } + installSessionReplay(replayOptions, obsContext, ldContext) + } + } + } + + /** + * Constructs the [ObservabilityService], publishes it as the active [LDObserve] delegate, + * and finishes wiring [obsContext] (sessionManager + global publication). + * + * Must run on the main thread; called from inside the [runOnMainThread] block in [init]. + */ + private fun installObservability( + application: Application, + mobileKey: String, + resource: Resource, + logger: ObserveLogger, + options: ObservabilityOptions, + obsContext: ObservabilityContext, + ) { + val service = ObservabilityService( + application, mobileKey, resource, logger, options, + ) + obsContext.sessionManager = service.sessionManager + context = obsContext + init(service) + } + + /** + * Creates the Session Replay plugin, registers + initializes it (which drains any pre-init + * buffer in [LDReplay]), and kicks off the initial identify in the background. + * + * Must run on the main thread; called from inside the [runOnMainThread] block in [init]. + */ + private fun installSessionReplay( + replayOptions: ReplayOptions, + obsContext: ObservabilityContext, + ldContext: LDObserveContext, + ) { + val plugin = SessionReplayPluginImpl(replayOptions) + sessionReplayPlugin = plugin + plugin.register(obsContext) + plugin.initialize() + plugin.sessionReplayService?.let { replayService -> + CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { + replayService.identifySession(ldContext) } } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt index 663799e816..13fb71bbbd 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -36,12 +36,22 @@ class SessionReplayTest { } @Test - fun `register creates service and wires up LDReplay`() { + fun `register creates service but defers wiring LDReplay`() { val sessionReplay = SessionReplayPluginImpl() sessionReplay.register(newContext()) assertNotNull(sessionReplay.sessionReplayService) + assertNull(LDReplay.client) + } + + @Test + fun `initialize wires up LDReplay after service creation`() { + val sessionReplay = SessionReplayPluginImpl() + + sessionReplay.register(newContext()) + sessionReplay.initialize() + assertNotNull(LDReplay.client) assertTrue(LDReplay.client is SessionReplayService) } From 318852778a441e2bc0f7dcad777f7e718cc32bed Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 7 May 2026 22:10:19 -0700 Subject: [PATCH 05/13] make it readable --- .../client/ObservabilityResource.kt | 84 +++++++++++++++++ .../observability/plugin/Observability.kt | 72 ++++++--------- .../replay/SessionReplayService.kt | 14 +-- .../replay/plugin/SessionReplay.kt | 4 +- .../replay/plugin/SessionReplayPluginImpl.kt | 28 ++++-- .../observability/sdk/LDObserve.kt | 28 +----- .../observability/sdk/LDReplay.kt | 17 ++-- .../observability/sdk/PreInitReplayBuffer.kt | 92 ++++++++----------- .../observability/replay/SessionReplayTest.kt | 6 +- 9 files changed, 194 insertions(+), 151 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt new file mode 100644 index 0000000000..ae936964b0 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt @@ -0,0 +1,84 @@ +package com.launchdarkly.observability.client + +import com.launchdarkly.observability.BuildConfig +import com.launchdarkly.observability.api.ObservabilityOptions +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.sdk.resources.Resource + +/** + * Default Highlight/OTel "distro" attributes attached to every observability [Resource]. + * + * Exposed (internally) so the [com.launchdarkly.observability.plugin.Observability] LDClient + * plugin can seed its mutable `distroAttributes` field with the same defaults the standalone + * [com.launchdarkly.observability.sdk.LDObserve.init] path uses, keeping the two init paths + * in sync without duplicating string literals. + */ +internal val DEFAULT_DISTRO_ATTRIBUTES: Map = mapOf( + "telemetry.distro.name" to "launchdarkly-observability-android", + "telemetry.distro.version" to BuildConfig.OBSERVABILITY_SDK_VERSION, +) + +/** + * Builds the OpenTelemetry [Resource] attached to every observability signal emitted by this SDK. + * + * Single source of truth for resource shape, used by both initialization paths: + * - [com.launchdarkly.observability.plugin.Observability.onPluginsReady] (LDClient plugin path), + * which flattens its [com.launchdarkly.sdk.android.integrations.EnvironmentMetadata] into the + * [applicationId], [applicationVersion], and [sdkVersion] params. + * - [com.launchdarkly.observability.sdk.LDObserve.init] (standalone path), which has no + * LDClient metadata and simply omits those params. + * + * Attribute precedence (later wins for duplicate keys): + * 1. OpenTelemetry default resource attributes, minus `service.name`. + * 2. `highlight.project_id` = [sdkKey]. + * 3. [distroAttributes] (defaults to [DEFAULT_DISTRO_ATTRIBUTES] — `telemetry.distro.{name,version}`). + * 4. Caller-supplied [ObservabilityOptions.resourceAttributes]. + * 5. `launchdarkly.application.id`, `launchdarkly.application.version`, `launchdarkly.sdk.version` + * when provided (LDClient plugin path only). + * + * The metadata-derived attributes (5) come *after* the user's [ObservabilityOptions.resourceAttributes] + * so user overrides cannot accidentally clobber LDClient identity. If a user genuinely wants to + * override `launchdarkly.*`, they shouldn't be using these attribute names anyway. + * + * @param sdkKey LaunchDarkly mobile key; written as `highlight.project_id`. + * @param options Observability options; [ObservabilityOptions.resourceAttributes] is appended. + * @param distroAttributes Distro identification, defaults to [DEFAULT_DISTRO_ATTRIBUTES]. + * The LDClient plugin passes its mutable `distroAttributes` field here + * so callers that customize the field still see their changes. + * @param applicationId `launchdarkly.application.id`, or `null` to omit. + * @param applicationVersion `launchdarkly.application.version`, or `null` to omit. + * @param sdkVersion `launchdarkly.sdk.version`, or `null` to omit. Already formatted + * (typically `"$sdkName/$sdkVersion"`) — this helper does not compose it. + */ +internal fun buildObservabilityResource( + sdkKey: String, + options: ObservabilityOptions, + distroAttributes: Map = DEFAULT_DISTRO_ATTRIBUTES, + applicationId: String? = null, + applicationVersion: String? = null, + sdkVersion: String? = null, +): Resource { + val builder = Attributes.builder() + + Resource.getDefault().attributes.forEach { key, value -> + if (key.key != "service.name") { + @Suppress("UNCHECKED_CAST") + builder.put(key as AttributeKey, value) + } + } + + builder.put("highlight.project_id", sdkKey) + + distroAttributes.forEach { (key, value) -> + builder.put(AttributeKey.stringKey(key), value) + } + + builder.putAll(options.resourceAttributes) + + applicationId?.let { builder.put("launchdarkly.application.id", it) } + applicationVersion?.let { builder.put("launchdarkly.application.version", it) } + sdkVersion?.let { builder.put("launchdarkly.sdk.version", it) } + + return Resource.create(builder.build()) +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt index 07e9781686..cd1c7e9819 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt @@ -4,9 +4,11 @@ import android.app.Application import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions +import com.launchdarkly.observability.client.DEFAULT_DISTRO_ATTRIBUTES import com.launchdarkly.observability.client.ObservabilityService import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.client.TelemetryInspector +import com.launchdarkly.observability.client.buildObservabilityResource import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata @@ -14,9 +16,6 @@ import com.launchdarkly.sdk.android.integrations.Hook import com.launchdarkly.sdk.android.integrations.Plugin import com.launchdarkly.sdk.android.integrations.PluginMetadata import com.launchdarkly.sdk.android.integrations.RegistrationCompleteResult -import io.opentelemetry.api.common.AttributeKey -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.sdk.resources.Resource import java.util.Collections /** @@ -54,10 +53,7 @@ class Observability( private val mobileKey: String, private val options: ObservabilityOptions = ObservabilityOptions() // new instance has reasonable defaults ) : Plugin() { - var distroAttributes: Map = mapOf( - "telemetry.distro.name" to SDK_NAME, - "telemetry.distro.version" to BuildConfig.OBSERVABILITY_SDK_VERSION - ) + var distroAttributes: Map = DEFAULT_DISTRO_ATTRIBUTES private val logger: ObserveLogger private val observabilityHook = ObservabilityHook() private var observabilityClient: ObservabilityService? = null @@ -105,44 +101,36 @@ class Observability( return } - val attributes = Attributes.builder() - Resource.getDefault().attributes.forEach { key, value -> - if (key.key != "service.name") { - @Suppress("UNCHECKED_CAST") - attributes.put(key as AttributeKey, value) - } - } - attributes.put("highlight.project_id", sdkKey) - distroAttributes.forEach { (key, value) -> - attributes.put(AttributeKey.stringKey(key), value) - } - attributes.putAll(options.resourceAttributes) - - metadata?.applicationInfo?.applicationId?.let { - attributes.put("launchdarkly.application.id", it) - } - - metadata?.applicationInfo?.applicationVersion?.let { - attributes.put("launchdarkly.application.version", it) - } - - metadata?.sdkMetadata?.name?.let { sdkName -> - metadata.sdkMetadata?.version?.let { sdkVersion -> - attributes.put("launchdarkly.sdk.version", "$sdkName/$sdkVersion") - } - } - - val builtResource = Resource.create(attributes.build()) - LDObserve.context?.resourceAttributes = builtResource.attributes + val resource = buildObservabilityResource( + sdkKey = sdkKey, + options = options, + distroAttributes = distroAttributes, + applicationId = metadata?.applicationInfo?.applicationId, + applicationVersion = metadata?.applicationInfo?.applicationVersion, + sdkVersion = composeLaunchDarklySdkVersion(metadata), + ) + LDObserve.context?.resourceAttributes = resource.attributes - val client = ObservabilityService( - application, sdkKey, builtResource, logger, options, + val observabilityService = ObservabilityService( + application, sdkKey, resource, logger, options, ) - observabilityClient = client - LDObserve.context?.sessionManager = client.sessionManager - LDObserve.init(client) + observabilityClient = observabilityService + LDObserve.context?.sessionManager = observabilityService.sessionManager + LDObserve.init(observabilityService) + + observabilityHook.delegate = observabilityService.hookExporter + } - observabilityHook.delegate = client.hookExporter + /** + * Combines `EnvironmentMetadata.sdkMetadata.{name, version}` into the single + * `launchdarkly.sdk.version` attribute value (`"$name/$version"`). Returns `null` if + * either piece is missing, in which case [buildObservabilityResource] omits the attribute. + */ + private fun composeLaunchDarklySdkVersion(metadata: EnvironmentMetadata?): String? { + val sdk = metadata?.sdkMetadata ?: return null + val name = sdk.name ?: return null + val version = sdk.version ?: return null + return "$name/$version" } fun getTelemetryInspector(): TelemetryInspector? { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index b98519c650..dd7a29fa78 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt @@ -73,7 +73,7 @@ class SessionReplayService( private val instrumentationScope = CoroutineScope(DispatcherProviderHolder.current.default + SupervisorJob()) private var captureJob: Job? = null private val shouldCapture = MutableStateFlow(false) - private val enabledState = MutableStateFlow(options.enabled) + private val _isEnabled = MutableStateFlow(options.enabled) private var processLifecycleObserver: DefaultLifecycleObserver? = null private var isInstalled: Boolean = false private var exporter: SessionReplayExporter? = null @@ -135,7 +135,7 @@ class SessionReplayService( // Images collector instrumentationScope.launch { captureManager?.captureFlow?.collect { capture -> - if (!enabledState.value) return@collect + if (!_isEnabled.value) return@collect eventQueue.send(ImageItemPayload(capture)) } } @@ -143,7 +143,7 @@ class SessionReplayService( // Interactions collector instrumentationScope.launch { interactionSource?.captureFlow?.collect { interaction -> - if (!enabledState.value) return@collect + if (!_isEnabled.value) return@collect eventQueue.send(InteractionItemPayload(interaction)) } } @@ -155,7 +155,7 @@ class SessionReplayService( */ private fun startCaptureStateObserver() { instrumentationScope.launch { - combine(shouldCapture, enabledState) { shouldRun, enabled -> shouldRun && enabled } + combine(shouldCapture, _isEnabled) { shouldRun, enabled -> shouldRun && enabled } .collect { shouldRun -> val running = captureJob?.isActive == true if (shouldRun == running) return@collect @@ -205,9 +205,9 @@ class SessionReplayService( * `false` simply pauses event production (mirroring `stop()`). */ override var isEnabled: Boolean - get() = enabledState.value + get() = _isEnabled.value set(value) { - enabledState.value = value + _isEnabled.value = value if (value) flushPendingIdentify() } @@ -304,7 +304,7 @@ class SessionReplayService( ) // When replay is disabled, cache the identify payload for later session init without sending it now. - if (!enabledState.value) { + if (!_isEnabled.value) { synchronized(pendingIdentifyLock) { pendingIdentify = event } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt index 1944c4d7e2..5668f5c7b2 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt @@ -41,14 +41,14 @@ class SessionReplay( } override fun register(client: LDClient, metadata: EnvironmentMetadata?) { - val context = LDObserve.context ?: run { + val obsContext = LDObserve.context ?: run { logger.warning( "Observability is not initialized; skipping SessionReplay registration. " + "Ensure the Observability plugin is registered before SessionReplay." ) return } - impl.register(context) + impl.register(obsContext) sessionReplayHook.delegate = impl.sessionReplayService } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt index 1fdbb908ad..c3b5c16567 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt @@ -20,25 +20,39 @@ class SessionReplayPluginImpl( var sessionReplayService: SessionReplayService? = null /** - * Registers a [SessionReplayService] backed by [observabilityContext] and wires it into - * [LDReplay] as the active replay service. + * Creates a [SessionReplayService] backed by [observabilityContext]. Wiring into [LDReplay] + * happens later in [initialize]. * * Dependencies are passed in explicitly rather than resolved from `LDObserve.context` so * this class has no hidden coupling to global state — callers are responsible for ensuring * observability has been initialized and providing its context. * - * No-ops if Session Replay has already been registered globally on [LDReplay]. + * No-ops (with a warning) if either: + * - this instance was already registered, or + * - some other [SessionReplayPluginImpl] instance already won the global registration race + * on [LDReplay] (e.g. both the standalone and LDClient plugin paths were used). */ fun register(observabilityContext: ObservabilityContext) { - if (sessionReplayService != null || LDReplay.client != null) { - logger.warning("Session Replay instance already exists; skipping.") + if (sessionReplayService != null) { + logger.warning("Session Replay already registered on this plugin instance; skipping.") + return + } + if (LDReplay.liveReplayService != null) { + logger.warning("Session Replay already registered globally on LDReplay; skipping.") return } - val service = SessionReplayService(options, observabilityContext) - sessionReplayService = service + sessionReplayService = SessionReplayService(options, observabilityContext) } + /** + * Initializes the service produced by [register] and publishes it to [LDReplay], draining + * any pre-init buffer. + * + * No-ops if [register] was never called or bailed (see its KDoc). Callers in the LDClient + * plugin path invoke this from `onPluginsReady` unconditionally, so the silent skip is + * expected, not a bug. + */ fun initialize() { val service = sessionReplayService ?: return service.initialize() diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt index 86eb07fe92..18a21d537f 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt @@ -1,19 +1,18 @@ package com.launchdarkly.observability.sdk import android.app.Application -import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.context.LDObserveContext import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.bridge.AttributeConverter import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.client.ObservabilityService +import com.launchdarkly.observability.client.buildObservabilityResource import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl import com.launchdarkly.observability.util.runOnMainThread -import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity import io.opentelemetry.api.trace.Span @@ -137,7 +136,7 @@ class LDObserve(private val client: Observe) : Observe { logger = logger ) - val resource = buildResource(mobileKey, options) + val resource = buildObservabilityResource(sdkKey = mobileKey, options = options) obsContext.resourceAttributes = resource.attributes // ObservabilityService and SessionReplayService install OpenTelemetry instrumentations @@ -198,29 +197,6 @@ class LDObserve(private val client: Observe) : Observe { } } - private fun buildResource(sdkKey: String, options: ObservabilityOptions): Resource { - val attributes = Attributes.builder() - Resource.getDefault().attributes.forEach { key, value -> - if (key.key != "service.name") { - @Suppress("UNCHECKED_CAST") - attributes.put(key as AttributeKey, value) - } - } - attributes.put("highlight.project_id", sdkKey) - attributes.put( - AttributeKey.stringKey("telemetry.distro.name"), - SDK_NAME - ) - attributes.put( - AttributeKey.stringKey("telemetry.distro.version"), - BuildConfig.OBSERVABILITY_SDK_VERSION - ) - attributes.putAll(options.resourceAttributes) - return Resource.create(attributes.build()) - } - - private const val SDK_NAME = "launchdarkly-observability-android" - override fun recordMetric(metric: Metric) = delegate.recordMetric(metric) override fun recordCount(metric: Metric) = delegate.recordCount(metric) override fun recordIncr(metric: Metric) = delegate.recordIncr(metric) 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 869e1a50ac..bcdd0abb20 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 @@ -20,7 +20,7 @@ object LDReplay { * Hook proxy for cross-platform bridges (C# / MAUI, React Native, etc.). */ val hookProxy: SessionReplayHookProxy? - get() = state.client?.let { SessionReplayHookProxy(it) } + get() = state.liveReplayService?.let { SessionReplayHookProxy(it) } /** * Whether session replay capture is currently enabled. @@ -39,12 +39,12 @@ object LDReplay { state.setEnabled(value) } - /** Convenience for `isEnabled = true`. Starts session replay capture. */ + /** Starts session replay capture. */ fun start() { isEnabled = true } - /** Convenience for `isEnabled = false`. Pauses session replay capture. */ + /** Pauses session replay capture. */ fun stop() { isEnabled = false } @@ -87,12 +87,13 @@ object LDReplay { } /** - * Read-only access to the live replay service. Used by SDK-internal call sites such as - * [com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl] to detect duplicate - * registrations. Tests should use [init] / [resetForTest] rather than mutating this directly. + * Read-only snapshot of the live replay service (or `null` before [init] has run). Used by + * SDK-internal call sites such as [com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl] + * to detect duplicate registrations. Tests should use [init] / [resetForTest] rather than + * mutating this directly. */ - internal val client: SessionReplayServicing? - get() = state.client + internal val liveReplayService: SessionReplayServicing? + get() = state.liveReplayService /** * Wires LDReplay to the active Session Replay service. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt index 6592d8d726..ddb2732618 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt @@ -4,53 +4,32 @@ import android.app.Activity import com.launchdarkly.observability.util.runOnMainThread /** - * Buffers caller intent that arrives before [LDReplay] has been wired up to a live replay - * service, then drains the buffer onto the replay service during [bind] without losing - * writes to the init race. - * - * The buffering is the whole reason this class exists: callers may set [LDReplay.isEnabled], - * register activities, or record identify completions before the SDK has finished initializing - * (e.g. `Application.onCreate` fires `LDReplay.start()` while `LDObserve.init(...)` is still - * dispatching the replay service setup to the main looper). Without this buffer, those calls - * would silently no-op against the placeholder; here, they're captured and replayed during - * [bind]. - * - * The class also routes post-init calls to the live replay service, but that's plumbing — - * the distinguishing trait is the safe pre-init capture. + * Captures [LDReplay] calls made before the live replay service is wired up, then replays + * them onto the service in [bind]. Without this, calls during the init race (e.g. an + * `LDReplay.start()` from `Application.onCreate` while `LDObserve.init(...)` is still + * dispatching to the main looper) would silently no-op. * * Buffered state: - * - [client]: the wired live replay service, or `null` before [bind] is called. Doubles as - * the "are we past init?" flag. - * - `pendingEnabled`: latest pre-init [LDReplay.isEnabled] write. `null` means "no value - * buffered" — distinct from `false`, which lets [bind] avoid clobbering the replay - * service's `ReplayOptions.enabled`-derived default. - * - `pendingActivities`: every pre-init [registerActivity] call, in submission order. All - * are replayed because each activity is independently meaningful. - * - `pendingIdentify`: most recent pre-init [afterIdentify] call. Older identifies are stale - * by definition, so we keep "latest wins" semantics rather than a queue. - * - * Concurrency contract (the part that actually keeps writes from being lost during init): - * - Reads of [isEnabled] are wait-free; the field is `@Volatile` and the read does not - * take the lock. Other buffers are not exposed for unlocked reading. - * - All mutations ([setEnabled], [registerActivity], [afterIdentify], [bind], [reset]) hold - * a private monitor lock so the "check `client`, then write buffer" pattern in the setters - * cannot interleave with [bind] publishing a replay service and clearing buffers (the - * lost-write race). - * - [bind]'s write order — apply → publish `client` → clear buffers — guarantees that any - * reader observing a cleared `pendingEnabled` is also observing the published `client` - * (per the JMM's volatile happens-before / synchronization-order rule: a volatile read - * sees the last write in synchronization order, and synchronization order is consistent - * with program order). - * - Setter [runOnMainThread] dispatches happen *outside* the lock so we don't hold the - * monitor while blocking on the main looper. + * - [liveReplayService]: the live replay service once [bind] has run; `null` doubles as the + * "pre-init" flag. + * - `pendingEnabled`: latest pre-init `isEnabled` write. `null` means "no buffered value", + * so [bind] won't clobber the replay service's `ReplayOptions.enabled` default. + * - `pendingActivities`: every pre-init [registerActivity], in submission order. + * - `pendingIdentify`: most recent pre-init [afterIdentify] only — older identifies are stale. * - * Visibility: `internal` because [LDReplay] (in this same package) needs to instantiate it, - * but no consumer outside the SDK module should depend on it. Tests within the module can - * exercise it directly if needed; production code should go through [LDReplay]. + * Concurrency: + * - [isEnabled] reads are lock-free via `@Volatile`; everything else holds a private monitor + * so the setters' "check [liveReplayService], then write buffer" can't race [bind]'s + * publish + clear. + * - [bind] writes in the order: apply buffers → publish [liveReplayService] → clear buffers, + * so any reader seeing a cleared buffer also sees the published service (JMM volatile + * ordering). + * - [runOnMainThread] dispatch happens outside the lock to avoid blocking on the main looper + * while the monitor is held. */ internal class PreInitReplayBuffer { @Volatile - var client: SessionReplayServicing? = null + var liveReplayService: SessionReplayServicing? = null private set @Volatile @@ -60,11 +39,11 @@ internal class PreInitReplayBuffer { private var pendingIdentify: PendingIdentify? = null val isEnabled: Boolean - get() = client?.isEnabled ?: pendingEnabled ?: false + get() = liveReplayService?.isEnabled ?: pendingEnabled ?: false fun setEnabled(value: Boolean) { - val replayServiceToForward: SessionReplayServicing? = synchronized(this) { - val current = client + val target: SessionReplayServicing? = synchronized(this) { + val current = liveReplayService if (current == null) { pendingEnabled = value null @@ -72,12 +51,12 @@ internal class PreInitReplayBuffer { current } } - replayServiceToForward?.let { runOnMainThread { it.isEnabled = value } } + target?.let { runOnMainThread { it.isEnabled = value } } } fun registerActivity(activity: Activity) { - val replayServiceToForward: SessionReplayServicing? = synchronized(this) { - val current = client + val target: SessionReplayServicing? = synchronized(this) { + val current = liveReplayService if (current == null) { pendingActivities.add(activity) null @@ -85,12 +64,12 @@ internal class PreInitReplayBuffer { current } } - replayServiceToForward?.let { runOnMainThread { it.registerActivity(activity) } } + target?.let { runOnMainThread { it.registerActivity(activity) } } } fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) { - val replayServiceToForward: SessionReplayServicing? = synchronized(this) { - val current = client + val target: SessionReplayServicing? = synchronized(this) { + val current = liveReplayService if (current == null) { // Defensive copy of [contextKeys] in case the caller mutates the map afterwards. pendingIdentify = PendingIdentify(contextKeys.toMap(), canonicalKey, completed) @@ -99,13 +78,13 @@ internal class PreInitReplayBuffer { current } } - replayServiceToForward?.let { + target?.let { runOnMainThread { it.afterIdentify(contextKeys, canonicalKey, completed) } } } fun flush() { - val current = client ?: return + val current = liveReplayService ?: return runOnMainThread { current.flush() } } @@ -118,9 +97,10 @@ internal class PreInitReplayBuffer { pendingActivities.forEach { replayService.registerActivity(it) } pendingIdentify?.let { replayService.afterIdentify(it.contextKeys, it.canonicalKey, it.completed) } - // Publish `client` before clearing buffers so a reader observing a cleared - // buffer is guaranteed to also see the live client (per JMM volatile ordering). - client = replayService + // Publish [liveReplayService] before clearing buffers so a reader observing a + // cleared buffer is guaranteed to also see the live service (per JMM volatile + // ordering). + liveReplayService = replayService pendingEnabled = null pendingActivities.clear() pendingIdentify = null @@ -129,7 +109,7 @@ internal class PreInitReplayBuffer { fun reset() { synchronized(this) { - client = null + liveReplayService = null pendingEnabled = null pendingActivities.clear() pendingIdentify = null diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt index 13fb71bbbd..5f33f14c69 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -42,7 +42,7 @@ class SessionReplayTest { sessionReplay.register(newContext()) assertNotNull(sessionReplay.sessionReplayService) - assertNull(LDReplay.client) + assertNull(LDReplay.liveReplayService) } @Test @@ -52,8 +52,8 @@ class SessionReplayTest { sessionReplay.register(newContext()) sessionReplay.initialize() - assertNotNull(LDReplay.client) - assertTrue(LDReplay.client is SessionReplayService) + assertNotNull(LDReplay.liveReplayService) + assertTrue(LDReplay.liveReplayService is SessionReplayService) } @Test From 6f1be0e60b56dba34ca83b08fac35b0efd586db7 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 7 May 2026 22:13:49 -0700 Subject: [PATCH 06/13] change back --- .../java/com/example/androidobservability/BaseApplication.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt index ef1e38784b..bc300a226c 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt @@ -58,7 +58,7 @@ open class BaseApplication : Application() { var testUrl: String? = null // example on creating OBS/SR with flagging sdk - open fun realInitLD() { + open fun realInit() { val observabilityPlugin = Observability( application = this@BaseApplication, mobileKey = LAUNCHDARKLY_MOBILE_KEY, @@ -98,7 +98,7 @@ open class BaseApplication : Application() { } // example on creating OBS/SR without flagging - open fun realInit() { + open fun realInitIndependent() { val effectiveOptions = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions From 08ac3244d25fdc460633028e8e18594234d4a308 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 7 May 2026 22:26:16 -0700 Subject: [PATCH 07/13] fix unit tests --- .../observability/util/MainThread.kt | 90 ++++++++++++++----- .../observability/replay/SessionReplayTest.kt | 5 ++ .../observability/sdk/LDReplayTest.kt | 5 ++ .../ObservabilityMainThreadTestHooks.kt | 52 +++++++++++ 4 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt index 4199561b19..278b69deb2 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt @@ -10,10 +10,72 @@ import java.util.concurrent.CountDownLatch * Several pieces of OpenTelemetry / Android instrumentation can only be installed on the UI thread * (e.g. lifecycle observers, view-tree listeners). These helpers centralise the looper check so we * don't repeat `Looper.myLooper() == Looper.getMainLooper()` everywhere. + * + * Production code uses [AndroidLooperMainThreadExecutor] (delegates to Android's main `Looper`). + * Plain JVM unit tests can override the executor via the test fixtures hook + * `com.launchdarkly.observability.testing.ObservabilityMainThreadTestHooks` so they don't have to + * mock `android.os.Looper`. */ +/** Strategy that backs [isMainThread], [runOnMainThread], and [postOnMainThread]. */ +internal interface MainThreadExecutor { + fun isMainThread(): Boolean + fun runOnMainThread(block: () -> Unit) + fun postOnMainThread(block: () -> Unit) +} + +/** Production executor: delegates to Android's main [Looper]. */ +internal object AndroidLooperMainThreadExecutor : MainThreadExecutor { + override fun isMainThread(): Boolean = + Looper.myLooper() == Looper.getMainLooper() + + override fun runOnMainThread(block: () -> Unit) { + if (isMainThread()) { + block() + return + } + val latch = CountDownLatch(1) + var thrown: Throwable? = null + Handler(Looper.getMainLooper()).post { + try { + block() + } catch (t: Throwable) { + thrown = t + } finally { + latch.countDown() + } + } + latch.await() + thrown?.let { throw it } + } + + override fun postOnMainThread(block: () -> Unit) { + Handler(Looper.getMainLooper()).post(block) + } +} + +/** + * Holds the active [MainThreadExecutor]. Mirrors [com.launchdarkly.observability.coroutines.DispatcherProviderHolder] + * so test fixtures can swap implementations without exposing the seam to SDK consumers. + */ +internal object MainThreadExecutorHolder { + @Volatile + private var executor: MainThreadExecutor = AndroidLooperMainThreadExecutor + + val current: MainThreadExecutor + get() = executor + + internal fun set(executor: MainThreadExecutor) { + this.executor = executor + } + + internal fun reset() { + executor = AndroidLooperMainThreadExecutor + } +} + /** Returns `true` when the calling thread is the Android main (UI) looper thread. */ -internal fun isMainThread(): Boolean = Looper.myLooper() == Looper.getMainLooper() +internal fun isMainThread(): Boolean = MainThreadExecutorHolder.current.isMainThread() /** * Throws [IllegalStateException] (via [check]) when invoked from a non-main thread. @@ -46,25 +108,8 @@ internal inline fun requireMainThread( * (the typical case for "kick off main-thread initialization"), use [postOnMainThread] instead * to sidestep the deadlock hazard entirely. */ -internal fun runOnMainThread(block: () -> Unit) { - if (isMainThread()) { - block() - return - } - val latch = CountDownLatch(1) - var thrown: Throwable? = null - Handler(Looper.getMainLooper()).post { - try { - block() - } catch (t: Throwable) { - thrown = t - } finally { - latch.countDown() - } - } - latch.await() - thrown?.let { throw it } -} +internal fun runOnMainThread(block: () -> Unit) = + MainThreadExecutorHolder.current.runOnMainThread(block) /** * Schedules [block] to run on the Android main thread without blocking the caller. @@ -79,6 +124,5 @@ internal fun runOnMainThread(block: () -> Unit) { * Exceptions thrown by [block] propagate on the main looper just like any other posted runnable * (i.e. they crash the process). Wrap [block] internally if you need to swallow or report them. */ -internal fun postOnMainThread(block: () -> Unit) { - Handler(Looper.getMainLooper()).post(block) -} +internal fun postOnMainThread(block: () -> Unit) = + MainThreadExecutorHolder.current.postOnMainThread(block) diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt index 5f33f14c69..9c66bdf1b3 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -5,6 +5,7 @@ import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl import com.launchdarkly.observability.sdk.LDReplay +import com.launchdarkly.observability.testing.ObservabilityMainThreadTestHooks import io.mockk.mockk import io.mockk.unmockkAll import org.junit.jupiter.api.AfterEach @@ -27,11 +28,15 @@ class SessionReplayTest { fun setUp() { // LDReplay is the global entry point this class wires up; reset it between tests. LDReplay.resetForTest() + // SessionReplayService.initialize() and PreInitReplayBuffer dispatches both go through + // the main-thread executor, which would otherwise hit Android's main Looper. + ObservabilityMainThreadTestHooks.overrideWithSynchronous() } @AfterEach fun tearDown() { LDReplay.resetForTest() + ObservabilityMainThreadTestHooks.reset() unmockkAll() } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt index b225038cfe..eb2e48a411 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt @@ -1,6 +1,7 @@ package com.launchdarkly.observability.sdk import android.app.Activity +import com.launchdarkly.observability.testing.ObservabilityMainThreadTestHooks import io.mockk.mockk import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -47,11 +48,15 @@ class LDReplayTest { fun setUp() { // LDReplay is a global singleton; reset every piece of state between tests. LDReplay.resetForTest() + // Post-init writes through PreInitReplayBuffer dispatch via runOnMainThread, which would + // hit Android's main Looper. Swap to a synchronous executor for the duration of the test. + ObservabilityMainThreadTestHooks.overrideWithSynchronous() } @AfterEach fun tearDown() { LDReplay.resetForTest() + ObservabilityMainThreadTestHooks.reset() } @Test diff --git a/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt b/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt new file mode 100644 index 0000000000..8141ef357e --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt @@ -0,0 +1,52 @@ +package com.launchdarkly.observability.testing + +import com.launchdarkly.observability.util.MainThreadExecutor +import com.launchdarkly.observability.util.MainThreadExecutorHolder + +/** + * Allows test modules to override the SDK's main-thread executor. + * + * The production executor delegates to Android's main `Looper`, which is unavailable in plain + * JVM unit tests (calls to `Looper.myLooper()` throw "Method not mocked"). This hook installs + * a synchronous executor that runs every block on the calling thread, so tests can exercise + * code paths guarded by `requireMainThread` / `runOnMainThread` without setting up Robolectric + * or `unitTests.returnDefaultValues = true`. + * + * Mirrors [ObservabilityDispatcherTestHooks] in spirit: visible only via the test fixtures + * artifact, never reachable by SDK consumers. + * + * Typical usage: + * + * ``` + * @BeforeEach fun setUp() { ObservabilityMainThreadTestHooks.overrideWithSynchronous() } + * @AfterEach fun tearDown() { ObservabilityMainThreadTestHooks.reset() } + * ``` + */ +object ObservabilityMainThreadTestHooks { + + /** + * Replaces the production main-thread executor with [SynchronousMainThreadExecutor]. + * Subsequent calls to `isMainThread()` return `true`; `runOnMainThread` and + * `postOnMainThread` execute their block immediately on the calling thread. + * + * Always pair with [reset] in `@AfterEach` so the swap doesn't leak across tests. + */ + fun overrideWithSynchronous() { + MainThreadExecutorHolder.set(SynchronousMainThreadExecutor) + } + + /** Restores the production [com.launchdarkly.observability.util.AndroidLooperMainThreadExecutor]. */ + fun reset() { + MainThreadExecutorHolder.reset() + } + + /** + * Test executor that runs everything on the calling thread. Reports `isMainThread() = true` + * so [com.launchdarkly.observability.util.requireMainThread] checks pass. + */ + private object SynchronousMainThreadExecutor : MainThreadExecutor { + override fun isMainThread(): Boolean = true + override fun runOnMainThread(block: () -> Unit) = block() + override fun postOnMainThread(block: () -> Unit) = block() + } +} From a03d828e16f64af4345780eb915558dd305e6e58 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 11:27:44 -0700 Subject: [PATCH 08/13] remove SDK_NAME constant from Observability class --- .../com/launchdarkly/observability/plugin/Observability.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt index cd1c7e9819..94870d4216 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt @@ -139,6 +139,5 @@ class Observability( companion object { const val PLUGIN_NAME = "@launchdarkly/observability-android" - const val SDK_NAME = "launchdarkly-observability-android" } } From e1cf0f61ea49569cd7da3660d4eb93e85b7cdf22 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 11:28:17 -0700 Subject: [PATCH 09/13] add warning --- .../replay/plugin/SessionReplayPluginImpl.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt index c3b5c16567..e06989a35c 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt @@ -49,12 +49,18 @@ class SessionReplayPluginImpl( * Initializes the service produced by [register] and publishes it to [LDReplay], draining * any pre-init buffer. * - * No-ops if [register] was never called or bailed (see its KDoc). Callers in the LDClient - * plugin path invoke this from `onPluginsReady` unconditionally, so the silent skip is - * expected, not a bug. + * Logs a warning and returns if [register] was never called or bailed (see its KDoc). The + * LDClient plugin path invokes this from `onPluginsReady` unconditionally, so this no-op + * branch is reachable on a legitimate cause (e.g. another plugin instance already won the + * global registration race) — that's why we warn instead of throwing. */ fun initialize() { - val service = sessionReplayService ?: return + val service = sessionReplayService ?: run { + logger.warning( + "Session Replay initialize() called before register() produced a service; skipping." + ) + return + } service.initialize() LDReplay.init(service) } From 67153ae7b950666532e3f73dbe40d3f15a69bb73 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 12:51:06 -0700 Subject: [PATCH 10/13] fix CI compilation --- .../observability/replay/ReplaySessionProtocol.kt | 2 ++ .../com/launchdarkly/observability/util/MainThread.kt | 6 +++--- .../testing/ObservabilityMainThreadTestHooks.kt | 9 ++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) rename sdk/@launchdarkly/observability-android/lib/src/{testFixtures => test}/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt (78%) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt index aceefd851a..0de48865e1 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt @@ -3,6 +3,7 @@ package com.launchdarkly.observability.replay import com.launchdarkly.observability.network.SamplingConfigResponse import com.launchdarkly.observability.sampling.SamplingConfig import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -136,6 +137,7 @@ open class IntEnumSerializer>( } } +@OptIn(ExperimentalSerializationApi::class) @Serializable data class EventNode( val type: NodeType, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt index 278b69deb2..f45c56a6bc 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt @@ -12,9 +12,9 @@ import java.util.concurrent.CountDownLatch * don't repeat `Looper.myLooper() == Looper.getMainLooper()` everywhere. * * Production code uses [AndroidLooperMainThreadExecutor] (delegates to Android's main `Looper`). - * Plain JVM unit tests can override the executor via the test fixtures hook - * `com.launchdarkly.observability.testing.ObservabilityMainThreadTestHooks` so they don't have to - * mock `android.os.Looper`. + * Plain JVM unit tests can override the executor via + * `com.launchdarkly.observability.testing.ObservabilityMainThreadTestHooks` (lives in this + * module's `src/test/kotlin/`) so they don't have to mock `android.os.Looper`. */ /** Strategy that backs [isMainThread], [runOnMainThread], and [postOnMainThread]. */ diff --git a/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt similarity index 78% rename from sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt rename to sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt index 8141ef357e..30e843aab0 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt @@ -4,7 +4,7 @@ import com.launchdarkly.observability.util.MainThreadExecutor import com.launchdarkly.observability.util.MainThreadExecutorHolder /** - * Allows test modules to override the SDK's main-thread executor. + * Allows in-module unit tests to override the SDK's main-thread executor. * * The production executor delegates to Android's main `Looper`, which is unavailable in plain * JVM unit tests (calls to `Looper.myLooper()` throw "Method not mocked"). This hook installs @@ -12,8 +12,11 @@ import com.launchdarkly.observability.util.MainThreadExecutorHolder * code paths guarded by `requireMainThread` / `runOnMainThread` without setting up Robolectric * or `unitTests.returnDefaultValues = true`. * - * Mirrors [ObservabilityDispatcherTestHooks] in spirit: visible only via the test fixtures - * artifact, never reachable by SDK consumers. + * Lives in `src/test/kotlin/` rather than `src/testFixtures/kotlin/` because Kotlin Gradle + * Plugin 2.0.21 does not generate a `compileDebugTestFixturesKotlin` task for AGP test fixtures + * variants, which would silently leave Kotlin sources under `src/testFixtures/kotlin/` out of + * the published jar. If/when consumer modules need this hook, either upgrade KGP (which adds + * Kotlin testFixtures support) or republish this file as Java/`src/testFixtures/java/`. * * Typical usage: * From 624cf02050adb3cbd80dffe22bbb6bc86db24602 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 13:06:21 -0700 Subject: [PATCH 11/13] TOCTOU --- .../observability/sdk/PreInitReplayBuffer.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt index ddb2732618..2dc84e8e93 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt @@ -18,12 +18,17 @@ import com.launchdarkly.observability.util.runOnMainThread * - `pendingIdentify`: most recent pre-init [afterIdentify] only — older identifies are stale. * * Concurrency: - * - [isEnabled] reads are lock-free via `@Volatile`; everything else holds a private monitor - * so the setters' "check [liveReplayService], then write buffer" can't race [bind]'s - * publish + clear. + * - [isEnabled] reads hold the private monitor so they observe a consistent snapshot of + * ([liveReplayService], `pendingEnabled`). Reading the two `@Volatile` fields lock-free + * is unsafe during [bind]: a reader can read `liveReplayService = null` (stale) and then + * `pendingEnabled = null` (cleared by `bind`), reporting `false` even when the buffered + * `true` was already applied to the live service. JMM happens-before flows forward from + * the later volatile read, not backward to the earlier one. + * - Setters and [bind] hold the same monitor so the setters' "check [liveReplayService], + * then write buffer" can't race [bind]'s publish + clear. * - [bind] writes in the order: apply buffers → publish [liveReplayService] → clear buffers, - * so any reader seeing a cleared buffer also sees the published service (JMM volatile - * ordering). + * so single-field readers of [liveReplayService] (e.g. consumers of the public getter) + * that observe the live service can rely on its state already being correct. * - [runOnMainThread] dispatch happens outside the lock to avoid blocking on the main looper * while the monitor is held. */ @@ -39,7 +44,16 @@ internal class PreInitReplayBuffer { private var pendingIdentify: PendingIdentify? = null val isEnabled: Boolean - get() = liveReplayService?.isEnabled ?: pendingEnabled ?: false + get() { + // Snapshot both fields under the same monitor [bind] uses, otherwise a reader can + // observe the dual-field tear documented in the class header and return `false` + // mid-bind. The live service's `isEnabled` itself is read outside the lock — + // the lock only needs to protect the (liveReplayService, pendingEnabled) pair. + val service = synchronized(this) { + liveReplayService ?: return pendingEnabled ?: false + } + return service.isEnabled + } fun setEnabled(value: Boolean) { val target: SessionReplayServicing? = synchronized(this) { From 8d34bae84b0498a353f8c55ed999e053867d080b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 13:18:55 -0700 Subject: [PATCH 12/13] fix failing --- .../replay/SessionReplayService.kt | 18 +++++++-- .../replay/plugin/SessionReplayPluginImpl.kt | 15 ++++++- .../observability/replay/SessionReplayTest.kt | 39 ++++++++++++++++--- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index dd7a29fa78..f6d252c35b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt @@ -80,14 +80,25 @@ class SessionReplayService( private val pendingIdentifyLock = Any() private var pendingIdentify: IdentifyItemPayload? = null - fun initialize() { + /** + * Installs replay instrumentation. Idempotent. + * + * Returns `true` if the service is now installed (either by this call or a previous one), + * `false` if installation could not proceed (currently: [observabilityContext]'s + * `sessionManager` is still null — typically because the [com.launchdarkly.observability.plugin.Observability] + * plugin hasn't registered yet on the LDClient plugin path). Callers must consult the + * return value before publishing this service as the live replay backend (e.g. via + * [com.launchdarkly.observability.sdk.LDReplay.init]); binding an uninstalled service + * permanently routes pre-init buffered calls into a non-functional instance. + */ + fun initialize(): Boolean { requireMainThread { "SessionReplayService must be initialized on the main thread" } - if (isInstalled) return + if (isInstalled) return true val sm = observabilityContext.sessionManager ?: run { logger.error("SessionReplayService.initialize() called before sessionManager is available; skipping.") - return + return false } sessionManager = sm @@ -129,6 +140,7 @@ class SessionReplayService( interactionSource?.attachToApplication(application) isInstalled = true + return true } private fun startCollectors() { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt index e06989a35c..da8eb9e22e 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt @@ -53,6 +53,13 @@ class SessionReplayPluginImpl( * LDClient plugin path invokes this from `onPluginsReady` unconditionally, so this no-op * branch is reachable on a legitimate cause (e.g. another plugin instance already won the * global registration race) — that's why we warn instead of throwing. + * + * If [SessionReplayService.initialize] reports that installation could not proceed (e.g. + * the Observability plugin hasn't populated `ObservabilityContext.sessionManager` yet on + * the LDClient plugin path), we deliberately skip [LDReplay.init]. Binding an + * uninstalled service would drain the pre-init buffer into an instance with no + * `sessionManager`, no exporter, and no lifecycle observers, leaving every subsequent + * `LDReplay` call permanently routed to a no-op with no recovery path. */ fun initialize() { val service = sessionReplayService ?: run { @@ -61,7 +68,13 @@ class SessionReplayPluginImpl( ) return } - service.initialize() + if (!service.initialize()) { + logger.warning( + "Session Replay service did not install (likely because Observability " + + "is not registered or its sessionManager is unavailable); skipping LDReplay wiring." + ) + return + } LDReplay.init(service) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt index 9c66bdf1b3..67f3e5361a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -6,12 +6,14 @@ import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.observability.testing.ObservabilityMainThreadTestHooks +import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll +import io.mockk.verify import org.junit.jupiter.api.AfterEach 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.Assertions.assertSame import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -51,14 +53,39 @@ class SessionReplayTest { } @Test - fun `initialize wires up LDReplay after service creation`() { - val sessionReplay = SessionReplayPluginImpl() + fun `initialize wires up LDReplay when service install succeeds`() { + // Substitute a stub service for the one register() created so we can decide the + // SessionReplayService.initialize() outcome without standing up a real SessionManager, + // ProcessLifecycleOwner, etc. — none of which are available in plain JVM tests. + val service = mockk(relaxed = true) + every { service.initialize() } returns true + val sessionReplay = SessionReplayPluginImpl().apply { + register(newContext()) + sessionReplayService = service + } - sessionReplay.register(newContext()) sessionReplay.initialize() - assertNotNull(LDReplay.liveReplayService) - assertTrue(LDReplay.liveReplayService is SessionReplayService) + assertSame(service, LDReplay.liveReplayService) + } + + @Test + fun `initialize skips LDReplay wiring when service install fails`() { + // Reproduces the bug where SessionReplayService.initialize() bails out (e.g. because + // ObservabilityContext.sessionManager is still null on the LDClient plugin path): + // the plugin must NOT publish a non-functional service to LDReplay, otherwise every + // subsequent LDReplay call routes to a dead instance with no recovery path. + val service = mockk(relaxed = true) + every { service.initialize() } returns false + val sessionReplay = SessionReplayPluginImpl().apply { + register(newContext()) + sessionReplayService = service + } + + sessionReplay.initialize() + + assertNull(LDReplay.liveReplayService) + verify(exactly = 1) { service.initialize() } } @Test From 1a8807b6a453f5dade28ac7035de126dee21daa5 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 13:42:11 -0700 Subject: [PATCH 13/13] fix initializaiton --- .../replay/SessionReplayService.kt | 12 ++++------ .../replay/plugin/SessionReplayPluginImpl.kt | 22 +++++++---------- .../observability/sdk/LDObserve.kt | 12 +++++----- .../observability/replay/SessionReplayTest.kt | 24 ++++++++++++++----- 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index f6d252c35b..8361f34477 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt @@ -83,13 +83,11 @@ class SessionReplayService( /** * Installs replay instrumentation. Idempotent. * - * Returns `true` if the service is now installed (either by this call or a previous one), - * `false` if installation could not proceed (currently: [observabilityContext]'s - * `sessionManager` is still null — typically because the [com.launchdarkly.observability.plugin.Observability] - * plugin hasn't registered yet on the LDClient plugin path). Callers must consult the - * return value before publishing this service as the live replay backend (e.g. via - * [com.launchdarkly.observability.sdk.LDReplay.init]); binding an uninstalled service - * permanently routes pre-init buffered calls into a non-functional instance. + * Returns `true` if the service is now installed (by this call or a previous one). + * Callers must consult the return value before publishing this service as the live replay + * backend (e.g. via [com.launchdarkly.observability.sdk.LDReplay.init]); binding an + * uninstalled service permanently routes pre-init buffered calls into a non-functional + * instance. */ fun initialize(): Boolean { requireMainThread { "SessionReplayService must be initialized on the main thread" } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt index da8eb9e22e..58b52d0309 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt @@ -49,33 +49,27 @@ class SessionReplayPluginImpl( * Initializes the service produced by [register] and publishes it to [LDReplay], draining * any pre-init buffer. * - * Logs a warning and returns if [register] was never called or bailed (see its KDoc). The - * LDClient plugin path invokes this from `onPluginsReady` unconditionally, so this no-op - * branch is reachable on a legitimate cause (e.g. another plugin instance already won the - * global registration race) — that's why we warn instead of throwing. - * - * If [SessionReplayService.initialize] reports that installation could not proceed (e.g. - * the Observability plugin hasn't populated `ObservabilityContext.sessionManager` yet on - * the LDClient plugin path), we deliberately skip [LDReplay.init]. Binding an - * uninstalled service would drain the pre-init buffer into an instance with no - * `sessionManager`, no exporter, and no lifecycle observers, leaving every subsequent - * `LDReplay` call permanently routed to a no-op with no recovery path. + * Returns `true` when the live service was published to [LDReplay] — i.e. post-init steps + * that depend on a fully installed service (most notably the initial `identifySession` + * kick-off in [com.launchdarkly.observability.sdk.LDObserve.init]) are safe to run. + * Callers without post-publish work can ignore the return value. */ - fun initialize() { + fun initialize(): Boolean { val service = sessionReplayService ?: run { logger.warning( "Session Replay initialize() called before register() produced a service; skipping." ) - return + return false } if (!service.initialize()) { logger.warning( "Session Replay service did not install (likely because Observability " + "is not registered or its sessionManager is unavailable); skipping LDReplay wiring." ) - return + return false } LDReplay.init(service) + return true } companion object { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt index 18a21d537f..33bbf0623b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt @@ -177,7 +177,8 @@ class LDObserve(private val client: Observe) : Observe { /** * Creates the Session Replay plugin, registers + initializes it (which drains any pre-init - * buffer in [LDReplay]), and kicks off the initial identify in the background. + * buffer in [LDReplay]), and — only if the underlying service was actually installed and + * published — kicks off the initial identify in the background. * * Must run on the main thread; called from inside the [runOnMainThread] block in [init]. */ @@ -189,11 +190,10 @@ class LDObserve(private val client: Observe) : Observe { val plugin = SessionReplayPluginImpl(replayOptions) sessionReplayPlugin = plugin plugin.register(obsContext) - plugin.initialize() - plugin.sessionReplayService?.let { replayService -> - CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { - replayService.identifySession(ldContext) - } + if (!plugin.initialize()) return + val replayService = plugin.sessionReplayService ?: return + CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { + replayService.identifySession(ldContext) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt index 67f3e5361a..874dabcd20 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -11,9 +11,11 @@ import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verify import org.junit.jupiter.api.AfterEach +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.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -64,17 +66,16 @@ class SessionReplayTest { sessionReplayService = service } - sessionReplay.initialize() + val published = sessionReplay.initialize() + assertTrue(published) assertSame(service, LDReplay.liveReplayService) } @Test fun `initialize skips LDReplay wiring when service install fails`() { - // Reproduces the bug where SessionReplayService.initialize() bails out (e.g. because - // ObservabilityContext.sessionManager is still null on the LDClient plugin path): - // the plugin must NOT publish a non-functional service to LDReplay, otherwise every - // subsequent LDReplay call routes to a dead instance with no recovery path. + // sessionReplayService is set by register() regardless of install outcome, so the + // boolean return is the only signal callers can rely on to gate post-publish work. val service = mockk(relaxed = true) every { service.initialize() } returns false val sessionReplay = SessionReplayPluginImpl().apply { @@ -82,12 +83,23 @@ class SessionReplayTest { sessionReplayService = service } - sessionReplay.initialize() + val published = sessionReplay.initialize() + assertFalse(published) assertNull(LDReplay.liveReplayService) verify(exactly = 1) { service.initialize() } } + @Test + fun `initialize returns false when register was never called`() { + val sessionReplay = SessionReplayPluginImpl() + + val published = sessionReplay.initialize() + + assertFalse(published) + assertNull(LDReplay.liveReplayService) + } + @Test fun `register no-ops when LDReplay already has a client`() { // Use the real wiring API to install a client; tests no longer poke fields directly.