diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index b0fdbdac6..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.0") + 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/app/src/main/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt index b2e06473f..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,20 +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.observability.plugin.Observability +import com.launchdarkly.observability.replay.PrivacyProfile +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.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.sdk.android.LDAndroidLogging -import com.launchdarkly.sdk.android.integrations.Plugin import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes -import java.util.Collections open class BaseApplication : Application() { @@ -32,14 +30,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 +42,12 @@ open class BaseApplication : Application() { options = testUrl?.let { pluginOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: pluginOptions ) + val sessionReplayPlugin = 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 +56,10 @@ open class BaseApplication : Application() { .mobileKey(LAUNCHDARKLY_MOBILE_KEY) .plugins( Components.plugins().setPlugins( - Collections.singletonList(observabilityPlugin) + listOf( + observabilityPlugin, + sessionReplayPlugin + ) ) ) .build() 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/README.md b/sdk/@launchdarkly/observability-android/README.md index 9d8c3e767..a9e25ff7f 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/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 53804b3bc..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.0") + 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/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 662f825c8..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,10 +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 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 @@ -54,11 +54,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 +72,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 +111,8 @@ class InstrumentationManager( resources, logger, telemetryInspector, - options + options, + instrumentations ) logProcessor = processor sdkLoggerProviderBuilder.addLogRecordProcessor(processor) @@ -125,7 +133,7 @@ class InstrumentationManager( } } - for (instrumentation in options.instrumentations) { + for (instrumentation in instrumentations) { rumBuilder.addInstrumentation(instrumentation) } @@ -402,6 +410,7 @@ class InstrumentationManager( logger: LDLogger, telemetryInspector: TelemetryInspector?, options: Options, + instrumentations: List, ): LogRecordProcessor { val primaryLogExporter = createOtlpLogExporter(options) sdkLoggerProviderBuilder.setResource(resource) @@ -432,7 +441,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/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/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/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 158533602..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 @@ -4,8 +4,10 @@ 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.ObservabilityContext import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.sdk.android.LDClient @@ -13,6 +15,7 @@ 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 @@ -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,39 +65,24 @@ 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 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) - - metadata?.applicationInfo?.applicationId?.let { - resourceBuilder.put("launchdarkly.application.id", 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") - } - } - - observabilityClient = ObservabilityClient(application, sdkKey, resourceBuilder.build(), logger, options) - observabilityClient?.let { LDObserve.init(it) } + LDObserve.context = ObservabilityContext( + sdkKey = sdkKey, + options = options, + application = application, + logger = logger + ) + } else { + logger.warn("ObservabilityContext could not be initialized for sdkKey: $sdkKey") } } @@ -103,11 +92,50 @@ class Observability( ) } + override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { + val sdkKey = metadata?.credential ?: "" + + 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?.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") + } + } + + val instrumentations = InstrumentationContributorManager.get(lDClient).flatMap { it.provideInstrumentations() } + 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") + } + } + } + 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/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 f044ee626..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 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. * @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 new file mode 100644 index 000000000..e42d6506a --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplay.kt @@ -0,0 +1,52 @@ +package com.launchdarkly.observability.replay + +import com.launchdarkly.observability.BuildConfig +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. + * + * This plugin depends on the Observability plugin being present and initialized first. + */ +class SessionReplay( + private val options: ReplayOptions = ReplayOptions(), +) : Plugin(), InstrumentationContributor { + + private var cachedInstrumentations: List? = null + + 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?) { + LDObserve.context?.let { + InstrumentationContributorManager.add(client, this) + } ?: run { + Timber.tag(TAG).e("Observability plugin is not initialized") + } + } + + 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" + 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/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/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/replay/SessionReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt new file mode 100644 index 000000000..606296a12 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -0,0 +1,81 @@ +package com.launchdarkly.observability.replay + +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 io.mockk.mockk +import io.mockk.unmockkAll +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 SessionReplayTest { + + private lateinit var client: LDClient + + @BeforeEach + fun setUp() { + InstrumentationContributorManager.reset() + client = mockk(relaxed = true) + LDObserve.context = null + } + + @AfterEach + fun tearDown() { + InstrumentationContributorManager.reset() + LDObserve.context = null + unmockkAll() + } + + @Test + fun `register adds session replay when observability is initialized`() { + LDObserve.context = ObservabilityContext( + sdkKey = "test-sdk-key", + options = Options(), + application = mockk(), + logger = mockk(relaxed = true), + ) + val sessionReplay = SessionReplay() + + sessionReplay.register(client, null) + + val contributors = InstrumentationContributorManager.get(client) + assertTrue(contributors.contains(sessionReplay)) + assertEquals(listOf(sessionReplay), contributors) + } + + @Test + fun `register doesn't add session replay when observability is not initialized`() { + val sessionReplay = SessionReplay() + sessionReplay.register(client, null) + + assertTrue(InstrumentationContributorManager.get(client).isEmpty()) + } + + @Test + fun `provideInstrumentations returns replay instrumentation if observability is initialized`() { + 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) + 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()) + } + +}