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..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 @@ -87,7 +87,7 @@ open class BaseApplication : Application() { .anonymous(true) .build() - LDClient.init(this@BaseApplication, ldConfig, context, 1) + LDClient.init(this@BaseApplication, ldConfig, context, 0) if (testUrl == null) { // intervenes in E2E tests by trigger spans @@ -98,7 +98,7 @@ open class BaseApplication : Application() { } // example on creating OBS/SR without flagging - open fun realIndependentInit() { + open fun realInitIndependent() { val effectiveOptions = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions @@ -107,25 +107,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/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/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index a18304a5b0..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.9.17 + 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 d6a41eb2e7..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(10)); + 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/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/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..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 @@ -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 @@ -77,16 +73,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 +92,45 @@ 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 + 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 client = ObservabilityService( - application, sdkKey, builtResource, logger, options, - ) - observabilityClient = client - LDObserve.context?.sessionManager = client.sessionManager - LDObserve.init(client) + 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 observabilityService = ObservabilityService( + application, sdkKey, resource, logger, options, + ) + observabilityClient = observabilityService + LDObserve.context?.sessionManager = observabilityService.sessionManager + LDObserve.init(observabilityService) + + observabilityHook.delegate = observabilityService.hookExporter + } - observabilityHook.delegate = client.hookExporter - } else { - logger.warn("Observability could not be initialized for sdkKey: $sdkKey") - } - } + /** + * 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? { @@ -148,6 +139,5 @@ class Observability( companion object { const val PLUGIN_NAME = "@launchdarkly/observability-android" - const val SDK_NAME = "launchdarkly-observability-android" } } 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/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index 0018029602..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 @@ -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,19 +73,30 @@ 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 _isEnabled = MutableStateFlow(options.enabled) private var processLifecycleObserver: DefaultLifecycleObserver? = null private var isInstalled: Boolean = false private var exporter: SessionReplayExporter? = null private val pendingIdentifyLock = Any() private var pendingIdentify: IdentifyItemPayload? = null - fun initialize() { - if (isInstalled) return + /** + * Installs replay instrumentation. Idempotent. + * + * 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" } + + if (isInstalled) return true val sm = observabilityContext.sessionManager ?: run { - logger.warn("SessionReplayService.initialize() called before sessionManager is available; skipping.") - return + logger.error("SessionReplayService.initialize() called before sessionManager is available; skipping.") + return false } sessionManager = sm @@ -126,13 +138,14 @@ class SessionReplayService( interactionSource?.attachToApplication(application) isInstalled = true + return true } private fun startCollectors() { // Images collector instrumentationScope.launch { captureManager?.captureFlow?.collect { capture -> - if (!isEnabled.value) return@collect + if (!_isEnabled.value) return@collect eventQueue.send(ImageItemPayload(capture)) } } @@ -140,7 +153,7 @@ class SessionReplayService( // Interactions collector instrumentationScope.launch { interactionSource?.captureFlow?.collect { interaction -> - if (!isEnabled.value) return@collect + if (!_isEnabled.value) return@collect eventQueue.send(InteractionItemPayload(interaction)) } } @@ -152,7 +165,7 @@ class SessionReplayService( */ private fun startCaptureStateObserver() { instrumentationScope.launch { - combine(shouldCapture, isEnabled) { shouldRun, enabled -> shouldRun && enabled } + combine(shouldCapture, _isEnabled) { shouldRun, enabled -> shouldRun && enabled } .collect { shouldRun -> val running = captureJob?.isActive == true if (shouldRun == running) return@collect @@ -196,14 +209,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() = _isEnabled.value + set(value) { + _isEnabled.value = value + if (value) flushPendingIdentify() + } override fun flush() { batchWorker.flush() @@ -298,7 +314,7 @@ class SessionReplayService( ) // When replay is disabled, cache the identify payload for later session init without sending it now. - if (!isEnabled.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 1989e33311..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 @@ -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 obsContext = LDObserve.context ?: run { + logger.warning( + "Observability is not initialized; skipping SessionReplay registration. " + + "Ensure the Observability plugin is registered before SessionReplay." + ) + return + } + impl.register(obsContext) 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/SessionReplayImpl.kt deleted file mode 100644 index 5d9d82f037..0000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayImpl.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.launchdarkly.observability.replay.plugin - -import com.launchdarkly.observability.replay.ReplayOptions -import com.launchdarkly.observability.replay.SessionReplayService -import com.launchdarkly.observability.sdk.LDObserve -import com.launchdarkly.observability.sdk.LDReplay -import java.util.logging.Logger - -/** - * Standalone Session Replay entry point. - * - * Use this directly via [LDObserve.init] for the standalone path (no LDClient). - * For the LDClient plugin path, use [SessionReplay] instead. - */ -class SessionReplayImpl( - 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 - } - - if (LDReplay.client != null) { - logger.warning("Session Replay instance already exists; skipping.") - return - } - - val service = SessionReplayService(options, context) - LDReplay.init(service) - sessionReplayService = service - } - - fun initialize() { - val service = checkNotNull(sessionReplayService) { - "SessionReplayService is not registered; call register() before initialize()." - } - service.initialize() - } - - companion object { - const val PLUGIN_NAME = "@launchdarkly/session-replay-android" - private val logger = Logger.getLogger("SessionReplay") - } -} 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 new file mode 100644 index 0000000000..58b52d0309 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayPluginImpl.kt @@ -0,0 +1,79 @@ +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 +import com.launchdarkly.observability.sdk.LDReplay +import java.util.logging.Logger + +/** + * 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] (which delegates to this class) instead. + */ +class SessionReplayPluginImpl( + private val options: ReplayOptions = ReplayOptions(), +) { + @Volatile + var sessionReplayService: SessionReplayService? = null + + /** + * 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 (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) { + 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 + } + + sessionReplayService = SessionReplayService(options, observabilityContext) + } + + /** + * Initializes the service produced by [register] and publishes it to [LDReplay], draining + * any pre-init buffer. + * + * 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(): Boolean { + val service = sessionReplayService ?: run { + logger.warning( + "Session Replay initialize() called before register() produced a service; skipping." + ) + 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 false + } + LDReplay.init(service) + return true + } + + companion object { + const val PLUGIN_NAME = "@launchdarkly/session-replay-android" + private val logger = Logger.getLogger("SessionReplay") + } +} 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..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 @@ -1,18 +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.SessionReplayImpl -import io.opentelemetry.api.common.AttributeKey +import com.launchdarkly.observability.replay.plugin.SessionReplayPluginImpl +import com.launchdarkly.observability.util.runOnMainThread import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity import io.opentelemetry.api.trace.Span @@ -104,7 +104,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) @@ -135,53 +135,68 @@ class LDObserve(private val client: Observe) : Observe { application = application, logger = logger ) - context = obsContext - val resource = buildResource(mobileKey, options) + val resource = buildObservabilityResource(sdkKey = mobileKey, options = options) obsContext.resourceAttributes = resource.attributes + // 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) { + 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) - - 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) - } - } - } } - 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) - } + /** + * Creates the Session Replay plugin, registers + initializes it (which drains any pre-init + * 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]. + */ + private fun installSessionReplay( + replayOptions: ReplayOptions, + obsContext: ObservabilityContext, + ldContext: LDObserveContext, + ) { + val plugin = SessionReplayPluginImpl(replayOptions) + sessionReplayPlugin = plugin + plugin.register(obsContext) + if (!plugin.initialize()) return + val replayService = plugin.sessionReplayService ?: return + CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { + replayService.identifySession(ldContext) } - 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 d927998ffa..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 @@ -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.liveReplayService?.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 - */ + /** Starts session replay capture. */ fun start() { - delegate.start() + isEnabled = true } - /** - * Stops session replay capture - */ + /** 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,63 @@ 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 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 liveReplayService: SessionReplayServicing? + get() = state.liveReplayService + + /** + * 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..2dc84e8e93 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/PreInitReplayBuffer.kt @@ -0,0 +1,138 @@ +package com.launchdarkly.observability.sdk + +import android.app.Activity +import com.launchdarkly.observability.util.runOnMainThread + +/** + * 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: + * - [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. + * + * Concurrency: + * - [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 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. + */ +internal class PreInitReplayBuffer { + @Volatile + var liveReplayService: SessionReplayServicing? = null + private set + + @Volatile + private var pendingEnabled: Boolean? = null + + private val pendingActivities = mutableListOf() + private var pendingIdentify: PendingIdentify? = null + + val isEnabled: Boolean + 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) { + val current = liveReplayService + if (current == null) { + pendingEnabled = value + null + } else { + current + } + } + target?.let { runOnMainThread { it.isEnabled = value } } + } + + fun registerActivity(activity: Activity) { + val target: SessionReplayServicing? = synchronized(this) { + val current = liveReplayService + if (current == null) { + pendingActivities.add(activity) + null + } else { + current + } + } + target?.let { runOnMainThread { it.registerActivity(activity) } } + } + + fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) { + 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) + null + } else { + current + } + } + target?.let { + runOnMainThread { it.afterIdentify(contextKeys, canonicalKey, completed) } + } + } + + fun flush() { + val current = liveReplayService ?: 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 [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 + } + } + + fun reset() { + synchronized(this) { + liveReplayService = 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..f45c56a6bc --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/util/MainThread.kt @@ -0,0 +1,128 @@ +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. + * + * Production code uses [AndroidLooperMainThreadExecutor] (delegates to Android's main `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]. */ +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 = MainThreadExecutorHolder.current.isMainThread() + +/** + * 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) = + MainThreadExecutorHolder.current.runOnMainThread(block) + +/** + * 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) = + 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 7b573ca86d..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 @@ -3,73 +3,111 @@ 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 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.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 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() + // 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() { - LDObserve.context = null - LDReplay.client = null + LDReplay.resetForTest() + ObservabilityMainThreadTestHooks.reset() 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 but defers wiring LDReplay`() { + val sessionReplay = SessionReplayPluginImpl() - sessionReplay.register() + sessionReplay.register(newContext()) assertNotNull(sessionReplay.sessionReplayService) - assertNotNull(LDReplay.client) - assertTrue(LDReplay.client is SessionReplayService) + assertNull(LDReplay.liveReplayService) } @Test - fun `register does nothing when observability is not initialized`() { - val sessionReplay = SessionReplayImpl() - sessionReplay.register() + 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 + } - assertNull(sessionReplay.sessionReplayService) - assertNull(LDReplay.client) + val published = sessionReplay.initialize() + + assertTrue(published) + assertSame(service, LDReplay.liveReplayService) } @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() + fun `initialize skips LDReplay wiring when service install fails`() { + // 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 { + register(newContext()) + sessionReplayService = service + } - assertNull(sessionReplay.sessionReplayService) + 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. + LDReplay.init(mockk(relaxed = true)) + val sessionReplay = SessionReplayPluginImpl() + + 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..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,74 +1,229 @@ 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 +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() + // 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 - 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) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt new file mode 100644 index 0000000000..30e843aab0 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/testing/ObservabilityMainThreadTestHooks.kt @@ -0,0 +1,55 @@ +package com.launchdarkly.observability.testing + +import com.launchdarkly.observability.util.MainThreadExecutor +import com.launchdarkly.observability.util.MainThreadExecutorHolder + +/** + * 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 + * 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`. + * + * 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: + * + * ``` + * @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() + } +}