From e5778c322985473e1856ca96c02a5fe6f473e476 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 23 Sep 2026 17:56:24 -0700 Subject: [PATCH 1/2] fix(observability-android): honor ObservabilityOptions.enabled ObservabilityOptions.enabled was declared but never read, so enabled = false still exported every log, span and metric. The service is still built so Session Replay keeps working, but with enabled = false no exporters, processors or metric reader are registered, the batch worker never starts, and crash reporting, launch-time instrumentation and the sampling-config fetch are skipped. This matches iOS, which leaves the service unstarted when isEnabled is false. Co-authored-by: Cursor --- .../DisablingConfigOptionsE2ETest.kt | 27 +++++++++++++++++++ .../observability/api/ObservabilityOptions.kt | 3 +++ .../client/ObservabilityService.kt | 24 +++++++++++++---- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/e2e/android/app/src/testCompose/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt b/e2e/android/app/src/testCompose/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt index 70236ab37f..9efaa1251c 100644 --- a/e2e/android/app/src/testCompose/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt +++ b/e2e/android/app/src/testCompose/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt @@ -33,6 +33,26 @@ class DisablingConfigOptionsE2ETest { application.tearDownTest() } + @Test + fun `Nothing should be exported when observability is disabled`() { + application.observabilityOptions = getOptionsAllEnabled().copy(enabled = false) + application.initForTest() + val otlpPrefix = "http://localhost:${application.mockWebServer?.port}/v1/" + + triggerTestLog(severity = Severity.WARN) + triggerTestSpan() + triggerError() + triggerTestMetric() + LDObserve.flush() + waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.SPANS) + + val inspector = application.telemetryInspector + assertTrue(inspector?.logExporter?.finishedLogRecordItems.orEmpty().isEmpty()) + assertTrue(inspector?.spanExporter?.finishedSpanItems.orEmpty().isEmpty()) + assertTrue(inspector?.metricExporter?.finishedMetricItems.orEmpty().isEmpty()) + assertFalse(requestsStartWith(otlpPrefix)) + } + @Test fun `Logs should NOT be exported when logsApiLevel is NONE`() { application.observabilityOptions = getOptionsAllEnabled().copy(logsApiLevel = ObservabilityOptions.LogLevel.NONE) @@ -248,6 +268,13 @@ class DisablingConfigOptionsE2ETest { } } + private fun requestsStartWith(prefix: String): Boolean { + while (true) { + val request = application.mockWebServer?.takeRequest(100, TimeUnit.MILLISECONDS) ?: return false + if (request.requestUrl.toString().startsWith(prefix)) return true + } + } + private fun triggerTestLog(severity: Severity = Severity.INFO) { LDObserve.recordLog( message = TEST_LOG_MESSAGE, 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 44eac866f9..dd5f3e3aef 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 @@ -16,6 +16,9 @@ const val DEFAULT_BACKEND_URL = "https://pub.observability.app.launchdarkly.com" /** * Configuration options for the Observability plugin. * + * @property enabled Whether the plugin exports telemetry. When `false` the plugin is installed and + * Session Replay keeps working, but no logs, traces, or metrics are exported and crash reporting + * and launch-time instrumentation are off. Defaults to `true`. * @property serviceName The service name for the application. Defaults to [DEFAULT_SERVICE_NAME]. * @property serviceVersion The version of the service. Defaults to the SDK version. * @property otlpEndpoint The OTLP exporter endpoint. Defaults to LaunchDarkly endpoint [DEFAULT_OTLP_ENDPOINT]. 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 2ecb7e7220..fa099f6c45 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 @@ -233,7 +233,14 @@ class ObservabilityService( init { requireMainThread { "ObservabilityService must be initialized on the main thread" } - registerOtlpExporters() + // With `enabled = false` the service is still built, so Session Replay keeps its session, + // screen, click and lifecycle signals, but nothing is exported: no exporters, span/log + // processors or metric reader are registered, the batch worker never starts, and the crash, + // launch-time and sampling-config network work is skipped. Mirrors iOS, which leaves the + // service unstarted when `isEnabled` is false. + if (observabilityOptions.enabled) { + registerOtlpExporters() + } val otelRumConfig = createOtelRumConfig() val rumBuilder = OpenTelemetryRum.builder(application, otelRumConfig) @@ -251,7 +258,7 @@ class ObservabilityService( // id and keep a single source of session identity across spans, logs, metrics, and replay. LDRumSessionManagerAccessor.setSessionManager(rumBuilder, ldSessionManager) - if (observabilityOptions.instrumentations.launchTime) { + if (observabilityOptions.enabled && observabilityOptions.instrumentations.launchTime) { addLaunchTimeInstrumentation(rumBuilder) } @@ -278,7 +285,9 @@ class ObservabilityService( override fun onSessionEnded(session: Session) {} }) - loadSamplingConfigAsync() + if (observabilityOptions.enabled) { + loadSamplingConfigAsync() + } otelMeter = otelRUM.openTelemetry.meterProvider.get(INSTRUMENTATION_SCOPE_NAME) otelLogger = otelRUM.openTelemetry.logsBridge.get(INSTRUMENTATION_SCOPE_NAME) @@ -346,7 +355,9 @@ class ObservabilityService( // always available; the `app_launch` span is gated by analytics.appLaunch inside the handler. appLaunchTracker.start() - batchWorker.start() + if (observabilityOptions.enabled) { + batchWorker.start() + } } /** @@ -450,7 +461,7 @@ class ObservabilityService( // [LDRumSessionManagerAccessor]), so no SessionConfig is applied here. val config = OtelRumConfig() - if (!observabilityOptions.instrumentations.crashReporting) { + if (!observabilityOptions.enabled || !observabilityOptions.instrumentations.crashReporting) { // Disables [io.opentelemetry.android.instrumentation.crash.CrashReporterInstrumentation.java] config.suppressInstrumentation("crash") } @@ -482,6 +493,7 @@ class ObservabilityService( private fun configureLoggerProvider(sdkLoggerProviderBuilder: SdkLoggerProviderBuilder): SdkLoggerProviderBuilder { sdkLoggerProviderBuilder.setResource(resources) + if (!observabilityOptions.enabled) return sdkLoggerProviderBuilder val delegates = buildList { add(EventLogRecordProcessor(eventQueue = eventQueue, batchWorker = batchWorker)) @@ -504,6 +516,7 @@ class ObservabilityService( private fun configureTracerProvider(sdkTracerProviderBuilder: SdkTracerProviderBuilder): SdkTracerProviderBuilder { sdkTracerProviderBuilder.setResource(resources) + if (!observabilityOptions.enabled) return sdkTracerProviderBuilder val debugExporters = buildList { if (observabilityOptions.debug) { @@ -531,6 +544,7 @@ class ObservabilityService( } private fun configureMeterProvider(sdkMeterProviderBuilder: SdkMeterProviderBuilder): SdkMeterProviderBuilder { + if (!observabilityOptions.enabled) return sdkMeterProviderBuilder.setResource(resources) val eventExporter = EventMetricExporter( eventQueue = eventQueue, temporalitySelector = AggregationTemporalitySelector.deltaPreferred(), From 1813342944d7556707ae43860024cecb88e82177 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 23 Sep 2026 18:14:00 -0700 Subject: [PATCH 2/2] ci: pin the dependency-scan actions and OPA version The evaluate-policy action resolves opa-version 'latest' through an unauthenticated GitHub API call. When that is rate-limited the tag is null, curl saves the HTML 404 page as /usr/local/bin/opa, and the result is cached under opa-null, so the policy step fails with a shell syntax error. Pin OPA to v1.9.0 (the policy uses 1.x syntax) and pin the actions to a commit SHA. Co-authored-by: Cursor --- .github/workflows/dependency-scan.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-scan.yml b/.github/workflows/dependency-scan.yml index b9bb925e6b..f88d5db940 100644 --- a/.github/workflows/dependency-scan.yml +++ b/.github/workflows/dependency-scan.yml @@ -13,11 +13,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Generate SBOM - uses: launchdarkly/gh-actions/actions/dependency-scan/generate-sbom@main + uses: launchdarkly/gh-actions/actions/dependency-scan/generate-sbom@8e082b23efd8be075c180606c8dc80b81e0c3544 # main with: types: 'nodejs' - name: Evaluate SBOM Policy - uses: launchdarkly/gh-actions/actions/dependency-scan/evaluate-policy@main + uses: launchdarkly/gh-actions/actions/dependency-scan/evaluate-policy@8e082b23efd8be075c180606c8dc80b81e0c3544 # main with: bom-file: bom.nodejs.json + # The action's default, `latest`, resolves through an unauthenticated + # GitHub API call; when rate-limited it downloads an HTML 404 page as + # the `opa` binary and caches it. The policy needs OPA 1.x syntax. + opa-version: v1.9.0