diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt index ff3fb29f34..9b308b0420 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt @@ -115,11 +115,7 @@ private fun MainScreen(viewModel: ViewModel, innerPadding: PaddingValues) { .verticalScroll(rememberScrollState()) .padding(16.dp) ) { - Text( - text = "Masking", - style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(bottom = 8.dp) - ) + SessionReplayHeader() HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) MaskingButtons() @@ -131,8 +127,6 @@ private fun MainScreen(viewModel: ViewModel, innerPadding: PaddingValues) { ) HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) - SessionReplayToggle() - IdentifyButtons(viewModel = viewModel) InstrumentationButtons(viewModel = viewModel) @@ -146,19 +140,19 @@ private fun MainScreen(viewModel: ViewModel, innerPadding: PaddingValues) { } @Composable -private fun SessionReplayToggle() { +private fun SessionReplayHeader() { var isSessionReplayEnabled by rememberSaveable { mutableStateOf(true) } Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), + .padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "Session Replay", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold) ) Switch( checked = isSessionReplayEnabled, @@ -295,7 +289,7 @@ private fun MaskingButtons() { ) MaskingRow( - name = "Check", + name = "Dialogs", ctx = context, activity1 = XMLMaskingActivity::class.java, activity2 = ComposeMaskingActivity::class.java diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt index efd1f702c2..26948a1b8b 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt @@ -96,7 +96,7 @@ class BenchmarkExecutor { } val exportDiffManager = ExportDiffManager(compression = method, scale = 1f) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300) + val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "benchmark") val json = Json var bytes = 0 var isFirst = true 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 837fac2379..536fc95bdf 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 @@ -88,7 +88,13 @@ open class BaseApplication : Application() { .anonymous(true) .build() - LDClient.init(this@BaseApplication, ldConfig, context) + LDClient.init(this@BaseApplication, ldConfig, context, 1) + val anonContext = LDContext.builder(ContextKind.DEFAULT, "anonymous-userkey") + .anonymous(true) + .build() + + //LDClient.get().identify(anonContext) + telemetryInspector = observabilityPlugin.getTelemetryInspector() if (testUrl == null) { diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDViewExtensions.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDViewExtensions.cs similarity index 100% rename from sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDViewExtensions.cs rename to sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDViewExtensions.cs 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 0d6e20f759..1203f27637 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 @@ -4,7 +4,6 @@ import android.app.Application import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.ObservabilityOptions 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 @@ -16,7 +15,10 @@ import com.launchdarkly.observability.sampling.SamplingTraceExporter import io.opentelemetry.android.OpenTelemetryRum import io.opentelemetry.android.OpenTelemetryRumBuilder import io.opentelemetry.android.config.OtelRumConfig +import io.opentelemetry.android.instrumentation.AndroidInstrumentation +import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.session.SessionConfig +import io.opentelemetry.android.session.SessionManager import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Logger import io.opentelemetry.api.logs.Severity @@ -64,7 +66,6 @@ import java.util.concurrent.TimeUnit * @param resources The OpenTelemetry resource describing this service. * @param logger The logger for internal logging. * @param observabilityOptions Additional configuration options for the SDK. - * @param instrumentations A list of custom instrumentations to be added. */ class InstrumentationManager( private val application: Application, @@ -72,9 +73,10 @@ class InstrumentationManager( private val resources: Resource, private val logger: LDLogger, private val observabilityOptions: ObservabilityOptions, - private val instrumentations: List, ) { private val otelRUM: OpenTelemetryRum + lateinit var sessionManager: SessionManager + private set private var otelMeter: Meter private var otelLogger: Logger private var otelTracer: Tracer @@ -100,6 +102,8 @@ class InstrumentationManager( initializeTelemetryInspector() val otelRumConfig = createOtelRumConfig() + var capturedSessionManager: SessionManager? = null + val rumBuilder = OpenTelemetryRum.builder(application, otelRumConfig) .addLoggerProviderCustomizer { sdkLoggerProviderBuilder, _ -> val processor = createLoggerProcessor( @@ -110,7 +114,6 @@ class InstrumentationManager( logger, telemetryInspector, observabilityOptions, - instrumentations ) logProcessor = processor return@addLoggerProviderCustomizer sdkLoggerProviderBuilder.addLogRecordProcessor(processor) @@ -122,15 +125,19 @@ class InstrumentationManager( return@addMeterProviderCustomizer configureMeterProvider(sdkMeterProviderBuilder) } - for (instrumentation in instrumentations) { - rumBuilder.addInstrumentation(instrumentation) - } + rumBuilder.addInstrumentation(object : AndroidInstrumentation { + override val name = "ld-session-manager-bridge" + override fun install(ctx: InstallationContext) { + capturedSessionManager = ctx.sessionManager + } + }) if (observabilityOptions.instrumentations.launchTime) { addLaunchTimeInstrumentation(rumBuilder) } otelRUM = rumBuilder.build() + sessionManager = capturedSessionManager!! loadSamplingConfigAsync() otelMeter = otelRUM.openTelemetry.meterProvider.get(INSTRUMENTATION_SCOPE_NAME) @@ -422,7 +429,6 @@ class InstrumentationManager( logger: LDLogger, telemetryInspector: TelemetryInspector?, observabilityOptions: ObservabilityOptions, - instrumentations: List, ): LogRecordProcessor { val primaryLogExporter = createOtlpLogExporter(observabilityOptions) sdkLoggerProviderBuilder.setResource(resource) @@ -434,28 +440,10 @@ class InstrumentationManager( observabilityOptions = observabilityOptions ) - val samplingProcessor = SamplingLogProcessor( + return SamplingLogProcessor( delegate = createBatchLogRecordProcessor(finalExporter), sampler = exportSampler ) - - /* - Here we set up a routing log processor that will route logs with a matching scope name to the - respective instrumentation's log record processor. If the log's scope name does not match - an instrumentation's scope name, it will fall through to the base processor. This was - originally added to route replay instrumentation logs through a separate log processing - pipeline to provide instrumentation specific caching and export. - */ - val routingLogRecordProcessor = RoutingLogRecordProcessor(fallthroughProcessor = samplingProcessor) - instrumentations.forEach { instrumentation -> - instrumentation.getLogRecordProcessor(credential = sdkKey)?.let { processor -> - instrumentation.getLoggerScopeName().let { scopeName -> - routingLogRecordProcessor.addProcessor(scopeName, processor) - } - } - } - - return routingLogRecordProcessor } private fun createOtlpLogExporter(observabilityOptions: ObservabilityOptions): LogRecordExporter { 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 770de0e7fa..98e6021069 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,7 +3,6 @@ package com.launchdarkly.observability.client import android.app.Application import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import io.opentelemetry.api.common.Attributes @@ -30,7 +29,6 @@ class ObservabilityClient : Observe { * @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, @@ -38,13 +36,14 @@ class ObservabilityClient : Observe { resource: Resource, logger: LDLogger, options: ObservabilityOptions, - instrumentations: List ) { this.instrumentationManager = InstrumentationManager( - application, sdkKey, resource, logger, options, instrumentations + application, sdkKey, resource, logger, options, ) } + val sessionManager get() = instrumentationManager.sessionManager + internal constructor( instrumentationManager: InstrumentationManager ) { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt index 4a5f358008..4698d4191d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityContext.kt @@ -3,6 +3,7 @@ package com.launchdarkly.observability.client import android.app.Application import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.ObservabilityOptions +import io.opentelemetry.android.session.SessionManager /** * Shared information between plugins. @@ -12,4 +13,5 @@ data class ObservabilityContext( val options: ObservabilityOptions, val application: Application, val logger: LDLogger, + var sessionManager: SessionManager? = null, ) 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 deleted file mode 100644 index 370fc58d35..0000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributor.kt +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 03c881e71b..0000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManager.kt +++ /dev/null @@ -1,48 +0,0 @@ -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 b3ea516215..fa447ec6e1 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 @@ -121,13 +121,12 @@ class Observability( } } - val instrumentations = InstrumentationContributorManager.get(lDClient).flatMap { it.provideInstrumentations() } - observabilityClient = ObservabilityClient( - application, sdkKey, resourceBuilder.build(), logger, options, instrumentations + val client = ObservabilityClient( + application, sdkKey, resourceBuilder.build(), logger, options, ) - observabilityClient?.let { - LDObserve.init(it) - } + observabilityClient = client + LDObserve.context?.sessionManager = client.sessionManager + LDObserve.init(client) } else { logger.warn("Observability could not be initialized for sdkKey: $sdkKey") } 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/SessionReplayService.kt similarity index 85% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index 1c8efa59a2..b8aba735bb 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/SessionReplayService.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.ProcessLifecycleOwner 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.CaptureManager import com.launchdarkly.observability.replay.exporter.IdentifyItemPayload import com.launchdarkly.observability.replay.exporter.ImageItemPayload @@ -15,9 +14,9 @@ import com.launchdarkly.observability.replay.exporter.InteractionItemPayload import com.launchdarkly.observability.replay.exporter.SessionReplayExporter import com.launchdarkly.observability.replay.transport.BatchWorker import com.launchdarkly.observability.replay.transport.EventQueue -import com.launchdarkly.observability.sdk.ReplayControl +import com.launchdarkly.observability.sdk.SessionReplayServicing +import com.launchdarkly.sdk.ContextKind import com.launchdarkly.sdk.LDContext -import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -31,8 +30,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException -private const val INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability.replay" - /** * Provides session replay instrumentation. Session replays that are sampled will appear on the LaunchDarkly dashboard. * @@ -61,10 +58,10 @@ private const val INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability.r * @see ReplayOptions for configuration options * @see PrivacyProfile for privacy settings */ -class ReplayInstrumentation( +class SessionReplayService( private val options: ReplayOptions = ReplayOptions(), private val observabilityContext: ObservabilityContext -) : LDExtendedInstrumentation, ReplayControl { +) : SessionReplayServicing { private lateinit var sessionManager: SessionManager private val logger: LDLogger = observabilityContext.logger @@ -82,35 +79,43 @@ class ReplayInstrumentation( private val pendingIdentifyLock = Any() private var pendingIdentify: IdentifyItemPayload? = null - override val name: String = INSTRUMENTATION_SCOPE_NAME - - override fun install(ctx: InstallationContext) { - // If already installed, do nothing. This prevents duplicating collectors and lifecycle listeners. - // We should refactor this if we want to support multiple sessions and install the instrumentation more than once + fun initialize() { if (isInstalled) return - sessionManager = ctx.sessionManager + val sm = observabilityContext.sessionManager ?: run { + logger.warn("SessionReplayService.initialize() called before sessionManager is available; skipping.") + return + } + sessionManager = sm + captureManager = CaptureManager( - sessionManager = ctx.sessionManager, + sessionManager = sm, options = options, logger = observabilityContext.logger ) - interactionSource = InteractionSource(ctx.sessionManager, options.scale) + interactionSource = InteractionSource(sm, options.scale) val initialIdentifyItemPayload = IdentifyItemPayload.from( contextFriendlyName = observabilityContext.options.contextFriendlyName, resourceAttributes = observabilityContext.options.resourceAttributes, - sessionId = null // initial payload is not part SR RRWeb event + sessionId = null ) + val application = observabilityContext.application + val appName = try { + application.packageManager.getApplicationLabel(application.applicationInfo).toString() + } catch (_: Exception) { + "Android app" + } val exporter = SessionReplayExporter( - organizationVerboseId = observabilityContext.sdkKey, // SDK key used as organization ID intentionally + organizationVerboseId = observabilityContext.sdkKey, backendUrl = observabilityContext.options.backendUrl, serviceName = observabilityContext.options.serviceName, serviceVersion = observabilityContext.options.serviceVersion, initialIdentifyItemPayload = initialIdentifyItemPayload, + title = appName, logger = logger ) - this@ReplayInstrumentation.exporter = exporter + this@SessionReplayService.exporter = exporter batchWorker.addExporter(exporter) batchWorker.start() @@ -118,7 +123,7 @@ class ReplayInstrumentation( startCaptureStateObserver() startProcessLifecycleObserver() - interactionSource?.attachToApplication(ctx.application) + interactionSource?.attachToApplication(application) isInstalled = true } @@ -269,7 +274,7 @@ class ReplayInstrumentation( timestamp: Long = System.currentTimeMillis() ) { if (!this::sessionManager.isInitialized || exporter == null) { - logger.warn("identifySession called before ReplayInstrumentation was installed; skipping.") + logger.warn("identifySession called before SessionReplayService was installed; skipping.") return } @@ -294,9 +299,30 @@ class ReplayInstrumentation( synchronized(pendingIdentifyLock) { pendingIdentify = null } + exporter?.sendIdentifyAndCache(event) eventQueue.send(event) } - override fun getLoggerScopeName(): String = INSTRUMENTATION_SCOPE_NAME + override fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) { + if (!completed) return + + val ldContext = buildLDContext(contextKeys) + instrumentationScope.launch { + identifySession(ldContext) + } + } + + private fun buildLDContext(contextKeys: Map): LDContext { + if (contextKeys.size == 1) { + val (kind, key) = contextKeys.entries.first() + return LDContext.create(ContextKind.of(kind), key) + } + val builder = LDContext.multiBuilder() + for ((kind, key) in contextKeys) { + builder.add(LDContext.create(ContextKind.of(kind), key)) + } + return builder.build() + } + } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt index 9ca76c577d..1f3eed550d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt @@ -11,6 +11,7 @@ import com.launchdarkly.observability.replay.IncrementalSource import com.launchdarkly.observability.replay.InteractionEvent import com.launchdarkly.observability.replay.NodeType import com.launchdarkly.observability.replay.Removal +import com.launchdarkly.observability.replay.transport.EventQueueItem import com.launchdarkly.observability.replay.RRWebCustomDataTag import com.launchdarkly.observability.replay.RRWebIncrementalSource import com.launchdarkly.observability.replay.RRWebMouseInteraction @@ -29,7 +30,8 @@ import kotlinx.serialization.json.putJsonObject * Encapsulates generation state like sid sequencing and canvas size accounting. */ class RRWebEventGenerator( - private val canvasDrawEntourage: Int + private val canvasDrawEntourage: Int, + private val title: String ) { companion object { private const val RRWEB_DOCUMENT_PADDING = 11 @@ -410,4 +412,54 @@ class RRWebEventGenerator( data = EventDataUnion.CustomEventDataWrapper(customData) ) } + + /** + * Generates a "Reload" custom event and a sequence of "wake-up" interaction events. + * Used by [SessionReplayExporter] to re-trigger player playback after session resumption. + */ + fun generateWakeUpEvents(timestamp: Long): List { + val imageId = imageNodeId ?: return emptyList() + + return listOf( + generateReloadEvent(timestamp), + // artificial mouse down/up to wake up player + generateMouseInteractionEvent(EventType.INCREMENTAL_SNAPSHOT, RRWebMouseInteraction.MOUSE_DOWN, imageId, timestamp), + generateMouseInteractionEvent(EventType.INCREMENTAL_SNAPSHOT, RRWebMouseInteraction.MOUSE_UP, imageId, timestamp) + ) + } + + private fun generateReloadEvent(timestamp: Long): Event { + val customData = buildJsonObject { + put("tag", JsonPrimitive(RRWebCustomDataTag.RELOAD.wireValue)) + put("payload", JsonPrimitive(title)) + } + return Event( + type = EventType.CUSTOM, + timestamp = timestamp, + sid = nextSid(), + data = EventDataUnion.CustomEventDataWrapper(customData) + ) + } + + private fun generateMouseInteractionEvent( + eventType: EventType, + interactionType: RRWebMouseInteraction, + id: Int, + timestamp: Long + ): Event { + val customData = buildJsonObject { + put("source", RRWebIncrementalSource.MOUSE_INTERACTION.code) + putJsonArray("texts") {} + put("type", interactionType.code) + put("id", id) + put("x", RRWEB_DOCUMENT_PADDING) + put("y", RRWEB_DOCUMENT_PADDING) + } + return Event( + type = eventType, + timestamp = timestamp, + sid = nextSid(), + data = EventDataUnion.CustomEventDataWrapper(customData) + ) + } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt index b767210aa9..4e10fef841 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt @@ -48,7 +48,7 @@ class SessionReplayApiService( "appVersion" to JsonPrimitive(serviceVersion), "serviceName" to JsonPrimitive(serviceName), "fingerprint" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params - "client_id" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params + "client_id" to JsonPrimitive("observability-android"), "network_recording_domains" to JsonArray(emptyList()), "privacy_setting" to JsonPrimitive("none"), // TODO: O11Y-631 - remove hardcoded params "id" to JsonPrimitive("") // TODO: O11Y-631 - remove hardcoded params @@ -77,7 +77,6 @@ class SessionReplayApiService( "user_identifier" to JsonPrimitive(userIdentifier), "user_object" to userObject ) - val response = graphqlClient.execute( queryFileName = IDENTIFY_REPLAY_SESSION_QUERY_FILE_PATH, variables = variables, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt index d9b2e771e9..95aaa1140e 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayExporter.kt @@ -30,6 +30,7 @@ class SessionReplayExporter( val serviceName: String, val serviceVersion: String, val initialIdentifyItemPayload: IdentifyItemPayload, + val title: String, private val injectedReplayApiService: SessionReplayApiService? = null, private val logger: LDLogger, private val canvasBufferLimit: Int = RRWEB_CANVAS_BUFFER_LIMIT, @@ -51,7 +52,8 @@ class SessionReplayExporter( private var identifyItemPayload = initialIdentifyItemPayload // TODO: O11Y-624 - need to implement sid, payloadId reset when multiple sessions occur in one application process lifecycle. private var payloadIdCounter = 0 - private val eventGenerator = RRWebEventGenerator(canvasDrawEntourage) + private var shouldWakeUpSession = true + private val eventGenerator = RRWebEventGenerator(canvasDrawEntourage, title) private data class LastCaptureState( val sessionId: String?, @@ -92,7 +94,8 @@ class SessionReplayExporter( } is IdentifyItemPayload -> { - payload.sessionId?.let { sessionId -> + val sessionId = payload.sessionId ?: lastCaptureSnapshot.sessionId + sessionId?.let { sessionId -> eventGenerator.generateIdentifyEvent(payload)?.let { identifyEvent -> eventsBySession.getOrPut(sessionId) { mutableListOf() }.add(identifyEvent) } @@ -118,8 +121,11 @@ class SessionReplayExporter( replayApiService.pushPayload(sessionId, "${nextPayloadId()}", events) // flushes generating canvas size into pushedCanvasSize pushedCanvasSize = eventGenerator.accumulatedCanvasSize + + wakeUpEvents(events, sessionId) } } + } catch (e: Exception) { // Roll back exporter state so retries regenerate identical events and payload ids. lastCaptureState = lastCaptureSnapshot @@ -131,6 +137,26 @@ class SessionReplayExporter( } } + private suspend fun wakeUpEvents( + events: MutableList, + sessionId: String + ) { + try { + if (shouldWakeUpSession) { + val lastEventTimestamp = events.lastOrNull()?.timestamp ?: 0L + val wakeUpEvents = eventGenerator.generateWakeUpEvents(lastEventTimestamp) + if (wakeUpEvents.isNotEmpty()) { + // we need a separate payload to wake up player + replayApiService.pushPayload(sessionId, "${nextPayloadId()}", wakeUpEvents) + shouldWakeUpSession = false + } + } + } catch (e: Exception) { + // put wake up in the try/catch do not break buffering logic + logger.error(e) + } + } + suspend fun sendIdentifyAndCache(newIdentifyEvent: IdentifyItemPayload) { exportMutex.withLock { val sessionId = newIdentifyEvent.sessionId diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt index 3fb9615da7..74188dbeea 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplay.kt @@ -1,11 +1,8 @@ package com.launchdarkly.observability.replay.plugin 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.replay.ReplayInstrumentation import com.launchdarkly.observability.replay.ReplayOptions +import com.launchdarkly.observability.replay.SessionReplayService import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.sdk.android.LDClient @@ -13,6 +10,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 timber.log.Timber import java.util.Collections @@ -23,12 +21,12 @@ import java.util.Collections */ class SessionReplay( private val options: ReplayOptions = ReplayOptions(), -) : Plugin(), InstrumentationContributor { +) : Plugin() { - private var cachedInstrumentations: List? = null + private val sessionReplayHook = SessionReplayHook() @Volatile - var replayInstrumentation: ReplayInstrumentation? = null + var sessionReplayService: SessionReplayService? = null override fun getMetadata(): PluginMetadata { return object : PluginMetadata() { @@ -38,28 +36,29 @@ class SessionReplay( } override fun register(client: LDClient, metadata: EnvironmentMetadata?) { - System.out.println("LD:SessionReplay:register LDObserve.context= ${LDObserve.context}") - LDObserve.context?.let { - InstrumentationContributorManager.add(client, this) - } ?: run { + val context = LDObserve.context ?: run { Timber.tag(TAG).e("Observability plugin is not initialized") + return } - } - override fun provideInstrumentations(): List = synchronized(this) { - val instrumentations = cachedInstrumentations ?: LDObserve.context?.let { context -> - val instrumentation = ReplayInstrumentation(options, context).also { replayInstrumentation = it } - listOf(instrumentation).also { cachedInstrumentations = it } - }.orEmpty() + if (LDReplay.client != null) { + Timber.tag(TAG).e("Session Replay instance already exists") + return + } - replayInstrumentation?.let(LDReplay::init) - instrumentations + val service = SessionReplayService(options, context) + LDReplay.init(service) + sessionReplayService = service + sessionReplayHook.delegate = service + } override fun getHooks(metadata: EnvironmentMetadata?): MutableList { - return Collections.singletonList( - SessionReplayHook(this) - ) + return Collections.singletonList(sessionReplayHook) + } + + override fun onPluginsReady(result: RegistrationCompleteResult?, metadata: EnvironmentMetadata?) { + sessionReplayService?.initialize() } companion object { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHook.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHook.kt index 9f250c9020..9e0fbb53d4 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHook.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHook.kt @@ -1,28 +1,18 @@ package com.launchdarkly.observability.replay.plugin -import com.launchdarkly.observability.coroutines.DispatcherProviderHolder -import com.launchdarkly.observability.replay.ReplayInstrumentation +import com.launchdarkly.observability.sdk.SessionReplayServicing import com.launchdarkly.sdk.android.integrations.Hook import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch /** - * This class is a hook implementation for recording flag evaluation and identify events - * on spans. + * Hook protocol adapter for the native Android SDK. + * Extracts data from SDK types and delegates to [SessionReplayServicing]. */ -class SessionReplayHook +class SessionReplayHook internal constructor() : Hook(HOOK_NAME) { -/** - * Creates an [SessionReplayHook] - * - */ -internal constructor( - val plugin: SessionReplay -) : Hook(HOOK_NAME) { - private val coroutineScope = CoroutineScope(DispatcherProviderHolder.current.default) + @Volatile + internal var delegate: SessionReplayServicing? = null override fun beforeIdentify( seriesContext: IdentifySeriesContext, @@ -36,13 +26,26 @@ internal constructor( seriesData: Map, result: IdentifySeriesResult ): Map { - if (result.status != IdentifySeriesResult.IdentifySeriesStatus.COMPLETED) { - return seriesData - } + val delegate = delegate ?: return seriesData - coroutineScope.launch { - plugin.replayInstrumentation?.identifySession(seriesContext.context) + val contextKeys = mutableMapOf() + val context = seriesContext.context + if (context.isMultiple) { + for (i in 0 until context.individualContextCount) { + val individual = context.getIndividualContext(i) + if (individual != null) { + contextKeys[individual.kind.toString()] = individual.key + } + } + } else { + contextKeys[context.kind.toString()] = context.key } + + delegate.afterIdentify( + contextKeys = contextKeys, + canonicalKey = context.fullyQualifiedKey, + completed = result.status == IdentifySeriesResult.IdentifySeriesStatus.COMPLETED + ) return seriesData } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHookProxy.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHookProxy.kt new file mode 100644 index 0000000000..b4f24e8351 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/plugin/SessionReplayHookProxy.kt @@ -0,0 +1,18 @@ +package com.launchdarkly.observability.replay.plugin + +import com.launchdarkly.observability.sdk.SessionReplayServicing + +/** + * JVM adapter for the C# / MAUI bridge. + * + * Accepts simple JVM types (String, Map) and delegates + * to [SessionReplayServicing] so the replay logic is written once. + * The C# NativeHookProxy delegates here via the Xamarin.Android binding. + */ +class SessionReplayHookProxy internal constructor( + private val sessionReplayService: SessionReplayServicing +) { + fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) { + sessionReplayService.afterIdentify(contextKeys, canonicalKey, completed) + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/AttributeConverter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/AttributeConverter.kt new file mode 100644 index 0000000000..623c832dd4 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/AttributeConverter.kt @@ -0,0 +1,63 @@ +package com.launchdarkly.observability.sdk + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.common.AttributesBuilder + +/** + * Converts untyped `Map` dictionaries (from bridge layers like .NET MAUI) + * into OTel [Attributes]. + * + * OTel Java [Attributes] is a flat key-value structure, so nested maps are flattened + * with dot-separated keys (e.g. `"nested.child"` for `{"nested": {"child": ...}}`). + */ +object AttributeConverter { + + /** + * Converts a `Map` into OTel [Attributes]. + * Nested maps are flattened with dot-separated keys. + */ + fun convert(source: Map?): Attributes { + if (source.isNullOrEmpty()) return Attributes.empty() + val builder = Attributes.builder() + flattenInto(builder, "", source) + return builder.build() + } + + internal fun flattenInto(builder: AttributesBuilder, prefix: String, source: Map) { + source.forEach { (key, value) -> + val fullKey = if (prefix.isEmpty()) key else "$prefix.$key" + putValue(builder, fullKey, value) + } + } + + @Suppress("UNCHECKED_CAST") + internal fun putValue(builder: AttributesBuilder, key: String, value: Any?) { + when (value) { + is String -> builder.put(AttributeKey.stringKey(key), value) + is Boolean -> builder.put(AttributeKey.booleanKey(key), value) + is Long -> builder.put(AttributeKey.longKey(key), value) + is Int -> builder.put(AttributeKey.longKey(key), value.toLong()) + is Double -> builder.put(AttributeKey.doubleKey(key), value) + is Float -> builder.put(AttributeKey.doubleKey(key), value.toDouble()) + is Map<*, *> -> flattenInto(builder, key, value as Map) + is List<*> -> putList(builder, key, value) + null -> {} + else -> builder.put(AttributeKey.stringKey(key), value.toString()) + } + } + + internal fun putList(builder: AttributesBuilder, key: String, list: List<*>) { + if (list.isEmpty()) return + val first = list.firstOrNull { it != null } ?: return + when (first) { + is String -> builder.put(AttributeKey.stringArrayKey(key), list.filterIsInstance()) + is Boolean -> builder.put(AttributeKey.booleanArrayKey(key), list.filterIsInstance()) + is Long -> builder.put(AttributeKey.longArrayKey(key), list.filterIsInstance()) + is Int -> builder.put(AttributeKey.longArrayKey(key), list.filterIsInstance().map { it.toLong() }) + is Double -> builder.put(AttributeKey.doubleArrayKey(key), list.filterIsInstance()) + is Float -> builder.put(AttributeKey.doubleArrayKey(key), list.filterIsInstance().map { it.toDouble() }) + else -> builder.put(AttributeKey.stringArrayKey(key), list.map { it?.toString() ?: "" }) + } + } +} 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 757e914e44..48b0d382d7 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 @@ -102,5 +102,22 @@ class LDObserve(private val client: Observe) : Observe { override fun recordLog(message: String, severity: Severity, attributes: Attributes) = delegate.recordLog(message, severity, attributes) override fun startSpan(name: String, attributes: Attributes): Span = delegate.startSpan(name, attributes) override fun flush(): Boolean = delegate.flush() + + /** + * Bridge-friendly overloads that avoid exposing OpenTelemetry types + * to callers such as the .NET MAUI native bridge. + */ + + fun recordError(message: String, cause: String? = null) { + val error = Error(message, if (cause != null) Throwable(cause) else null) + delegate.recordError(error, Attributes.empty()) + } + + fun recordLog(message: String, severityNumber: Int, attributes: Map? = null) { + val severity = Severity.values().firstOrNull { it.severityNumber == severityNumber } + ?: Severity.INFO + val attrs = AttributeConverter.convert(attributes) + delegate.recordLog(message, severity, attrs) + } } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt index a8e93bffcb..203f3e3495 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDReplay.kt @@ -1,5 +1,7 @@ package com.launchdarkly.observability.sdk +import com.launchdarkly.observability.replay.plugin.SessionReplayHookProxy + /** * LDReplay is the singleton entry point for controlling Session Replay capture. * @@ -7,17 +9,28 @@ package com.launchdarkly.observability.sdk */ object LDReplay { @Volatile - private var delegate: ReplayControl = object : ReplayControl { + internal var client: SessionReplayServicing? = null + + /** + * Hook proxy for the C# / MAUI bridge. + */ + val hookProxy: SessionReplayHookProxy? + get() = client?.let { SessionReplayHookProxy(it) } + + @Volatile + private var delegate: SessionReplayServicing = object : SessionReplayServicing { override fun start() {} override fun stop() {} override fun flush() {} + override fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) {} } /** * Wires LDReplay to the active Session Replay controller. */ - internal fun init(controller: ReplayControl) { + internal fun init(controller: SessionReplayServicing) { delegate = controller + client = controller } /** @@ -42,8 +55,9 @@ object LDReplay { } } -internal interface ReplayControl { +internal interface SessionReplayServicing { fun start() fun stop() fun flush() + fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) } 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 1c1cf8d9af..a1fdc791a4 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 @@ -2,9 +2,7 @@ package com.launchdarkly.observability.client import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation import com.launchdarkly.observability.sampling.ExportSampler -import io.mockk.every import io.mockk.mockk import io.mockk.verify import io.opentelemetry.api.common.Attributes @@ -14,11 +12,6 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -/** - * Test class focused on testing the createLoggerProcessor method logic. - * This test verifies that the RoutingLogRecordProcessor is properly configured - * with instrumentation-specific log record processors. - */ class InstrumentationManagerTest { private lateinit var mockSdkLoggerProviderBuilder: SdkLoggerProviderBuilder @@ -39,60 +32,7 @@ class InstrumentationManagerTest { } @Test - fun `createLoggerProcessor should register instrumentation log record processors with correct scope names`() { - // Arrange - val mockInstrumentation1 = mockk(relaxed = true) - 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 - - testObservabilityOptions = ObservabilityOptions() - - // Act - val logProcessor = InstrumentationManager.createLoggerProcessor( - sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, - exportSampler = mockExportSampler, - sdkKey = testSdkKey, - resource = testResource, - logger = mockLogger, - telemetryInspector = null, - observabilityOptions = testObservabilityOptions, - 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) } - verify { mockInstrumentation2.getLoggerScopeName() } - verify { mockInstrumentation2.getLogRecordProcessor(testSdkKey) } - } - - @Test - fun `createLoggerProcessor should handle instrumentations with null log record processors`() { - // Arrange - val mockInstrumentation = mockk(relaxed = true) - val scopeName = "com.test.instrumentation" - - every { mockInstrumentation.getLoggerScopeName() } returns scopeName - every { mockInstrumentation.getLogRecordProcessor(testSdkKey) } returns null - - testObservabilityOptions = ObservabilityOptions() - - // Act + fun `createLoggerProcessor returns a valid processor`() { val logProcessor = InstrumentationManager.createLoggerProcessor( sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, exportSampler = mockExportSampler, @@ -101,42 +41,9 @@ class InstrumentationManagerTest { logger = mockLogger, telemetryInspector = null, observabilityOptions = testObservabilityOptions, - 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 - verify(exactly = 0) { mockInstrumentation.getLoggerScopeName() } - } - - @Test - fun `createLoggerProcessor should handle empty instrumentations list`() { - // Arrange - testObservabilityOptions = ObservabilityOptions() - - // Act - val logProcessor = InstrumentationManager.createLoggerProcessor( - sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, - exportSampler = mockExportSampler, - sdkKey = testSdkKey, - resource = testResource, - logger = mockLogger, - telemetryInspector = null, - observabilityOptions = testObservabilityOptions, - instrumentations = listOf() - ) - - // Assert - assertNotNull(logProcessor) - - // Verify that the logger provider builder was configured verify { mockSdkLoggerProviderBuilder.setResource(testResource) } } -} \ No newline at end of file +} 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 deleted file mode 100644 index ad1f5a24a5..0000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/plugin/InstrumentationContributorManagerTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -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/SessionReplayExporterTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayExporterTest.kt index cf61f97b6d..3e57901d2d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayExporterTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayExporterTest.kt @@ -34,10 +34,11 @@ class SessionReplayExporterTest { backendUrl = "http://test.com", serviceName = "test-service", serviceVersion = "1.0.0", + initialIdentifyItemPayload = identifyEvent, + title = "test-app", injectedReplayApiService = mockService, canvasBufferLimit = 45, canvasDrawEntourage = 1, - initialIdentifyItemPayload = identifyEvent, logger = mockk() ) } @@ -54,6 +55,7 @@ class SessionReplayExporterTest { serviceName = "test-service", serviceVersion = "1.0.0", initialIdentifyItemPayload = identifyEvent, + title = "test-app", injectedReplayApiService = mockService, logger = mockk() ) @@ -71,6 +73,7 @@ class SessionReplayExporterTest { serviceName = "test-service", serviceVersion = "1.0.0", initialIdentifyItemPayload = identifyEvent, + title = "test-app", logger = mockk() ) @@ -214,8 +217,8 @@ class SessionReplayExporterTest { // Verify identifyReplaySession is called twice (first capture + dimension change) coVerify(exactly = 1) { mockService.identifyReplaySession(eq("session-a"), any()) } - // Verify pushPayload is called for all captures - coVerify(exactly = 1) { + // Verify pushPayload is called for captures + wake-up events + coVerify(exactly = 2) { mockService.pushPayload("session-a", any(), any()) } @@ -286,7 +289,7 @@ class SessionReplayExporterTest { mockService.initializeReplaySession("test-org", "session-a") } coVerify(exactly = 1) { mockService.identifyReplaySession(eq("session-a"), any()) } - coVerify(exactly = 1) { + coVerify(exactly = 2) { mockService.pushPayload("session-a", any(), any()) } } @@ -352,7 +355,7 @@ class SessionReplayExporterTest { // Verify API calls: First capture should be full, second should be incremental coVerify(exactly = 1) { mockService.initializeReplaySession("test-org", "session-a") } coVerify(exactly = 1) { mockService.identifyReplaySession(eq("session-a"), any()) } - coVerify(exactly = 1) { mockService.pushPayload("session-a", any(), any()) } + coVerify(exactly = 2) { mockService.pushPayload("session-a", any(), any()) } } @Test 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 d44c9d6fb5..dee71c2a04 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/SessionReplayTest.kt @@ -3,14 +3,15 @@ package com.launchdarkly.observability.replay import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.client.ObservabilityContext -import com.launchdarkly.observability.plugin.InstrumentationContributorManager import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.observability.sdk.LDObserve +import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.sdk.android.LDClient import io.mockk.mockk import io.mockk.unmockkAll import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -21,20 +22,20 @@ class SessionReplayTest { @BeforeEach fun setUp() { - InstrumentationContributorManager.reset() client = mockk(relaxed = true) LDObserve.context = null + LDReplay.client = null } @AfterEach fun tearDown() { - InstrumentationContributorManager.reset() LDObserve.context = null + LDReplay.client = null unmockkAll() } @Test - fun `register adds session replay when observability is initialized`() { + fun `register creates service and wires up when observability is initialized`() { LDObserve.context = ObservabilityContext( sdkKey = "test-sdk-key", options = ObservabilityOptions(), @@ -45,38 +46,18 @@ class SessionReplayTest { sessionReplay.register(client, null) - val contributors = InstrumentationContributorManager.get(client) - assertTrue(contributors.contains(sessionReplay)) - assertEquals(listOf(sessionReplay), contributors) + assertNotNull(sessionReplay.sessionReplayService) + assertNotNull(LDReplay.client) + assertTrue(LDReplay.client is SessionReplayService) } @Test - fun `register doesn't add session replay when observability is not initialized`() { + fun `register does nothing 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 = ObservabilityOptions(), - 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()) + assertNull(sessionReplay.sessionReplayService) + assertNull(LDReplay.client) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt index d3dbb4c5c3..e034a92291 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt @@ -17,7 +17,7 @@ import org.junit.jupiter.api.Test class RRWebEventGeneratorTest { @Test fun `convenience export frame uses jpeg mime type`() { - val generator = RRWebEventGenerator(canvasDrawEntourage = 1) + val generator = RRWebEventGenerator(canvasDrawEntourage = 1, title = "test") val exportFrame = ExportFrame("AQ==", 88, 120, 1L, "session") val events = generator.generateCaptureFullEvents(exportFrame) @@ -28,7 +28,7 @@ class RRWebEventGeneratorTest { @Test fun `keyframe incremental resolves removes before map reset`() { - val generator = RRWebEventGenerator(canvasDrawEntourage = 1) + val generator = RRWebEventGenerator(canvasDrawEntourage = 1, title = "test") val sigA = ImageSignature(rows = 1, columns = 1, tileWidth = 64, tileHeight = 22, tileSignatures = listOf(TileSignature(101))) val sigB = ImageSignature(rows = 1, columns = 1, tileWidth = 64, tileHeight = 22, tileSignatures = listOf(TileSignature(202))) @@ -69,7 +69,7 @@ class RRWebEventGeneratorTest { @Test fun `backtracking supports two remove-only rollbacks`() { - val generator = RRWebEventGenerator(canvasDrawEntourage = 1) + val generator = RRWebEventGenerator(canvasDrawEntourage = 1, title = "test") val sigA = ImageSignature(rows = 1, columns = 1, tileWidth = 64, tileHeight = 22, tileSignatures = listOf(TileSignature(101))) val sigB = ImageSignature(rows = 1, columns = 1, tileWidth = 64, tileHeight = 22, tileSignatures = listOf(TileSignature(202))) val sigC = ImageSignature(rows = 1, columns = 1, tileWidth = 64, tileHeight = 22, tileSignatures = listOf(TileSignature(303))) diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RawFramesRRWebEventGeneratorTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RawFramesRRWebEventGeneratorTest.kt index 54b4b4908d..4710e81a99 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RawFramesRRWebEventGeneratorTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RawFramesRRWebEventGeneratorTest.kt @@ -41,7 +41,7 @@ class RawFramesRRWebEventGeneratorTest { val method = ReplayOptions.CompressionMethod.OverlayTiles(layers = 15, backtracking = false) val tileDiffManager = mockk() val exportDiffManager = ExportDiffManager(compression = method, scale = 1f, tileDiffManager = tileDiffManager) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300) + val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "test") val sigRed = imageSignature(11) val sigGreen = imageSignature(22) @@ -91,7 +91,7 @@ class RawFramesRRWebEventGeneratorTest { val method = ReplayOptions.CompressionMethod.OverlayTiles(layers = 15, backtracking = true) val tileDiffManager = mockk() val exportDiffManager = ExportDiffManager(compression = method, scale = 1f, tileDiffManager = tileDiffManager) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300) + val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "test") val sigBase = imageSignature(101) val sigBar = imageSignature(202) @@ -149,7 +149,7 @@ class RawFramesRRWebEventGeneratorTest { val method = ReplayOptions.CompressionMethod.OverlayTiles(layers = 15, backtracking = true) val tileDiffManager = mockk() val exportDiffManager = ExportDiffManager(compression = method, scale = 1f, tileDiffManager = tileDiffManager) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300) + val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "test") val sigA = imageSignature(301) val sigB = imageSignature(302) @@ -227,7 +227,7 @@ class RawFramesRRWebEventGeneratorTest { val method = ReplayOptions.CompressionMethod.OverlayTiles(layers = 3, backtracking = true) val tileDiffManager = mockk() val exportDiffManager = ExportDiffManager(compression = method, scale = 1f, tileDiffManager = tileDiffManager) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300) + val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "test") val sigA = imageSignature(801) val sigB = imageSignature(802) @@ -310,7 +310,7 @@ class RawFramesRRWebEventGeneratorTest { val method = ReplayOptions.CompressionMethod.OverlayTiles(layers = 3, backtracking = true) val tileDiffManager = mockk() val exportDiffManager = ExportDiffManager(compression = method, scale = 1f, tileDiffManager = tileDiffManager) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300) + val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "test") val sigA = ImageSignature( rows = 1, columns = 2, tileWidth = 60, tileHeight = 88, diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/AttributeConverterTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/AttributeConverterTest.kt new file mode 100644 index 0000000000..f60eb6bf9e --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/AttributeConverterTest.kt @@ -0,0 +1,228 @@ +package com.launchdarkly.observability.sdk + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class AttributeConverterTest { + + @Nested + inner class ScalarValues { + + @Test + fun `converts String value`() { + val result = AttributeConverter.convert(mapOf("key" to "hello")) + assertEquals("hello", result.get(AttributeKey.stringKey("key"))) + } + + @Test + fun `converts Boolean value`() { + val result = AttributeConverter.convert(mapOf("flag" to true)) + assertEquals(true, result.get(AttributeKey.booleanKey("flag"))) + } + + @Test + fun `converts Int value as Long`() { + val result = AttributeConverter.convert(mapOf("count" to 42)) + assertEquals(42L, result.get(AttributeKey.longKey("count"))) + } + + @Test + fun `converts Long value`() { + val result = AttributeConverter.convert(mapOf("big" to 123456789L)) + assertEquals(123456789L, result.get(AttributeKey.longKey("big"))) + } + + @Test + fun `converts Double value`() { + val result = AttributeConverter.convert(mapOf("rate" to 3.14)) + assertEquals(3.14, result.get(AttributeKey.doubleKey("rate"))) + } + + @Test + fun `converts Float value as Double`() { + val result = AttributeConverter.convert(mapOf("rate" to 1.5f)) + assertEquals(1.5, result.get(AttributeKey.doubleKey("rate"))) + } + + @Test + fun `falls back to string for unsupported types`() { + val obj = object { + override fun toString() = "custom-object" + } + val result = AttributeConverter.convert(mapOf("thing" to obj)) + assertEquals("custom-object", result.get(AttributeKey.stringKey("thing"))) + } + + @Test + fun `skips null values`() { + val result = AttributeConverter.convert(mapOf("key" to null)) + assertTrue(result.isEmpty) + } + } + + @Nested + inner class NestedMaps { + + @Test + fun `flattens nested map with dot-separated keys`() { + val source = mapOf( + "parent" to mapOf("child" to "value") + ) + val result = AttributeConverter.convert(source) + assertEquals("value", result.get(AttributeKey.stringKey("parent.child"))) + } + + @Test + fun `flattens deeply nested maps`() { + val source = mapOf( + "level1" to mapOf( + "level2" to mapOf( + "level3" to mapOf( + "value" to 42 + ) + ) + ) + ) + val result = AttributeConverter.convert(source) + assertEquals(42L, result.get(AttributeKey.longKey("level1.level2.level3.value"))) + } + + @Test + fun `flattens nested map alongside flat keys`() { + val source = mapOf( + "flat" to "top", + "nested" to mapOf("inner" to "deep") + ) + val result = AttributeConverter.convert(source) + assertEquals("top", result.get(AttributeKey.stringKey("flat"))) + assertEquals("deep", result.get(AttributeKey.stringKey("nested.inner"))) + } + } + + @Nested + inner class ListValues { + + @Test + fun `converts list of Strings`() { + val result = AttributeConverter.convert(mapOf("tags" to listOf("a", "b", "c"))) + assertEquals(listOf("a", "b", "c"), result.get(AttributeKey.stringArrayKey("tags"))) + } + + @Test + fun `converts list of Booleans`() { + val result = AttributeConverter.convert(mapOf("flags" to listOf(true, false, true))) + assertEquals(listOf(true, false, true), result.get(AttributeKey.booleanArrayKey("flags"))) + } + + @Test + fun `converts list of Ints as Longs`() { + val result = AttributeConverter.convert(mapOf("ids" to listOf(1, 2, 3))) + assertEquals(listOf(1L, 2L, 3L), result.get(AttributeKey.longArrayKey("ids"))) + } + + @Test + fun `converts list of Longs`() { + val result = AttributeConverter.convert(mapOf("ids" to listOf(10L, 20L))) + assertEquals(listOf(10L, 20L), result.get(AttributeKey.longArrayKey("ids"))) + } + + @Test + fun `converts list of Doubles`() { + val result = AttributeConverter.convert(mapOf("rates" to listOf(1.1, 2.2))) + assertEquals(listOf(1.1, 2.2), result.get(AttributeKey.doubleArrayKey("rates"))) + } + + @Test + fun `converts list of Floats as Doubles`() { + val result = AttributeConverter.convert(mapOf("rates" to listOf(1.5f, 2.5f))) + assertEquals(listOf(1.5, 2.5), result.get(AttributeKey.doubleArrayKey("rates"))) + } + + @Test + fun `skips empty list`() { + val result = AttributeConverter.convert(mapOf("empty" to emptyList())) + assertNull(result.get(AttributeKey.stringArrayKey("empty"))) + } + + @Test + fun `skips list of all nulls`() { + val result = AttributeConverter.convert(mapOf("nulls" to listOf(null, null))) + assertNull(result.get(AttributeKey.stringArrayKey("nulls"))) + } + + @Test + fun `falls back to string list for unsupported element types`() { + data class Custom(val v: Int) + val result = AttributeConverter.convert(mapOf("objs" to listOf(Custom(1), Custom(2)))) + assertEquals(listOf("Custom(v=1)", "Custom(v=2)"), result.get(AttributeKey.stringArrayKey("objs"))) + } + } + + @Nested + inner class EdgeCases { + + @Test + fun `returns empty Attributes for null source`() { + val result = AttributeConverter.convert(null) + assertTrue(result.isEmpty) + } + + @Test + fun `returns empty Attributes for empty map`() { + val result = AttributeConverter.convert(emptyMap()) + assertTrue(result.isEmpty) + } + + @Test + fun `handles HashMap input`() { + val source = hashMapOf("key" to "value") + val result = AttributeConverter.convert(source) + assertEquals("value", result.get(AttributeKey.stringKey("key"))) + } + } + + @Nested + inner class MauiSamplePayload { + + @Test + fun `converts the MAUI sample nested payload end-to-end`() { + val source = mapOf( + "test-log" to "maui", + "nested" to mapOf( + "array" to listOf(1) + ) + ) + + val result = AttributeConverter.convert(source) + + assertEquals("maui", result.get(AttributeKey.stringKey("test-log"))) + assertEquals(listOf(1L), result.get(AttributeKey.longArrayKey("nested.array"))) + } + + @Test + fun `converts mixed nested payload`() { + val source = mapOf( + "service" to "android", + "metadata" to mapOf( + "version" to "1.0", + "flags" to listOf(true, false), + "deep" to mapOf( + "value" to 99.9 + ) + ) + ) + + val result = AttributeConverter.convert(source) + + assertEquals("android", result.get(AttributeKey.stringKey("service"))) + assertEquals("1.0", result.get(AttributeKey.stringKey("metadata.version"))) + assertEquals(listOf(true, false), result.get(AttributeKey.booleanArrayKey("metadata.flags"))) + assertEquals(99.9, result.get(AttributeKey.doubleKey("metadata.deep.value"))) + } + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt index 7459f74141..99dac6184c 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sdk/LDReplayTest.kt @@ -5,7 +5,7 @@ import org.junit.jupiter.api.Test class LDReplayTest { - private class TestControl : ReplayControl { + private class TestControl : SessionReplayServicing { var startCalls = 0 var stopCalls = 0 var flushCalls = 0 @@ -21,6 +21,8 @@ class LDReplayTest { override fun flush() { flushCalls++ } + + override fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) {} } @Test