From 30982774446bad9f5941d1f6a0ff869ed2d4d0ff Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 27 Mar 2026 14:55:19 -0700 Subject: [PATCH 1/9] renaming to ObservabilityService to match Swift --- .../{ObservabilityClient.kt => ObservabilityService.kt} | 6 +++--- .../com/launchdarkly/observability/plugin/Observability.kt | 6 +++--- .../kotlin/com/launchdarkly/observability/sdk/LDObserve.kt | 6 +++--- ...servabilityClientTest.kt => ObservabilityServiceTest.kt} | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) rename sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/{ObservabilityClient.kt => ObservabilityService.kt} (96%) rename sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/{ObservabilityClientTest.kt => ObservabilityServiceTest.kt} (79%) 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/ObservabilityService.kt similarity index 96% rename from sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityClient.kt rename to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index 4c68230f37..bf78ab8e35 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/ObservabilityService.kt @@ -13,19 +13,19 @@ import io.opentelemetry.api.trace.Span import io.opentelemetry.sdk.resources.Resource /** - * The [ObservabilityClient] can be used for recording observability data such as + * The [ObservabilityService] can be used for recording observability data such as * metrics, logs, errors, and traces. * * 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. * */ -class ObservabilityClient : Observe { +class ObservabilityService : Observe { private val instrumentationManager: InstrumentationManager internal val hookExporter: ObservabilityHookExporter /** - * Creates a new ObservabilityClient. + * Creates a new ObservabilityService. * * @param application The application instance. * @param sdkKey The SDK key for the environment. 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 76f99732e5..86f44b5e0d 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 @@ -6,7 +6,7 @@ import com.launchdarkly.logging.LDLogger import com.launchdarkly.logging.Logs import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.client.ObservabilityClient +import com.launchdarkly.observability.client.ObservabilityService import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.sdk.LDObserve @@ -56,7 +56,7 @@ class Observability( ) : Plugin() { private val logger: LDLogger private val observabilityHook = ObservabilityHook() - private var observabilityClient: ObservabilityClient? = null + private var observabilityClient: ObservabilityService? = null private var client: LDClient? = null init { @@ -115,7 +115,7 @@ class Observability( } } - val client = ObservabilityClient( + val client = ObservabilityService( application, sdkKey, resourceBuilder.build(), logger, options, ) observabilityClient = client 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 f3d8392aa8..af3a00b8c9 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,7 +1,7 @@ package com.launchdarkly.observability.sdk import com.launchdarkly.observability.bridge.AttributeConverter -import com.launchdarkly.observability.client.ObservabilityClient +import com.launchdarkly.observability.client.ObservabilityService import com.launchdarkly.observability.client.ObservabilityContext import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe @@ -83,10 +83,10 @@ class LDObserve(private val client: Observe) : Observe { internal set @Volatile - internal var observabilityClient: ObservabilityClient? = null + internal var observabilityClient: ObservabilityService? = null private set - fun init(client: ObservabilityClient) { + fun init(client: ObservabilityService) { observabilityClient = client delegate = LDObserve(client) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityClientTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt similarity index 79% rename from sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityClientTest.kt rename to sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt index 4cf757241c..cd565a86d0 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityClientTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt @@ -7,10 +7,10 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -class ObservabilityClientTest { +class ObservabilityServiceTest { private lateinit var mockInstrumentationManager: InstrumentationManager - private lateinit var observabilityClient: ObservabilityClient + private lateinit var observabilityClient: ObservabilityService @BeforeEach fun setup() { @@ -18,7 +18,7 @@ class ObservabilityClientTest { every { flush() } returns true } - observabilityClient = ObservabilityClient(mockInstrumentationManager) + observabilityClient = ObservabilityService(mockInstrumentationManager) } @Test From ecb214e87c0bddcdc7de1098fbe11b12a5f3369d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 27 Mar 2026 15:13:42 -0700 Subject: [PATCH 2/9] Remove instrumentation manager --- .../client/InstrumentationManager.kt | 487 ------------------ .../client/ObservabilityService.kt | 478 +++++++++++++++-- .../replay/SessionReplayService.kt | 2 +- .../observability/sampling/SpansSampler.kt | 4 +- .../client/InstrumentationManagerTest.kt | 49 -- .../client/ObservabilityServiceTest.kt | 44 +- .../sampling/SpansSamplerTest.kt | 6 +- 7 files changed, 466 insertions(+), 604 deletions(-) delete mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt delete mode 100644 sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/InstrumentationManagerTest.kt 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 deleted file mode 100644 index 653f833c1a..0000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt +++ /dev/null @@ -1,487 +0,0 @@ -package com.launchdarkly.observability.client - -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.Metric -import com.launchdarkly.observability.network.GraphQLClient -import com.launchdarkly.observability.network.SamplingApiService -import com.launchdarkly.observability.sampling.CustomSampler -import com.launchdarkly.observability.sampling.ExportSampler -import com.launchdarkly.observability.sampling.SamplingConfig -import com.launchdarkly.observability.sampling.SamplingLogProcessor -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 -import io.opentelemetry.api.metrics.DoubleGauge -import io.opentelemetry.api.metrics.DoubleHistogram -import io.opentelemetry.api.metrics.LongCounter -import io.opentelemetry.api.metrics.LongUpDownCounter -import io.opentelemetry.api.metrics.Meter -import io.opentelemetry.api.trace.Span -import io.opentelemetry.api.trace.Tracer -import io.opentelemetry.context.Context -import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter -import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter -import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter -import io.opentelemetry.sdk.common.CompletableResultCode -import io.opentelemetry.sdk.logs.LogRecordProcessor -import io.opentelemetry.sdk.logs.SdkLoggerProviderBuilder -import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor -import io.opentelemetry.sdk.logs.export.LogRecordExporter -import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder -import io.opentelemetry.sdk.metrics.export.AggregationTemporalitySelector -import io.opentelemetry.sdk.metrics.export.MetricExporter -import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader -import io.opentelemetry.sdk.resources.Resource -import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder -import io.opentelemetry.sdk.trace.SpanProcessor -import io.opentelemetry.sdk.trace.export.BatchSpanProcessor -import io.opentelemetry.sdk.trace.export.SpanExporter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap -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 for authentication. - * @param resources The OpenTelemetry resource describing this service. - * @param logger The logger for internal logging. - * @param observabilityOptions Additional configuration options for the SDK. - */ -class InstrumentationManager( - private val application: Application, - private val sdkKey: String, - private val resources: Resource, - private val logger: LDLogger, - private val observabilityOptions: ObservabilityOptions, -) { - private val otelRUM: OpenTelemetryRum - var sessionManager: SessionManager? = null - private set - private var otelMeter: Meter - private var otelLogger: Logger - private var otelTracer: Tracer - private var customSampler = CustomSampler() - private val graphqlClient = GraphQLClient( - endpoint = observabilityOptions.backendUrl, - logger = logger - ) - private val samplingApiService = SamplingApiService(graphqlClient) - private var telemetryInspector: TelemetryInspector? = null - private var spanProcessor: SpanProcessor? = null - private var logProcessor: LogRecordProcessor? = null - private var metricsReader: PeriodicMetricReader? = null - private val gaugeCache = ConcurrentHashMap() - private val counterCache = ConcurrentHashMap() - private val histogramCache = ConcurrentHashMap() - private val upDownCounterCache = ConcurrentHashMap() - - //TODO: Evaluate if this class should have a close/shutdown method to close this scope - private val scope = CoroutineScope(DispatcherProviderHolder.current.io + SupervisorJob()) - - init { - initializeTelemetryInspector() - val otelRumConfig = createOtelRumConfig() - - var capturedSessionManager: SessionManager? = null - - val rumBuilder = OpenTelemetryRum.builder(application, otelRumConfig) - .addLoggerProviderCustomizer { sdkLoggerProviderBuilder, _ -> - val processor = createLoggerProcessor( - sdkLoggerProviderBuilder, - customSampler, - sdkKey, - resources, - logger, - telemetryInspector, - observabilityOptions, - ) - logProcessor = processor - return@addLoggerProviderCustomizer sdkLoggerProviderBuilder.addLogRecordProcessor(processor) - } - .addTracerProviderCustomizer { sdkTracerProviderBuilder, _ -> - return@addTracerProviderCustomizer configureTracerProvider(sdkTracerProviderBuilder) - } - .addMeterProviderCustomizer { sdkMeterProviderBuilder, _ -> - return@addMeterProviderCustomizer configureMeterProvider(sdkMeterProviderBuilder) - } - - 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 - if (sessionManager == null) { - logger.warn("SessionManager was not captured during OpenTelemetryRum.build(); session-dependent features will be unavailable.") - } - loadSamplingConfigAsync() - - otelMeter = otelRUM.openTelemetry.meterProvider.get(INSTRUMENTATION_SCOPE_NAME) - otelLogger = otelRUM.openTelemetry.logsBridge.get(INSTRUMENTATION_SCOPE_NAME) - otelTracer = otelRUM.openTelemetry.tracerProvider.get(INSTRUMENTATION_SCOPE_NAME) - } - - private fun createOtelRumConfig(): OtelRumConfig { - val config = OtelRumConfig() - .setSessionConfig(SessionConfig(backgroundInactivityTimeout = observabilityOptions.sessionBackgroundTimeout)) - - if (!observabilityOptions.instrumentations.crashReporting) { - // Disables [io.opentelemetry.android.instrumentation.crash.CrashReporterInstrumentation.java] - config.suppressInstrumentation("crash") - } - - if(!observabilityOptions.instrumentations.activityLifecycle) { - // Disables [io.opentelemetry.android.instrumentation.activity.ActivityLifecycleInstrumentation.java] - config.suppressInstrumentation("activity") - } - - return config - } - - private fun addLaunchTimeInstrumentation(rumBuilder: OpenTelemetryRumBuilder) { - val launchTimeInstrumentation = LaunchTimeInstrumentation( - application = application, - metricRecorder = { metric -> - val histogram = histogramCache.getOrPut(metric.name) { - otelMeter.histogramBuilder(metric.name).build() - } - histogram.record(metric.value, metric.attributes.addSessionId()) - } - ) - rumBuilder.addInstrumentation(launchTimeInstrumentation) - } - - private fun configureTracerProvider(sdkTracerProviderBuilder: SdkTracerProviderBuilder): SdkTracerProviderBuilder { - val primarySpanExporter = createOtlpSpanExporter() - sdkTracerProviderBuilder.setResource(resources) - - val finalExporter = createSpanExporter(primarySpanExporter) - val processor = createBatchSpanProcessor(finalExporter) - - spanProcessor = processor - return sdkTracerProviderBuilder.addSpanProcessor(processor) - } - - private fun configureMeterProvider(sdkMeterProviderBuilder: SdkMeterProviderBuilder): SdkMeterProviderBuilder { - val primaryMetricExporter = createOtlpMetricExporter() - - val finalExporter = createMetricExporter(primaryMetricExporter) - val metricReader = createPeriodicMetricReader(finalExporter) - - metricsReader = metricReader - return sdkMeterProviderBuilder - .setResource(resources) - .registerMetricReader(metricReader) - } - - private fun createOtlpSpanExporter(): SpanExporter { - return OtlpHttpSpanExporter.builder() - .setEndpoint(observabilityOptions.otlpEndpoint + TRACES_PATH) - .setHeaders { observabilityOptions.customHeaders } - .build() - } - - private fun createOtlpMetricExporter(): MetricExporter { - return OtlpHttpMetricExporter.builder() - .setEndpoint(observabilityOptions.otlpEndpoint + METRICS_PATH) - .setHeaders { observabilityOptions.customHeaders } - .setAggregationTemporalitySelector(AggregationTemporalitySelector.deltaPreferred()) - .build() - } - - private fun createSpanExporter(primaryExporter: SpanExporter): SpanExporter { - val baseExporter = if (observabilityOptions.debug) { - SpanExporter.composite( - buildList { - add(primaryExporter) - add(DebugSpanExporter(logger)) - telemetryInspector?.let { add(it.spanExporter) } - } - ) - } else { - primaryExporter - } - - return SamplingTraceExporter(baseExporter, customSampler) - } - - private fun createMetricExporter(primaryExporter: MetricExporter): MetricExporter { - return if (observabilityOptions.debug) { - CompositeMetricExporter( - buildList { - add(primaryExporter) - add(DebugMetricExporter(logger)) - telemetryInspector?.let { add(it.metricExporter) } - } - ) - } else { - primaryExporter - } - } - - private fun createPeriodicMetricReader(metricExporter: MetricExporter): PeriodicMetricReader { - // Configure a periodic reader that pushes metrics every 10 seconds. - return PeriodicMetricReader.builder(metricExporter) - .setInterval(METRICS_EXPORT_INTERVAL_MS, TimeUnit.MILLISECONDS) - .build() - } - - private fun initializeTelemetryInspector() { - if (observabilityOptions.debug) { - telemetryInspector = TelemetryInspector() - } - } - - private fun loadSamplingConfigAsync() { - scope.launch { - val samplingConfig = getSamplingConfig() - if (samplingConfig != null) { - logger.info("Sampling configuration was successfully loaded") - } - customSampler.setConfig(samplingConfig) - } - } - - private fun createBatchSpanProcessor(spanExporter: SpanExporter): BatchSpanProcessor { - return BatchSpanProcessor.builder(spanExporter) - .setMaxQueueSize(BATCH_MAX_QUEUE_SIZE) - .setScheduleDelay(BATCH_SCHEDULE_DELAY_MS, TimeUnit.MILLISECONDS) - .setExporterTimeout(BATCH_EXPORTER_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .setMaxExportBatchSize(BATCH_MAX_EXPORT_SIZE) - .build() - } - - fun recordMetric(metric: Metric) { - if (!observabilityOptions.metricsApi.enabled) return - - val gauge = gaugeCache.getOrPut(metric.name) { - otelMeter.gaugeBuilder(metric.name).build() - } - gauge.set(metric.value, metric.attributes.addSessionId()) - } - - fun recordCount(metric: Metric) { - if (!observabilityOptions.metricsApi.enabled) return - - // TODO: handle double casting to long better - val counter = counterCache.getOrPut(metric.name) { - otelMeter.counterBuilder(metric.name).build() - } - counter.add(metric.value.toLong(), metric.attributes.addSessionId()) - } - - fun recordIncr(metric: Metric) { - if (!observabilityOptions.metricsApi.enabled) return - - val counter = counterCache.getOrPut(metric.name) { - otelMeter.counterBuilder(metric.name).build() - } - // It increments the value until the metric is exported, then it’s reset. - counter.add(1, metric.attributes.addSessionId()) - } - - fun recordHistogram(metric: Metric) { - if (!observabilityOptions.metricsApi.enabled) return - - val histogram = histogramCache.getOrPut(metric.name) { - otelMeter.histogramBuilder(metric.name).build() - } - histogram.record(metric.value, metric.attributes.addSessionId()) - } - - fun recordUpDownCounter(metric: Metric) { - if (!observabilityOptions.metricsApi.enabled) return - - val upDownCounter = upDownCounterCache.getOrPut(metric.name) { - otelMeter.upDownCounterBuilder(metric.name).build() - } - upDownCounter.add(metric.value.toLong(), metric.attributes.addSessionId()) - } - - fun recordLog( - message: String, - severity: Severity, - attributes: Attributes - ) { - if (observabilityOptions.logsApiLevel.level > severity.severityNumber) return - - otelLogger.logRecordBuilder() - .setBody(message) - .setTimestamp(System.currentTimeMillis(), TimeUnit.MILLISECONDS) - .setSeverity(severity) - .setSeverityText(severity.toString()) - .setAllAttributes(attributes) - .emit() - } - - fun recordError(error: Error, attributes: Attributes) { - if (!observabilityOptions.tracesApi.includeErrors) return - - val span = otelTracer - .spanBuilder(ERROR_SPAN_NAME) - .setParent(Context.current().with(Span.current())) - .startSpan() - - val attrBuilder = Attributes.builder() - attrBuilder.putAll(attributes) - - span.recordException(error, attrBuilder.build()) - span.end() - } - - fun startSpan(name: String, attributes: Attributes): Span { - if (!observabilityOptions.tracesApi.includeSpans) return Span.getInvalid() - - return otelTracer.spanBuilder(name) - .setAllAttributes(attributes) - .startSpan() - } - - /** - * Returns the telemetry inspector if debug option is enabled. - * - * @return TelemetryInspector instance or null - */ - fun getTelemetryInspector(): TelemetryInspector? = telemetryInspector - - /** - * Returns the tracer instance for creating spans. - * - * @return Tracer instance - */ - fun getTracer(): Tracer = otelTracer - - /** - * Flushes all pending telemetry data (traces, logs, metrics). - * @return true if all flush operations succeeded, false otherwise - */ - fun flush(): Boolean { - val results = listOfNotNull( - spanProcessor?.forceFlush(), - logProcessor?.forceFlush(), - metricsReader?.forceFlush() - ) - - // Wait for all flush operations to complete with a single 5 second timeout - return CompletableResultCode.ofAll(results) - .join(FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .isSuccess - } - - /** - * Fetches sampling configuration from GraphQL endpoint - * @return SamplingConfig or null if error occurs - */ - private suspend fun getSamplingConfig(): SamplingConfig? { - return try { - samplingApiService.getSamplingConfig(sdkKey) - } catch (err: Exception) { - logger.warn("Failed to get sampling config: ${err.message}") - null - } - } - - private fun Attributes.addSessionId() = this.toBuilder().put(SESSION_ID_ATTRIBUTE, otelRUM.rumSessionId).build() - - companion object { - private const val METRICS_PATH = "/v1/metrics" - private const val LOGS_PATH = "/v1/logs" - private const val TRACES_PATH = "/v1/traces" - private const val INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability" - const val ERROR_SPAN_NAME = "highlight.error" - const val SESSION_ID_ATTRIBUTE = "session.id" - private const val BATCH_MAX_QUEUE_SIZE = 100 - private const val BATCH_SCHEDULE_DELAY_MS = 1000L - private const val BATCH_EXPORTER_TIMEOUT_MS = 5000L - private const val BATCH_MAX_EXPORT_SIZE = 10 - private const val METRICS_EXPORT_INTERVAL_MS = 10_000L - private const val FLUSH_TIMEOUT_SECONDS = 5L - - internal fun createLoggerProcessor( - sdkLoggerProviderBuilder: SdkLoggerProviderBuilder, - exportSampler: ExportSampler, - sdkKey: String, - resource: Resource, - logger: LDLogger, - telemetryInspector: TelemetryInspector?, - observabilityOptions: ObservabilityOptions, - ): LogRecordProcessor { - val primaryLogExporter = createOtlpLogExporter(observabilityOptions) - sdkLoggerProviderBuilder.setResource(resource) - - val finalExporter = createLogExporter( - primaryExporter = primaryLogExporter, - logger = logger, - telemetryInspector = telemetryInspector, - observabilityOptions = observabilityOptions - ) - - return SamplingLogProcessor( - delegate = createBatchLogRecordProcessor(finalExporter), - sampler = exportSampler - ) - } - - private fun createOtlpLogExporter(observabilityOptions: ObservabilityOptions): LogRecordExporter { - return OtlpHttpLogRecordExporter.builder() - .setEndpoint(observabilityOptions.otlpEndpoint + LOGS_PATH) - .setHeaders { observabilityOptions.customHeaders } - .build() - } - - private fun createLogExporter( - primaryExporter: LogRecordExporter, - logger: LDLogger, - telemetryInspector: TelemetryInspector?, - observabilityOptions: ObservabilityOptions - ): LogRecordExporter { - return if (observabilityOptions.debug) { - LogRecordExporter.composite( - buildList { - add(primaryExporter) - add(DebugLogExporter(logger)) - telemetryInspector?.let { add(it.logExporter) } - } - ) - } else { - primaryExporter - } - } - - fun createBatchLogRecordProcessor(logRecordExporter: LogRecordExporter): BatchLogRecordProcessor { - return BatchLogRecordProcessor.builder(logRecordExporter) - .setMaxQueueSize(BATCH_MAX_QUEUE_SIZE) - .setScheduleDelay(BATCH_SCHEDULE_DELAY_MS, TimeUnit.MILLISECONDS) - .setExporterTimeout(BATCH_EXPORTER_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .setMaxExportBatchSize(BATCH_MAX_EXPORT_SIZE) - .build() - } - } -} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index bf78ab8e35..6523e06ab7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -3,98 +3,378 @@ package com.launchdarkly.observability.client 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.Metric import com.launchdarkly.observability.interfaces.Observe +import com.launchdarkly.observability.network.GraphQLClient +import com.launchdarkly.observability.network.SamplingApiService import com.launchdarkly.observability.plugin.ObservabilityHookExporter +import com.launchdarkly.observability.sampling.CustomSampler +import com.launchdarkly.observability.sampling.ExportSampler +import com.launchdarkly.observability.sampling.SamplingConfig +import com.launchdarkly.observability.sampling.SamplingLogProcessor +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 +import io.opentelemetry.api.metrics.DoubleGauge +import io.opentelemetry.api.metrics.DoubleHistogram +import io.opentelemetry.api.metrics.LongCounter +import io.opentelemetry.api.metrics.LongUpDownCounter +import io.opentelemetry.api.metrics.Meter import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.context.Context +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.LogRecordProcessor +import io.opentelemetry.sdk.logs.SdkLoggerProviderBuilder +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder +import io.opentelemetry.sdk.metrics.export.AggregationTemporalitySelector +import io.opentelemetry.sdk.metrics.export.MetricExporter +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder +import io.opentelemetry.sdk.trace.SpanProcessor +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor +import io.opentelemetry.sdk.trace.export.SpanExporter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit /** * The [ObservabilityService] can be used for recording observability data such as * metrics, logs, errors, and traces. * + * 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. + * * 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 for authentication. + * @param resources The OpenTelemetry resource describing this service. + * @param logger The logger for internal logging. + * @param observabilityOptions Additional configuration options for the SDK. */ -class ObservabilityService : Observe { - private val instrumentationManager: InstrumentationManager +class ObservabilityService( + private val application: Application, + private val sdkKey: String, + private val resources: Resource, + private val logger: LDLogger, + private val observabilityOptions: ObservabilityOptions, +) : Observe { + private val otelRUM: OpenTelemetryRum + var sessionManager: SessionManager? = null + private set + private var otelMeter: Meter + private var otelLogger: Logger + private var otelTracer: Tracer + private var customSampler = CustomSampler() + private val graphqlClient = GraphQLClient( + endpoint = observabilityOptions.backendUrl, + logger = logger + ) + private val samplingApiService = SamplingApiService(graphqlClient) + private var telemetryInspector: TelemetryInspector? = null + private var spanProcessor: SpanProcessor? = null + private var logProcessor: LogRecordProcessor? = null + private var metricsReader: PeriodicMetricReader? = null + private val gaugeCache = ConcurrentHashMap() + private val counterCache = ConcurrentHashMap() + private val histogramCache = ConcurrentHashMap() + private val upDownCounterCache = ConcurrentHashMap() internal val hookExporter: ObservabilityHookExporter - /** - * Creates a new ObservabilityService. - * - * @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. - */ - constructor( - application: Application, - sdkKey: String, - resource: Resource, - logger: LDLogger, - options: ObservabilityOptions, - ) { - this.instrumentationManager = InstrumentationManager( - application, sdkKey, resource, logger, options, - ) - this.hookExporter = ObservabilityHookExporter( + //TODO: Evaluate if this class should have a close/shutdown method to close this scope + private val scope = CoroutineScope(DispatcherProviderHolder.current.io + SupervisorJob()) + + init { + initializeTelemetryInspector() + val otelRumConfig = createOtelRumConfig() + + var capturedSessionManager: SessionManager? = null + + val rumBuilder = OpenTelemetryRum.builder(application, otelRumConfig) + .addLoggerProviderCustomizer { sdkLoggerProviderBuilder, _ -> + val processor = createLoggerProcessor( + sdkLoggerProviderBuilder, + customSampler, + sdkKey, + resources, + logger, + telemetryInspector, + observabilityOptions, + ) + logProcessor = processor + return@addLoggerProviderCustomizer sdkLoggerProviderBuilder.addLogRecordProcessor(processor) + } + .addTracerProviderCustomizer { sdkTracerProviderBuilder, _ -> + return@addTracerProviderCustomizer configureTracerProvider(sdkTracerProviderBuilder) + } + .addMeterProviderCustomizer { sdkMeterProviderBuilder, _ -> + return@addMeterProviderCustomizer configureMeterProvider(sdkMeterProviderBuilder) + } + + 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 + if (sessionManager == null) { + logger.warn("SessionManager was not captured during OpenTelemetryRum.build(); session-dependent features will be unavailable.") + } + loadSamplingConfigAsync() + + otelMeter = otelRUM.openTelemetry.meterProvider.get(INSTRUMENTATION_SCOPE_NAME) + otelLogger = otelRUM.openTelemetry.logsBridge.get(INSTRUMENTATION_SCOPE_NAME) + otelTracer = otelRUM.openTelemetry.tracerProvider.get(INSTRUMENTATION_SCOPE_NAME) + + hookExporter = ObservabilityHookExporter( withSpans = true, withValue = true, - tracerProvider = { instrumentationManager.getTracer() }, - contextFriendlyName = options.contextFriendlyName + tracerProvider = { getTracer() }, + contextFriendlyName = observabilityOptions.contextFriendlyName ) } - val sessionManager: SessionManager? get() = instrumentationManager.sessionManager + private fun createOtelRumConfig(): OtelRumConfig { + val config = OtelRumConfig() + .setSessionConfig(SessionConfig(backgroundInactivityTimeout = observabilityOptions.sessionBackgroundTimeout)) - internal constructor( - instrumentationManager: InstrumentationManager - ) { - this.instrumentationManager = instrumentationManager - this.hookExporter = ObservabilityHookExporter( - withSpans = true, - withValue = true, - tracerProvider = { instrumentationManager.getTracer() }, - contextFriendlyName = null + if (!observabilityOptions.instrumentations.crashReporting) { + // Disables [io.opentelemetry.android.instrumentation.crash.CrashReporterInstrumentation.java] + config.suppressInstrumentation("crash") + } + + if(!observabilityOptions.instrumentations.activityLifecycle) { + // Disables [io.opentelemetry.android.instrumentation.activity.ActivityLifecycleInstrumentation.java] + config.suppressInstrumentation("activity") + } + + return config + } + + private fun addLaunchTimeInstrumentation(rumBuilder: OpenTelemetryRumBuilder) { + val launchTimeInstrumentation = LaunchTimeInstrumentation( + application = application, + metricRecorder = { metric -> + val histogram = histogramCache.getOrPut(metric.name) { + otelMeter.histogramBuilder(metric.name).build() + } + histogram.record(metric.value, metric.attributes.addSessionId()) + } ) + rumBuilder.addInstrumentation(launchTimeInstrumentation) + } + + private fun configureTracerProvider(sdkTracerProviderBuilder: SdkTracerProviderBuilder): SdkTracerProviderBuilder { + val primarySpanExporter = createOtlpSpanExporter() + sdkTracerProviderBuilder.setResource(resources) + + val finalExporter = createSpanExporter(primarySpanExporter) + val processor = createBatchSpanProcessor(finalExporter) + + spanProcessor = processor + return sdkTracerProviderBuilder.addSpanProcessor(processor) + } + + private fun configureMeterProvider(sdkMeterProviderBuilder: SdkMeterProviderBuilder): SdkMeterProviderBuilder { + val primaryMetricExporter = createOtlpMetricExporter() + + val finalExporter = createMetricExporter(primaryMetricExporter) + val metricReader = createPeriodicMetricReader(finalExporter) + + metricsReader = metricReader + return sdkMeterProviderBuilder + .setResource(resources) + .registerMetricReader(metricReader) + } + + private fun createOtlpSpanExporter(): SpanExporter { + return OtlpHttpSpanExporter.builder() + .setEndpoint(observabilityOptions.otlpEndpoint + TRACES_PATH) + .setHeaders { observabilityOptions.customHeaders } + .build() + } + + private fun createOtlpMetricExporter(): MetricExporter { + return OtlpHttpMetricExporter.builder() + .setEndpoint(observabilityOptions.otlpEndpoint + METRICS_PATH) + .setHeaders { observabilityOptions.customHeaders } + .setAggregationTemporalitySelector(AggregationTemporalitySelector.deltaPreferred()) + .build() + } + + private fun createSpanExporter(primaryExporter: SpanExporter): SpanExporter { + val baseExporter = if (observabilityOptions.debug) { + SpanExporter.composite( + buildList { + add(primaryExporter) + add(DebugSpanExporter(logger)) + telemetryInspector?.let { add(it.spanExporter) } + } + ) + } else { + primaryExporter + } + + return SamplingTraceExporter(baseExporter, customSampler) + } + + private fun createMetricExporter(primaryExporter: MetricExporter): MetricExporter { + return if (observabilityOptions.debug) { + CompositeMetricExporter( + buildList { + add(primaryExporter) + add(DebugMetricExporter(logger)) + telemetryInspector?.let { add(it.metricExporter) } + } + ) + } else { + primaryExporter + } + } + + private fun createPeriodicMetricReader(metricExporter: MetricExporter): PeriodicMetricReader { + return PeriodicMetricReader.builder(metricExporter) + .setInterval(METRICS_EXPORT_INTERVAL_MS, TimeUnit.MILLISECONDS) + .build() + } + + private fun initializeTelemetryInspector() { + if (observabilityOptions.debug) { + telemetryInspector = TelemetryInspector() + } + } + + private fun loadSamplingConfigAsync() { + scope.launch { + val samplingConfig = getSamplingConfig() + if (samplingConfig != null) { + logger.info("Sampling configuration was successfully loaded") + } + customSampler.setConfig(samplingConfig) + } + } + + private fun createBatchSpanProcessor(spanExporter: SpanExporter): BatchSpanProcessor { + return BatchSpanProcessor.builder(spanExporter) + .setMaxQueueSize(BATCH_MAX_QUEUE_SIZE) + .setScheduleDelay(BATCH_SCHEDULE_DELAY_MS, TimeUnit.MILLISECONDS) + .setExporterTimeout(BATCH_EXPORTER_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .setMaxExportBatchSize(BATCH_MAX_EXPORT_SIZE) + .build() } override fun recordMetric(metric: Metric) { - instrumentationManager.recordMetric(metric) + if (!observabilityOptions.metricsApi.enabled) return + + val gauge = gaugeCache.getOrPut(metric.name) { + otelMeter.gaugeBuilder(metric.name).build() + } + gauge.set(metric.value, metric.attributes.addSessionId()) } override fun recordCount(metric: Metric) { - instrumentationManager.recordCount(metric) + if (!observabilityOptions.metricsApi.enabled) return + + // TODO: handle double casting to long better + val counter = counterCache.getOrPut(metric.name) { + otelMeter.counterBuilder(metric.name).build() + } + counter.add(metric.value.toLong(), metric.attributes.addSessionId()) } override fun recordIncr(metric: Metric) { - instrumentationManager.recordIncr(metric) + if (!observabilityOptions.metricsApi.enabled) return + + val counter = counterCache.getOrPut(metric.name) { + otelMeter.counterBuilder(metric.name).build() + } + // It increments the value until the metric is exported, then it's reset. + counter.add(1, metric.attributes.addSessionId()) } override fun recordHistogram(metric: Metric) { - instrumentationManager.recordHistogram(metric) + if (!observabilityOptions.metricsApi.enabled) return + + val histogram = histogramCache.getOrPut(metric.name) { + otelMeter.histogramBuilder(metric.name).build() + } + histogram.record(metric.value, metric.attributes.addSessionId()) } override fun recordUpDownCounter(metric: Metric) { - instrumentationManager.recordUpDownCounter(metric) + if (!observabilityOptions.metricsApi.enabled) return + + val upDownCounter = upDownCounterCache.getOrPut(metric.name) { + otelMeter.upDownCounterBuilder(metric.name).build() + } + upDownCounter.add(metric.value.toLong(), metric.attributes.addSessionId()) } - override fun recordError(error: Error, attributes: Attributes) { - instrumentationManager.recordError(error, attributes) + override fun recordLog( + message: String, + severity: Severity, + attributes: Attributes + ) { + if (observabilityOptions.logsApiLevel.level > severity.severityNumber) return + + otelLogger.logRecordBuilder() + .setBody(message) + .setTimestamp(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + .setSeverity(severity) + .setSeverityText(severity.toString()) + .setAllAttributes(attributes) + .emit() } - override fun recordLog(message: String, severity: Severity, attributes: Attributes) { - instrumentationManager.recordLog(message, severity, attributes) + override fun recordError(error: Error, attributes: Attributes) { + if (!observabilityOptions.tracesApi.includeErrors) return + + val span = otelTracer + .spanBuilder(ERROR_SPAN_NAME) + .setParent(Context.current().with(Span.current())) + .startSpan() + + val attrBuilder = Attributes.builder() + attrBuilder.putAll(attributes) + + span.recordException(error, attrBuilder.build()) + span.end() } override fun startSpan(name: String, attributes: Attributes): Span { - return instrumentationManager.startSpan(name, attributes) + if (!observabilityOptions.tracesApi.includeSpans) return Span.getInvalid() + + return otelTracer.spanBuilder(name) + .setAllAttributes(attributes) + .startSpan() } /** @@ -106,18 +386,118 @@ class ObservabilityService : Observe { * * @return TelemetryInspector instance if debug is enabled, null otherwise */ - fun getTelemetryInspector(): TelemetryInspector? { - return instrumentationManager.getTelemetryInspector() - } + fun getTelemetryInspector(): TelemetryInspector? = telemetryInspector /** * Returns the tracer instance for creating spans. * * @return Tracer instance */ - fun getTracer() = instrumentationManager.getTracer() + fun getTracer(): Tracer = otelTracer + /** + * Flushes all pending telemetry data (traces, logs, metrics). + * @return true if all flush operations succeeded, false otherwise + */ override fun flush(): Boolean { - return instrumentationManager.flush() + val results = listOfNotNull( + spanProcessor?.forceFlush(), + logProcessor?.forceFlush(), + metricsReader?.forceFlush() + ) + + return CompletableResultCode.ofAll(results) + .join(FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .isSuccess + } + + /** + * Fetches sampling configuration from GraphQL endpoint + * @return SamplingConfig or null if error occurs + */ + private suspend fun getSamplingConfig(): SamplingConfig? { + return try { + samplingApiService.getSamplingConfig(sdkKey) + } catch (err: Exception) { + logger.warn("Failed to get sampling config: ${err.message}") + null + } + } + + private fun Attributes.addSessionId() = this.toBuilder().put(SESSION_ID_ATTRIBUTE, otelRUM.rumSessionId).build() + + companion object { + private const val METRICS_PATH = "/v1/metrics" + private const val LOGS_PATH = "/v1/logs" + private const val TRACES_PATH = "/v1/traces" + private const val INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability" + const val ERROR_SPAN_NAME = "highlight.error" + const val SESSION_ID_ATTRIBUTE = "session.id" + private const val BATCH_MAX_QUEUE_SIZE = 100 + private const val BATCH_SCHEDULE_DELAY_MS = 1000L + private const val BATCH_EXPORTER_TIMEOUT_MS = 5000L + private const val BATCH_MAX_EXPORT_SIZE = 10 + private const val METRICS_EXPORT_INTERVAL_MS = 10_000L + private const val FLUSH_TIMEOUT_SECONDS = 5L + + internal fun createLoggerProcessor( + sdkLoggerProviderBuilder: SdkLoggerProviderBuilder, + exportSampler: ExportSampler, + sdkKey: String, + resource: Resource, + logger: LDLogger, + telemetryInspector: TelemetryInspector?, + observabilityOptions: ObservabilityOptions, + ): LogRecordProcessor { + val primaryLogExporter = createOtlpLogExporter(observabilityOptions) + sdkLoggerProviderBuilder.setResource(resource) + + val finalExporter = createLogExporter( + primaryExporter = primaryLogExporter, + logger = logger, + telemetryInspector = telemetryInspector, + observabilityOptions = observabilityOptions + ) + + return SamplingLogProcessor( + delegate = createBatchLogRecordProcessor(finalExporter), + sampler = exportSampler + ) + } + + private fun createOtlpLogExporter(observabilityOptions: ObservabilityOptions): LogRecordExporter { + return OtlpHttpLogRecordExporter.builder() + .setEndpoint(observabilityOptions.otlpEndpoint + LOGS_PATH) + .setHeaders { observabilityOptions.customHeaders } + .build() + } + + private fun createLogExporter( + primaryExporter: LogRecordExporter, + logger: LDLogger, + telemetryInspector: TelemetryInspector?, + observabilityOptions: ObservabilityOptions + ): LogRecordExporter { + return if (observabilityOptions.debug) { + LogRecordExporter.composite( + buildList { + add(primaryExporter) + add(DebugLogExporter(logger)) + telemetryInspector?.let { add(it.logExporter) } + } + ) + } else { + primaryExporter + } + } + + fun createBatchLogRecordProcessor(logRecordExporter: LogRecordExporter): BatchLogRecordProcessor { + return BatchLogRecordProcessor.builder(logRecordExporter) + .setMaxQueueSize(BATCH_MAX_QUEUE_SIZE) + .setScheduleDelay(BATCH_SCHEDULE_DELAY_MS, TimeUnit.MILLISECONDS) + .setExporterTimeout(BATCH_EXPORTER_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .setMaxExportBatchSize(BATCH_MAX_EXPORT_SIZE) + .build() + } } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt index b8aba735bb..9c6b04a6ed 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayService.kt @@ -233,7 +233,7 @@ class SessionReplayService( } } - // TODO: O11Y-621 - This should be called somewhere (Probably inside InstrumentationManager.kt) to shutdown the instrumentation. + // TODO: O11Y-621 - This should be called somewhere (Probably inside ObservabilityService.kt) to shutdown the instrumentation. fun shutdown() { pauseCapture() stopProcessLifecycleObserver() diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/SpansSampler.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/SpansSampler.kt index d94ada54fb..0d55276185 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/SpansSampler.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/SpansSampler.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.sampling -import com.launchdarkly.observability.client.InstrumentationManager +import com.launchdarkly.observability.client.ObservabilityService import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.SpanKind import io.opentelemetry.context.Context @@ -36,7 +36,7 @@ class SpansSampler( override fun getDescription(): String = "LaunchDarklySpansSampler" private fun shouldRecordSpan(spanName: String): Boolean { - val isErrorSpan = spanName == InstrumentationManager.ERROR_SPAN_NAME + val isErrorSpan = spanName == ObservabilityService.ERROR_SPAN_NAME return when { isErrorSpan -> allowErrorSpans 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 deleted file mode 100644 index a1fdc791a4..0000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/InstrumentationManagerTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.launchdarkly.observability.client - -import com.launchdarkly.logging.LDLogger -import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.sampling.ExportSampler -import io.mockk.mockk -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 - -class InstrumentationManagerTest { - - private lateinit var mockSdkLoggerProviderBuilder: SdkLoggerProviderBuilder - private lateinit var mockExportSampler: ExportSampler - private lateinit var mockLogger: LDLogger - private lateinit var testResource: Resource - private lateinit var testSdkKey: String - private lateinit var testObservabilityOptions: ObservabilityOptions - - @BeforeEach - fun setup() { - mockSdkLoggerProviderBuilder = mockk(relaxed = true) - mockExportSampler = mockk(relaxed = true) - mockLogger = mockk(relaxed = true) - testResource = Resource.create(Attributes.empty()) - testSdkKey = "test-sdk-key" - testObservabilityOptions = ObservabilityOptions() - } - - @Test - fun `createLoggerProcessor returns a valid processor`() { - val logProcessor = InstrumentationManager.createLoggerProcessor( - sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, - exportSampler = mockExportSampler, - sdkKey = testSdkKey, - resource = testResource, - logger = mockLogger, - telemetryInspector = null, - observabilityOptions = testObservabilityOptions, - ) - - assertNotNull(logProcessor) - verify { mockSdkLoggerProviderBuilder.setResource(testResource) } - } -} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt index cd565a86d0..b76f5e4200 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityServiceTest.kt @@ -1,31 +1,49 @@ package com.launchdarkly.observability.client -import io.mockk.every +import com.launchdarkly.logging.LDLogger +import com.launchdarkly.observability.api.ObservabilityOptions +import com.launchdarkly.observability.sampling.ExportSampler import io.mockk.mockk import io.mockk.verify -import org.junit.jupiter.api.Assertions.assertTrue +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 class ObservabilityServiceTest { - private lateinit var mockInstrumentationManager: InstrumentationManager - private lateinit var observabilityClient: ObservabilityService + private lateinit var mockSdkLoggerProviderBuilder: SdkLoggerProviderBuilder + private lateinit var mockExportSampler: ExportSampler + private lateinit var mockLogger: LDLogger + private lateinit var testResource: Resource + private lateinit var testSdkKey: String + private lateinit var testObservabilityOptions: ObservabilityOptions @BeforeEach fun setup() { - mockInstrumentationManager = mockk { - every { flush() } returns true - } - - observabilityClient = ObservabilityService(mockInstrumentationManager) + mockSdkLoggerProviderBuilder = mockk(relaxed = true) + mockExportSampler = mockk(relaxed = true) + mockLogger = mockk(relaxed = true) + testResource = Resource.create(Attributes.empty()) + testSdkKey = "test-sdk-key" + testObservabilityOptions = ObservabilityOptions() } @Test - fun `should delegate flush to underlying InstrumentationManager and propagate result`() { - val result = observabilityClient.flush() + fun `createLoggerProcessor returns a valid processor`() { + val logProcessor = ObservabilityService.createLoggerProcessor( + sdkLoggerProviderBuilder = mockSdkLoggerProviderBuilder, + exportSampler = mockExportSampler, + sdkKey = testSdkKey, + resource = testResource, + logger = mockLogger, + telemetryInspector = null, + observabilityOptions = testObservabilityOptions, + ) - assertTrue(result) - verify(exactly = 1) { mockInstrumentationManager.flush() } + assertNotNull(logProcessor) + verify { mockSdkLoggerProviderBuilder.setResource(testResource) } } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/SpansSamplerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/SpansSamplerTest.kt index 440a45ed8b..c0f110661d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/SpansSamplerTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/SpansSamplerTest.kt @@ -1,6 +1,6 @@ package com.launchdarkly.observability.sampling -import com.launchdarkly.observability.client.InstrumentationManager +import com.launchdarkly.observability.client.ObservabilityService import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.SpanKind import io.opentelemetry.context.Context @@ -32,7 +32,7 @@ class SpansSamplerTest { fun `should record error spans when error sampling enabled`() { val sampler = SpansSampler(allowNormalSpans = false, allowErrorSpans = true) - val result = sample(sampler, spanName = InstrumentationManager.ERROR_SPAN_NAME) + val result = sample(sampler, spanName = ObservabilityService.ERROR_SPAN_NAME) assertEquals(SamplingDecision.RECORD_AND_SAMPLE, result.decision) } @@ -41,7 +41,7 @@ class SpansSamplerTest { fun `should drop error spans when error sampling disabled`() { val sampler = SpansSampler(allowNormalSpans = true, allowErrorSpans = false) - val result = sample(sampler, spanName = InstrumentationManager.ERROR_SPAN_NAME) + val result = sample(sampler, spanName = ObservabilityService.ERROR_SPAN_NAME) assertEquals(SamplingDecision.DROP, result.decision) } From b34ffb86fae56a72edb69a7289aa86df14fea23f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 27 Mar 2026 16:03:43 -0700 Subject: [PATCH 3/9] Refactor opentelemetry testing --- .../androidobservability/BaseApplication.kt | 3 --- .../androidobservability/SamplingE2ETest.kt | 4 ++-- .../androidobservability/TestApplication.kt | 6 +++++ .../example/androidobservability/TestUtils.kt | 4 ++-- .../lib/build.gradle.kts | 8 +++---- .../observability/api/ObservabilityOptions.kt | 5 ++++ .../client/ObservabilityService.kt | 20 +--------------- .../client/TelemetryInspector.kt | 23 +++++++++---------- .../observability/plugin/Observability.kt | 2 +- .../testing/InMemoryTelemetryInspector.kt | 18 +++++++++++++++ 10 files changed, 50 insertions(+), 43 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/InMemoryTelemetryInspector.kt diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt index dde23d36bb..de279acd31 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 @@ -4,7 +4,6 @@ import android.app.Application import android.util.Log import android.widget.ImageView import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayOptions @@ -42,7 +41,6 @@ open class BaseApplication : Application() { logAdapter = LDAndroidLogging.adapter(), ) - var telemetryInspector: TelemetryInspector? = null var testUrl: String? = null open fun realInit() { @@ -89,7 +87,6 @@ open class BaseApplication : Application() { .build() LDClient.init(this@BaseApplication, ldConfig, context, 1) - telemetryInspector = observabilityPlugin.getTelemetryInspector() if (testUrl == null) { // intervenes in E2E tests by trigger spans diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt b/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt index ca0fd6756e..acfc3fc819 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/SamplingE2ETest.kt @@ -5,7 +5,7 @@ import androidx.test.core.app.ApplicationProvider import com.example.androidobservability.TestUtils.TelemetryType import com.example.androidobservability.TestUtils.waitForTelemetryData import com.launchdarkly.observability.api.ObservabilityOptions -import com.launchdarkly.observability.client.TelemetryInspector +import com.launchdarkly.observability.testing.InMemoryTelemetryInspector import com.launchdarkly.observability.sdk.LDObserve import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes @@ -46,7 +46,7 @@ class SamplingE2ETest { val testCoroutineRule = TestCoroutineRule() private val application = ApplicationProvider.getApplicationContext() as TestApplication - private var telemetryInspector: TelemetryInspector? = null + private var telemetryInspector: InMemoryTelemetryInspector? = null @Before fun setUp() { diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt b/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt index bb75e6b291..dc8e806090 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/TestApplication.kt @@ -1,5 +1,6 @@ package com.example.androidobservability +import com.launchdarkly.observability.testing.InMemoryTelemetryInspector import com.launchdarkly.sdk.android.LDClient import io.opentelemetry.android.features.diskbuffering.SignalFromDiskExporter import okhttp3.mockwebserver.MockResponse @@ -10,6 +11,8 @@ class TestApplication : BaseApplication() { private val host = "127.0.0.1" var mockWebServer: MockWebServer? = null + var telemetryInspector: InMemoryTelemetryInspector? = null + private set override fun onCreate() { // The Application class won't be initialized unless initForTest() is executed. This helps us to set up @@ -40,6 +43,9 @@ class TestApplication : BaseApplication() { fun initForTest() { setupMockServer() + val inspector = InMemoryTelemetryInspector() + telemetryInspector = inspector + observabilityOptions = observabilityOptions.copy(telemetryInspector = inspector) super.realInit() } diff --git a/e2e/android/app/src/test/java/com/example/androidobservability/TestUtils.kt b/e2e/android/app/src/test/java/com/example/androidobservability/TestUtils.kt index 3f95de146a..7257f7d2a0 100644 --- a/e2e/android/app/src/test/java/com/example/androidobservability/TestUtils.kt +++ b/e2e/android/app/src/test/java/com/example/androidobservability/TestUtils.kt @@ -1,12 +1,12 @@ package com.example.androidobservability -import com.launchdarkly.observability.client.TelemetryInspector +import com.launchdarkly.observability.testing.InMemoryTelemetryInspector object TestUtils { fun waitForTelemetryData( maxWaitMs: Long = 5000, - telemetryInspector: TelemetryInspector?, + telemetryInspector: InMemoryTelemetryInspector?, telemetryType: TelemetryType ): Boolean { val startTime = System.currentTimeMillis() diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 391873a075..9acb301920 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -47,10 +47,7 @@ dependencies { implementation("io.opentelemetry:opentelemetry-sdk-logs:1.51.0") // TODO: Evaluate risks associated with incubator APIs - implementation("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") - - // Testing exporters for telemetry inspection - implementation("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") + // implementation("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") // OTEL Android implementation("io.opentelemetry.android:core:0.11.0-alpha") @@ -61,6 +58,8 @@ dependencies { implementation("io.opentelemetry.android.instrumentation:activity:0.11.0-alpha") // Use JUnit Jupiter for testing. + // Testing exporters for telemetry inspection + testImplementation("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -68,6 +67,7 @@ dependencies { testImplementation("io.mockk:mockk:1.14.5") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testFixturesImplementation("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") testFixturesImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt index 0a96555c36..04dfeea988 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt @@ -2,6 +2,7 @@ package com.launchdarkly.observability.api import com.launchdarkly.logging.LDLogAdapter import com.launchdarkly.observability.BuildConfig +import com.launchdarkly.observability.client.TelemetryInspector import com.launchdarkly.sdk.android.LDTimberLogging import io.opentelemetry.api.common.Attributes import kotlin.time.Duration @@ -28,6 +29,9 @@ const val DEFAULT_BACKEND_URL = "https://pub.observability.app.launchdarkly.com" * @property instrumentations Options for configuring automatic instrumentations. See [Instrumentations]. * @property logAdapter The log adapter to use. Defaults to using the LaunchDarkly SDK's LDTimberLogging.adapter(). Use LDAndroidLogging.adapter() to use the Android logging adapter. * @property loggerName The name of the logger to use. Defaults to "LaunchDarklyObservabilityPlugin". + * @property telemetryInspector Optional [TelemetryInspector] for intercepting exported telemetry during testing. + * When provided together with [debug] = true, the inspector's exporters are wired into composite + * exporters so that test code can assert on the data that flows through the SDK. */ data class ObservabilityOptions( val enabled: Boolean = true, @@ -46,6 +50,7 @@ data class ObservabilityOptions( val instrumentations: Instrumentations = Instrumentations(), val logAdapter: LDLogAdapter = LDTimberLogging.adapter(), // This follows the LaunchDarkly SDK's default log adapter val loggerName: String = "LaunchDarklyObservabilityPlugin", + val telemetryInspector: TelemetryInspector? = null, ){ /** * Options for configuring traces. diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index 6523e06ab7..98a3ae2cfd 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -92,7 +92,7 @@ class ObservabilityService( logger = logger ) private val samplingApiService = SamplingApiService(graphqlClient) - private var telemetryInspector: TelemetryInspector? = null + private val telemetryInspector: TelemetryInspector? = observabilityOptions.telemetryInspector private var spanProcessor: SpanProcessor? = null private var logProcessor: LogRecordProcessor? = null private var metricsReader: PeriodicMetricReader? = null @@ -106,7 +106,6 @@ class ObservabilityService( private val scope = CoroutineScope(DispatcherProviderHolder.current.io + SupervisorJob()) init { - initializeTelemetryInspector() val otelRumConfig = createOtelRumConfig() var capturedSessionManager: SessionManager? = null @@ -266,12 +265,6 @@ class ObservabilityService( .build() } - private fun initializeTelemetryInspector() { - if (observabilityOptions.debug) { - telemetryInspector = TelemetryInspector() - } - } - private fun loadSamplingConfigAsync() { scope.launch { val samplingConfig = getSamplingConfig() @@ -377,17 +370,6 @@ class ObservabilityService( .startSpan() } - /** - * Returns the telemetry inspector for accessing intercepted telemetry data. - * - * This method provides access to spans and logs that have been exported by the SDK - * for debugging, testing, or other purposes. The inspector is only available - * if debug was enabled via "Options.debug". - * - * @return TelemetryInspector instance if debug is enabled, null otherwise - */ - fun getTelemetryInspector(): TelemetryInspector? = telemetryInspector - /** * Returns the tracer instance for creating spans. * diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/TelemetryInspector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/TelemetryInspector.kt index bd53635d29..dd1660792e 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/TelemetryInspector.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/TelemetryInspector.kt @@ -1,19 +1,18 @@ package com.launchdarkly.observability.client -import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter -import io.opentelemetry.sdk.testing.exporter.InMemoryMetricExporter -import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.metrics.export.MetricExporter +import io.opentelemetry.sdk.trace.export.SpanExporter /** - * This class wraps the OpenTelemetry testing exporters + * Provides access to telemetry exporters for inspecting exported data during testing. * - * @param spanExporter The in-memory span exporter to read from - * @param logExporter The in-memory log exporter to read from - * @param metricExporter The in-memory metric exporter to read from + * Production code uses the base exporter types to wire into composite exporters. + * Test code should use [com.launchdarkly.observability.testing.InMemoryTelemetryInspector] + * which exposes the concrete in-memory exporter types for assertions. */ -class TelemetryInspector( -) { - val spanExporter: InMemorySpanExporter by lazy { InMemorySpanExporter.create() } - val logExporter:InMemoryLogRecordExporter by lazy { InMemoryLogRecordExporter.create() } - val metricExporter: InMemoryMetricExporter by lazy { InMemoryMetricExporter.create() } +interface TelemetryInspector { + val spanExporter: SpanExporter + val logExporter: LogRecordExporter + val metricExporter: MetricExporter } 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 86f44b5e0d..b1c6db1094 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 @@ -130,7 +130,7 @@ class Observability( } fun getTelemetryInspector(): TelemetryInspector? { - return observabilityClient?.getTelemetryInspector() + return options.telemetryInspector } companion object { diff --git a/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/InMemoryTelemetryInspector.kt b/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/InMemoryTelemetryInspector.kt new file mode 100644 index 0000000000..5ee0bcd7a3 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/testFixtures/kotlin/com/launchdarkly/observability/testing/InMemoryTelemetryInspector.kt @@ -0,0 +1,18 @@ +package com.launchdarkly.observability.testing + +import com.launchdarkly.observability.client.TelemetryInspector +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricExporter +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter + +/** + * [TelemetryInspector] backed by in-memory exporters from the OpenTelemetry SDK testing library. + * + * Provides concrete [InMemorySpanExporter], [InMemoryLogRecordExporter], and + * [InMemoryMetricExporter] instances so tests can assert on exported telemetry data. + */ +class InMemoryTelemetryInspector : TelemetryInspector { + override val spanExporter: InMemorySpanExporter by lazy { InMemorySpanExporter.create() } + override val logExporter: InMemoryLogRecordExporter by lazy { InMemoryLogRecordExporter.create() } + override val metricExporter: InMemoryMetricExporter by lazy { InMemoryMetricExporter.create() } +} From 426428690a193aa8ecc8f9186505db52f9a07215 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 27 Mar 2026 16:59:25 -0700 Subject: [PATCH 4/9] revert incubator --- sdk/@launchdarkly/observability-android/lib/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 9acb301920..222acde44c 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -47,7 +47,7 @@ dependencies { implementation("io.opentelemetry:opentelemetry-sdk-logs:1.51.0") // TODO: Evaluate risks associated with incubator APIs - // implementation("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") + implementation("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") // OTEL Android implementation("io.opentelemetry.android:core:0.11.0-alpha") From 2941a913d128cecb109d7d63ad77b53492052cc8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 27 Mar 2026 17:18:12 -0700 Subject: [PATCH 5/9] address feedback --- sdk/@launchdarkly/observability-android/lib/build.gradle.kts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 222acde44c..5d49e8453f 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -46,7 +46,8 @@ dependencies { implementation("io.opentelemetry:opentelemetry-sdk-metrics:1.51.0") implementation("io.opentelemetry:opentelemetry-sdk-logs:1.51.0") - // TODO: Evaluate risks associated with incubator APIs + // Required at runtime by io.opentelemetry.android:core, which uses incubator APIs + // internally for the logs bridge. Can be removed once the OTel Android SDK drops this dependency. implementation("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") // OTEL Android @@ -67,7 +68,7 @@ dependencies { testImplementation("io.mockk:mockk:1.14.5") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") - testFixturesImplementation("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") + testFixturesApi("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") testFixturesImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") } From b77c4ca633cf6560bfc3b3712ff534b586f018f0 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 30 Mar 2026 14:19:42 -0700 Subject: [PATCH 6/9] minus 3 dependency --- .../android/native/LDObserve/build.gradle.kts | 13 ------------- .../observability/Directory.Build.props | 2 +- .../observability/LDObservability.Fat.csproj | 4 +++- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts index 9ec534c444..bc3511c5f4 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts @@ -37,14 +37,11 @@ configurations { } dependencies { - implementation("androidx.core:core-ktx:1.15.0") "copyDependencies"("androidx.core:core-ktx:1.15.0") // Copy dependencies for binding library // Uncomment line below and replace dependency.name.goes.here with your dependency - - implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") "copyDependencies"("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0") @@ -73,16 +70,10 @@ dependencies { implementation("io.opentelemetry:opentelemetry-sdk-logs:1.51.0") "copyDependencies"("io.opentelemetry:opentelemetry-sdk-logs:1.51.0") - // TODO: Evaluate risks associated with incubator APIs implementation("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") "copyDependencies"("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") - // Testing exporters for telemetry inspection - implementation("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") - "copyDependencies"("io.opentelemetry:opentelemetry-sdk-testing:1.51.0") - - // OTEL Android implementation("io.opentelemetry.android:core:0.11.0-alpha") "copyDependencies"("io.opentelemetry.android:core:0.11.0-alpha") @@ -98,12 +89,8 @@ dependencies { implementation("io.opentelemetry.android.instrumentation:activity:0.11.0-alpha") "copyDependencies"("io.opentelemetry.android.instrumentation:activity:0.11.0-alpha") - implementation("io.opentelemetry.android:android-agent:0.11.0-alpha") "copyDependencies"("io.opentelemetry.android:android-agent:0.11.0-alpha") - -// implementation("io.opentelemetry.android:opentelemetry-android-bom:0.11.0-alpha") -// "copyDependencies"("io.opentelemetry.android:opentelemetry-android-bom:0.11.0-alpha") } // Copy dependencies for binding library diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index ac284c84b5..9af47e15e4 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props @@ -1,7 +1,7 @@ LaunchDarkly.SessionReplay - 0.5.2 + 0.5.3 false LaunchDarkly LaunchDarkly diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj index 1345abfe15..68f900f8d6 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj +++ b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj @@ -98,7 +98,9 @@ - + From bf0a4c159dee9267c222fa1fc9cdfa5f67aa85b1 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 30 Mar 2026 16:21:16 -0700 Subject: [PATCH 7/9] simplify maui projects to do not have repetitions --- .../observability/Directory.Build.props | 2 +- .../observability/LDObservability.Fat.csproj | 39 +---- .../observability/LDObservability.csproj | 146 +----------------- .../observability/NativeAndroidDeps.props | 57 +++++++ .../LDObservability.Fat.targets | 105 +------------ .../LDObservability.android.targets | 9 -- .../LDObservability.ios.targets | 12 -- .../mobile-dotnet/sample/MauiSample9.csproj | 4 + 8 files changed, 76 insertions(+), 298 deletions(-) create mode 100644 sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props delete mode 100644 sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.android.targets delete mode 100644 sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.ios.targets diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index 9af47e15e4..c2cbc30106 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props @@ -1,7 +1,7 @@ LaunchDarkly.SessionReplay - 0.5.3 + 0.5.5 false LaunchDarkly LaunchDarkly diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj index 68f900f8d6..cf97b4fd96 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj +++ b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj @@ -76,43 +76,14 @@ Pack="true" PackagePath="lib/net9.0-ios18.0/" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + + - diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj index f05b3b8540..30f959f4e8 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj +++ b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj @@ -80,150 +80,18 @@ - - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - - - false - false - - - false - false - - - false - false - - - false - false - - + + + + + false false - + false false - - false - false - - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - - - - false - - - - - false - diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props new file mode 100644 index 0000000000..4bd00e03c2 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props @@ -0,0 +1,57 @@ + + + + <_LDNativeDepsDir Condition="'$(_LDNativeDepsDir)' == ''">$(MSBuildThisFileDirectory)..\android\native\LDObserve\bin\Release\net9.0-android\outputs\deps\ + <_LDNativeAarDir Condition="'$(_LDNativeAarDir)' == ''">$(MSBuildThisFileDirectory)..\android\native\LDObserve\bin\Release\net9.0-android\outputs\aar\ + + + + + <_LDNativeAAR Include="$(_LDNativeDepsDir)android-agent-*.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)android-instrumentation-*.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)common-0*.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)common-api-*.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)core-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)crash-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)anr-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)activity-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)fragment-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)network-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)services-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)session-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)slowrendering-*-alpha.aar" /> + <_LDNativeAAR Include="$(_LDNativeDepsDir)startup-*-alpha.aar" /> + + + <_LDNativeAAR Include="$(_LDNativeDepsDir)launchdarkly-android-client-sdk-*.aar" /> + + + <_LDNativeAAR Include="$(_LDNativeDepsDir)lib-release.aar" /> + + + <_LDNativeAAR Include="$(_LDNativeDepsDir)timber-*.aar" /> + + + <_LDNativeAAR Include="$(_LDNativeAarDir)LDObserve-release.aar" /> + + + <_LDNativeJAR Include="$(_LDNativeDepsDir)opentelemetry-*.jar" + Exclude="$(_LDNativeDepsDir)opentelemetry-sdk-extension-incubator-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-extension-autoconfigure-1*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-testing-*.jar" /> + <_LDNativeJAR Include="$(_LDNativeDepsDir)jackson-*.jar" /> + <_LDNativeJAR Include="$(_LDNativeDepsDir)launchdarkly-*.jar" /> + <_LDNativeJAR Include="$(_LDNativeDepsDir)okhttp-*.jar" /> + <_LDNativeJAR Include="$(_LDNativeDepsDir)okio-*.jar" /> + + diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.Fat.targets b/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.Fat.targets index 399f52cb56..9ed2ff3bcb 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.Fat.targets +++ b/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.Fat.targets @@ -1,103 +1,10 @@ - - - - + false false - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - false - false - - - - - false - false - - - false - false - - - false - false - - - false - false - - + false false @@ -110,12 +17,4 @@ true - - - - - false - false - - diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.android.targets b/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.android.targets deleted file mode 100644 index 20b177c91c..0000000000 --- a/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.android.targets +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.ios.targets b/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.ios.targets deleted file mode 100644 index 411b74775e..0000000000 --- a/sdk/@launchdarkly/mobile-dotnet/observability/buildTransitive/LDObservability.ios.targets +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Framework - true - true - - - - - diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/MauiSample9.csproj b/sdk/@launchdarkly/mobile-dotnet/sample/MauiSample9.csproj index c72409d7fe..8c61d1664a 100644 --- a/sdk/@launchdarkly/mobile-dotnet/sample/MauiSample9.csproj +++ b/sdk/@launchdarkly/mobile-dotnet/sample/MauiSample9.csproj @@ -84,4 +84,8 @@ true + + true + + From f1e1ff3b88c5407e9ab7d72a268d74be275c9125 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 31 Mar 2026 16:05:45 -0700 Subject: [PATCH 8/9] refactor: update OpenTelemetry JAR handling in project files - Adjusted the exclusion rules for OpenTelemetry JARs in LDObservability.Fat.csproj to ensure the main autoconfigure JAR is excluded while re-including the autoconfigure-spi JAR. - Added a note in build.gradle.kts to clarify the filtering of OpenTelemetry JARs for NuGet packaging. --- .../android/native/LDObserve/build.gradle.kts | 1 + .../observability/LDObservability.Fat.csproj | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts index bc3511c5f4..6dd93f25b1 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation("com.launchdarkly:launchdarkly-observability-android:0.0.0-local") // TODO: revise these versions to be as old as usable for compatibility + // OpenTelemetry JARs copied here are filtered for NuGet in observability/LDObservability.Fat.csproj (autoconfigure vs autoconfigure-spi). implementation("io.opentelemetry:opentelemetry-api:1.51.0") "copyDependencies"("io.opentelemetry:opentelemetry-api:1.51.0") diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj index 68f900f8d6..0a4cff306a 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj +++ b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj @@ -97,9 +97,13 @@ - + + From fb0458ede037167ba1ca535497ec3ceb9adc3276 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 31 Mar 2026 16:11:10 -0700 Subject: [PATCH 9/9] doesn't account version --- .../mobile-dotnet/android/native/LDObserve/build.gradle.kts | 2 +- .../mobile-dotnet/observability/NativeAndroidDeps.props | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts index 6dd93f25b1..1307643422 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/build.gradle.kts @@ -52,7 +52,7 @@ dependencies { implementation("com.launchdarkly:launchdarkly-observability-android:0.0.0-local") // TODO: revise these versions to be as old as usable for compatibility - // OpenTelemetry JARs copied here are filtered for NuGet in observability/LDObservability.Fat.csproj (autoconfigure vs autoconfigure-spi). + // OpenTelemetry JARs copied here are filtered in observability/NativeAndroidDeps.props (autoconfigure vs autoconfigure-spi). implementation("io.opentelemetry:opentelemetry-api:1.51.0") "copyDependencies"("io.opentelemetry:opentelemetry-api:1.51.0") diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props index 4bd00e03c2..345902d582 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/NativeAndroidDeps.props @@ -46,9 +46,11 @@ <_LDNativeAAR Include="$(_LDNativeAarDir)LDObserve-release.aar" /> - + <_LDNativeJAR Include="$(_LDNativeDepsDir)opentelemetry-*.jar" - Exclude="$(_LDNativeDepsDir)opentelemetry-sdk-extension-incubator-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-extension-autoconfigure-1*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-testing-*.jar" /> + Exclude="$(_LDNativeDepsDir)opentelemetry-sdk-extension-incubator-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-extension-autoconfigure-*.jar;$(_LDNativeDepsDir)opentelemetry-sdk-testing-*.jar" /> + <_LDNativeJAR Include="$(_LDNativeDepsDir)opentelemetry-sdk-extension-autoconfigure-spi-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)jackson-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)launchdarkly-*.jar" /> <_LDNativeJAR Include="$(_LDNativeDepsDir)okhttp-*.jar" />