From 23047d79e6528d9e59cc6e4d0abf344824d02d17 Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Fri, 5 Dec 2025 16:56:52 -0300 Subject: [PATCH 1/9] Add Session Replay plugin Introduces the Session Replay plugin for the LaunchDarkly Android SDK, allowing session recording as an extension to Observability. Refactors plugin and instrumentation management by adding PluginManager and InstrumentationContributor, removes direct instrumentation from Options, and updates ObservabilityClient and InstrumentationManager to support plugin-contributed instrumentations. Updates documentation and sample usage to reflect the new plugin architecture. Also bumps launchdarkly-android-client-sdk dependency to 5.9.2. --- e2e/android/app/build.gradle.kts | 2 +- .../androidobservability/BaseApplication.kt | 20 ++--- .../observability-android/README.md | 25 +++++++ .../lib/build.gradle.kts | 2 +- .../launchdarkly/observability/api/Options.kt | 4 - .../client/InstrumentationManager.kt | 22 ++++-- .../client/ObservabilityClient.kt | 27 ++++--- .../observability/client/PluginManager.kt | 64 ++++++++++++++++ .../plugin/InstrumentationContributor.kt | 10 +++ .../observability/plugin/Observability.kt | 73 ++++++++++++------- .../observability/replay/ReplayOptions.kt | 2 +- .../observability/replay/SessionReplay.kt | 44 +++++++++++ 12 files changed, 236 insertions(+), 59 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/PluginManager.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributor.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index b0fdbdac6..9a4a5467f 100644 --- a/e2e/android/app/build.gradle.kts +++ b/e2e/android/app/build.gradle.kts @@ -54,7 +54,7 @@ dependencies { // Uncomment to use the publicly released version (note this may be behind branch/main) // implementation("com.launchdarkly:launchdarkly-observability-android:0.2.0") - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.9.0") + implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.9.2") implementation("io.opentelemetry:opentelemetry-api:1.51.0") implementation("io.opentelemetry:opentelemetry-sdk:1.51.0") 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 b2e06473f..50d83f8bc 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 @@ -12,6 +12,7 @@ import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayInstrumentation import com.launchdarkly.observability.replay.ReplayOptions +import com.launchdarkly.observability.replay.SessionReplay import com.launchdarkly.sdk.android.LDAndroidLogging import com.launchdarkly.sdk.android.integrations.Plugin import io.opentelemetry.api.common.AttributeKey @@ -32,14 +33,6 @@ open class BaseApplication : Application() { ), debug = true, logAdapter = LDAndroidLogging.adapter(), - // TODO: consider these being factories so that the obs plugin can pass instantiation data, log adapter - instrumentations = listOf( - ReplayInstrumentation( - options = ReplayOptions( - privacyProfile = PrivacyProfile(maskText = false) - ) - ) - ), ) var telemetryInspector: TelemetryInspector? = null @@ -52,6 +45,12 @@ open class BaseApplication : Application() { options = testUrl?.let { pluginOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: pluginOptions ) + val replayPlugin = SessionReplay( + options = ReplayOptions( + privacyProfile = PrivacyProfile(maskText = false) + ) + ) + // 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. @@ -60,7 +59,10 @@ open class BaseApplication : Application() { .mobileKey(LAUNCHDARKLY_MOBILE_KEY) .plugins( Components.plugins().setPlugins( - Collections.singletonList(observabilityPlugin) + listOf( + observabilityPlugin, + replayPlugin + ) ) ) .build() diff --git a/sdk/@launchdarkly/observability-android/README.md b/sdk/@launchdarkly/observability-android/README.md index 492fffcda..8f228c17d 100644 --- a/sdk/@launchdarkly/observability-android/README.md +++ b/sdk/@launchdarkly/observability-android/README.md @@ -169,6 +169,31 @@ span.end() ### Session Replay +#### Enable Session Replay + +Add the Session Replay plugin **after** Observability when configuring the LaunchDarkly SDK: + +```kotlin +import com.launchdarkly.observability.plugin.Observability +import com.launchdarkly.observability.replay.SessionReplay + +val ldConfig = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled) + .mobileKey("your-mobile-key") + .plugins( + Components.plugins().setPlugins( + listOf( + Observability(this@MyApplication, "your-mobile-key"), + SessionReplay() // depends on Observability being present first + ) + ) + ) + .build() +``` + +Notes: +- SessionReplay depends on Observability. If Observability is missing or listed after SessionReplay, the plugin logs an error and stays inactive. +- Observability runs fine without SessionReplay; adding SessionReplay extends the Observability pipeline to include session recording. + #### Masking sensitive UI Use `ldMask()` to mark views that should be masked in session replay. There are helpers for both XML-based Views and Jetpack Compose. diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 53804b3bc..c7b9f78c7 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -20,7 +20,7 @@ allprojects { } dependencies { - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.9.0") + implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.9.2") implementation("com.jakewharton.timber:timber:5.0.1") // Android diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/Options.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/Options.kt index 8f19f400e..4c3693e00 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/Options.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/Options.kt @@ -2,7 +2,6 @@ package com.launchdarkly.observability.api import com.launchdarkly.logging.LDLogAdapter import com.launchdarkly.observability.BuildConfig -import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.sdk.android.LDTimberLogging import io.opentelemetry.api.common.Attributes import kotlin.time.Duration @@ -29,7 +28,6 @@ const val DEFAULT_BACKEND_URL = "https://pub.observability.app.launchdarkly.com" * @property disableMetrics Disables metrics if true. Defaults to false. * @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 loggerName The name of the logger to use. Defaults to "LaunchDarklyObservabilityPlugin". - * @property instrumentations List of additional instrumentations to use */ data class Options( val serviceName: String = DEFAULT_SERVICE_NAME, @@ -46,6 +44,4 @@ data class Options( val disableMetrics: Boolean = false, val logAdapter: LDLogAdapter = LDTimberLogging.adapter(), // this follows the LaunchDarkly SDK's default log adapter val loggerName: String = "LaunchDarklyObservabilityPlugin", - // TODO: update this to provide a list of factories instead of instances - val instrumentations: List = emptyList() ) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt index 89a19e7bc..c54f0291e 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt @@ -13,6 +13,7 @@ import com.launchdarkly.observability.sampling.SamplingLogProcessor import com.launchdarkly.observability.sampling.SamplingTraceExporter import com.launchdarkly.observability.sampling.SpansSampler import com.launchdarkly.observability.coroutines.DispatcherProviderHolder +import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import io.opentelemetry.android.OpenTelemetryRum import io.opentelemetry.android.config.OtelRumConfig import io.opentelemetry.android.features.diskbuffering.DiskBufferingConfig @@ -54,11 +55,17 @@ import java.util.concurrent.TimeUnit /** * Manages instrumentation for LaunchDarkly Observability. * + * This class is responsible for setting up and managing the OpenTelemetry RUM (Real User Monitoring) + * instrumentation. It configures the providers for logs, traces, and metrics based on the + * provided options. It also handles dynamic sampling configuration and provides methods to + * record various telemetry signals like metrics, logs, and traces. + * * @param application The application instance. - * @param sdkKey The SDK key. + * @param sdkKey The SDK key for authentication. * @param resources The OpenTelemetry resource describing this service. - * @param logger The logger. - * @param options Additional options. + * @param logger The logger for internal logging. + * @param options Additional configuration options for the SDK. + * @param instrumentations A list of custom instrumentations to be added. */ class InstrumentationManager( private val application: Application, @@ -66,6 +73,7 @@ class InstrumentationManager( private val resources: Resource, private val logger: LDLogger, private val options: Options, + private val instrumentations: List, ) { private val otelRUM: OpenTelemetryRum private var otelMeter: Meter @@ -104,7 +112,8 @@ class InstrumentationManager( resources, logger, telemetryInspector, - options + options, + instrumentations ) logProcessor = processor sdkLoggerProviderBuilder.addLogRecordProcessor(processor) @@ -125,7 +134,7 @@ class InstrumentationManager( } } - for (instrumentation in options.instrumentations) { + for (instrumentation in instrumentations) { rumBuilder.addInstrumentation(instrumentation) } @@ -412,6 +421,7 @@ class InstrumentationManager( logger: LDLogger, telemetryInspector: TelemetryInspector?, options: Options, + instrumentations: List, ): LogRecordProcessor { val primaryLogExporter = createOtlpLogExporter(options) sdkLoggerProviderBuilder.setResource(resource) @@ -442,7 +452,7 @@ class InstrumentationManager( pipeline to provide instrumentation specific caching and export. */ val routingLogRecordProcessor = RoutingLogRecordProcessor(fallthroughProcessor = baseProcessor) - options.instrumentations.forEach { instrumentation -> + instrumentations.forEach { instrumentation -> instrumentation.getLogRecordProcessor(credential = sdkKey)?.let { processor -> instrumentation.getLoggerScopeName().let { scopeName -> routingLogRecordProcessor.addProcessor(scopeName, processor) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityClient.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityClient.kt index 8b3aafd80..e906b51d5 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityClient.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityClient.kt @@ -3,6 +3,7 @@ package com.launchdarkly.observability.client import android.app.Application import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.Options +import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import io.opentelemetry.api.common.Attributes @@ -14,26 +15,34 @@ import io.opentelemetry.sdk.resources.Resource * The [ObservabilityClient] can be used for recording observability data such as * metrics, logs, errors, and traces. * - * It is recommended to use the [Observability] plugin with the LaunchDarkly Android - * Client SDK, as that will automatically initialize the [LDObserve] singleton instance. + * It is recommended to use the [com.launchdarkly.observability.plugin.Observability] plugin with the LaunchDarkly Android + * Client SDK, as that will automatically initialize the [com.launchdarkly.observability.sdk.LDObserve] singleton instance. * - * @param application The application instance. - * @param sdkKey The SDK key. - * @param resource The resource. - * @param logger The logger. - * @param options Additional options for the client. */ class ObservabilityClient : Observe { private val instrumentationManager: InstrumentationManager + /** + * Creates a new ObservabilityClient. + * + * @param application The application instance. + * @param sdkKey The SDK key for the environment. + * @param resource The resource. + * @param logger The logger. + * @param options Additional options for the client. + * @param instrumentations A list of extended instrumentation providers. + */ constructor( application: Application, sdkKey: String, resource: Resource, logger: LDLogger, - options: Options + options: Options, + instrumentations: List ) { - this.instrumentationManager = InstrumentationManager(application, sdkKey, resource, logger, options) + this.instrumentationManager = InstrumentationManager( + application, sdkKey, resource, logger, options, instrumentations + ) } internal constructor( diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/PluginManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/PluginManager.kt new file mode 100644 index 000000000..cab1b5a36 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/PluginManager.kt @@ -0,0 +1,64 @@ +package com.launchdarkly.observability.client + +import com.launchdarkly.observability.plugin.InstrumentationContributor +import com.launchdarkly.observability.plugin.Observability +import com.launchdarkly.sdk.android.LDClient +import com.launchdarkly.sdk.android.integrations.Plugin +import java.util.WeakHashMap + +/** + * Manages a collection of plugins associated with an [LDClient] instance. + * + * This object provides a central place to register and retrieve plugins for a given LDClient. + * It uses a [WeakHashMap] to store plugins, which allows the [LDClient] instances and + * associated plugins to be garbage collected when they are no longer in use. + */ +internal object PluginManager { + private val plugins = WeakHashMap>() + + /** + * Adds a [Plugin] to the list of plugins associated with the given [LDClient]. + * + * If no plugins have been added for the client before, a new list is created. + * + * @param client The [LDClient] to associate the plugin with. + * @param plugin The [Plugin] to add. + */ + fun add(client: LDClient, plugin: Plugin) { + synchronized(plugins) { + plugins.getOrPut(client) { mutableListOf() }.add(plugin) + } + } + + /** + * Retrieves the list of [Plugin]s associated with the given [LDClient]. + * + * The returned list is a snapshot and is safe to iterate over. + * + * @param client The [LDClient] to get the plugins for. + * @return A list of [Plugin]s, or null if no plugins are associated with the client. + */ + fun get(client: LDClient): List? = synchronized(plugins) { + plugins[client]?.toList() + } + + /** + * Retrieves the list of [InstrumentationContributor] plugins associated with the given [LDClient]. + * + * @param client The [LDClient] to get the instrumentations for. + * @return A list of [InstrumentationContributor]s, or null if no plugins are associated with the client. + */ + fun getInstrumentations(client: LDClient): List? = synchronized(plugins) { + plugins[client]?.filterIsInstance() + } + + /** + * Checks if the observability plugin has been initialized for the given [LDClient]. + * + * @param client The [LDClient] to check. + * @return True if the observability plugin is initialized, false otherwise. + */ + fun isObservabilityInitialized(client: LDClient): Boolean { + return get(client)?.any { it.metadata.name == Observability.PLUGIN_NAME } == true + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributor.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributor.kt new file mode 100644 index 000000000..370fc58d3 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributor.kt @@ -0,0 +1,10 @@ +package com.launchdarkly.observability.plugin +import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation + +/** + * Plugins can implement this to contribute OpenTelemetry instrumentations that should + * be installed by the Observability plugin. + */ +interface InstrumentationContributor { + fun provideInstrumentations(): List +} 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 158533602..37c649e0a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt @@ -4,11 +4,14 @@ 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.Options import com.launchdarkly.observability.client.ObservabilityClient +import com.launchdarkly.observability.client.PluginManager import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient +import com.launchdarkly.sdk.android.RegistrationCompleteResult import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata import com.launchdarkly.sdk.android.integrations.Hook import com.launchdarkly.sdk.android.integrations.Plugin @@ -53,6 +56,7 @@ class Observability( ) : Plugin() { private val logger: LDLogger private var observabilityClient: ObservabilityClient? = null + private var client: LDClient? = null init { val actualLogAdapter = Logs.level(options.logAdapter, if (options.debug) LDLogLevel.DEBUG else DEFAULT_LOG_LEVEL) @@ -61,53 +65,66 @@ class Observability( override fun getMetadata(): PluginMetadata { return object : PluginMetadata() { - override fun getName(): String = "@launchdarkly/observability-android" - - // Uncomment once metadata supports version -// override fun getVersion(): String = BuildConfig.OBSERVABILITY_SDK_VERSION + override fun getName(): String = PLUGIN_NAME + override fun getVersion(): String = BuildConfig.OBSERVABILITY_SDK_VERSION } } override fun register(client: LDClient, metadata: EnvironmentMetadata?) { + this.client = client + PluginManager.add(client, this) + } + + override fun getHooks(metadata: EnvironmentMetadata?): MutableList { + return Collections.singletonList( + TracingHook(withSpans = true, withValue = true) { observabilityClient?.getTracer() } + ) + } + + override fun registrationComplete(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { val sdkKey = metadata?.credential ?: "" - if (mobileKey == sdkKey) { - val resourceBuilder = Resource.getDefault().toBuilder() - resourceBuilder.put("service.name", options.serviceName) - resourceBuilder.put("service.version", options.serviceVersion) - resourceBuilder.put("highlight.project_id", sdkKey) - resourceBuilder.putAll(options.resourceAttributes) + client?.let { lDClient -> + if (mobileKey == sdkKey) { + val resourceBuilder = Resource.getDefault().toBuilder() + resourceBuilder.put("service.name", options.serviceName) + resourceBuilder.put("service.version", options.serviceVersion) + resourceBuilder.put("highlight.project_id", sdkKey) + resourceBuilder.putAll(options.resourceAttributes) - metadata?.applicationInfo?.applicationId?.let { - resourceBuilder.put("launchdarkly.application.id", it) - } + metadata?.applicationInfo?.applicationId?.let { + resourceBuilder.put("launchdarkly.application.id", it) + } - metadata?.applicationInfo?.applicationVersion?.let { - resourceBuilder.put("launchdarkly.application.version", it) - } + metadata?.applicationInfo?.applicationVersion?.let { + resourceBuilder.put("launchdarkly.application.version", it) + } - metadata?.sdkMetadata?.name?.let { sdkName -> - metadata.sdkMetadata?.version?.let { sdkVersion -> - resourceBuilder.put("launchdarkly.sdk.version", "$sdkName/$sdkVersion") + metadata?.sdkMetadata?.name?.let { sdkName -> + metadata.sdkMetadata?.version?.let { sdkVersion -> + resourceBuilder.put("launchdarkly.sdk.version", "$sdkName/$sdkVersion") + } } - } - observabilityClient = ObservabilityClient(application, sdkKey, resourceBuilder.build(), logger, options) - observabilityClient?.let { LDObserve.init(it) } + val instrumentations = PluginManager.getInstrumentations(lDClient)?.flatMap { it.provideInstrumentations() }.orEmpty() + observabilityClient = ObservabilityClient( + application, sdkKey, resourceBuilder.build(), logger, options, instrumentations + ) + observabilityClient?.let { + LDObserve.init(it) + } + } else { + logger.warn("Observability could not be initialized for sdkKey: $sdkKey") + } } } - override fun getHooks(metadata: EnvironmentMetadata?): MutableList { - return Collections.singletonList( - TracingHook(withSpans = true, withValue = true) { observabilityClient?.getTracer() } - ) - } - fun getTelemetryInspector(): TelemetryInspector? { return observabilityClient?.getTelemetryInspector() } companion object { val DEFAULT_LOG_LEVEL: LDLogLevel = LDLogLevel.INFO + const val PLUGIN_NAME = "@launchdarkly/observability-android" } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt index f044ee626..b8cbd74eb 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt @@ -4,7 +4,7 @@ import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.DEFAULT_BACKEND_URL /** - * Options for the [ReplayInstrumentation] + * Options for Session Replay plugin. * * @property backendUrl The backend URL for sending replay data. Defaults to LaunchDarkly url. * @property debug enables verbose logging if true as well as other debug functionality. Defaults to false. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt new file mode 100644 index 000000000..56fe66049 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt @@ -0,0 +1,44 @@ +package com.launchdarkly.observability.replay + +import android.util.Log +import com.launchdarkly.observability.BuildConfig +import com.launchdarkly.observability.client.PluginManager +import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation +import com.launchdarkly.observability.plugin.InstrumentationContributor +import com.launchdarkly.sdk.android.LDClient +import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata +import com.launchdarkly.sdk.android.integrations.Plugin +import com.launchdarkly.sdk.android.integrations.PluginMetadata + +/** + * Session Replay plugin for the LaunchDarkly Android SDK. + * + * This plugin depends on the Observability plugin being present and initialized first. + */ +class SessionReplay( + private val options: ReplayOptions = ReplayOptions(), +) : Plugin(), InstrumentationContributor { + + override fun getMetadata(): PluginMetadata { + return object : PluginMetadata() { + override fun getName(): String = PLUGIN_NAME + override fun getVersion(): String = BuildConfig.OBSERVABILITY_SDK_VERSION + } + } + + override fun register(client: LDClient, metadata: EnvironmentMetadata?) { + if (PluginManager.isObservabilityInitialized(client)) { + PluginManager.add(client, this) + } else { + Log.e("SessionReplay", "Observability plugin not initialized") + } + } + + override fun provideInstrumentations(): List { + return listOf(ReplayInstrumentation(options)) + } + + companion object { + const val PLUGIN_NAME = "@launchdarkly/session-replay-android" + } +} From 0f813271737a2da895697cb77d6dd55e96484c35 Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Fri, 5 Dec 2025 17:38:29 -0300 Subject: [PATCH 2/9] Move PluginManager to plugin package Renamed and relocated PluginManager from the client package to the plugin package for better organization. --- .../launchdarkly/observability/plugin/Observability.kt | 2 +- .../observability/{client => plugin}/PluginManager.kt | 10 ++++------ .../launchdarkly/observability/replay/SessionReplay.kt | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) rename sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/{client => plugin}/PluginManager.kt (86%) 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 37c649e0a..b9f95ba0c 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 @@ -7,7 +7,7 @@ import com.launchdarkly.logging.Logs import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.Options import com.launchdarkly.observability.client.ObservabilityClient -import com.launchdarkly.observability.client.PluginManager +import com.launchdarkly.observability.plugin.PluginManager import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/PluginManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt similarity index 86% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/PluginManager.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt index cab1b5a36..9a5a91462 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/PluginManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt @@ -1,16 +1,14 @@ -package com.launchdarkly.observability.client +package com.launchdarkly.observability.plugin -import com.launchdarkly.observability.plugin.InstrumentationContributor -import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.integrations.Plugin import java.util.WeakHashMap /** - * Manages a collection of plugins associated with an [LDClient] instance. + * Manages a collection of plugins associated with an [com.launchdarkly.sdk.android.LDClient] instance. * * This object provides a central place to register and retrieve plugins for a given LDClient. - * It uses a [WeakHashMap] to store plugins, which allows the [LDClient] instances and + * It uses a [java.util.WeakHashMap] to store plugins, which allows the [com.launchdarkly.sdk.android.LDClient] instances and * associated plugins to be garbage collected when they are no longer in use. */ internal object PluginManager { @@ -61,4 +59,4 @@ internal object PluginManager { fun isObservabilityInitialized(client: LDClient): Boolean { return get(client)?.any { it.metadata.name == Observability.PLUGIN_NAME } == true } -} +} \ No newline at end of file diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt index 56fe66049..6ce6e83e5 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt @@ -2,7 +2,7 @@ package com.launchdarkly.observability.replay import android.util.Log import com.launchdarkly.observability.BuildConfig -import com.launchdarkly.observability.client.PluginManager +import com.launchdarkly.observability.plugin.PluginManager import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.observability.plugin.InstrumentationContributor import com.launchdarkly.sdk.android.LDClient From 2cf58ca8b7e52d7be9335bf0380c9517805637dd Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Fri, 12 Dec 2025 00:39:50 -0300 Subject: [PATCH 3/9] Upgrade to LD Android SDK 5.10.0 and Kotlin 2.2.0 --- e2e/android/app/build.gradle.kts | 2 +- e2e/android/gradle/libs.versions.toml | 2 +- .../observability-android/gradle/libs.versions.toml | 4 ++-- sdk/@launchdarkly/observability-android/lib/build.gradle.kts | 2 +- .../observability/client/InstrumentationManager.kt | 5 ++--- .../com/launchdarkly/observability/plugin/Observability.kt | 5 ++--- 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index 9a4a5467f..fe6701aa1 100644 --- a/e2e/android/app/build.gradle.kts +++ b/e2e/android/app/build.gradle.kts @@ -54,7 +54,7 @@ dependencies { // Uncomment to use the publicly released version (note this may be behind branch/main) // implementation("com.launchdarkly:launchdarkly-observability-android:0.2.0") - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.9.2") + implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.10.0") implementation("io.opentelemetry:opentelemetry-api:1.51.0") implementation("io.opentelemetry:opentelemetry-sdk:1.51.0") diff --git a/e2e/android/gradle/libs.versions.toml b/e2e/android/gradle/libs.versions.toml index 81b782ae8..720a93c29 100644 --- a/e2e/android/gradle/libs.versions.toml +++ b/e2e/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.9.2" -kotlin = "2.0.21" +kotlin = "2.2.0" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" diff --git a/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml b/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml index 3e8fa6daa..3b0cf294b 100644 --- a/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml +++ b/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml @@ -2,5 +2,5 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [plugins] -kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.1.20" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.1.20" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.2.0" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.2.0" } diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index c7b9f78c7..21734786b 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -20,7 +20,7 @@ allprojects { } dependencies { - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.9.2") + implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.10.0") implementation("com.jakewharton.timber:timber:5.0.1") // Android diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt index 8b041b241..e2a955304 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt @@ -3,6 +3,8 @@ package com.launchdarkly.observability.client import android.app.Application import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.Options +import com.launchdarkly.observability.coroutines.DispatcherProviderHolder +import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.network.GraphQLClient import com.launchdarkly.observability.network.SamplingApiService @@ -12,11 +14,8 @@ import com.launchdarkly.observability.sampling.SamplingConfig import com.launchdarkly.observability.sampling.SamplingLogProcessor import com.launchdarkly.observability.sampling.SamplingTraceExporter import com.launchdarkly.observability.sampling.SpansSampler -import com.launchdarkly.observability.coroutines.DispatcherProviderHolder -import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import io.opentelemetry.android.OpenTelemetryRum import io.opentelemetry.android.config.OtelRumConfig -import io.opentelemetry.android.features.diskbuffering.DiskBufferingConfig import io.opentelemetry.android.session.SessionConfig import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Logger 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 b9f95ba0c..ee2069c00 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 @@ -7,15 +7,14 @@ import com.launchdarkly.logging.Logs import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.Options import com.launchdarkly.observability.client.ObservabilityClient -import com.launchdarkly.observability.plugin.PluginManager import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient -import com.launchdarkly.sdk.android.RegistrationCompleteResult 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 io.opentelemetry.sdk.resources.Resource import java.util.Collections @@ -81,7 +80,7 @@ class Observability( ) } - override fun registrationComplete(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { + override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { val sdkKey = metadata?.credential ?: "" client?.let { lDClient -> From 7e2e23a1fe69e814556d0575ee7d3c1f9bc721cb Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Fri, 12 Dec 2025 01:31:40 -0300 Subject: [PATCH 4/9] Update BaseApplication.kt --- .../androidobservability/BaseApplication.kt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 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 50d83f8bc..d81fed354 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 @@ -3,21 +3,18 @@ package com.example.androidobservability import android.app.Application import com.launchdarkly.observability.api.Options import com.launchdarkly.observability.client.TelemetryInspector -import com.launchdarkly.sdk.ContextKind -import com.launchdarkly.sdk.LDContext -import com.launchdarkly.sdk.android.Components -import com.launchdarkly.sdk.android.LDClient -import com.launchdarkly.sdk.android.LDConfig import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.observability.replay.PrivacyProfile -import com.launchdarkly.observability.replay.ReplayInstrumentation import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.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.integrations.Plugin +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 java.util.Collections open class BaseApplication : Application() { @@ -45,7 +42,7 @@ open class BaseApplication : Application() { options = testUrl?.let { pluginOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: pluginOptions ) - val replayPlugin = SessionReplay( + val sessionReplayPlugin = SessionReplay( options = ReplayOptions( privacyProfile = PrivacyProfile(maskText = false) ) @@ -61,7 +58,7 @@ open class BaseApplication : Application() { Components.plugins().setPlugins( listOf( observabilityPlugin, - replayPlugin + sessionReplayPlugin ) ) ) From e0d377d394148effbb27cfc59d28adb4452b5f39 Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Fri, 12 Dec 2025 02:15:23 -0300 Subject: [PATCH 5/9] Add reset helper and unit tests for PluginManager and SessionReplay --- .../observability/plugin/PluginManager.kt | 17 +++- .../observability/replay/SessionReplay.kt | 2 +- .../client/InstrumentationManagerTest.kt | 69 +++++++------- .../observability/plugin/PluginManagerTest.kt | 93 +++++++++++++++++++ .../observability/replay/SessionReplayTest.kt | 92 ++++++++++++++++++ 5 files changed, 234 insertions(+), 39 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt index 9a5a91462..f1d7f1d9c 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt @@ -34,10 +34,10 @@ internal object PluginManager { * The returned list is a snapshot and is safe to iterate over. * * @param client The [LDClient] to get the plugins for. - * @return A list of [Plugin]s, or null if no plugins are associated with the client. + * @return A list of [Plugin]s, or empty if no plugins are associated with the client. */ - fun get(client: LDClient): List? = synchronized(plugins) { - plugins[client]?.toList() + fun get(client: LDClient): List = synchronized(plugins) { + plugins[client]?.toList().orEmpty() } /** @@ -57,6 +57,13 @@ internal object PluginManager { * @return True if the observability plugin is initialized, false otherwise. */ fun isObservabilityInitialized(client: LDClient): Boolean { - return get(client)?.any { it.metadata.name == Observability.PLUGIN_NAME } == true + return get(client).any { it.metadata.name == Observability.PLUGIN_NAME } } -} \ No newline at end of file + + /** + * Clears all plugin registrations. + */ + fun reset() = synchronized(plugins) { + plugins.clear() + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt index 6ce6e83e5..d2de4af3d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt @@ -30,7 +30,7 @@ class SessionReplay( if (PluginManager.isObservabilityInitialized(client)) { PluginManager.add(client, this) } else { - Log.e("SessionReplay", "Observability plugin not initialized") + Log.e("SessionReplay", "Observability plugin is not initialized") } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/InstrumentationManagerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/InstrumentationManagerTest.kt index 091175d18..1fbece339 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/InstrumentationManagerTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/InstrumentationManagerTest.kt @@ -10,9 +10,9 @@ import io.mockk.verify import io.opentelemetry.api.common.Attributes import io.opentelemetry.sdk.logs.SdkLoggerProviderBuilder import io.opentelemetry.sdk.resources.Resource +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* /** * Test class focused on testing the createLoggerProcessor method logic. @@ -45,34 +45,35 @@ class InstrumentationManagerTest { val mockInstrumentation2 = mockk(relaxed = true) val mockLogRecordProcessor1 = mockk(relaxed = true) val mockLogRecordProcessor2 = mockk(relaxed = true) - + val scopeName1 = "com.test.instrumentation1" val scopeName2 = "com.test.instrumentation2" - + every { mockInstrumentation1.getLoggerScopeName() } returns scopeName1 every { mockInstrumentation1.getLogRecordProcessor(testSdkKey) } returns mockLogRecordProcessor1 every { mockInstrumentation2.getLoggerScopeName() } returns scopeName2 every { mockInstrumentation2.getLogRecordProcessor(testSdkKey) } returns mockLogRecordProcessor2 - testOptions = Options(instrumentations = listOf(mockInstrumentation1, mockInstrumentation2)) + testOptions = Options() // Act val logProcessor = InstrumentationManager.createLoggerProcessor( - mockSdkLoggerProviderBuilder, - mockExportSampler, - testSdkKey, - testResource, - mockLogger, - null, - testOptions + sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, + exportSampler = mockExportSampler, + sdkKey = testSdkKey, + resource = testResource, + logger = mockLogger, + telemetryInspector = null, + options = testOptions, + instrumentations = listOf(mockInstrumentation1, mockInstrumentation2) ) // Assert assertNotNull(logProcessor) - + // Verify that the logger provider builder was configured with resource verify { mockSdkLoggerProviderBuilder.setResource(testResource) } - + // Verify that instrumentation methods were called verify { mockInstrumentation1.getLoggerScopeName() } verify { mockInstrumentation1.getLogRecordProcessor(testSdkKey) } @@ -85,29 +86,30 @@ class InstrumentationManagerTest { // Arrange val mockInstrumentation = mockk(relaxed = true) val scopeName = "com.test.instrumentation" - + every { mockInstrumentation.getLoggerScopeName() } returns scopeName every { mockInstrumentation.getLogRecordProcessor(testSdkKey) } returns null - testOptions = Options(instrumentations = listOf(mockInstrumentation)) + testOptions = Options() // Act val logProcessor = InstrumentationManager.createLoggerProcessor( - mockSdkLoggerProviderBuilder, - mockExportSampler, - testSdkKey, - testResource, - mockLogger, - null, - testOptions + sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, + exportSampler = mockExportSampler, + sdkKey = testSdkKey, + resource = testResource, + logger = mockLogger, + telemetryInspector = null, + options = testOptions, + instrumentations = listOf(mockInstrumentation) ) // Assert assertNotNull(logProcessor) - + // Verify that the logger provider builder was configured verify { mockSdkLoggerProviderBuilder.setResource(testResource) } - + // Verify that instrumentation methods were called verify { mockInstrumentation.getLogRecordProcessor(testSdkKey) } // Verify that getLoggerScopeName() is NOT called when getLogRecordProcessor returns null @@ -117,22 +119,23 @@ class InstrumentationManagerTest { @Test fun `createLoggerProcessor should handle empty instrumentations list`() { // Arrange - testOptions = Options(instrumentations = emptyList()) + testOptions = Options() // Act val logProcessor = InstrumentationManager.createLoggerProcessor( - mockSdkLoggerProviderBuilder, - mockExportSampler, - testSdkKey, - testResource, - mockLogger, - null, - testOptions + sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, + exportSampler = mockExportSampler, + sdkKey = testSdkKey, + resource = testResource, + logger = mockLogger, + telemetryInspector = null, + options = testOptions, + instrumentations = listOf() ) // Assert assertNotNull(logProcessor) - + // Verify that the logger provider builder was configured verify { mockSdkLoggerProviderBuilder.setResource(testResource) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt new file mode 100644 index 000000000..9bbbc1373 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt @@ -0,0 +1,93 @@ +package com.launchdarkly.observability.plugin + +import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation +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 io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PluginManagerTest { + + private lateinit var client: LDClient + + @BeforeEach + fun setUp() { + client = mockk() + } + + @AfterEach + fun tearDown() { + PluginManager.reset() + } + + @Test + fun `add() stores plugins associated to a client`() { + val pluginOne = StubPlugin("plugin-one") + val pluginTwo = StubPlugin("plugin-two") + val pluginThree = StubPlugin("plugin-three") + + PluginManager.add(client, pluginOne) + PluginManager.add(client, pluginTwo) + + val firstSnapshot = PluginManager.get(client) + + PluginManager.add(client, pluginThree) + + val secondSnapshot = PluginManager.get(client) + + assertEquals(listOf(pluginOne, pluginTwo), firstSnapshot) + assertEquals(listOf(pluginOne, pluginTwo, pluginThree), secondSnapshot) + } + + @Test + fun `getInstrumentations returns only instrumentation contributors for client`() { + val instrumentationPlugin = StubInstrumentationContributorPlugin() + val regularPlugin = StubPlugin("regular-plugin") + + PluginManager.add(client, regularPlugin) + PluginManager.add(client, instrumentationPlugin) + + val instrumentations = PluginManager.getInstrumentations(client) + + assertEquals(listOf(instrumentationPlugin), instrumentations) + } + + @Test + fun `isObservabilityInitialized reports presence of observability plugin`() { + PluginManager.add(client, StubPlugin("other-plugin")) + + assertFalse(PluginManager.isObservabilityInitialized(client)) + + PluginManager.add(client, StubPlugin(Observability.PLUGIN_NAME)) + + assertTrue(PluginManager.isObservabilityInitialized(client)) + } + + private open class StubPlugin(private val pluginName: String) : Plugin() { + override fun getMetadata(): PluginMetadata { + return object : PluginMetadata() { + override fun getName(): String = pluginName + override fun getVersion(): String = "test-version" + } + } + + override fun register(client: LDClient, metadata: EnvironmentMetadata?) = Unit + override fun getHooks(metadata: EnvironmentMetadata?): MutableList = mutableListOf() + override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) = Unit + } + + private class StubInstrumentationContributorPlugin : StubPlugin("instrumentation-contributor-plugin"), InstrumentationContributor { + private val instrumentation: LDExtendedInstrumentation = mockk() + + override fun provideInstrumentations(): List = listOf(instrumentation) + } +} 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 new file mode 100644 index 000000000..a12d47157 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -0,0 +1,92 @@ +package com.launchdarkly.observability.replay + +import android.util.Log +import com.launchdarkly.observability.plugin.Observability +import com.launchdarkly.observability.plugin.PluginManager +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 io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SessionReplayTest { + + private lateinit var client: LDClient + + @BeforeEach + fun setUp() { + PluginManager.reset() + client = mockk(relaxed = true) + } + + @AfterEach + fun tearDown() { + PluginManager.reset() + unmockkAll() + } + + @Test + fun `register adds session replay when observability is initialized`() { + PluginManager.add(client, StubPlugin(Observability.PLUGIN_NAME)) + val sessionReplay = SessionReplay() + + sessionReplay.register(client, null) + + val plugins = PluginManager.get(client) + assertTrue(plugins.contains(sessionReplay)) + assertEquals(listOf(sessionReplay), PluginManager.getInstrumentations(client)) + } + + @Test + fun `register logs error when observability is not initialized`() { + mockkStatic(Log::class) + every { Log.e(any(), any()) } returns 0 + val sessionReplay = SessionReplay() + + sessionReplay.register(client, null) + + assertTrue(PluginManager.get(client).isEmpty()) + verify(exactly = 1) { Log.e("SessionReplay", "Observability plugin is not initialized") } + } + + @Test + fun `provideInstrumentations returns replay instrumentation with provided options`() { + val options = ReplayOptions(serviceName = "service-x") + val sessionReplay = SessionReplay(options) + + val instrumentations = sessionReplay.provideInstrumentations() + + assertEquals(1, instrumentations.size) + val instrumentation = instrumentations.first() + assertTrue(instrumentation is ReplayInstrumentation) + val optionsField = ReplayInstrumentation::class.java.getDeclaredField("options") + optionsField.isAccessible = true + val capturedOptions = optionsField.get(instrumentation) + assertSame(options, capturedOptions) + } + + private open class StubPlugin(private val pluginName: String) : Plugin() { + override fun getMetadata(): PluginMetadata { + return object : PluginMetadata() { + override fun getName(): String = pluginName + override fun getVersion(): String = "test-version" + } + } + + override fun register(client: LDClient, metadata: EnvironmentMetadata?) = Unit + override fun getHooks(metadata: EnvironmentMetadata?): MutableList = mutableListOf() + override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) = Unit + } +} From 600eadf1641133e533483a5567514a3a9cf090b0 Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Sun, 14 Dec 2025 03:10:43 -0300 Subject: [PATCH 6/9] Add ObservabilityContext and replace PluginManager with InstrumentationContributorManager - Remove PluginManager and introduce InstrumentationContributorManager to register/retrieve contributors per LDClient. - Update Observability to collect instrumentations from registered contributors. - Add ObservabilityContext and expose it via LDObserve.context so dependent plugins (e.g. Session Replay) can access config/dependencies. - Update SessionReplay, ReplayInstrumentation, and ReplayOptions to follow the new flow. - Update tests: add InstrumentationContributorManagerTest and adjust SessionReplayTest to use LDObserve.context/Timber. --- .../client/ObservabilityContext.kt | 15 +++ .../InstrumentationContributorManager.kt | 48 ++++++++++ .../observability/plugin/Observability.kt | 10 +- .../observability/plugin/PluginManager.kt | 69 -------------- .../replay/ReplayInstrumentation.kt | 28 +++--- .../observability/replay/ReplayOptions.kt | 7 -- .../observability/replay/SessionReplay.kt | 24 +++-- .../observability/sdk/LDObserve.kt | 8 ++ .../InstrumentationContributorManagerTest.kt | 66 +++++++++++++ .../observability/plugin/PluginManagerTest.kt | 93 ------------------- .../observability/replay/SessionReplayTest.kt | 75 ++++++--------- 11 files changed, 204 insertions(+), 239 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManager.kt delete mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManagerTest.kt delete mode 100644 sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt 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 new file mode 100644 index 000000000..acbbe0e84 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt @@ -0,0 +1,15 @@ +package com.launchdarkly.observability.client + +import android.app.Application +import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.api.Options + +/** + * Shared information between plugins. + */ +data class ObservabilityContext( + val sdkKey: String, + val options: Options, + val application: Application, + val logger: LDLogger, +) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManager.kt new file mode 100644 index 000000000..03c881e71 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManager.kt @@ -0,0 +1,48 @@ +package com.launchdarkly.observability.plugin + +import com.launchdarkly.sdk.android.LDClient +import java.util.WeakHashMap + +/** + * Manages a collection of instrumentation contributors associated with an [com.launchdarkly.sdk.android.LDClient] instance. + * + * This object provides a central place to register and retrieve instrumentation contributors for a given LDClient. + * It uses a [java.util.WeakHashMap] to store contributors, which allows the [com.launchdarkly.sdk.android.LDClient] instances and + * associated contributors to be garbage collected when they are no longer in use. + */ +internal object InstrumentationContributorManager { + private val contributors = WeakHashMap>() + + /** + * Adds a [InstrumentationContributor] to the list of contributors associated with the given [LDClient]. + * + * If no contributors have been added for the client before, a new list is created. + * + * @param client The [LDClient] to associate the contributor with. + * @param contributor The [InstrumentationContributor] to add. + */ + fun add(client: LDClient, contributor: InstrumentationContributor) { + synchronized(contributors) { + contributors.getOrPut(client) { mutableListOf() }.add(contributor) + } + } + + /** + * Retrieves a list of [InstrumentationContributor]s associated with the given [LDClient]. + * + * The returned list is a snapshot and is safe to iterate over. + * + * @param client The [LDClient] to get the contributors for. + * @return A list of [InstrumentationContributor]s, or empty if no contributors are associated with the client. + */ + fun get(client: LDClient): List = synchronized(contributors) { + contributors[client]?.toList().orEmpty() + } + + /** + * Clears all contributor registrations. + */ + fun reset() = synchronized(contributors) { + contributors.clear() + } +} 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 ee2069c00..120beeab4 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 @@ -7,6 +7,7 @@ import com.launchdarkly.logging.Logs import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.Options import com.launchdarkly.observability.client.ObservabilityClient +import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient @@ -71,7 +72,12 @@ class Observability( override fun register(client: LDClient, metadata: EnvironmentMetadata?) { this.client = client - PluginManager.add(client, this) + LDObserve.context = ObservabilityContext( + sdkKey = metadata?.credential ?: mobileKey, + options = options, + application = application, + logger = logger + ) } override fun getHooks(metadata: EnvironmentMetadata?): MutableList { @@ -105,7 +111,7 @@ class Observability( } } - val instrumentations = PluginManager.getInstrumentations(lDClient)?.flatMap { it.provideInstrumentations() }.orEmpty() + val instrumentations = InstrumentationContributorManager.get(lDClient).flatMap { it.provideInstrumentations() } observabilityClient = ObservabilityClient( application, sdkKey, resourceBuilder.build(), logger, options, instrumentations ) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt deleted file mode 100644 index f1d7f1d9c..000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/PluginManager.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.launchdarkly.observability.plugin - -import com.launchdarkly.sdk.android.LDClient -import com.launchdarkly.sdk.android.integrations.Plugin -import java.util.WeakHashMap - -/** - * Manages a collection of plugins associated with an [com.launchdarkly.sdk.android.LDClient] instance. - * - * This object provides a central place to register and retrieve plugins for a given LDClient. - * It uses a [java.util.WeakHashMap] to store plugins, which allows the [com.launchdarkly.sdk.android.LDClient] instances and - * associated plugins to be garbage collected when they are no longer in use. - */ -internal object PluginManager { - private val plugins = WeakHashMap>() - - /** - * Adds a [Plugin] to the list of plugins associated with the given [LDClient]. - * - * If no plugins have been added for the client before, a new list is created. - * - * @param client The [LDClient] to associate the plugin with. - * @param plugin The [Plugin] to add. - */ - fun add(client: LDClient, plugin: Plugin) { - synchronized(plugins) { - plugins.getOrPut(client) { mutableListOf() }.add(plugin) - } - } - - /** - * Retrieves the list of [Plugin]s associated with the given [LDClient]. - * - * The returned list is a snapshot and is safe to iterate over. - * - * @param client The [LDClient] to get the plugins for. - * @return A list of [Plugin]s, or empty if no plugins are associated with the client. - */ - fun get(client: LDClient): List = synchronized(plugins) { - plugins[client]?.toList().orEmpty() - } - - /** - * Retrieves the list of [InstrumentationContributor] plugins associated with the given [LDClient]. - * - * @param client The [LDClient] to get the instrumentations for. - * @return A list of [InstrumentationContributor]s, or null if no plugins are associated with the client. - */ - fun getInstrumentations(client: LDClient): List? = synchronized(plugins) { - plugins[client]?.filterIsInstance() - } - - /** - * Checks if the observability plugin has been initialized for the given [LDClient]. - * - * @param client The [LDClient] to check. - * @return True if the observability plugin is initialized, false otherwise. - */ - fun isObservabilityInitialized(client: LDClient): Boolean { - return get(client).any { it.metadata.name == Observability.PLUGIN_NAME } - } - - /** - * Clears all plugin registrations. - */ - fun reset() = synchronized(plugins) { - plugins.clear() - } -} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt index 9ed438134..076af611d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.replay -import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.observability.replay.capture.CaptureSource @@ -30,7 +30,7 @@ private const val BATCH_MAX_EXPORT_SIZE = 10 /** * Provides session replay instrumentation. Session replays that are sampled will appear on the LaunchDarkly dashboard. * - * @param options Configuration options for replay behavior including privacy settings, capture interval, and backend URL + * @param options Configuration options for replay behavior including privacy settings and capture interval * * @sample * ```kotlin @@ -65,6 +65,7 @@ private const val BATCH_MAX_EXPORT_SIZE = 10 */ class ReplayInstrumentation( private val options: ReplayOptions = ReplayOptions(), + private val observabilityContext: ObservabilityContext ) : LDExtendedInstrumentation { private lateinit var _otelLogger: Logger @@ -78,10 +79,11 @@ class ReplayInstrumentation( override fun install(ctx: InstallationContext) { _otelLogger = ctx.openTelemetry.logsBridge.get(INSTRUMENTATION_SCOPE_NAME) - // TODO: Use real LDClient logger after creating SR Plugin - val logger = LDLogger.none() - _captureSource = - CaptureSource(ctx.sessionManager, options.privacyProfile.asMatchersList(), logger) + _captureSource = CaptureSource( + sessionManager = ctx.sessionManager, + maskMatchers = options.privacyProfile.asMatchersList(), + logger = observabilityContext.logger + ) _interactionSource = InteractionSource(ctx.sessionManager) // TODO: O11Y-621 - don't use global scope @@ -100,7 +102,7 @@ class ReplayInstrumentation( } GlobalScope.launch(DispatcherProviderHolder.current.default) { - _interactionSource.captureFlow.collect{ interaction-> + _interactionSource.captureFlow.collect { interaction -> // Serialize positions list to JSON using StringBuilder for performance val positionsJson = StringBuilder().apply { append('[') @@ -142,7 +144,7 @@ class ReplayInstrumentation( if (!_isPaused) { return } - + // Clear paused flag and start/resume periodic capture _isPaused = false internalStartCapture() @@ -156,14 +158,14 @@ class ReplayInstrumentation( if (_isPaused) { return } - + // pause the periodic capture by terminating the job _isPaused = true _captureJob?.cancel() _captureJob = null } } - + private fun internalStartCapture() { // TODO: O11Y-621 - don't use global scope _captureJob = GlobalScope.launch(DispatcherProviderHolder.current.default) { @@ -184,9 +186,9 @@ class ReplayInstrumentation( override fun getLogRecordProcessor(credential: String): LogRecordProcessor { val exporter = RRwebGraphQLReplayLogExporter( organizationVerboseId = credential, // the SDK credential is used as the organization ID intentionally - backendUrl = options.backendUrl, - serviceName = options.serviceName, - serviceVersion = options.serviceVersion, + backendUrl = observabilityContext.options.backendUrl, + serviceName = observabilityContext.options.serviceName, + serviceVersion = observabilityContext.options.serviceVersion, ) return BatchLogRecordProcessor.builder(exporter) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt index b8cbd74eb..111e16f1a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt @@ -1,20 +1,13 @@ package com.launchdarkly.observability.replay -import com.launchdarkly.observability.BuildConfig -import com.launchdarkly.observability.api.DEFAULT_BACKEND_URL - /** * Options for Session Replay plugin. * - * @property backendUrl The backend URL for sending replay data. Defaults to LaunchDarkly url. * @property debug enables verbose logging if true as well as other debug functionality. Defaults to false. * @property privacyProfile privacy profile that controls masking behavior * @property capturePeriodMillis period between captures */ data class ReplayOptions( - val serviceName: String = "observability-android", - val serviceVersion: String = BuildConfig.OBSERVABILITY_SDK_VERSION, - val backendUrl: String = DEFAULT_BACKEND_URL, val debug: Boolean = false, val privacyProfile: PrivacyProfile = PrivacyProfile(), val capturePeriodMillis: Long = 1000, // defaults to ever 1 second diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt index d2de4af3d..a6f5068f6 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt @@ -1,14 +1,15 @@ package com.launchdarkly.observability.replay -import android.util.Log import com.launchdarkly.observability.BuildConfig -import com.launchdarkly.observability.plugin.PluginManager import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.observability.plugin.InstrumentationContributor +import com.launchdarkly.observability.plugin.InstrumentationContributorManager +import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata import com.launchdarkly.sdk.android.integrations.Plugin import com.launchdarkly.sdk.android.integrations.PluginMetadata +import timber.log.Timber /** * Session Replay plugin for the LaunchDarkly Android SDK. @@ -19,6 +20,12 @@ class SessionReplay( private val options: ReplayOptions = ReplayOptions(), ) : Plugin(), InstrumentationContributor { + private val instrumentations: List by lazy { + LDObserve.context?.let { context -> + listOf(ReplayInstrumentation(options, context)) + }.orEmpty() + } + override fun getMetadata(): PluginMetadata { return object : PluginMetadata() { override fun getName(): String = PLUGIN_NAME @@ -27,18 +34,17 @@ class SessionReplay( } override fun register(client: LDClient, metadata: EnvironmentMetadata?) { - if (PluginManager.isObservabilityInitialized(client)) { - PluginManager.add(client, this) - } else { - Log.e("SessionReplay", "Observability plugin is not initialized") + LDObserve.context?.let { + InstrumentationContributorManager.add(client, this) + } ?: run { + Timber.tag(TAG).e("Observability plugin is not initialized") } } - override fun provideInstrumentations(): List { - return listOf(ReplayInstrumentation(options)) - } + override fun provideInstrumentations(): List = instrumentations 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/sdk/LDObserve.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt index 40c91c5fb..0fff082c2 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,6 +1,7 @@ package com.launchdarkly.observability.sdk import com.launchdarkly.observability.client.ObservabilityClient +import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import io.opentelemetry.api.common.Attributes @@ -73,6 +74,13 @@ class LDObserve(private val client: Observe) : Observe { } } + /** + * Shared context for other plugins (e.g. Session Replay) to access Observability configuration and dependencies. + */ + @Volatile + var context: ObservabilityContext? = null + internal set + fun init(client: ObservabilityClient) { delegate = LDObserve(client) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManagerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManagerTest.kt new file mode 100644 index 000000000..ad1f5a24a --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManagerTest.kt @@ -0,0 +1,66 @@ +package com.launchdarkly.observability.plugin + +import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation +import com.launchdarkly.sdk.android.LDClient +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class InstrumentationContributorManagerTest { + + private lateinit var client: LDClient + + @BeforeEach + fun setUp() { + client = mockk() + } + + @AfterEach + fun tearDown() { + InstrumentationContributorManager.reset() + } + + @Test + fun `add() stores contributors associated to a client and get() returns them`() { + val contributorOne = MockInstrumentationContributor() + val contributorTwo = MockInstrumentationContributor() + val contributorThree = MockInstrumentationContributor() + + InstrumentationContributorManager.add(client, contributorOne) + InstrumentationContributorManager.add(client, contributorTwo) + + val firstSnapshot = InstrumentationContributorManager.get(client) + + InstrumentationContributorManager.add(client, contributorThree) + + val secondSnapshot = InstrumentationContributorManager.get(client) + + assertEquals(listOf(contributorOne, contributorTwo), firstSnapshot) + assertEquals(listOf(contributorOne, contributorTwo, contributorThree), secondSnapshot) + } + + @Test + fun `reset clears all contributors`() { + val contributorOne = MockInstrumentationContributor() + val contributorTwo = MockInstrumentationContributor() + + InstrumentationContributorManager.add(client, contributorOne) + InstrumentationContributorManager.add(client, contributorTwo) + + val firstSnapshot = InstrumentationContributorManager.get(client) + + InstrumentationContributorManager.reset() + + val secondSnapshot = InstrumentationContributorManager.get(client) + + assertEquals(listOf(contributorOne, contributorTwo), firstSnapshot) + assertTrue(secondSnapshot.isEmpty()) + } + + private class MockInstrumentationContributor() : InstrumentationContributor { + override fun provideInstrumentations(): List = emptyList() + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt deleted file mode 100644 index 9bbbc1373..000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/PluginManagerTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.launchdarkly.observability.plugin - -import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation -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 io.mockk.mockk -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class PluginManagerTest { - - private lateinit var client: LDClient - - @BeforeEach - fun setUp() { - client = mockk() - } - - @AfterEach - fun tearDown() { - PluginManager.reset() - } - - @Test - fun `add() stores plugins associated to a client`() { - val pluginOne = StubPlugin("plugin-one") - val pluginTwo = StubPlugin("plugin-two") - val pluginThree = StubPlugin("plugin-three") - - PluginManager.add(client, pluginOne) - PluginManager.add(client, pluginTwo) - - val firstSnapshot = PluginManager.get(client) - - PluginManager.add(client, pluginThree) - - val secondSnapshot = PluginManager.get(client) - - assertEquals(listOf(pluginOne, pluginTwo), firstSnapshot) - assertEquals(listOf(pluginOne, pluginTwo, pluginThree), secondSnapshot) - } - - @Test - fun `getInstrumentations returns only instrumentation contributors for client`() { - val instrumentationPlugin = StubInstrumentationContributorPlugin() - val regularPlugin = StubPlugin("regular-plugin") - - PluginManager.add(client, regularPlugin) - PluginManager.add(client, instrumentationPlugin) - - val instrumentations = PluginManager.getInstrumentations(client) - - assertEquals(listOf(instrumentationPlugin), instrumentations) - } - - @Test - fun `isObservabilityInitialized reports presence of observability plugin`() { - PluginManager.add(client, StubPlugin("other-plugin")) - - assertFalse(PluginManager.isObservabilityInitialized(client)) - - PluginManager.add(client, StubPlugin(Observability.PLUGIN_NAME)) - - assertTrue(PluginManager.isObservabilityInitialized(client)) - } - - private open class StubPlugin(private val pluginName: String) : Plugin() { - override fun getMetadata(): PluginMetadata { - return object : PluginMetadata() { - override fun getName(): String = pluginName - override fun getVersion(): String = "test-version" - } - } - - override fun register(client: LDClient, metadata: EnvironmentMetadata?) = Unit - override fun getHooks(metadata: EnvironmentMetadata?): MutableList = mutableListOf() - override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) = Unit - } - - private class StubInstrumentationContributorPlugin : StubPlugin("instrumentation-contributor-plugin"), InstrumentationContributor { - private val instrumentation: LDExtendedInstrumentation = mockk() - - override fun provideInstrumentations(): List = listOf(instrumentation) - } -} 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 a12d47157..b0ef0e7b6 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,22 +1,15 @@ package com.launchdarkly.observability.replay -import android.util.Log -import com.launchdarkly.observability.plugin.Observability -import com.launchdarkly.observability.plugin.PluginManager +import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.api.Options +import com.launchdarkly.observability.client.ObservabilityContext +import com.launchdarkly.observability.plugin.InstrumentationContributorManager +import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient -import com.launchdarkly.sdk.android.integrations.EnvironmentMetadata -import com.launchdarkly.sdk.android.integrations.Hook -import com.launchdarkly.sdk.android.integrations.Plugin -import com.launchdarkly.sdk.android.integrations.PluginMetadata -import com.launchdarkly.sdk.android.integrations.RegistrationCompleteResult -import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -27,66 +20,56 @@ class SessionReplayTest { @BeforeEach fun setUp() { - PluginManager.reset() + InstrumentationContributorManager.reset() client = mockk(relaxed = true) + LDObserve.context = null } @AfterEach fun tearDown() { - PluginManager.reset() + InstrumentationContributorManager.reset() + LDObserve.context = null unmockkAll() } @Test fun `register adds session replay when observability is initialized`() { - PluginManager.add(client, StubPlugin(Observability.PLUGIN_NAME)) + LDObserve.context = ObservabilityContext( + sdkKey = "test-sdk-key", + options = Options(), + application = mockk(), + logger = mockk(relaxed = true), + ) val sessionReplay = SessionReplay() sessionReplay.register(client, null) - val plugins = PluginManager.get(client) - assertTrue(plugins.contains(sessionReplay)) - assertEquals(listOf(sessionReplay), PluginManager.getInstrumentations(client)) + val contributors = InstrumentationContributorManager.get(client) + assertTrue(contributors.contains(sessionReplay)) + assertEquals(listOf(sessionReplay), contributors) } @Test - fun `register logs error when observability is not initialized`() { - mockkStatic(Log::class) - every { Log.e(any(), any()) } returns 0 + fun `register doesn't add session replay when observability is not initialized`() { val sessionReplay = SessionReplay() - sessionReplay.register(client, null) - assertTrue(PluginManager.get(client).isEmpty()) - verify(exactly = 1) { Log.e("SessionReplay", "Observability plugin is not initialized") } + assertTrue(InstrumentationContributorManager.get(client).isEmpty()) } @Test - fun `provideInstrumentations returns replay instrumentation with provided options`() { - val options = ReplayOptions(serviceName = "service-x") - val sessionReplay = SessionReplay(options) + fun `provideInstrumentations returns replay instrumentation`() { + LDObserve.context = ObservabilityContext( + sdkKey = "test-sdk-key", + options = Options(), + application = mockk(), + logger = mockk(relaxed = true), + ) + val sessionReplay = SessionReplay(ReplayOptions(debug = true)) val instrumentations = sessionReplay.provideInstrumentations() - assertEquals(1, instrumentations.size) - val instrumentation = instrumentations.first() - assertTrue(instrumentation is ReplayInstrumentation) - val optionsField = ReplayInstrumentation::class.java.getDeclaredField("options") - optionsField.isAccessible = true - val capturedOptions = optionsField.get(instrumentation) - assertSame(options, capturedOptions) + assertTrue(instrumentations.first() is ReplayInstrumentation) } - private open class StubPlugin(private val pluginName: String) : Plugin() { - override fun getMetadata(): PluginMetadata { - return object : PluginMetadata() { - override fun getName(): String = pluginName - override fun getVersion(): String = "test-version" - } - } - - override fun register(client: LDClient, metadata: EnvironmentMetadata?) = Unit - override fun getHooks(metadata: EnvironmentMetadata?): MutableList = mutableListOf() - override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) = Unit - } } From 0e0707d6cecc84d2c913e17308d7287e6b2b8b5f Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Sun, 14 Dec 2025 21:06:40 -0300 Subject: [PATCH 7/9] Fix potential bugs --- .../observability/plugin/Observability.kt | 12 ++++++------ .../observability/replay/SessionReplay.kt | 14 ++++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) 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 120beeab4..58bd11634 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 @@ -72,12 +72,6 @@ class Observability( override fun register(client: LDClient, metadata: EnvironmentMetadata?) { this.client = client - LDObserve.context = ObservabilityContext( - sdkKey = metadata?.credential ?: mobileKey, - options = options, - application = application, - logger = logger - ) } override fun getHooks(metadata: EnvironmentMetadata?): MutableList { @@ -116,6 +110,12 @@ class Observability( application, sdkKey, resourceBuilder.build(), logger, options, instrumentations ) observabilityClient?.let { + LDObserve.context = ObservabilityContext( + sdkKey = mobileKey, + options = options, + application = application, + logger = logger + ) LDObserve.init(it) } } else { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt index a6f5068f6..e42d6506a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt @@ -20,11 +20,7 @@ class SessionReplay( private val options: ReplayOptions = ReplayOptions(), ) : Plugin(), InstrumentationContributor { - private val instrumentations: List by lazy { - LDObserve.context?.let { context -> - listOf(ReplayInstrumentation(options, context)) - }.orEmpty() - } + private var cachedInstrumentations: List? = null override fun getMetadata(): PluginMetadata { return object : PluginMetadata() { @@ -41,7 +37,13 @@ class SessionReplay( } } - override fun provideInstrumentations(): List = instrumentations + override fun provideInstrumentations(): List { + return synchronized(this) { + cachedInstrumentations ?: LDObserve.context?.let { context -> + listOf(ReplayInstrumentation(options, context)).also { cachedInstrumentations = it } + }.orEmpty() + } + } companion object { const val PLUGIN_NAME = "@launchdarkly/session-replay-android" From 8d295c59e16cd0bdd142d2eb9f182a465562c910 Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Sun, 14 Dec 2025 21:10:23 -0300 Subject: [PATCH 8/9] Add test case to SessionReplayTest --- .../observability/replay/SessionReplayTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 b0ef0e7b6..606296a12 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 @@ -58,7 +58,7 @@ class SessionReplayTest { } @Test - fun `provideInstrumentations returns replay instrumentation`() { + fun `provideInstrumentations returns replay instrumentation if observability is initialized`() { LDObserve.context = ObservabilityContext( sdkKey = "test-sdk-key", options = Options(), @@ -72,4 +72,10 @@ class SessionReplayTest { assertTrue(instrumentations.first() is ReplayInstrumentation) } + @Test + fun `provideInstrumentations returns null if observability is not initialized`() { + val sessionReplay = SessionReplay(ReplayOptions(debug = true)) + assertTrue(sessionReplay.provideInstrumentations().isEmpty()) + } + } From 4bb166e3cbcbec164ffcac0fe3f3ba4b44647417 Mon Sep 17 00:00:00 2001 From: Agustin Grognetti Date: Sun, 14 Dec 2025 23:30:58 -0300 Subject: [PATCH 9/9] Update Observability.kt --- .../observability/plugin/Observability.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 58bd11634..913870771 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 @@ -72,6 +72,18 @@ class Observability( override fun register(client: LDClient, metadata: EnvironmentMetadata?) { this.client = client + val sdkKey = metadata?.credential ?: "" + + if (mobileKey == sdkKey) { + LDObserve.context = ObservabilityContext( + sdkKey = sdkKey, + options = options, + application = application, + logger = logger + ) + } else { + logger.warn("ObservabilityContext could not be initialized for sdkKey: $sdkKey") + } } override fun getHooks(metadata: EnvironmentMetadata?): MutableList { @@ -110,12 +122,6 @@ class Observability( application, sdkKey, resourceBuilder.build(), logger, options, instrumentations ) observabilityClient?.let { - LDObserve.context = ObservabilityContext( - sdkKey = mobileKey, - options = options, - application = application, - logger = logger - ) LDObserve.init(it) } } else {