From 87f755e919d5fb61ddf698fc61271a1b9515fdb5 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 15:32:51 -0700 Subject: [PATCH 01/23] Launch without reference --- .../androidobservability/BaseApplication.kt | 51 +++++++-- .../androidobservability/TestApplication.kt | 2 - .../example/LDObserve/ObservabilityBridge.kt | 68 +++--------- .../lib/build.gradle.kts | 2 +- .../replay/plugin/SessionReplay.kt | 5 +- .../observability/sdk/LDObserve.kt | 102 +++++++++++++++++- 6 files changed, 161 insertions(+), 69 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 d6dd6ce93..297c81500 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 @@ -9,16 +9,17 @@ import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.observability.replay.view +import com.launchdarkly.observability.sdk.LDObserve +import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.sdk.ContextKind import com.launchdarkly.sdk.LDContext import com.launchdarkly.sdk.android.Components +import com.launchdarkly.sdk.android.FeatureFlagChangeListener import com.launchdarkly.sdk.android.LDAndroidLogging import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.LDConfig import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes -import com.launchdarkly.observability.sdk.LDReplay -import com.launchdarkly.sdk.android.FeatureFlagChangeListener open class BaseApplication : Application() { @@ -41,18 +42,38 @@ open class BaseApplication : Application() { logAdapter = LDAndroidLogging.adapter(), ) + val sessionReplayPlugin = SessionReplay( + options = ReplayOptions( + enabled = false, + privacyProfile = PrivacyProfile( + maskText = false, + maskWebViews = true, + maskViews = listOf( + view(ImageView::class.java), + ), + maskXMLViewIds = listOf("smoothieTitle") + ) + ) + ) + var testUrl: String? = null open fun realInit() { - val observabilityPlugin = Observability( + val effectiveOptions = testUrl?.let { + observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) + } ?: observabilityOptions + + val context = LDContext.builder(ContextKind.DEFAULT, "example-user-key") + .anonymous(true) + .build() + + LDObserve.init( application = this@BaseApplication, mobileKey = LAUNCHDARKLY_MOBILE_KEY, - options = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions - ) - - val sessionReplayPlugin = SessionReplay( - options = ReplayOptions( - enabled = false, + ldContext = context, + options = effectiveOptions, + replayOptions = ReplayOptions( + enabled = true, privacyProfile = PrivacyProfile( maskText = false, maskWebViews = true, @@ -64,6 +85,18 @@ open class BaseApplication : Application() { ) ) + //LDReplay.start() + } + + open fun realFlagInit() { + val observabilityPlugin = Observability( + application = this@BaseApplication, + mobileKey = LAUNCHDARKLY_MOBILE_KEY, + options = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions + ) + + + // Set LAUNCHDARKLY_MOBILE_KEY to your LaunchDarkly mobile key found on the LaunchDarkly // dashboard in the start guide. // If you want to disable the Auto EnvironmentAttributes functionality. diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt b/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt index dc8e80609..48a0e1c8b 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt @@ -1,7 +1,6 @@ package com.example.androidobservability import com.launchdarkly.observability.testing.InMemoryTelemetryInspector -import com.launchdarkly.sdk.android.LDClient import io.opentelemetry.android.features.diskbuffering.SignalFromDiskExporter import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -54,7 +53,6 @@ class TestApplication : BaseApplication() { it.shutdown() mockWebServer = null } - LDClient.get().close() SignalFromDiskExporter.resetForTesting() super.onTerminate() } diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt index bc5bb939b..640fdae86 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt @@ -4,19 +4,13 @@ import android.app.Application import com.example.LDObserve.BridgeLogger import com.example.LDObserve.SystemOutBridgeLogger import com.launchdarkly.observability.BuildConfig -import com.launchdarkly.observability.client.TelemetryInspector -import com.launchdarkly.observability.interfaces.Metric -import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.observability.bridge.AttributeConverter +import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay -import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.sdk.ContextKind import com.launchdarkly.sdk.LDContext -import com.launchdarkly.sdk.android.Components import com.launchdarkly.sdk.android.LDAndroidLogging -import com.launchdarkly.sdk.android.LDClient -import com.launchdarkly.sdk.android.LDConfig public class ObservabilityBridge( private val logger: BridgeLogger = SystemOutBridgeLogger() @@ -63,7 +57,7 @@ public class ObservabilityBridge( replay: LDSessionReplayOptions, observabilityVersion: String ) { - logger.info("LD:ObservabilityBridge start called, ver" + observabilityVersion) + logger.info("LD:ObservabilityBridge start called, ver$observabilityVersion") val resourceAttributes = try { AttributeConverter.convert(observability.attributes) @@ -93,23 +87,7 @@ public class ObservabilityBridge( throw t } - val observabilityPlugin = try { - Observability( - application = app, - mobileKey = mobileKey, - options = nativeObservabilityOptions - ) - } catch (t: Throwable) { - printException("LD:ObservabilityBridge failed to create Observability plugin", t) - throw t - } - - observabilityPlugin.distroAttributes = mapOf( - "telemetry.distro.name" to "observability-maui-android", - "telemetry.distro.version" to observabilityVersion - ) - - val nativeSessionReplayOptions = try { + val nativeReplayOptions = try { val privacy = replay.privacy com.launchdarkly.observability.replay.ReplayOptions( enabled = replay.isEnabled, @@ -125,37 +103,12 @@ public class ObservabilityBridge( throw t } - val sessionReplayPlugin = try { - SessionReplay(options = nativeSessionReplayOptions) - } catch (t: Throwable) { - printException("LD:ObservabilityBridge failed to create SessionReplay plugin", t) - throw t - } - logger.info( - "LD:ObservabilityBridge Session replay enabled=${nativeSessionReplayOptions.enabled}, " + + "LD:ObservabilityBridge Session replay enabled=${nativeReplayOptions.enabled}, " + "backendUrl=${nativeObservabilityOptions.backendUrl}" ) - val ldConfig = try { - LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled) - .mobileKey(mobileKey) - .offline(true) - .plugins( - Components.plugins().setPlugins( - listOf( - observabilityPlugin, - sessionReplayPlugin - ) - ) - ) - .build() - } catch (t: Throwable) { - printException("LD:ObservabilityBridge failed to build LDConfig", t) - throw t - } - - val context = try { + val ldContext = try { LDContext.builder(ContextKind.DEFAULT, "maui-user-key") .anonymous(true) .build() @@ -165,9 +118,15 @@ public class ObservabilityBridge( } try { - LDClient.init(app, ldConfig, context) + LDObserve.init( + application = app, + mobileKey = mobileKey, + ldContext = ldContext, + options = nativeObservabilityOptions, + replayOptions = nativeReplayOptions + ) } catch (t: Throwable) { - printException("LD:ObservabilityBridge LDClient.init failed", t) + printException("LD:ObservabilityBridge LDObserve.init failed", t) throw t } } @@ -181,4 +140,3 @@ public class ObservabilityBridge( logger.error(writer.toString()) } } - diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 5d49e8453..8664d3399 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -21,7 +21,7 @@ allprojects { } dependencies { - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") + implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") implementation("com.jakewharton.timber:timber:5.0.1") // AndroidX 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 74188dbee..0583a895f 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 @@ -36,6 +36,10 @@ class SessionReplay( } override fun register(client: LDClient, metadata: EnvironmentMetadata?) { + register() + } + + fun register() { val context = LDObserve.context ?: run { Timber.tag(TAG).e("Observability plugin is not initialized") return @@ -50,7 +54,6 @@ class SessionReplay( LDReplay.init(service) sessionReplayService = service sessionReplayHook.delegate = service - } override fun getHooks(metadata: EnvironmentMetadata?): MutableList { 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 25feffc64..db4f10f3f 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,14 +1,29 @@ package com.launchdarkly.observability.sdk +import android.app.Application +import com.launchdarkly.logging.LDLogLevel +import com.launchdarkly.logging.LDLogger +import com.launchdarkly.logging.Logs +import com.launchdarkly.observability.BuildConfig +import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.bridge.AttributeConverter -import com.launchdarkly.observability.client.ObservabilityService import com.launchdarkly.observability.client.ObservabilityContext +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.SessionReplay +import com.launchdarkly.sdk.LDContext +import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.sdk.resources.Resource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * LDObserve is the singleton entry point for recording observability data such as @@ -92,6 +107,91 @@ class LDObserve(private val client: Observe) : Observe { delegate = LDObserve(client) } + @Volatile + private var sessionReplayPlugin: SessionReplay? = null + + /** + * Standalone initialization that sets up observability (and optionally session replay) + * without requiring [com.launchdarkly.sdk.android.LDClient]. + * + * Use this when you want observability and/or session replay to run independently of the + * LaunchDarkly feature-flag SDK. + * + * @param application The Android [Application] instance. + * @param mobileKey The LaunchDarkly mobile key used for authentication. + * @param ldContext The [LDContext] identifying the current user/context. + * @param options Configuration for observability telemetry. + * @param replayOptions Optional configuration for session replay. Pass `null` (the default) + * to skip session replay initialization. + */ + fun init( + application: Application, + mobileKey: String, + ldContext: LDContext, + options: ObservabilityOptions = ObservabilityOptions(), + replayOptions: ReplayOptions? = null + ) { + val actualLogAdapter = Logs.level( + options.logAdapter, + if (options.debug) LDLogLevel.DEBUG else LDLogLevel.INFO + ) + val logger = LDLogger.withAdapter(actualLogAdapter, options.loggerName) + + val obsContext = ObservabilityContext( + sdkKey = mobileKey, + options = options, + application = application, + logger = logger + ) + context = obsContext + + 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 = SessionReplay(replayOptions) + sessionReplayPlugin = plugin + plugin.register() + plugin.sessionReplayService?.initialize() + if (replayOptions.enabled && ldContext != null) { + 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) + } + } + 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) From 4027c979cd681cd84fb15e25b81ecf355633ede1 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 16:58:43 -0700 Subject: [PATCH 02/23] fix unit tests --- .../androidobservability/BaseApplication.kt | 5 +- .../android/native/LDObserve/build.gradle.kts | 4 +- .../example/LDObserve/ObservabilityBridge.kt | 9 +- .../lib/build.gradle.kts | 7 +- .../observability/api/ObservabilityOptions.kt | 8 +- .../observability/devlog/LDContextCompat.kt | 19 +++++ .../observability/devlog/LDObserveContext.kt | 85 +++++++++++++++++++ .../observability/devlog/LDObserveLogging.kt | 32 +++++++ .../observability/devlog/ObserveLogAdapter.kt | 32 +++++++ .../observability/devlog/ObserveLogBridge.kt | 80 +++++++++++++++++ .../observability/plugin/Observability.kt | 7 +- .../replay/SessionReplayService.kt | 20 ++--- .../replay/exporter/IdentifyItemPayload.kt | 13 ++- .../observability/sdk/LDObserve.kt | 16 ++-- .../observability/replay/SessionReplayTest.kt | 8 +- 15 files changed, 287 insertions(+), 58 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveLogging.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogAdapter.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.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 297c81500..55e6797c1 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 @@ -9,13 +9,13 @@ import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.observability.replay.view +import com.launchdarkly.observability.devlog.LDObserveContext import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.sdk.ContextKind import com.launchdarkly.sdk.LDContext import com.launchdarkly.sdk.android.Components import com.launchdarkly.sdk.android.FeatureFlagChangeListener -import com.launchdarkly.sdk.android.LDAndroidLogging import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.LDConfig import io.opentelemetry.api.common.AttributeKey @@ -39,7 +39,6 @@ open class BaseApplication : Application() { instrumentations = ObservabilityOptions.Instrumentations( crashReporting = true, launchTime = true, activityLifecycle = true ), - logAdapter = LDAndroidLogging.adapter(), ) val sessionReplayPlugin = SessionReplay( @@ -63,7 +62,7 @@ open class BaseApplication : Application() { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions - val context = LDContext.builder(ContextKind.DEFAULT, "example-user-key") + val context = LDObserveContext.builder(LDObserveContext.DEFAULT_KIND, "example-user-key") .anonymous(true) .build() diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts index 6dd93f25b..89b64ed90 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts @@ -42,8 +42,8 @@ dependencies { // Copy dependencies for binding library // Uncomment line below and replace dependency.name.goes.here with your dependency - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") - "copyDependencies"("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") + // implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") + // "copyDependencies"("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") // Intentionally use a non-existent version so this dependency MUST be // satisfied by composite-build substitution (settings.gradle.kts). diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt index 640fdae86..ed0233cc5 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt @@ -8,9 +8,7 @@ import com.launchdarkly.observability.bridge.AttributeConverter import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay -import com.launchdarkly.sdk.ContextKind -import com.launchdarkly.sdk.LDContext -import com.launchdarkly.sdk.android.LDAndroidLogging +import com.launchdarkly.observability.devlog.LDObserveContext public class ObservabilityBridge( private val logger: BridgeLogger = SystemOutBridgeLogger() @@ -80,7 +78,6 @@ public class ObservabilityBridge( instrumentations = com.launchdarkly.observability.api.ObservabilityOptions.Instrumentations( crashReporting = false, launchTime = observability.launchTime, activityLifecycle = true ), - logAdapter = LDAndroidLogging.adapter(), ) } catch (t: Throwable) { printException("LD:ObservabilityBridge failed to build ObservabilityOptions", t) @@ -109,11 +106,11 @@ public class ObservabilityBridge( ) val ldContext = try { - LDContext.builder(ContextKind.DEFAULT, "maui-user-key") + LDObserveContext.builder(LDObserveContext.DEFAULT_KIND, "maui-user-key") .anonymous(true) .build() } catch (t: Throwable) { - printException("LD:ObservabilityBridge failed to build LDContext", t) + printException("LD:ObservabilityBridge failed to build LDObserveContext", t) throw t } diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 8664d3399..d318967d4 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -21,8 +21,11 @@ allprojects { } dependencies { - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") - implementation("com.jakewharton.timber:timber:5.0.1") + compileOnly("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") + compileOnly("com.jakewharton.timber:timber:5.0.1") + + testImplementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") + testImplementation("com.jakewharton.timber:timber:5.0.1") // AndroidX // This only used by Session Replay. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt index 04dfeea98..3490cae64 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt @@ -1,9 +1,9 @@ package com.launchdarkly.observability.api -import com.launchdarkly.logging.LDLogAdapter import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.client.TelemetryInspector -import com.launchdarkly.sdk.android.LDTimberLogging +import com.launchdarkly.observability.devlog.LDObserveLogging +import com.launchdarkly.observability.devlog.ObserveLogAdapter import io.opentelemetry.api.common.Attributes import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -27,7 +27,7 @@ const val DEFAULT_BACKEND_URL = "https://pub.observability.app.launchdarkly.com" * @property tracesApi Options for configuring traces. See [TracesApi]. Tracing is enabled by default. * @property metricsApi Options for configuring metrics. See [MetricsApi]. Metrics are enabled by default. * @property instrumentations Options for configuring automatic instrumentations. See [Instrumentations]. - * @property logAdapter The log adapter to use. Defaults to using the LaunchDarkly SDK's LDTimberLogging.adapter(). Use LDAndroidLogging.adapter() to use the Android logging adapter. + * @property logAdapter The log adapter to use. Defaults to [LDObserveLogging.adapter] which writes to Android's native Log API. * @property loggerName The name of the logger to use. Defaults to "LaunchDarklyObservabilityPlugin". * @property telemetryInspector Optional [TelemetryInspector] for intercepting exported telemetry during testing. * When provided together with [debug] = true, the inspector's exporters are wired into composite @@ -48,7 +48,7 @@ data class ObservabilityOptions( val tracesApi: TracesApi = TracesApi.enabled(), val metricsApi: MetricsApi = MetricsApi.enabled(), val instrumentations: Instrumentations = Instrumentations(), - val logAdapter: LDLogAdapter = LDTimberLogging.adapter(), // This follows the LaunchDarkly SDK's default log adapter + val logAdapter: ObserveLogAdapter = LDObserveLogging.adapter(), val loggerName: String = "LaunchDarklyObservabilityPlugin", val telemetryInspector: TelemetryInspector? = null, ){ diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt new file mode 100644 index 000000000..c818d4bcc --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt @@ -0,0 +1,19 @@ +package com.launchdarkly.observability.devlog + +import com.launchdarkly.sdk.LDContext + +/** + * Converts an [LDContext] to an [LDObserveContext] for use in the Observability SDK. + * + * Use this at integration boundaries where code receives an [LDContext] from the + * LaunchDarkly Client SDK and needs to pass it into observability/session-replay APIs. + */ +fun LDContext.toLDObserveContext(): LDObserveContext { + if (isMultiple) { + val subs = (0 until individualContextCount).map { i -> + getIndividualContext(i).toLDObserveContext() + } + return LDObserveContext.createMulti(*subs.toTypedArray()) + } + return LDObserveContext.create(kind.toString(), key) +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt new file mode 100644 index 000000000..3d8f82649 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt @@ -0,0 +1,85 @@ +package com.launchdarkly.observability.devlog + +/** + * Lightweight context representation for the Observability SDK, independent of + * `com.launchdarkly.sdk.LDContext`. + * + * A context carries a kind/key pair that identifies a user (or entity) in telemetry + * and session-replay payloads. Multi-kind contexts aggregate several single-kind + * contexts under one umbrella. + * + * ```kotlin + * // single context + * val ctx = LDObserveContext.builder("user", "user-123") + * .anonymous(true) + * .build() + * + * // multi context + * val multi = LDObserveContext.createMulti( + * LDObserveContext.create("user", "user-123"), + * LDObserveContext.create("device", "iphone"), + * ) + * ``` + */ +class LDObserveContext private constructor( + val kind: String, + val key: String, + val name: String? = null, + val anonymous: Boolean = false, + private val contexts: List? = null, +) { + val isMultiple: Boolean get() = contexts != null + + val individualContextCount: Int get() = contexts?.size ?: 0 + + fun getIndividualContext(index: Int): LDObserveContext = + contexts?.get(index) ?: throw IndexOutOfBoundsException("Not a multi-kind context") + + /** + * Returns the fully qualified key. For a single context this is just [key]; + * for a multi context it joins all sub-context keys in `kind:key` form. + */ + val fullyQualifiedKey: String + get() = if (isMultiple) { + contexts!!.joinToString(":") { "${it.kind}:${it.key}" } + } else { + key + } + + override fun toString(): String = + if (isMultiple) "LDObserveContext.multi(${contexts!!.joinToString { it.toString() }})" + else "LDObserveContext(kind=$kind, key=$key)" + + companion object { + const val DEFAULT_KIND = "user" + + fun create(kind: String, key: String): LDObserveContext = + LDObserveContext(kind = kind, key = key) + + fun createMulti(vararg contexts: LDObserveContext): LDObserveContext { + require(contexts.size >= 2) { "Multi-kind context requires at least 2 contexts" } + return LDObserveContext( + kind = "multi", + key = contexts.joinToString(":") { it.key }, + contexts = contexts.toList() + ) + } + + fun builder(kind: String, key: String): Builder = Builder(kind, key) + } + + class Builder(private val kind: String, private val key: String) { + private var name: String? = null + private var anonymous: Boolean = false + + fun name(name: String) = apply { this.name = name } + fun anonymous(anonymous: Boolean) = apply { this.anonymous = anonymous } + + fun build(): LDObserveContext = LDObserveContext( + kind = kind, + key = key, + name = name, + anonymous = anonymous + ) + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveLogging.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveLogging.kt new file mode 100644 index 000000000..152105487 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveLogging.kt @@ -0,0 +1,32 @@ +package com.launchdarkly.observability.devlog + +import android.util.Log + +/** + * Default [ObserveLogAdapter] that writes to Android's native [Log] API. + * + * ```kotlin + * val options = ObservabilityOptions( + * logAdapter = LDObserveLogging.adapter(), + * ) + * ``` + */ +object LDObserveLogging { + + /** + * Returns an [ObserveLogAdapter] backed by Android's [Log]. + */ + @JvmStatic + fun adapter(): ObserveLogAdapter = ObserveLogAdapter { name -> ChannelImpl(name) } + + private class ChannelImpl(private val tag: String) : ObserveLogAdapter.Channel { + override fun log(level: ObserveLogLevel, message: String) { + when (level) { + ObserveLogLevel.DEBUG -> Log.d(tag, message) + ObserveLogLevel.INFO -> Log.i(tag, message) + ObserveLogLevel.WARN -> Log.w(tag, message) + ObserveLogLevel.ERROR -> Log.e(tag, message) + } + } + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogAdapter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogAdapter.kt new file mode 100644 index 000000000..2212ec590 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogAdapter.kt @@ -0,0 +1,32 @@ +package com.launchdarkly.observability.devlog + +/** + * Logging adapter interface for the Observability SDK. + * + * Implement this interface to direct SDK log output to your preferred logging framework. + * The default implementation ([LDObserveLogging]) writes to Android's native [android.util.Log]. + */ +fun interface ObserveLogAdapter { + + /** + * Creates a logging channel for the given tag/name. + */ + fun newChannel(name: String): Channel + + /** + * A logging channel that receives formatted log messages. + */ + interface Channel { + fun log(level: ObserveLogLevel, message: String) + } +} + +/** + * Log levels used by [ObserveLogAdapter]. + */ +enum class ObserveLogLevel { + DEBUG, + INFO, + WARN, + ERROR +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.kt new file mode 100644 index 000000000..db58dfa58 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.kt @@ -0,0 +1,80 @@ +package com.launchdarkly.observability.devlog + +import com.launchdarkly.logging.LDLogAdapter +import com.launchdarkly.logging.LDLogLevel +import com.launchdarkly.logging.LDLogger + +/** + * Bridges [ObserveLogAdapter] to [LDLogAdapter] so that internal code using [LDLogger] keeps + * working while the public API is free of `com.launchdarkly.logging` types. + */ +internal fun ObserveLogAdapter.toLDLogAdapter(): LDLogAdapter { + val outer = this + return LDLogAdapter { name -> + val channel = outer.newChannel(name) + object : LDLogAdapter.Channel { + override fun isEnabled(level: LDLogLevel): Boolean = true + + override fun log(level: LDLogLevel, message: Any?) { + channel.log(level.toObserveLevel(), message?.toString() ?: "") + } + + override fun log(level: LDLogLevel, format: String, param: Any?) { + channel.log(level.toObserveLevel(), format(format, param)) + } + + override fun log(level: LDLogLevel, format: String, param1: Any?, param2: Any?) { + channel.log(level.toObserveLevel(), format(format, param1, param2)) + } + + override fun log(level: LDLogLevel, format: String, vararg params: Any?) { + channel.log(level.toObserveLevel(), format(format, *params)) + } + } + } +} + +/** + * Builds an [LDLogger] from an [ObserveLogAdapter], applying the appropriate level filter + * based on the debug flag. + */ +internal fun buildLDLogger( + adapter: ObserveLogAdapter, + loggerName: String, + debug: Boolean +): LDLogger { + val ldAdapter = com.launchdarkly.logging.Logs.level( + adapter.toLDLogAdapter(), + if (debug) LDLogLevel.DEBUG else LDLogLevel.INFO + ) + return LDLogger.withAdapter(ldAdapter, loggerName) +} + +private fun LDLogLevel.toObserveLevel(): ObserveLogLevel = when (this) { + LDLogLevel.DEBUG -> ObserveLogLevel.DEBUG + LDLogLevel.INFO -> ObserveLogLevel.INFO + LDLogLevel.WARN -> ObserveLogLevel.WARN + LDLogLevel.ERROR -> ObserveLogLevel.ERROR + else -> ObserveLogLevel.DEBUG +} + +/** + * Simple `{}` placeholder formatting, equivalent to `SimpleFormat.format` from + * `com.launchdarkly.logging`. + */ +private fun format(template: String, vararg args: Any?): String { + val sb = StringBuilder(template.length + args.size * 16) + var argIdx = 0 + var i = 0 + while (i < template.length) { + if (i + 1 < template.length && template[i] == '{' && template[i + 1] == '}' && argIdx < args.size) { + sb.append(args[argIdx]) + argIdx++ + i += 2 + } else { + sb.append(template[i]) + i++ + } + } + return sb.toString() +} 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 9debe7652..fdea282da 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 @@ -1,15 +1,14 @@ package com.launchdarkly.observability.plugin import android.app.Application -import com.launchdarkly.logging.LDLogLevel import com.launchdarkly.logging.LDLogger -import com.launchdarkly.logging.Logs import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityService import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve +import com.launchdarkly.observability.devlog.buildLDLogger import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata import com.launchdarkly.sdk.android.integrations.Hook @@ -66,8 +65,7 @@ class Observability( private var client: LDClient? = null init { - val actualLogAdapter = Logs.level(options.logAdapter, if (options.debug) LDLogLevel.DEBUG else DEFAULT_LOG_LEVEL) - logger = LDLogger.withAdapter(actualLogAdapter, options.loggerName) + logger = buildLDLogger(options.logAdapter, options.loggerName, options.debug) } override fun getMetadata(): PluginMetadata { @@ -150,7 +148,6 @@ class Observability( } companion object { - val DEFAULT_LOG_LEVEL: LDLogLevel = LDLogLevel.INFO 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/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index c27787014..3f5a604d2 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 @@ -14,9 +14,8 @@ import com.launchdarkly.observability.replay.exporter.InteractionItemPayload import com.launchdarkly.observability.replay.exporter.SessionReplayExporter import com.launchdarkly.observability.replay.transport.BatchWorker import com.launchdarkly.observability.replay.transport.EventQueue +import com.launchdarkly.observability.devlog.LDObserveContext import com.launchdarkly.observability.sdk.SessionReplayServicing -import com.launchdarkly.sdk.ContextKind -import com.launchdarkly.sdk.LDContext import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -270,7 +269,7 @@ class SessionReplayService( } suspend fun identifySession( - ldContext: LDContext, + ldContext: LDObserveContext, timestamp: Long = System.currentTimeMillis() ) { if (!this::sessionManager.isInitialized || exporter == null) { @@ -307,22 +306,19 @@ class SessionReplayService( override fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) { if (!completed) return - val ldContext = buildLDContext(contextKeys) + val observeContext = buildObserveContext(contextKeys) instrumentationScope.launch { - identifySession(ldContext) + identifySession(observeContext) } } - private fun buildLDContext(contextKeys: Map): LDContext { + private fun buildObserveContext(contextKeys: Map): LDObserveContext { if (contextKeys.size == 1) { val (kind, key) = contextKeys.entries.first() - return LDContext.create(ContextKind.of(kind), key) + return LDObserveContext.create(kind, key) } - val builder = LDContext.multiBuilder() - for ((kind, key) in contextKeys) { - builder.add(LDContext.create(ContextKind.of(kind), key)) - } - return builder.build() + val subs = contextKeys.map { (kind, key) -> LDObserveContext.create(kind, key) } + return LDObserveContext.createMulti(*subs.toTypedArray()) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt index 65145dc60..ddfc23591 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt @@ -1,8 +1,8 @@ package com.launchdarkly.observability.replay.exporter +import com.launchdarkly.observability.devlog.LDObserveContext import com.launchdarkly.observability.replay.transport.EventExporting import com.launchdarkly.observability.replay.transport.EventQueueItemPayload -import com.launchdarkly.sdk.LDContext import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes @@ -37,7 +37,7 @@ data class IdentifyItemPayload( fun from( contextFriendlyName: String? = null, resourceAttributes: Attributes, - ldContext: LDContext? = null, + ldContext: LDObserveContext? = null, timestamp: Long = System.currentTimeMillis(), sessionId: String? ): IdentifyItemPayload { @@ -45,22 +45,21 @@ data class IdentifyItemPayload( flattenAttributes(resourceAttributes, attributes) - // Merge LDContext kind->key entries into attributes if (ldContext != null) { if (ldContext.isMultiple) { val count = ldContext.individualContextCount for (i in 0 until count) { val sub = ldContext.getIndividualContext(i) - val kind = sub.kind.toString() + val kind = sub.kind val key = sub.key - if (!key.isNullOrEmpty()) { + if (key.isNotEmpty()) { attributes[kind] = key } } } else { - val kind = ldContext.kind.toString() + val kind = ldContext.kind val key = ldContext.key - if (!key.isNullOrEmpty()) { + if (key.isNotEmpty()) { attributes[kind] = key } } 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 db4f10f3f..1a4eb197e 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,11 +1,10 @@ package com.launchdarkly.observability.sdk import android.app.Application -import com.launchdarkly.logging.LDLogLevel -import com.launchdarkly.logging.LDLogger -import com.launchdarkly.logging.Logs import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions +import com.launchdarkly.observability.devlog.LDObserveContext +import com.launchdarkly.observability.devlog.buildLDLogger import com.launchdarkly.observability.bridge.AttributeConverter import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.client.ObservabilityService @@ -13,7 +12,6 @@ import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.plugin.SessionReplay -import com.launchdarkly.sdk.LDContext import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity @@ -119,7 +117,7 @@ class LDObserve(private val client: Observe) : Observe { * * @param application The Android [Application] instance. * @param mobileKey The LaunchDarkly mobile key used for authentication. - * @param ldContext The [LDContext] identifying the current user/context. + * @param ldContext The [LDObserveContext] identifying the current user/context. * @param options Configuration for observability telemetry. * @param replayOptions Optional configuration for session replay. Pass `null` (the default) * to skip session replay initialization. @@ -127,15 +125,11 @@ class LDObserve(private val client: Observe) : Observe { fun init( application: Application, mobileKey: String, - ldContext: LDContext, + ldContext: LDObserveContext, options: ObservabilityOptions = ObservabilityOptions(), replayOptions: ReplayOptions? = null ) { - val actualLogAdapter = Logs.level( - options.logAdapter, - if (options.debug) LDLogLevel.DEBUG else LDLogLevel.INFO - ) - val logger = LDLogger.withAdapter(actualLogAdapter, options.loggerName) + val logger = buildLDLogger(options.logAdapter, options.loggerName, options.debug) val obsContext = ObservabilityContext( sdkKey = mobileKey, 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 dee71c2a0..d698998ca 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,7 +6,6 @@ import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay -import com.launchdarkly.sdk.android.LDClient import io.mockk.mockk import io.mockk.unmockkAll import org.junit.jupiter.api.AfterEach @@ -18,11 +17,8 @@ import org.junit.jupiter.api.Test class SessionReplayTest { - private lateinit var client: LDClient - @BeforeEach fun setUp() { - client = mockk(relaxed = true) LDObserve.context = null LDReplay.client = null } @@ -44,7 +40,7 @@ class SessionReplayTest { ) val sessionReplay = SessionReplay() - sessionReplay.register(client, null) + sessionReplay.register() assertNotNull(sessionReplay.sessionReplayService) assertNotNull(LDReplay.client) @@ -54,7 +50,7 @@ class SessionReplayTest { @Test fun `register does nothing when observability is not initialized`() { val sessionReplay = SessionReplay() - sessionReplay.register(client, null) + sessionReplay.register() assertNull(sessionReplay.sessionReplayService) assertNull(LDReplay.client) From a5400f64ec7ace3425daf3d8e16a46a11226835b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 17:02:24 -0700 Subject: [PATCH 03/23] minus one --- .../mobile-dotnet/observability/NativeAndroidDeps.props | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props index 57f0c2537..a7e92dbfe 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props @@ -50,7 +50,6 @@ _LDNativeJAR Include autoconfigure-spi-*.jar when needed. autoconfigure-1* was unsafe on OTel 2.x. --> <_LDNativeJAR Include="$(_LDNativeDepsDir)opentelemetry-*.jar" Exclude="$(_LDNativeDepsDir)opentelemetry-sdk-extension-incubator-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-extension-autoconfigure-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-testing-*.jar" /> - <_LDNativeJAR Include="$(_LDNativeDepsDir)jackson-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)launchdarkly-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)okhttp-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)okio-*.jar" /> From 7275c5b722ca178a214674db03055aba71ee8d0d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 17:29:28 -0700 Subject: [PATCH 04/23] sdk dependencies --- .../observability/NativeAndroidDeps.props | 7 -- .../lib/build.gradle.kts | 2 - .../observability/client/DebugLogExporter.kt | 4 +- .../client/DebugMetricExporter.kt | 4 +- .../observability/client/DebugSpanExporter.kt | 4 +- .../client/ObservabilityContext.kt | 4 +- .../client/ObservabilityService.kt | 8 +- .../observability/devlog/ObserveLogBridge.kt | 80 ------------------- .../observability/devlog/ObserveLogger.kt | 47 +++++++++++ .../observability/network/GraphQLClient.kt | 4 +- .../observability/plugin/Observability.kt | 7 +- .../replay/SessionReplayService.kt | 4 +- .../replay/capture/CaptureManager.kt | 4 +- .../replay/capture/ImageCaptureService.kt | 4 +- .../replay/capture/WindowInspector.kt | 4 +- .../replay/exporter/SessionReplayExporter.kt | 4 +- .../replay/masking/ComposeMaskTarget.kt | 6 +- .../replay/masking/MaskCollector.kt | 4 +- .../replay/plugin/SessionReplay.kt | 44 +++------- .../replay/plugin/SessionReplayImpl.kt | 45 +++++++++++ .../replay/transport/BatchWorker.kt | 4 +- .../observability/sdk/LDObserve.kt | 10 +-- .../client/ObservabilityServiceTest.kt | 4 +- .../observability/replay/SessionReplayTest.kt | 10 +-- .../replay/transport/BatchWorkerTest.kt | 8 +- 25 files changed, 152 insertions(+), 174 deletions(-) delete mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogger.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayImpl.kt diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props index a7e92dbfe..44c763288 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props @@ -34,15 +34,9 @@ <_LDNativeAAR Include="$(_LDNativeDepsDir)slowrendering-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)startup-*-alpha.aar" /> - - <_LDNativeAAR Include="$(_LDNativeDepsDir)launchdarkly-android-client-sdk-*.aar" /> - <_LDNativeAAR Include="$(_LDNativeDepsDir)lib-release.aar" /> - - <_LDNativeAAR Include="$(_LDNativeDepsDir)timber-*.aar" /> - <_LDNativeAAR Include="$(_LDNativeAarDir)LDObserve-release.aar" /> @@ -50,7 +44,6 @@ _LDNativeJAR Include autoconfigure-spi-*.jar when needed. autoconfigure-1* was unsafe on OTel 2.x. --> <_LDNativeJAR Include="$(_LDNativeDepsDir)opentelemetry-*.jar" Exclude="$(_LDNativeDepsDir)opentelemetry-sdk-extension-incubator-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-extension-autoconfigure-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-testing-*.jar" /> - <_LDNativeJAR Include="$(_LDNativeDepsDir)launchdarkly-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)okhttp-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)okio-*.jar" /> diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index d318967d4..85c3b2c7e 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -22,10 +22,8 @@ allprojects { dependencies { compileOnly("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") - compileOnly("com.jakewharton.timber:timber:5.0.1") testImplementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") - testImplementation("com.jakewharton.timber:timber:5.0.1") // AndroidX // This only used by Session Replay. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt index c0668e268..6b31bcae7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt @@ -1,11 +1,11 @@ package com.launchdarkly.observability.client -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.logs.data.LogRecordData import io.opentelemetry.sdk.logs.export.LogRecordExporter -class DebugLogExporter(private val logger: LDLogger) : LogRecordExporter { +class DebugLogExporter(private val logger: ObserveLogger) : LogRecordExporter { override fun export(logRecords: Collection): CompletableResultCode { for (record in logRecords) { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt index 7cc3725ac..3174b3f80 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.client -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.metrics.InstrumentType import io.opentelemetry.sdk.metrics.data.AggregationTemporality @@ -8,7 +8,7 @@ import io.opentelemetry.sdk.metrics.data.MetricData import io.opentelemetry.sdk.metrics.export.AggregationTemporalitySelector import io.opentelemetry.sdk.metrics.export.MetricExporter -class DebugMetricExporter(private val logger: LDLogger): MetricExporter { +class DebugMetricExporter(private val logger: ObserveLogger): MetricExporter { override fun export(metrics: Collection): CompletableResultCode? { for (metric in metrics) { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt index 3de8eb5fa..ad67daa2b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt @@ -1,11 +1,11 @@ package com.launchdarkly.observability.client -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.sdk.trace.export.SpanExporter -class DebugSpanExporter(private val logger: LDLogger) : SpanExporter { +class DebugSpanExporter(private val logger: ObserveLogger) : SpanExporter { override fun export(spans: Collection): CompletableResultCode { for (span in spans) { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt index e2d6cde75..a2f64f20b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt @@ -1,8 +1,8 @@ package com.launchdarkly.observability.client import android.app.Application -import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.ObservabilityOptions +import com.launchdarkly.observability.devlog.ObserveLogger import io.opentelemetry.android.session.SessionManager import io.opentelemetry.api.common.Attributes @@ -13,7 +13,7 @@ data class ObservabilityContext( val sdkKey: String, val options: ObservabilityOptions, val application: Application, - val logger: LDLogger, + val logger: ObserveLogger, var sessionManager: SessionManager? = null, var resourceAttributes: Attributes = Attributes.empty(), ) 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 cc3b3ce77..baf524c83 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 @@ -1,7 +1,7 @@ package com.launchdarkly.observability.client import android.app.Application -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.bridge.emitLog import com.launchdarkly.observability.coroutines.DispatcherProviderHolder @@ -78,7 +78,7 @@ class ObservabilityService( private val application: Application, private val sdkKey: String, private val resources: Resource, - private val logger: LDLogger, + private val logger: ObserveLogger, private val observabilityOptions: ObservabilityOptions, ) : Observe { private val otelRUM: OpenTelemetryRum @@ -429,7 +429,7 @@ class ObservabilityService( exportSampler: ExportSampler, sdkKey: String, resource: Resource, - logger: LDLogger, + logger: ObserveLogger, telemetryInspector: TelemetryInspector?, observabilityOptions: ObservabilityOptions, ): LogRecordProcessor { @@ -458,7 +458,7 @@ class ObservabilityService( private fun createLogExporter( primaryExporter: LogRecordExporter, - logger: LDLogger, + logger: ObserveLogger, telemetryInspector: TelemetryInspector?, observabilityOptions: ObservabilityOptions ): LogRecordExporter { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.kt deleted file mode 100644 index db58dfa58..000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogBridge.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.launchdarkly.observability.devlog - -import com.launchdarkly.logging.LDLogAdapter -import com.launchdarkly.logging.LDLogLevel -import com.launchdarkly.logging.LDLogger - -/** - * Bridges [ObserveLogAdapter] to [LDLogAdapter] so that internal code using [LDLogger] keeps - * working while the public API is free of `com.launchdarkly.logging` types. - */ -internal fun ObserveLogAdapter.toLDLogAdapter(): LDLogAdapter { - val outer = this - return LDLogAdapter { name -> - val channel = outer.newChannel(name) - object : LDLogAdapter.Channel { - override fun isEnabled(level: LDLogLevel): Boolean = true - - override fun log(level: LDLogLevel, message: Any?) { - channel.log(level.toObserveLevel(), message?.toString() ?: "") - } - - override fun log(level: LDLogLevel, format: String, param: Any?) { - channel.log(level.toObserveLevel(), format(format, param)) - } - - override fun log(level: LDLogLevel, format: String, param1: Any?, param2: Any?) { - channel.log(level.toObserveLevel(), format(format, param1, param2)) - } - - override fun log(level: LDLogLevel, format: String, vararg params: Any?) { - channel.log(level.toObserveLevel(), format(format, *params)) - } - } - } -} - -/** - * Builds an [LDLogger] from an [ObserveLogAdapter], applying the appropriate level filter - * based on the debug flag. - */ -internal fun buildLDLogger( - adapter: ObserveLogAdapter, - loggerName: String, - debug: Boolean -): LDLogger { - val ldAdapter = com.launchdarkly.logging.Logs.level( - adapter.toLDLogAdapter(), - if (debug) LDLogLevel.DEBUG else LDLogLevel.INFO - ) - return LDLogger.withAdapter(ldAdapter, loggerName) -} - -private fun LDLogLevel.toObserveLevel(): ObserveLogLevel = when (this) { - LDLogLevel.DEBUG -> ObserveLogLevel.DEBUG - LDLogLevel.INFO -> ObserveLogLevel.INFO - LDLogLevel.WARN -> ObserveLogLevel.WARN - LDLogLevel.ERROR -> ObserveLogLevel.ERROR - else -> ObserveLogLevel.DEBUG -} - -/** - * Simple `{}` placeholder formatting, equivalent to `SimpleFormat.format` from - * `com.launchdarkly.logging`. - */ -private fun format(template: String, vararg args: Any?): String { - val sb = StringBuilder(template.length + args.size * 16) - var argIdx = 0 - var i = 0 - while (i < template.length) { - if (i + 1 < template.length && template[i] == '{' && template[i + 1] == '}' && argIdx < args.size) { - sb.append(args[argIdx]) - argIdx++ - i += 2 - } else { - sb.append(template[i]) - i++ - } - } - return sb.toString() -} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogger.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogger.kt new file mode 100644 index 000000000..59c8b93e3 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogger.kt @@ -0,0 +1,47 @@ +package com.launchdarkly.observability.devlog + +/** + * Internal logger used throughout the Observability SDK. + * + * Wraps an [ObserveLogAdapter.Channel] with level filtering and convenience + * methods that mirror common logging APIs. + */ +class ObserveLogger internal constructor( + private val channel: ObserveLogAdapter.Channel, + private val minLevel: ObserveLogLevel, +) { + fun debug(message: String) { + if (minLevel <= ObserveLogLevel.DEBUG) channel.log(ObserveLogLevel.DEBUG, message) + } + + fun info(message: String) { + if (minLevel <= ObserveLogLevel.INFO) channel.log(ObserveLogLevel.INFO, message) + } + + fun warn(message: String) { + if (minLevel <= ObserveLogLevel.WARN) channel.log(ObserveLogLevel.WARN, message) + } + + fun warn(message: String, t: Throwable) { + if (minLevel <= ObserveLogLevel.WARN) channel.log(ObserveLogLevel.WARN, "$message: ${t.message}") + } + + fun error(message: String) { + if (minLevel <= ObserveLogLevel.ERROR) channel.log(ObserveLogLevel.ERROR, message) + } + + fun error(message: String, t: Throwable) { + if (minLevel <= ObserveLogLevel.ERROR) channel.log(ObserveLogLevel.ERROR, "$message: ${t.message}") + } + + fun error(t: Throwable) { + if (minLevel <= ObserveLogLevel.ERROR) channel.log(ObserveLogLevel.ERROR, t.message ?: t.toString()) + } + + companion object { + fun build(adapter: ObserveLogAdapter, name: String, debug: Boolean): ObserveLogger { + val level = if (debug) ObserveLogLevel.DEBUG else ObserveLogLevel.INFO + return ObserveLogger(adapter.newChannel(name), level) + } + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt index 18a78020b..71d117c0d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.network -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer @@ -48,7 +48,7 @@ interface UrlConnectionProvider { class GraphQLClient( val endpoint: String, val headers: Map = emptyMap(), - private val logger: LDLogger, + private val logger: ObserveLogger, private val json: Json = Json { isLenient = true ignoreUnknownKeys = true 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 fdea282da..87c9382b5 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 @@ -1,14 +1,13 @@ package com.launchdarkly.observability.plugin import android.app.Application -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityService import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve -import com.launchdarkly.observability.devlog.buildLDLogger import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata import com.launchdarkly.sdk.android.integrations.Hook @@ -59,13 +58,13 @@ class Observability( "telemetry.distro.name" to SDK_NAME, "telemetry.distro.version" to BuildConfig.OBSERVABILITY_SDK_VERSION ) - private val logger: LDLogger + private val logger: ObserveLogger private val observabilityHook = ObservabilityHook() private var observabilityClient: ObservabilityService? = null private var client: LDClient? = null init { - logger = buildLDLogger(options.logAdapter, options.loggerName, options.debug) + logger = ObserveLogger.build(options.logAdapter, options.loggerName, options.debug) } override fun getMetadata(): PluginMetadata { 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 3f5a604d2..a2c066cb4 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 @@ -4,7 +4,7 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.replay.capture.CaptureManager @@ -63,7 +63,7 @@ class SessionReplayService( ) : SessionReplayServicing { private lateinit var sessionManager: SessionManager - private val logger: LDLogger = observabilityContext.logger + private val logger: ObserveLogger = observabilityContext.logger private val eventQueue = EventQueue() private val batchWorker = BatchWorker(eventQueue, logger) private var captureManager: CaptureManager? = null diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt index 22442bc52..3da567486 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.replay.capture -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.replay.ReplayOptions import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.flow.MutableSharedFlow @@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.asSharedFlow class CaptureManager( private val sessionManager: SessionManager, private val options: ReplayOptions, - private val logger: LDLogger, + private val logger: ObserveLogger, // TODO: O11Y-628 - add captureQuality options ) { private val _captureEventFlow = MutableSharedFlow() diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt index d089d945c..8d6e1b2e8 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt @@ -15,7 +15,7 @@ import android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION import androidx.annotation.RequiresApi import androidx.core.graphics.createBitmap import androidx.core.graphics.withTranslation -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.calculateScaleFactor @@ -30,7 +30,7 @@ import kotlin.coroutines.resume class ImageCaptureService( private val options: ReplayOptions, - private val logger: LDLogger, + private val logger: ObserveLogger, ) { data class RawFrame( val bitmap: Bitmap, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt index 86c5518a0..0f945b7cf 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt @@ -9,11 +9,11 @@ import android.os.Build import android.view.View import android.view.Window import android.view.WindowManager -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.replay.utils.locationOnScreen import kotlin.jvm.javaClass -class WindowInspector(private val logger: LDLogger) { +class WindowInspector(private val logger: ObserveLogger) { fun appWindows(appContext: Context? = null): List { val appUid = appContext?.applicationInfo?.uid diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt index 972c4bbe4..d9bb76b74 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.replay.exporter -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.network.GraphQLClient import com.launchdarkly.observability.replay.Event import com.launchdarkly.observability.replay.capture.ExportFrame @@ -32,7 +32,7 @@ class SessionReplayExporter( val initialIdentifyItemPayload: IdentifyItemPayload, val title: String, private val injectedReplayApiService: SessionReplayApiService? = null, - private val logger: LDLogger, + private val logger: ObserveLogger, private val canvasBufferLimit: Int = RRWEB_CANVAS_BUFFER_LIMIT, canvasDrawEntourage: Int = RRWEB_CANVAS_DRAW_ENTOURAGE ) : EventExporting { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt index 91cd36d6d..43247f55f 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.semantics.getOrNull import com.launchdarkly.observability.api.LdMaskSemanticsKey import androidx.compose.ui.geometry.Rect as MaskRect import androidx.core.view.isNotEmpty -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger /** * Compose target with a non-null [SemanticsConfiguration]. @@ -26,7 +26,7 @@ data class ComposeMaskTarget( val boundsInWindow: MaskRect, ) : MaskTarget { companion object { - fun from(composeView: AbstractComposeView, logger: LDLogger): ComposeMaskTarget? { + fun from(composeView: AbstractComposeView, logger: ObserveLogger): ComposeMaskTarget? { val root = getRootSemanticsNode(composeView, logger) ?: return null return ComposeMaskTarget( view = composeView, @@ -40,7 +40,7 @@ data class ComposeMaskTarget( * Gets the SemanticsOwner from a ComposeView using reflection. This is necessary because * AndroidComposeView and semanticsOwner are not publicly exposed. */ - private fun getRootSemanticsNode(composeView: AbstractComposeView, logger: LDLogger): SemanticsNode? { + private fun getRootSemanticsNode(composeView: AbstractComposeView, logger: ObserveLogger): SemanticsNode? { return try { if (composeView.isNotEmpty()) { val androidComposeView = composeView.getChildAt(0) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt index c15a9f6b3..fa1647bd6 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt @@ -3,7 +3,7 @@ package com.launchdarkly.observability.replay.masking import android.graphics.Matrix import android.view.View import android.view.ViewGroup -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import kotlin.collections.plusAssign import com.launchdarkly.observability.replay.utils.locationOnScreen @@ -30,7 +30,7 @@ data class MaskContext( * * This encapsulates both Jetpack Compose and native View detection logic. */ -class MaskCollector(private val logger: LDLogger) { +class MaskCollector(private val logger: ObserveLogger) { /** * Find sensitive areas from all views in the provided [root] view. * 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 0583a895f..1989e3331 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,58 +2,39 @@ package com.launchdarkly.observability.replay.plugin import com.launchdarkly.observability.BuildConfig 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 com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata 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 timber.log.Timber import java.util.Collections /** - * Session Replay plugin for the LaunchDarkly Android SDK. + * LDClient plugin adapter for Session Replay. * - * This plugin depends on the Observability plugin being present and initialized first. + * Wraps [SessionReplayImpl] so it can be registered as a [Plugin] with the LaunchDarkly + * Android Client SDK. Only loaded when using the LDClient integration path. */ class SessionReplay( - private val options: ReplayOptions = ReplayOptions(), + options: ReplayOptions = ReplayOptions(), ) : Plugin() { + private val impl = SessionReplayImpl(options) private val sessionReplayHook = SessionReplayHook() - @Volatile - var sessionReplayService: SessionReplayService? = null + val sessionReplayService get() = impl.sessionReplayService override fun getMetadata(): PluginMetadata { return object : PluginMetadata() { - override fun getName(): String = PLUGIN_NAME + override fun getName(): String = SessionReplayImpl.PLUGIN_NAME override fun getVersion(): String = BuildConfig.OBSERVABILITY_SDK_VERSION } } override fun register(client: LDClient, metadata: EnvironmentMetadata?) { - register() - } - - fun register() { - val context = LDObserve.context ?: run { - Timber.tag(TAG).e("Observability plugin is not initialized") - return - } - - if (LDReplay.client != null) { - Timber.tag(TAG).e("Session Replay instance already exists") - return - } - - val service = SessionReplayService(options, context) - LDReplay.init(service) - sessionReplayService = service - sessionReplayHook.delegate = service + impl.register() + sessionReplayHook.delegate = impl.sessionReplayService } override fun getHooks(metadata: EnvironmentMetadata?): MutableList { @@ -61,11 +42,6 @@ class SessionReplay( } override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { - sessionReplayService?.initialize() - } - - companion object { - const val PLUGIN_NAME = "@launchdarkly/session-replay-android" - private const val TAG = "SessionReplay" + impl.initialize() } } 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 new file mode 100644 index 000000000..0e1720030 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayImpl.kt @@ -0,0 +1,45 @@ +package com.launchdarkly.observability.replay.plugin + +import android.util.Log +import com.launchdarkly.observability.replay.ReplayOptions +import com.launchdarkly.observability.replay.SessionReplayService +import com.launchdarkly.observability.sdk.LDObserve +import com.launchdarkly.observability.sdk.LDReplay + +/** + * 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 { + Log.e(TAG, "Observability is not initialized; skipping SessionReplay registration.") + return + } + + if (LDReplay.client != null) { + Log.e(TAG, "Session Replay instance already exists; skipping.") + return + } + + val service = SessionReplayService(options, context) + LDReplay.init(service) + sessionReplayService = service + } + + fun initialize() { + sessionReplayService?.initialize() + } + + companion object { + const val PLUGIN_NAME = "@launchdarkly/session-replay-android" + private const val TAG = "SessionReplay" + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt index 24d77e922..5d6e85270 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt @@ -1,7 +1,7 @@ package com.launchdarkly.observability.replay.transport import android.os.SystemClock -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProvider import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import kotlinx.coroutines.CoroutineScope @@ -14,7 +14,7 @@ import kotlin.random.Random internal class BatchWorker( private val eventQueue: EventQueue, - private val logger: LDLogger, + private val logger: ObserveLogger, dispatcherProvider: DispatcherProvider = DispatcherProviderHolder.current, ) { private val scope = CoroutineScope(dispatcherProvider.default + SupervisorJob()) 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 1a4eb197e..8082f8748 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 @@ -4,14 +4,14 @@ import android.app.Application import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.devlog.LDObserveContext -import com.launchdarkly.observability.devlog.buildLDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.bridge.AttributeConverter import com.launchdarkly.observability.client.ObservabilityContext 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.SessionReplay +import com.launchdarkly.observability.replay.plugin.SessionReplayImpl import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity @@ -106,7 +106,7 @@ class LDObserve(private val client: Observe) : Observe { } @Volatile - private var sessionReplayPlugin: SessionReplay? = null + private var sessionReplayPlugin: SessionReplayImpl? = null /** * Standalone initialization that sets up observability (and optionally session replay) @@ -129,7 +129,7 @@ class LDObserve(private val client: Observe) : Observe { options: ObservabilityOptions = ObservabilityOptions(), replayOptions: ReplayOptions? = null ) { - val logger = buildLDLogger(options.logAdapter, options.loggerName, options.debug) + val logger = ObserveLogger.build(options.logAdapter, options.loggerName, options.debug) val obsContext = ObservabilityContext( sdkKey = mobileKey, @@ -149,7 +149,7 @@ class LDObserve(private val client: Observe) : Observe { init(service) if (replayOptions != null) { - val plugin = SessionReplay(replayOptions) + val plugin = SessionReplayImpl(replayOptions) sessionReplayPlugin = plugin plugin.register() plugin.sessionReplayService?.initialize() diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt index b76f5e420..3129d262d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.client -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.sampling.ExportSampler import io.mockk.mockk @@ -16,7 +16,7 @@ class ObservabilityServiceTest { private lateinit var mockSdkLoggerProviderBuilder: SdkLoggerProviderBuilder private lateinit var mockExportSampler: ExportSampler - private lateinit var mockLogger: LDLogger + private lateinit var mockLogger: ObserveLogger private lateinit var testResource: Resource private lateinit var testSdkKey: String private lateinit var testObservabilityOptions: ObservabilityOptions 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 d698998ca..ab875dd44 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 @@ -1,9 +1,9 @@ package com.launchdarkly.observability.replay -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityContext -import com.launchdarkly.observability.replay.plugin.SessionReplay +import com.launchdarkly.observability.replay.plugin.SessionReplayImpl import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay import io.mockk.mockk @@ -36,9 +36,9 @@ class SessionReplayTest { sdkKey = "test-sdk-key", options = ObservabilityOptions(), application = mockk(), - logger = mockk(relaxed = true), + logger = mockk(relaxed = true), ) - val sessionReplay = SessionReplay() + val sessionReplay = SessionReplayImpl() sessionReplay.register() @@ -49,7 +49,7 @@ class SessionReplayTest { @Test fun `register does nothing when observability is not initialized`() { - val sessionReplay = SessionReplay() + val sessionReplay = SessionReplayImpl() sessionReplay.register() assertNull(sessionReplay.sessionReplayService) diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt index 4a1152675..e5a403b70 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt @@ -1,7 +1,7 @@ package com.launchdarkly.observability.replay.transport import android.os.SystemClock -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.devlog.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProvider import io.mockk.every import io.mockk.mockk @@ -27,7 +27,7 @@ class BatchWorkerTest { val dispatcher = StandardTestDispatcher(testScheduler) val dispatcherProvider = TestDispatcherProvider(dispatcher) - val logger = mockk(relaxed = true) + val logger = mockk(relaxed = true) val eventQueue = EventQueue() val worker = BatchWorker(eventQueue, logger, dispatcherProvider) @@ -54,7 +54,7 @@ class BatchWorkerTest { val dispatcher = StandardTestDispatcher(testScheduler) val dispatcherProvider = TestDispatcherProvider(dispatcher) - val logger = mockk(relaxed = true) + val logger = mockk(relaxed = true) val eventQueue = EventQueue() val worker = BatchWorker(eventQueue, logger, dispatcherProvider) @@ -85,7 +85,7 @@ class BatchWorkerTest { val dispatcher = StandardTestDispatcher(testScheduler) val dispatcherProvider = TestDispatcherProvider(dispatcher) - val logger = mockk(relaxed = true) + val logger = mockk(relaxed = true) val eventQueue = EventQueue() val worker = BatchWorker(eventQueue, logger, dispatcherProvider) From 0de049fff027cfd9fe91dde796527011aea19949 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 18:19:58 -0700 Subject: [PATCH 05/23] conditional inclusion --- .../observability/Directory.Build.props | 2 +- .../observability/NativeAndroidDeps.props | 2 +- .../observability-android/lib/build.gradle.kts | 13 ++++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index 0c60e3438..b0ff36fc3 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.8.0 + 0.9.1 false LaunchDarkly LaunchDarkly diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props index 44c763288..26912570b 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props @@ -24,7 +24,7 @@ <_LDNativeAAR Include="$(_LDNativeDepsDir)common-0*.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)common-api-*.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)core-*-alpha.aar" /> - <_LDNativeAAR Include="$(_LDNativeDepsDir)crash-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)anr-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)activity-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)fragment-*-alpha.aar" /> diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 85c3b2c7e..b28992b46 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -20,10 +20,17 @@ allprojects { } } -dependencies { - compileOnly("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") +val isIncludedBuild = gradle.parent != null - testImplementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") +dependencies { + if (isIncludedBuild) { + // MAUI / dotnet composite build — LDClient is not bundled at runtime + compileOnly("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") + testImplementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") + } else { + // Standalone Android build — LDClient is a regular dependency + implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") + } // AndroidX // This only used by Session Replay. From f93c4accdfbfd4999dbcf22a533744586cfc7948 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 18:33:35 -0700 Subject: [PATCH 06/23] maui condition --- .../observability-android/lib/build.gradle.kts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index b28992b46..9ce81cf48 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -20,15 +20,13 @@ allprojects { } } -val isIncludedBuild = gradle.parent != null +val isIncludedByMaui = gradle.parent?.rootProject?.name == "LDObserve" dependencies { - if (isIncludedBuild) { - // MAUI / dotnet composite build — LDClient is not bundled at runtime + if (isIncludedByMaui) { compileOnly("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") testImplementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") } else { - // Standalone Android build — LDClient is a regular dependency implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") } From 123437253d9e2c49af21cae6445f1383c7ab7036 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 19:00:36 -0700 Subject: [PATCH 07/23] maui exclusion sdk --- .../mobile-dotnet/observability/Directory.Build.props | 2 +- .../main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index b0ff36fc3..c415340a1 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.1 + 0.9.2 false LaunchDarkly LaunchDarkly 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 8082f8748..834b75a46 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 @@ -153,7 +153,7 @@ class LDObserve(private val client: Observe) : Observe { sessionReplayPlugin = plugin plugin.register() plugin.sessionReplayService?.initialize() - if (replayOptions.enabled && ldContext != null) { + if (replayOptions.enabled) { plugin.sessionReplayService?.let { replayService -> CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { replayService.identifySession(ldContext) From 29b8018057e3d968f5ad4d5b2785450aa0442f60 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 19:18:19 -0700 Subject: [PATCH 08/23] fix attributes --- .../com/example/LDObserve/ObservabilityBridge.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt index ed0233cc5..b1a9286ad 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt @@ -9,6 +9,7 @@ import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.observability.devlog.LDObserveContext +import io.opentelemetry.api.common.Attributes public class ObservabilityBridge( private val logger: BridgeLogger = SystemOutBridgeLogger() @@ -58,7 +59,12 @@ public class ObservabilityBridge( logger.info("LD:ObservabilityBridge start called, ver$observabilityVersion") val resourceAttributes = try { - AttributeConverter.convert(observability.attributes) + val converted = AttributeConverter.convert(observability.attributes) + Attributes.builder() + .putAll(converted) + .put("telemetry.distro.name", MAUI_DISTRO_NAME) + .put("telemetry.distro.version", observabilityVersion) + .build() } catch (t: Throwable) { printException("LD:resourceAttributes failed to build resourceAttributes", t) throw t @@ -128,6 +134,10 @@ public class ObservabilityBridge( } } + companion object { + private const val MAUI_DISTRO_NAME = "observability-maui-android" + } + private fun printException(prefix: String, t: Throwable) { logger.error("$prefix ${t::class.java.name}: ${t.message}") val writer = java.io.StringWriter() From 11f3ea66c2646418a9eaaedec1c45408870bd9d8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 19:24:59 -0700 Subject: [PATCH 09/23] fix unit test --- .../observability/replay/plugin/SessionReplayImpl.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 0e1720030..d8eb76d94 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/SessionReplayImpl.kt @@ -1,10 +1,10 @@ package com.launchdarkly.observability.replay.plugin -import android.util.Log 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. @@ -20,12 +20,12 @@ class SessionReplayImpl( fun register() { val context = LDObserve.context ?: run { - Log.e(TAG, "Observability is not initialized; skipping SessionReplay registration.") + logger.warning("Observability is not initialized; skipping SessionReplay registration.") return } if (LDReplay.client != null) { - Log.e(TAG, "Session Replay instance already exists; skipping.") + logger.warning("Session Replay instance already exists; skipping.") return } @@ -40,6 +40,6 @@ class SessionReplayImpl( companion object { const val PLUGIN_NAME = "@launchdarkly/session-replay-android" - private const val TAG = "SessionReplay" + private val logger = Logger.getLogger("SessionReplay") } } From a90ed6723c5ed633ff62d0933022ca147d49f0b0 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 19:57:04 -0700 Subject: [PATCH 10/23] fix --- .../launchdarkly/observability/devlog/LDContextCompat.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt index c818d4bcc..ba6d068d7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt @@ -15,5 +15,9 @@ fun LDContext.toLDObserveContext(): LDObserveContext { } return LDObserveContext.createMulti(*subs.toTypedArray()) } - return LDObserveContext.create(kind.toString(), key) + val builder = LDObserveContext.builder(kind.toString(), key) + .anonymous(isAnonymous) + val n = getName() + if (n != null) builder.name(n) + return builder.build() } From 9452f288eff9e5ccef5e6f1d86ed9802f502e80d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 14 Apr 2026 19:59:50 -0700 Subject: [PATCH 11/23] more fixes --- .../observability/devlog/LDObserveContext.kt | 15 +++++++++------ .../observability/replay/SessionReplayService.kt | 8 +++++--- .../replay/exporter/IdentifyItemPayload.kt | 5 +++-- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt index 3d8f82649..bc52e21b8 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt @@ -36,14 +36,17 @@ class LDObserveContext private constructor( contexts?.get(index) ?: throw IndexOutOfBoundsException("Not a multi-kind context") /** - * Returns the fully qualified key. For a single context this is just [key]; - * for a multi context it joins all sub-context keys in `kind:key` form. + * Returns the fully qualified key, matching `LDContext` semantics: + * - Single "user" context: just [key] + * - Single non-"user" context: `kind:key` + * - Multi context: sub-context keys sorted by kind, joined as `kind:key` */ val fullyQualifiedKey: String - get() = if (isMultiple) { - contexts!!.joinToString(":") { "${it.kind}:${it.key}" } - } else { - key + get() = when { + isMultiple -> contexts!!.sortedBy { it.kind } + .joinToString(":") { "${it.kind}:${it.key}" } + kind == DEFAULT_KIND -> key + else -> "$kind:$key" } override fun toString(): String = 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 a2c066cb4..6164b9b77 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 @@ -270,7 +270,8 @@ class SessionReplayService( suspend fun identifySession( ldContext: LDObserveContext, - timestamp: Long = System.currentTimeMillis() + timestamp: Long = System.currentTimeMillis(), + canonicalKeyOverride: String? = null ) { if (!this::sessionManager.isInitialized || exporter == null) { logger.warn("identifySession called before SessionReplayService was installed; skipping.") @@ -283,7 +284,8 @@ class SessionReplayService( resourceAttributes = observabilityContext.resourceAttributes, ldContext = ldContext, timestamp = timestamp, - sessionId = sessionId + sessionId = sessionId, + canonicalKeyOverride = canonicalKeyOverride ) // When replay is disabled, cache the identify payload for later session init without sending it now. @@ -308,7 +310,7 @@ class SessionReplayService( val observeContext = buildObserveContext(contextKeys) instrumentationScope.launch { - identifySession(observeContext) + identifySession(observeContext, canonicalKeyOverride = canonicalKey) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt index ddfc23591..01f03e8b5 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt @@ -39,7 +39,8 @@ data class IdentifyItemPayload( resourceAttributes: Attributes, ldContext: LDObserveContext? = null, timestamp: Long = System.currentTimeMillis(), - sessionId: String? + sessionId: String?, + canonicalKeyOverride: String? = null ): IdentifyItemPayload { val attributes: MutableMap = mutableMapOf() @@ -65,7 +66,7 @@ data class IdentifyItemPayload( } } - val canonicalKey = ldContext?.fullyQualifiedKey ?: "unknown" + val canonicalKey = canonicalKeyOverride ?: ldContext?.fullyQualifiedKey ?: "unknown" attributes["key"] = contextFriendlyName ?: canonicalKey attributes["canonicalKey"] = canonicalKey From 0761bc60551a4827e5cfc0c88c463ea7649e981b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 15 Apr 2026 10:05:41 -0700 Subject: [PATCH 12/23] rename package --- .../androidobservability/BaseApplication.kt | 2 +- .../example/LDObserve/ObservabilityBridge.kt | 2 +- .../observability/api/ObservabilityOptions.kt | 4 ++-- .../observability/client/DebugLogExporter.kt | 2 +- .../client/DebugMetricExporter.kt | 2 +- .../observability/client/DebugSpanExporter.kt | 2 +- .../client/ObservabilityContext.kt | 2 +- .../client/ObservabilityService.kt | 2 +- .../{devlog => context}/LDObserveContext.kt | 2 +- .../{devlog => context}/LDObserveLogging.kt | 2 +- .../{devlog => context}/ObserveLogAdapter.kt | 2 +- .../{devlog => context}/ObserveLogger.kt | 2 +- .../observability/devlog/LDContextCompat.kt | 23 ------------------- .../observability/network/GraphQLClient.kt | 2 +- .../observability/plugin/Observability.kt | 2 +- .../replay/SessionReplayService.kt | 4 ++-- .../replay/capture/CaptureManager.kt | 2 +- .../replay/capture/ImageCaptureService.kt | 2 +- .../replay/capture/WindowInspector.kt | 2 +- .../replay/exporter/IdentifyItemPayload.kt | 2 +- .../replay/exporter/SessionReplayExporter.kt | 2 +- .../replay/masking/ComposeMaskTarget.kt | 2 +- .../replay/masking/MaskCollector.kt | 2 +- .../replay/transport/BatchWorker.kt | 2 +- .../observability/sdk/LDObserve.kt | 4 ++-- .../client/ObservabilityServiceTest.kt | 2 +- .../observability/replay/SessionReplayTest.kt | 2 +- .../replay/transport/BatchWorkerTest.kt | 2 +- 28 files changed, 30 insertions(+), 53 deletions(-) rename sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/{devlog => context}/LDObserveContext.kt (98%) rename sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/{devlog => context}/LDObserveLogging.kt (94%) rename sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/{devlog => context}/ObserveLogAdapter.kt (93%) rename sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/{devlog => context}/ObserveLogger.kt (97%) delete mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.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 55e6797c1..047ca4412 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 @@ -9,7 +9,7 @@ import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.observability.replay.view -import com.launchdarkly.observability.devlog.LDObserveContext +import com.launchdarkly.observability.context.LDObserveContext import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.sdk.ContextKind diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt index b1a9286ad..ea2de5156 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt @@ -8,7 +8,7 @@ import com.launchdarkly.observability.bridge.AttributeConverter import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay -import com.launchdarkly.observability.devlog.LDObserveContext +import com.launchdarkly.observability.context.LDObserveContext import io.opentelemetry.api.common.Attributes public class ObservabilityBridge( diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt index 3490cae64..ac7a84b9e 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt @@ -2,8 +2,8 @@ package com.launchdarkly.observability.api import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.client.TelemetryInspector -import com.launchdarkly.observability.devlog.LDObserveLogging -import com.launchdarkly.observability.devlog.ObserveLogAdapter +import com.launchdarkly.observability.context.LDObserveLogging +import com.launchdarkly.observability.context.ObserveLogAdapter import io.opentelemetry.api.common.Attributes import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt index 6b31bcae7..2bc2ab90a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.client -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.logs.data.LogRecordData import io.opentelemetry.sdk.logs.export.LogRecordExporter diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt index 3174b3f80..7747d1799 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugMetricExporter.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.client -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.metrics.InstrumentType import io.opentelemetry.sdk.metrics.data.AggregationTemporality diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt index ad67daa2b..eed4b7525 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugSpanExporter.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.client -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.sdk.trace.export.SpanExporter diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt index a2f64f20b..45eba71a1 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt @@ -2,7 +2,7 @@ package com.launchdarkly.observability.client import android.app.Application import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import io.opentelemetry.android.session.SessionManager import io.opentelemetry.api.common.Attributes 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 baf524c83..f7fcc8a1c 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 @@ -1,7 +1,7 @@ package com.launchdarkly.observability.client import android.app.Application -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.bridge.emitLog import com.launchdarkly.observability.coroutines.DispatcherProviderHolder diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt similarity index 98% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt index bc52e21b8..54f294c7b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveContext.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt @@ -1,4 +1,4 @@ -package com.launchdarkly.observability.devlog +package com.launchdarkly.observability.context /** * Lightweight context representation for the Observability SDK, independent of diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveLogging.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveLogging.kt similarity index 94% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveLogging.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveLogging.kt index 152105487..74cc27243 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDObserveLogging.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveLogging.kt @@ -1,4 +1,4 @@ -package com.launchdarkly.observability.devlog +package com.launchdarkly.observability.context import android.util.Log diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogAdapter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogAdapter.kt similarity index 93% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogAdapter.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogAdapter.kt index 2212ec590..0942d24f4 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogAdapter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogAdapter.kt @@ -1,4 +1,4 @@ -package com.launchdarkly.observability.devlog +package com.launchdarkly.observability.context /** * Logging adapter interface for the Observability SDK. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogger.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogger.kt similarity index 97% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogger.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogger.kt index 59c8b93e3..4d7f35e30 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/ObserveLogger.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogger.kt @@ -1,4 +1,4 @@ -package com.launchdarkly.observability.devlog +package com.launchdarkly.observability.context /** * Internal logger used throughout the Observability SDK. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt deleted file mode 100644 index ba6d068d7..000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/devlog/LDContextCompat.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.observability.devlog - -import com.launchdarkly.sdk.LDContext - -/** - * Converts an [LDContext] to an [LDObserveContext] for use in the Observability SDK. - * - * Use this at integration boundaries where code receives an [LDContext] from the - * LaunchDarkly Client SDK and needs to pass it into observability/session-replay APIs. - */ -fun LDContext.toLDObserveContext(): LDObserveContext { - if (isMultiple) { - val subs = (0 until individualContextCount).map { i -> - getIndividualContext(i).toLDObserveContext() - } - return LDObserveContext.createMulti(*subs.toTypedArray()) - } - val builder = LDObserveContext.builder(kind.toString(), key) - .anonymous(isAnonymous) - val n = getName() - if (n != null) builder.name(n) - return builder.build() -} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt index 71d117c0d..c3d1c0a96 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.network -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer 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 87c9382b5..e726268c2 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 @@ -1,7 +1,7 @@ package com.launchdarkly.observability.plugin import android.app.Application -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityService 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 6164b9b77..0d8110cff 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 @@ -4,7 +4,7 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.replay.capture.CaptureManager @@ -14,7 +14,7 @@ import com.launchdarkly.observability.replay.exporter.InteractionItemPayload import com.launchdarkly.observability.replay.exporter.SessionReplayExporter import com.launchdarkly.observability.replay.transport.BatchWorker import com.launchdarkly.observability.replay.transport.EventQueue -import com.launchdarkly.observability.devlog.LDObserveContext +import com.launchdarkly.observability.context.LDObserveContext import com.launchdarkly.observability.sdk.SessionReplayServicing import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.CoroutineScope diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt index 3da567486..55717059f 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.replay.capture -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.replay.ReplayOptions import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt index 8d6e1b2e8..4017ba5ac 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/ImageCaptureService.kt @@ -15,7 +15,7 @@ import android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION import androidx.annotation.RequiresApi import androidx.core.graphics.createBitmap import androidx.core.graphics.withTranslation -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.calculateScaleFactor diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt index 0f945b7cf..e85809ce8 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt @@ -9,7 +9,7 @@ import android.os.Build import android.view.View import android.view.Window import android.view.WindowManager -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.replay.utils.locationOnScreen import kotlin.jvm.javaClass diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt index 01f03e8b5..92a47ff30 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.replay.exporter -import com.launchdarkly.observability.devlog.LDObserveContext +import com.launchdarkly.observability.context.LDObserveContext import com.launchdarkly.observability.replay.transport.EventExporting import com.launchdarkly.observability.replay.transport.EventQueueItemPayload import io.opentelemetry.api.common.AttributeKey diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt index d9bb76b74..79659113a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.replay.exporter -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.network.GraphQLClient import com.launchdarkly.observability.replay.Event import com.launchdarkly.observability.replay.capture.ExportFrame diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt index 43247f55f..c2deabfd7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.semantics.getOrNull import com.launchdarkly.observability.api.LdMaskSemanticsKey import androidx.compose.ui.geometry.Rect as MaskRect import androidx.core.view.isNotEmpty -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger /** * Compose target with a non-null [SemanticsConfiguration]. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt index fa1647bd6..45816d9dc 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt @@ -3,7 +3,7 @@ package com.launchdarkly.observability.replay.masking import android.graphics.Matrix import android.view.View import android.view.ViewGroup -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import kotlin.collections.plusAssign import com.launchdarkly.observability.replay.utils.locationOnScreen diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt index 5d6e85270..99d63f4d9 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt @@ -1,7 +1,7 @@ package com.launchdarkly.observability.replay.transport import android.os.SystemClock -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProvider import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import kotlinx.coroutines.CoroutineScope 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 834b75a46..d8b5f481b 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 @@ -3,8 +3,8 @@ package com.launchdarkly.observability.sdk import android.app.Application import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.devlog.LDObserveContext -import com.launchdarkly.observability.devlog.ObserveLogger +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 diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt index 3129d262d..7beee78f3 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.client -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.sampling.ExportSampler import io.mockk.mockk 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 ab875dd44..38653938c 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 @@ -1,6 +1,6 @@ package com.launchdarkly.observability.replay -import com.launchdarkly.observability.devlog.ObserveLogger +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 diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt index e5a403b70..a19b60569 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/transport/BatchWorkerTest.kt @@ -1,7 +1,7 @@ package com.launchdarkly.observability.replay.transport import android.os.SystemClock -import com.launchdarkly.observability.devlog.ObserveLogger +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProvider import io.mockk.every import io.mockk.mockk From c55df39d73f7ac00bc055ed21e9ca3e5196298d8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 16 Apr 2026 13:38:37 -0700 Subject: [PATCH 13/23] Update fullyQualifiedKey to use fullyQualifiedKey from contexts for improved clarity --- .../com/launchdarkly/observability/context/LDObserveContext.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt index 54f294c7b..6a7befda9 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt @@ -44,7 +44,7 @@ class LDObserveContext private constructor( val fullyQualifiedKey: String get() = when { isMultiple -> contexts!!.sortedBy { it.kind } - .joinToString(":") { "${it.kind}:${it.key}" } + .joinToString(":") { it.fullyQualifiedKey } kind == DEFAULT_KIND -> key else -> "$kind:$key" } From 13920ef057d6cee0eaf6fa705003469e15d8e8cd Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 16 Apr 2026 14:20:23 -0700 Subject: [PATCH 14/23] address feadback --- .../androidobservability/BaseApplication.kt | 62 +++++++++---------- .../android/native/LDObserve/build.gradle.kts | 5 -- .../observability/Directory.Build.props | 2 +- .../replay/plugin/SessionReplayImpl.kt | 15 ++--- 4 files changed, 39 insertions(+), 45 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 047ca4412..8b2668aad 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,44 +58,12 @@ open class BaseApplication : Application() { var testUrl: String? = null open fun realInit() { - val effectiveOptions = testUrl?.let { - observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) - } ?: observabilityOptions - - val context = LDObserveContext.builder(LDObserveContext.DEFAULT_KIND, "example-user-key") - .anonymous(true) - .build() - - LDObserve.init( - application = this@BaseApplication, - mobileKey = LAUNCHDARKLY_MOBILE_KEY, - ldContext = context, - options = effectiveOptions, - replayOptions = ReplayOptions( - enabled = true, - privacyProfile = PrivacyProfile( - maskText = false, - maskWebViews = true, - maskViews = listOf( - view(ImageView::class.java), - ), - maskXMLViewIds = listOf("smoothieTitle") - ) - ) - ) - - //LDReplay.start() - } - - open fun realFlagInit() { val observabilityPlugin = Observability( application = this@BaseApplication, mobileKey = LAUNCHDARKLY_MOBILE_KEY, options = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions ) - - // Set LAUNCHDARKLY_MOBILE_KEY to your LaunchDarkly mobile key found on the LaunchDarkly // dashboard in the start guide. // If you want to disable the Auto EnvironmentAttributes functionality. @@ -128,6 +96,36 @@ open class BaseApplication : Application() { LDReplay.start() } + open fun realIndependentInit() { + val effectiveOptions = testUrl?.let { + observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) + } ?: observabilityOptions + + val context = LDObserveContext.builder(LDObserveContext.DEFAULT_KIND, "example-user-key") + .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") + ) + ) + ) + + LDReplay.start() + } + fun flagEvaluation() { val flagKey = "feature1" val value = LDClient.get().boolVariation(flagKey, false) diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts index 89b64ed90..cb75f8c64 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts @@ -40,11 +40,6 @@ dependencies { implementation("androidx.core:core-ktx:1.15.0") "copyDependencies"("androidx.core:core-ktx:1.15.0") - // Copy dependencies for binding library - // Uncomment line below and replace dependency.name.goes.here with your dependency - // implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") - // "copyDependencies"("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") - // Intentionally use a non-existent version so this dependency MUST be // satisfied by composite-build substitution (settings.gradle.kts). // If substitution breaks, the build should fail instead of silently diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index c415340a1..b74721f84 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.2 + 0.9.3 false LaunchDarkly LaunchDarkly 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 index d8eb76d94..be380181d 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/SessionReplayImpl.kt @@ -19,14 +19,12 @@ class SessionReplayImpl( var sessionReplayService: SessionReplayService? = null fun register() { - val context = LDObserve.context ?: run { - logger.warning("Observability is not initialized; skipping SessionReplay registration.") - return + val context = checkNotNull(LDObserve.context) { + "Observability is not initialized; cannot register SessionReplay." } - if (LDReplay.client != null) { - logger.warning("Session Replay instance already exists; skipping.") - return + check(LDReplay.client == null) { + "Session Replay instance already exists; cannot register again." } val service = SessionReplayService(options, context) @@ -35,7 +33,10 @@ class SessionReplayImpl( } fun initialize() { - sessionReplayService?.initialize() + val service = checkNotNull(sessionReplayService) { + "SessionReplayService is not registered; call register() before initialize()." + } + service.initialize() } companion object { From 7cea36a8a1f8af5b21918a4845096bee3c128c44 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 16 Apr 2026 15:20:53 -0700 Subject: [PATCH 15/23] matching to java --- .../observability/context/LDObserveContext.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt index 6a7befda9..5b8530a22 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt @@ -38,15 +38,18 @@ class LDObserveContext private constructor( /** * Returns the fully qualified key, matching `LDContext` semantics: * - Single "user" context: just [key] - * - Single non-"user" context: `kind:key` - * - Multi context: sub-context keys sorted by kind, joined as `kind:key` + * - Single non-"user" context: `kind:escapedKey` + * - Multi context: sub-context keys sorted by kind, joined as `kind:escapedKey` + * + * Keys containing `:` or `%` are percent-escaped to avoid ambiguity in the + * composite key format, matching `LDContext.getFullyQualifiedKey()` in the Java SDK. */ val fullyQualifiedKey: String get() = when { isMultiple -> contexts!!.sortedBy { it.kind } - .joinToString(":") { it.fullyQualifiedKey } + .joinToString(":") { "${it.kind}:${escapeKey(it.key)}" } kind == DEFAULT_KIND -> key - else -> "$kind:$key" + else -> "$kind:${escapeKey(key)}" } override fun toString(): String = @@ -56,6 +59,9 @@ class LDObserveContext private constructor( companion object { const val DEFAULT_KIND = "user" + private fun escapeKey(key: String): String = + key.replace("%", "%25").replace(":", "%3A") + fun create(kind: String, key: String): LDObserveContext = LDObserveContext(kind = kind, key = key) From dae4510bdb916f1bc0a94b0258198482517f453d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 16 Apr 2026 15:22:08 -0700 Subject: [PATCH 16/23] remove unused --- .../observability/replay/plugin/SessionReplayImpl.kt | 2 -- 1 file changed, 2 deletions(-) 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 index be380181d..b4ce00f21 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/SessionReplayImpl.kt @@ -4,7 +4,6 @@ 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. @@ -41,6 +40,5 @@ class SessionReplayImpl( companion object { const val PLUGIN_NAME = "@launchdarkly/session-replay-android" - private val logger = Logger.getLogger("SessionReplay") } } From 216c87220cd654b9a201291775ed50c5eef06c0a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 16 Apr 2026 15:29:54 -0700 Subject: [PATCH 17/23] update --- .../observability/sdk/LDObserve.kt | 8 +++--- .../observability/replay/SessionReplayTest.kt | 26 +++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) 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 d8b5f481b..6e899971b 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 @@ -153,11 +153,9 @@ class LDObserve(private val client: Observe) : Observe { sessionReplayPlugin = plugin plugin.register() plugin.sessionReplayService?.initialize() - if (replayOptions.enabled) { - plugin.sessionReplayService?.let { replayService -> - CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { - replayService.identifySession(ldContext) - } + 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 38653938c..e821c9a51 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 @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows class SessionReplayTest { @@ -48,12 +49,33 @@ class SessionReplayTest { } @Test - fun `register does nothing when observability is not initialized`() { + fun `register throws when observability is not initialized`() { val sessionReplay = SessionReplayImpl() - sessionReplay.register() + + assertThrows { + sessionReplay.register() + } assertNull(sessionReplay.sessionReplayService) assertNull(LDReplay.client) } + @Test + fun `register throws 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() + + assertThrows { + sessionReplay.register() + } + + assertNull(sessionReplay.sessionReplayService) + } + } From 3e1dcc2fbc570ff7b2875880c6b3fba86913b1be Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 16 Apr 2026 15:34:20 -0700 Subject: [PATCH 18/23] Refactor SessionReplay registration logic to avoid exceptions when observability is not initialized or when a session replay already exists, adding logging for clarity. --- .../replay/plugin/SessionReplayImpl.kt | 12 ++++++++---- .../observability/replay/SessionReplayTest.kt | 14 ++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) 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 index b4ce00f21..5d9d82f03 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/SessionReplayImpl.kt @@ -4,6 +4,7 @@ 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. @@ -18,12 +19,14 @@ class SessionReplayImpl( var sessionReplayService: SessionReplayService? = null fun register() { - val context = checkNotNull(LDObserve.context) { - "Observability is not initialized; cannot register SessionReplay." + val context = LDObserve.context ?: run { + logger.warning("Observability is not initialized; skipping SessionReplay registration.") + return } - check(LDReplay.client == null) { - "Session Replay instance already exists; cannot register again." + if (LDReplay.client != null) { + logger.warning("Session Replay instance already exists; skipping.") + return } val service = SessionReplayService(options, context) @@ -40,5 +43,6 @@ class SessionReplayImpl( 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/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt index e821c9a51..7b573ca86 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 @@ -14,7 +14,6 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows class SessionReplayTest { @@ -49,19 +48,16 @@ class SessionReplayTest { } @Test - fun `register throws when observability is not initialized`() { + fun `register does nothing when observability is not initialized`() { val sessionReplay = SessionReplayImpl() - - assertThrows { - sessionReplay.register() - } + sessionReplay.register() assertNull(sessionReplay.sessionReplayService) assertNull(LDReplay.client) } @Test - fun `register throws when session replay already exists`() { + fun `register does nothing when session replay already exists`() { LDObserve.context = ObservabilityContext( sdkKey = "test-sdk-key", options = ObservabilityOptions(), @@ -71,9 +67,7 @@ class SessionReplayTest { LDReplay.client = mockk(relaxed = true) val sessionReplay = SessionReplayImpl() - assertThrows { - sessionReplay.register() - } + sessionReplay.register() assertNull(sessionReplay.sessionReplayService) } From 8d7564cec2c4fa485f4e1766f60fc008e74e753f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 17 Apr 2026 11:27:13 -0700 Subject: [PATCH 19/23] clean comment out code --- .../mobile-dotnet/observability/NativeAndroidDeps.props | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props index 26912570b..4a2c82f26 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props @@ -24,7 +24,6 @@ <_LDNativeAAR Include="$(_LDNativeDepsDir)common-0*.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)common-api-*.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)core-*-alpha.aar" /> - <_LDNativeAAR Include="$(_LDNativeDepsDir)anr-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)activity-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)fragment-*-alpha.aar" /> From 032938f356ebb02736ccb4c22899c9f2e9a9bf42 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 17 Apr 2026 12:32:16 -0700 Subject: [PATCH 20/23] silence otlp connection exceptions in e2e tests --- e2e/android/app/build.gradle.kts | 7 +++++++ .../app/src/test/resources/logging.properties | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 e2e/android/app/src/test/resources/logging.properties diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index 0aba5dfa8..50a86af99 100644 --- a/e2e/android/app/build.gradle.kts +++ b/e2e/android/app/build.gradle.kts @@ -84,6 +84,13 @@ android { } } +tasks.withType().configureEach { + systemProperty( + "java.util.logging.config.file", + project.file("src/test/resources/logging.properties").absolutePath + ) +} + dependencies { // Uncomment to use the local project implementation(project(":observability-android")) diff --git a/e2e/android/app/src/test/resources/logging.properties b/e2e/android/app/src/test/resources/logging.properties new file mode 100644 index 000000000..566888f81 --- /dev/null +++ b/e2e/android/app/src/test/resources/logging.properties @@ -0,0 +1,19 @@ +# JUL configuration for unit tests. +# +# The OTLP HTTP exporter logs SEVERE stack traces whenever it can't reach its +# endpoint. In tests the exporter is pointed at a per-test MockWebServer that +# only has the sampling-config response enqueued, so every span/log/metric +# export fails with either "unexpected end of stream" (no response in queue) +# or "Connection refused" (previous test's server already shut down). These +# failures are irrelevant because assertions read telemetry from +# InMemoryTelemetryInspector, not HTTP. Silence those loggers to keep test +# output readable. + +handlers = java.util.logging.ConsoleHandler +.level = INFO + +java.util.logging.ConsoleHandler.level = INFO +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter + +io.opentelemetry.exporter.internal.http.HttpExporter.level = OFF +io.opentelemetry.exporter.internal.grpc.GrpcExporter.level = OFF From ed2e57097f65dfa9b35f62202c090d98fad7a124 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 17 Apr 2026 13:32:23 -0700 Subject: [PATCH 21/23] fix unit tests --- .../DisablingConfigOptionsE2ETest.kt | 29 ++++++++++++++----- .../androidobservability/SamplingE2ETest.kt | 6 ++++ .../androidobservability/TestApplication.kt | 24 ++++++++++++++- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt b/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt index df4a8eb72..162b95a44 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt @@ -14,6 +14,7 @@ import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue +import org.junit.After import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -27,6 +28,11 @@ class DisablingConfigOptionsE2ETest { private val application = ApplicationProvider.getApplicationContext() as TestApplication + @After + fun tearDown() { + application.tearDownTest() + } + @Test fun `Logs should NOT be exported when logsApiLevel is NONE`() { application.observabilityOptions = getOptionsAllEnabled().copy(logsApiLevel = ObservabilityOptions.LogLevel.NONE) @@ -46,15 +52,16 @@ class DisablingConfigOptionsE2ETest { fun `Logs should NOT be exported when log severity is lower than logsApiLevel`() { application.observabilityOptions = getOptionsAllEnabled().copy(logsApiLevel = ObservabilityOptions.LogLevel.INFO) application.initForTest() - val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs" triggerTestLog(severity = Severity.TRACE) LDObserve.flush() + // With logsApiLevel = INFO the SDK still emits its own INFO "LD.identify" log during init, + // so we can't assert on "no logs at all" or "no /v1/logs request". The intent is that the + // TRACE test-log specifically is filtered out. waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS) val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems - assertTrue(logsExported?.isEmpty() == true) - assertFalse(requestsContainsUrl(logsUrl)) + assertFalse(logsExported.orEmpty().any { it.bodyValue?.value.toString() == TEST_LOG_MESSAGE }) } @Test @@ -195,15 +202,18 @@ class DisablingConfigOptionsE2ETest { instrumentations = ObservabilityOptions.Instrumentations(crashReporting = false) ) application.initForTest() - val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs" Thread { throw RuntimeException("Exception for testing") }.start() waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS) val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems - assertFalse(requestsContainsUrl(logsUrl)) - assertEquals(0, logsExported?.size) + // logsApiLevel is independent of crash reporting: unrelated SDK logs (e.g. LD.identify) + // may still be exported. We only assert that no crash log reached the exporter. + val crashLogs = logsExported.orEmpty().filter { + it.instrumentationScopeInfo.name == CRASH_INSTRUMENTATION_SCOPE + } + assertEquals(0, crashLogs.size) } @Test @@ -238,7 +248,7 @@ class DisablingConfigOptionsE2ETest { private fun triggerTestLog(severity: Severity = Severity.INFO) { LDObserve.recordLog( - message = "test-log", + message = TEST_LOG_MESSAGE, severity = severity ) } @@ -275,4 +285,9 @@ class DisablingConfigOptionsE2ETest { ) ) } + + companion object { + private const val TEST_LOG_MESSAGE = "test-log" + private const val CRASH_INSTRUMENTATION_SCOPE = "io.opentelemetry.crash" + } } diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt b/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt index acfc3fc81..b0a1ea143 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt @@ -12,6 +12,7 @@ import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity import junit.framework.TestCase.assertEquals import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -55,6 +56,11 @@ class SamplingE2ETest { telemetryInspector = application.telemetryInspector } + @After + fun tearDown() { + application.tearDownTest() + } + @Test fun `should avoid exporting logs matching sampling configuration for logs`() = runTest { triggerLogs() diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt b/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt index 48a0e1c8b..96446ed64 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt @@ -1,6 +1,7 @@ package com.example.androidobservability import com.launchdarkly.observability.testing.InMemoryTelemetryInspector +import com.launchdarkly.sdk.android.LDClient import io.opentelemetry.android.features.diskbuffering.SignalFromDiskExporter import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -48,12 +49,33 @@ class TestApplication : BaseApplication() { super.realInit() } - override fun onTerminate() { + /** + * Tears down per-test state so tests don't leak into each other. + * + * Robolectric isolates Android framework classes with a per-method sandbox classloader, + * but third-party JVM classes (including `com.launchdarkly.sdk.android.LDClient`) live + * in the shared system classloader. `LDClient.init()` silently returns the existing + * client if already initialized, which means a second test's `Observability` plugin is + * never registered and `LDObserve` keeps pointing at the previous test's + * `ObservabilityService` (with a now-dead mock server port). Closing `LDClient` resets + * its `instances` static so the next `init` fully reinitializes. + */ + fun tearDownTest() { + try { + LDClient.get().close() + } catch (_: Throwable) { + // LDClient may not have been initialized successfully; ignore. + } mockWebServer?.let { it.shutdown() mockWebServer = null } + telemetryInspector = null SignalFromDiskExporter.resetForTesting() + } + + override fun onTerminate() { + tearDownTest() super.onTerminate() } } From 898e1b71f22e74d2747120cc69e32321ec36a48f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 17 Apr 2026 15:05:35 -0700 Subject: [PATCH 22/23] address feedback --- .../java/com/example/androidobservability/BaseApplication.kt | 2 ++ 1 file changed, 2 insertions(+) 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 8b2668aad..9430a1642 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 @@ -57,6 +57,7 @@ open class BaseApplication : Application() { var testUrl: String? = null + // example on creating OBS/SR with flagging sdk open fun realInit() { val observabilityPlugin = Observability( application = this@BaseApplication, @@ -96,6 +97,7 @@ open class BaseApplication : Application() { LDReplay.start() } + // example on creating OBS/SR without flagging open fun realIndependentInit() { val effectiveOptions = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) From 6ad6a882d70c63a9d626c4707761bb8a3221ca97 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 17 Apr 2026 16:39:16 -0700 Subject: [PATCH 23/23] fix --- .../androidobservability/SamplingE2ETest.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt b/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt index b0a1ea143..be43ffddd 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt @@ -64,12 +64,16 @@ class SamplingE2ETest { @Test fun `should avoid exporting logs matching sampling configuration for logs`() = runTest { triggerLogs() - telemetryInspector?.logExporter?.flush() + // Force the BatchLogRecordProcessor to drain everything to the exporter so the + // assertion doesn't race with the batch scheduler. + LDObserve.flush() waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS) - val logsExported = telemetryInspector?.logExporter?.finishedLogRecordItems?.map { - it.bodyValue?.value.toString() - } + val logsExported = telemetryInspector?.logExporter?.finishedLogRecordItems + ?.map { it.bodyValue?.value.toString() } + // The SDK emits an unrelated "LD.identify" INFO log during init; filter it out so + // this test only observes the logs triggered by triggerLogs(). + ?.filter { it != LD_IDENTIFY_LOG_BODY } // Only first and final logs should be exported assertEquals(2, logsExported?.size) @@ -80,7 +84,7 @@ class SamplingE2ETest { @Test fun `should avoid exporting spans matching sampling configuration for spans`() = runTest { triggerSpans() - telemetryInspector?.spanExporter?.flush() + LDObserve.flush() waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.SPANS) val spansExported = telemetryInspector?.spanExporter?.finishedSpanItems?.map { @@ -225,4 +229,8 @@ class SamplingE2ETest { ) ) } + + companion object { + private const val LD_IDENTIFY_LOG_BODY = "LD.identify" + } }