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 d6dd6ce93b..9430a1642c 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.context.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.LDAndroidLogging +import com.launchdarkly.sdk.android.FeatureFlagChangeListener 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() { @@ -38,11 +39,25 @@ open class BaseApplication : Application() { instrumentations = ObservabilityOptions.Instrumentations( crashReporting = true, launchTime = true, activityLifecycle = true ), - 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 + // example on creating OBS/SR with flagging sdk open fun realInit() { val observabilityPlugin = Observability( application = this@BaseApplication, @@ -50,20 +65,6 @@ open class BaseApplication : Application() { options = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions ) - val sessionReplayPlugin = SessionReplay( - options = ReplayOptions( - enabled = false, - privacyProfile = PrivacyProfile( - maskText = false, - maskWebViews = true, - maskViews = listOf( - view(ImageView::class.java), - ), - maskXMLViewIds = listOf("smoothieTitle") - ) - ) - ) - // 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. @@ -96,6 +97,37 @@ 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) + } ?: 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/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt b/e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt index df4a8eb72c..162b95a440 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 acfc3fc819..be43ffddd6 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,15 +56,24 @@ class SamplingE2ETest { telemetryInspector = application.telemetryInspector } + @After + fun tearDown() { + application.tearDownTest() + } + @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) @@ -74,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 { @@ -219,4 +229,8 @@ class SamplingE2ETest { ) ) } + + companion object { + private const val LD_IDENTIFY_LOG_BODY = "LD.identify" + } } 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 dc8e806090..96446ed64c 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 @@ -49,13 +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 } - LDClient.get().close() + telemetryInspector = null SignalFromDiskExporter.resetForTesting() + } + + override fun onTerminate() { + tearDownTest() super.onTerminate() } } 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 6dd93f25b1..cb75f8c64f 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/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 bc5bb939b8..ea2de5156b 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,12 @@ 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 +import com.launchdarkly.observability.context.LDObserveContext +import io.opentelemetry.api.common.Attributes public class ObservabilityBridge( private val logger: BridgeLogger = SystemOutBridgeLogger() @@ -63,10 +56,15 @@ 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) + 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 @@ -86,30 +84,13 @@ 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) 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,53 +106,38 @@ 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 { - LDContext.builder(ContextKind.DEFAULT, "maui-user-key") + val ldContext = try { + 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 } 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 } } + 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() @@ -181,4 +147,3 @@ public class ObservabilityBridge( logger.error(writer.toString()) } } - diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index 0c60e34386..b74721f840 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.3 false LaunchDarkly LaunchDarkly diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props index 57f0c25379..4a2c82f26c 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)crash-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)anr-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)activity-*-alpha.aar" /> <_LDNativeAAR Include="$(_LDNativeDepsDir)fragment-*-alpha.aar" /> @@ -34,15 +33,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,8 +43,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" /> diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 5d49e8453f..9ce81cf48c 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -20,9 +20,15 @@ allprojects { } } +val isIncludedByMaui = gradle.parent?.rootProject?.name == "LDObserve" + dependencies { - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") - implementation("com.jakewharton.timber:timber:5.0.1") + if (isIncludedByMaui) { + compileOnly("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") + testImplementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.1") + } else { + implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.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 04dfeea988..ac7a84b9ed 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.context.LDObserveLogging +import com.launchdarkly.observability.context.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/client/DebugLogExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/DebugLogExporter.kt index c0668e268b..2bc2ab90aa 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.context.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 7cc3725ace..7747d17992 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.context.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 3de8eb5fa5..eed4b75253 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.context.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 e2d6cde756..45eba71a19 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.context.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 cc3b3ce77d..f7fcc8a1c7 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.context.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/context/LDObserveContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt new file mode 100644 index 0000000000..5b8530a224 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveContext.kt @@ -0,0 +1,94 @@ +package com.launchdarkly.observability.context + +/** + * 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, matching `LDContext` semantics: + * - Single "user" context: just [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.kind}:${escapeKey(it.key)}" } + kind == DEFAULT_KIND -> key + else -> "$kind:${escapeKey(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" + + private fun escapeKey(key: String): String = + key.replace("%", "%25").replace(":", "%3A") + + 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/context/LDObserveLogging.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveLogging.kt new file mode 100644 index 0000000000..74cc272437 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/LDObserveLogging.kt @@ -0,0 +1,32 @@ +package com.launchdarkly.observability.context + +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/context/ObserveLogAdapter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogAdapter.kt new file mode 100644 index 0000000000..0942d24f4b --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogAdapter.kt @@ -0,0 +1,32 @@ +package com.launchdarkly.observability.context + +/** + * 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/context/ObserveLogger.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogger.kt new file mode 100644 index 0000000000..4d7f35e30a --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/context/ObserveLogger.kt @@ -0,0 +1,47 @@ +package com.launchdarkly.observability.context + +/** + * 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 18a78020b9..c3d1c0a969 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.context.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 9debe76528..e726268c2a 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,9 +1,7 @@ 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.context.ObserveLogger import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityService @@ -60,14 +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 { - val actualLogAdapter = Logs.level(options.logAdapter, if (options.debug) LDLogLevel.DEBUG else DEFAULT_LOG_LEVEL) - logger = LDLogger.withAdapter(actualLogAdapter, options.loggerName) + logger = ObserveLogger.build(options.logAdapter, options.loggerName, options.debug) } override fun getMetadata(): PluginMetadata { @@ -150,7 +147,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 c63e80c5df..0018029602 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 @@ -5,7 +5,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.context.ObserveLogger import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.replay.capture.CaptureManager @@ -15,9 +15,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.context.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 @@ -65,7 +64,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 @@ -279,8 +278,9 @@ class SessionReplayService( } suspend fun identifySession( - ldContext: LDContext, - timestamp: Long = System.currentTimeMillis() + ldContext: LDObserveContext, + timestamp: Long = System.currentTimeMillis(), + canonicalKeyOverride: String? = null ) { if (!this::sessionManager.isInitialized || exporter == null) { logger.warn("identifySession called before SessionReplayService was installed; skipping.") @@ -293,7 +293,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. @@ -316,22 +317,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, canonicalKeyOverride = canonicalKey) } } - 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/capture/CaptureManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureManager.kt index 22442bc52d..55717059f9 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.context.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 d089d945ca..4017ba5acc 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.context.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 86c5518a06..e85809ce8f 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.context.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/IdentifyItemPayload.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/IdentifyItemPayload.kt index 65145dc605..92a47ff304 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.context.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,36 +37,36 @@ data class IdentifyItemPayload( fun from( contextFriendlyName: String? = null, resourceAttributes: Attributes, - ldContext: LDContext? = null, + ldContext: LDObserveContext? = null, timestamp: Long = System.currentTimeMillis(), - sessionId: String? + sessionId: String?, + canonicalKeyOverride: String? = null ): IdentifyItemPayload { val attributes: MutableMap = mutableMapOf() 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 } } } - val canonicalKey = ldContext?.fullyQualifiedKey ?: "unknown" + val canonicalKey = canonicalKeyOverride ?: ldContext?.fullyQualifiedKey ?: "unknown" attributes["key"] = contextFriendlyName ?: canonicalKey attributes["canonicalKey"] = canonicalKey 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 972c4bbe4f..79659113a0 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.context.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 91cd36d6db..c2deabfd76 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.context.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 c15a9f6b39..45816d9dce 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.context.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 74188dbeea..1989e33311 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,55 +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?) { - 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 { @@ -58,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 0000000000..5d9d82f037 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayImpl.kt @@ -0,0 +1,48 @@ +package com.launchdarkly.observability.replay.plugin + +import com.launchdarkly.observability.replay.ReplayOptions +import com.launchdarkly.observability.replay.SessionReplayService +import com.launchdarkly.observability.sdk.LDObserve +import com.launchdarkly.observability.sdk.LDReplay +import java.util.logging.Logger + +/** + * Standalone Session Replay entry point. + * + * Use this directly via [LDObserve.init] for the standalone path (no LDClient). + * For the LDClient plugin path, use [SessionReplay] instead. + */ +class SessionReplayImpl( + private val options: ReplayOptions = ReplayOptions(), +) { + @Volatile + var sessionReplayService: SessionReplayService? = null + + fun register() { + val context = LDObserve.context ?: run { + logger.warning("Observability is not initialized; skipping SessionReplay registration.") + return + } + + if (LDReplay.client != null) { + logger.warning("Session Replay instance already exists; skipping.") + return + } + + val service = SessionReplayService(options, context) + LDReplay.init(service) + sessionReplayService = service + } + + fun initialize() { + val service = checkNotNull(sessionReplayService) { + "SessionReplayService is not registered; call register() before initialize()." + } + service.initialize() + } + + companion object { + const val PLUGIN_NAME = "@launchdarkly/session-replay-android" + private val logger = Logger.getLogger("SessionReplay") + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/transport/BatchWorker.kt index 24d77e9221..99d63f4d97 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.context.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 25feffc647..6e899971b4 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,27 @@ package com.launchdarkly.observability.sdk +import android.app.Application +import com.launchdarkly.observability.BuildConfig +import com.launchdarkly.observability.api.ObservabilityOptions +import com.launchdarkly.observability.context.LDObserveContext +import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.bridge.AttributeConverter -import com.launchdarkly.observability.client.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.SessionReplayImpl +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 +105,85 @@ class LDObserve(private val client: Observe) : Observe { delegate = LDObserve(client) } + @Volatile + private var sessionReplayPlugin: SessionReplayImpl? = 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 [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. + */ + fun init( + application: Application, + mobileKey: String, + ldContext: LDObserveContext, + options: ObservabilityOptions = ObservabilityOptions(), + replayOptions: ReplayOptions? = null + ) { + val logger = ObserveLogger.build(options.logAdapter, options.loggerName, options.debug) + + 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 = SessionReplayImpl(replayOptions) + sessionReplayPlugin = plugin + plugin.register() + plugin.sessionReplayService?.initialize() + plugin.sessionReplayService?.let { replayService -> + CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { + replayService.identifySession(ldContext) + } + } + } + } + + private fun buildResource(sdkKey: String, options: ObservabilityOptions): Resource { + val attributes = Attributes.builder() + Resource.getDefault().attributes.forEach { key, value -> + if (key.key != "service.name") { + @Suppress("UNCHECKED_CAST") + attributes.put(key as AttributeKey, value) + } + } + attributes.put("highlight.project_id", sdkKey) + attributes.put( + AttributeKey.stringKey("telemetry.distro.name"), + SDK_NAME + ) + attributes.put( + AttributeKey.stringKey("telemetry.distro.version"), + BuildConfig.OBSERVABILITY_SDK_VERSION + ) + attributes.putAll(options.resourceAttributes) + return Resource.create(attributes.build()) + } + + private const val SDK_NAME = "launchdarkly-observability-android" + override fun recordMetric(metric: Metric) = delegate.recordMetric(metric) override fun recordCount(metric: Metric) = delegate.recordCount(metric) override fun recordIncr(metric: Metric) = delegate.recordIncr(metric) diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt index b76f5e4200..7beee78f3a 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.context.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 dee71c2a04..7b573ca86d 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,12 +1,11 @@ package com.launchdarkly.observability.replay -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.context.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 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 } @@ -40,11 +36,11 @@ 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(client, null) + sessionReplay.register() assertNotNull(sessionReplay.sessionReplayService) assertNotNull(LDReplay.client) @@ -53,11 +49,27 @@ class SessionReplayTest { @Test fun `register does nothing when observability is not initialized`() { - val sessionReplay = SessionReplay() - sessionReplay.register(client, null) + val sessionReplay = SessionReplayImpl() + sessionReplay.register() assertNull(sessionReplay.sessionReplayService) assertNull(LDReplay.client) } + @Test + fun `register does nothing when session replay already exists`() { + LDObserve.context = ObservabilityContext( + sdkKey = "test-sdk-key", + options = ObservabilityOptions(), + application = mockk(), + logger = mockk(relaxed = true), + ) + LDReplay.client = mockk(relaxed = true) + val sessionReplay = SessionReplayImpl() + + sessionReplay.register() + + 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 4a11526753..a19b605697 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.context.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)