diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt index b1e24bed4a..6d0df29a8d 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt @@ -85,6 +85,11 @@ open class BaseApplication : Application() { options = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions ) + if (testUrl == null) { + // Runs before LDClient.init below initializes observability (set-before-init). + GlobalAttributesRepro.applyGlobalAttributes() + } + // Set LAUNCHDARKLY_MOBILE_KEY to your LaunchDarkly mobile key found on the LaunchDarkly // dashboard in the start guide. // If you want to disable the Auto EnvironmentAttributes functionality. diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/GlobalAttributesRepro.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/GlobalAttributesRepro.kt new file mode 100644 index 0000000000..5feb440350 --- /dev/null +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/GlobalAttributesRepro.kt @@ -0,0 +1,36 @@ +package com.example.androidobservability + +import android.content.Context +import android.util.Log +import android.widget.Toast +import com.launchdarkly.observability.sdk.LDObserve + +/** + * Repro for "global attributes (e.g. UTM params) on all telemetry". + * + * [applyGlobalAttributes] runs at app startup before the observability SDK has finished + * initializing, so every span, log, and metric recorded afterwards should carry + * [GLOBAL_ATTRIBUTES]. The existing "Send custom span" and "Trigger Log" buttons print + * the expectation for the row they produce via [note]. + */ +object GlobalAttributesRepro { + private const val TAG = "GlobalAttributesRepro" + + val GLOBAL_ATTRIBUTES: Map = mapOf( + "utm_campaign" to "spring_promo", + "utm_source" to "email", + ) + + const val EXPECT_GLOBAL = "[#1] expect utm_campaign=spring_promo on this row" + const val EXPECT_PER_CALL_WINS = "[#1] expect utm_source=per_call (per-call wins over global)" + + fun applyGlobalAttributes() { + LDObserve.setAttributes(GLOBAL_ATTRIBUTES) + Log.i(TAG, "[#1] applied global attributes before SDK init: $GLOBAL_ATTRIBUTES") + } + + fun note(context: Context, action: String, vararg expectations: String) { + expectations.forEach { Log.i(TAG, "$action: $it") } + Toast.makeText(context, expectations.joinToString("\n"), Toast.LENGTH_LONG).show() + } +} diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivityViewModel.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivityViewModel.kt index be89f1daa2..4cf69106f4 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivityViewModel.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivityViewModel.kt @@ -75,6 +75,12 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica } fun triggerLog() { + GlobalAttributesRepro.note( + getApplication(), + "Trigger Log", + GlobalAttributesRepro.EXPECT_GLOBAL, + GlobalAttributesRepro.EXPECT_PER_CALL_WINS, + ) // `recordLog` takes a plain map via `properties`, so no need to build OTel attributes. LDObserve.recordLog( "Test Log", @@ -87,7 +93,8 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica "test-long" to 9_000_000_000L, "test-double" to 3.14, "test-array" to listOf(3.14), - "test-nested" to mapOf("array" to listOf(1)) + "test-nested" to mapOf("array" to listOf(1)), + "utm_source" to "per_call", ) ) } @@ -157,6 +164,7 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica fun triggerCustomSpan(spanName: String) { if (spanName.isNotEmpty()) { + GlobalAttributesRepro.note(getApplication(), "Send custom span", GlobalAttributesRepro.EXPECT_GLOBAL) viewModelScope.launch(Dispatchers.IO) { val customSpan = LDObserve.startSpan( name = spanName, diff --git a/sdk/@launchdarkly/observability-android/README.md b/sdk/@launchdarkly/observability-android/README.md index f39bdf18f4..ee7b9b2d37 100644 --- a/sdk/@launchdarkly/observability-android/README.md +++ b/sdk/@launchdarkly/observability-android/README.md @@ -387,6 +387,10 @@ LDObserve.trackScreenView( category = "Account", properties = mapOf("tab" to "overview") ) + +// Attach attributes to everything recorded from now on (see Global Attributes below). +LDObserve.setAttributes(mapOf("utm_campaign" to "spring_promo")) +LDObserve.removeAttributes(listOf("utm_campaign")) ``` `recordLog`, `startSpan`, `track`, and `trackScreenView` accept attributes/data as a @@ -397,6 +401,42 @@ OpenTelemetry typing (as shown for `recordError` above). For `trackScreenView`, properties are applied at lower precedence than the reserved `event.*` taxonomy fields, so they can never clobber them. +### Global Attributes + +Use `LDObserve.setAttributes` to attach attributes to every span (manual and automatic, +such as lifecycle, screen view, click, and error spans), log record (including crash +reports), and metric data point recorded after the call. `LDObserve.removeAttributes` removes keys again. + +```kotlin +import com.launchdarkly.observability.sdk.LDObserve +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes + +// Merged by key into the existing global attributes. +LDObserve.setAttributes( + Attributes.of( + AttributeKey.stringKey("tenant.id"), "acme", + AttributeKey.stringKey("app.flavor"), "beta" + ) +) + +// A plain map works too, with the same conversion rules as `properties`. +LDObserve.setAttributes(mapOf("experiment.cohort" to "b")) + +LDObserve.removeAttributes(listOf("app.flavor")) +``` + +- **Precedence:** attributes passed to an individual call (`startSpan`, `recordLog`, + `recordMetric`, ...) win over global attributes, which win over resource attributes. + Globals only fill keys that are not already present; they never overwrite. +- **Spans** take the global values current when the span starts. +- **Lifetime:** global attributes live in memory for the process. They are not persisted + and are not cleared when the session rotates. +- **Before initialization:** calls made before the SDK finishes initializing take effect + once it does. +- **Reserved keys:** SDK-managed session keys (`session.id`, `highlight.session_id`) are + ignored and a warning is logged. + ### Deobfuscating Release Stack Traces R8 shrinks and obfuscates release builds, so a crash or a recorded error arrives naming diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/GlobalAttributesStore.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/GlobalAttributesStore.kt new file mode 100644 index 0000000000..b1d408b91d --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/GlobalAttributesStore.kt @@ -0,0 +1,112 @@ +package com.launchdarkly.observability.client + +import com.launchdarkly.observability.context.LDObserveLogging +import com.launchdarkly.observability.context.ObserveLogger +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.common.AttributesBuilder +import java.util.concurrent.atomic.AtomicReference + +/** + * Process-wide, in-memory store behind [com.launchdarkly.observability.interfaces.Observe.setAttributes] + * and [com.launchdarkly.observability.interfaces.Observe.removeAttributes]. + * + * It is a singleton (rather than state on [ObservabilityService]) so calls made before the SDK is + * initialized still take effect: the global-attribute processors and metric recorders read it once + * the pipeline exists. Values are not persisted and survive session rotation. + * + * Reads are a lock-free load of an immutable [Attributes] snapshot; writers serialize on a lock and + * publish a rebuilt snapshot. Keys are matched by name, so re-setting a key with a different value + * type replaces it. + */ +internal object GlobalAttributesStore { + + /** + * Keys owned by the SDK's session management. Globals must never shadow or spoof them. + */ + val RESERVED_KEYS: Set = setOf("session.id", "highlight.session_id") + + private val snapshot = AtomicReference(Attributes.empty()) + private val writeLock = Any() + + /** + * Logger used to report ignored reserved keys. Replaced with the configured SDK logger when + * [ObservabilityService] is created; until then it falls back to the default Android logger. + */ + @Volatile + var logger: ObserveLogger? = null + + fun current(): Attributes = snapshot.get() + + fun set(attributes: Attributes) { + val accepted = LinkedHashMap, Any>>() + for ((key, value) in attributes.asMap()) { + if (key.key in RESERVED_KEYS) { + resolveLogger().warn("Ignoring reserved global attribute key '${key.key}'") + continue + } + accepted[key.key] = key to value + } + if (accepted.isEmpty()) return + + synchronized(writeLock) { + val builder = Attributes.builder() + for ((key, value) in snapshot.get().asMap()) { + if (key.key !in accepted) builder.putUnchecked(key, value) + } + for ((key, value) in accepted.values) { + builder.putUnchecked(key, value) + } + snapshot.set(builder.build()) + } + } + + fun remove(keys: Collection) { + if (keys.isEmpty()) return + val toRemove = keys.toHashSet() + + synchronized(writeLock) { + val current = snapshot.get() + if (current.asMap().keys.none { it.key in toRemove }) return + val builder = Attributes.builder() + for ((key, value) in current.asMap()) { + if (key.key !in toRemove) builder.putUnchecked(key, value) + } + snapshot.set(builder.build()) + } + } + + /** + * Returns the current globals overlaid with [explicit]. Explicit values win for any key name + * they define, matching the span/log precedence where globals only fill missing keys. + */ + fun merge(explicit: Attributes): Attributes { + val globals = snapshot.get() + if (globals.isEmpty) return explicit + if (explicit.isEmpty) return globals + + val explicitKeys = explicit.asMap().keys.mapTo(HashSet()) { it.key } + val builder = Attributes.builder() + for ((key, value) in globals.asMap()) { + if (key.key !in explicitKeys) builder.putUnchecked(key, value) + } + builder.putAll(explicit) + return builder.build() + } + + /** Test hook: drops every global attribute. */ + internal fun clear() { + synchronized(writeLock) { + snapshot.set(Attributes.empty()) + } + } + + private fun resolveLogger(): ObserveLogger = + logger ?: ObserveLogger.build(LDObserveLogging.adapter(), DEFAULT_LOGGER_NAME, debug = false).also { logger = it } + + private const val DEFAULT_LOGGER_NAME = "LaunchDarklyObservabilityPlugin" +} + +@Suppress("UNCHECKED_CAST") +internal fun AttributesBuilder.putUnchecked(key: AttributeKey<*>, value: Any): AttributesBuilder = + put(key as AttributeKey, value) 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 e8b04b60b7..f6f60bdf23 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 @@ -18,6 +18,7 @@ import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.interfaces.Observe import com.launchdarkly.observability.logs.EventLogRecordProcessor +import com.launchdarkly.observability.logs.GlobalAttributesLogRecordProcessor import com.launchdarkly.observability.logs.OtlpLogExporter import com.launchdarkly.observability.metrics.EventMetricExporter import com.launchdarkly.observability.metrics.OtlpMetricExporter @@ -33,6 +34,7 @@ import com.launchdarkly.observability.sampling.CustomSampler import com.launchdarkly.observability.sampling.SamplingConfig import com.launchdarkly.observability.sampling.SamplingLogProcessor import com.launchdarkly.observability.traces.EventSpanProcessor +import com.launchdarkly.observability.traces.GlobalAttributesSpanProcessor import com.launchdarkly.observability.traces.OtlpTraceExporter import com.launchdarkly.observability.util.requireMainThread import io.opentelemetry.android.LDRumSessionManagerAccessor @@ -232,6 +234,7 @@ class ObservabilityService( init { requireMainThread { "ObservabilityService must be initialized on the main thread" } + GlobalAttributesStore.logger = logger // 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 @@ -485,7 +488,7 @@ class ObservabilityService( val histogram = histogramCache.getOrPut(metric.name) { otelMeter.histogramBuilder(metric.name).build() } - histogram.record(metric.value, metric.attributes.addSessionId()) + histogram.record(metric.value, metricAttributes(metric.attributes)) } ) rumBuilder.addInstrumentation(launchTimeInstrumentation) @@ -509,6 +512,8 @@ class ObservabilityService( delegate = LogRecordProcessor.composite(delegates), sampler = customSampler, ) + // Globals must be applied before sampling and export so exported records carry them. + sdkLoggerProviderBuilder.addLogRecordProcessor(GlobalAttributesLogRecordProcessor()) sdkLoggerProviderBuilder.addLogRecordProcessor(otlpProcessor) return sdkLoggerProviderBuilder @@ -538,6 +543,8 @@ class ObservabilityService( delegateExporter = delegateExporter, batchWorker = batchWorker, ) + // Globals must be applied before sampling and export so exported spans carry them. + sdkTracerProviderBuilder.addSpanProcessor(GlobalAttributesSpanProcessor()) sdkTracerProviderBuilder.addSpanProcessor(otlpProcessor) return sdkTracerProviderBuilder @@ -592,7 +599,7 @@ class ObservabilityService( val gauge = gaugeCache.getOrPut(metric.name) { otelMeter.gaugeBuilder(metric.name).build() } - gauge.set(metric.value, metric.attributes.addSessionId()) + gauge.set(metric.value, metricAttributes(metric.attributes)) } override fun recordCount(metric: Metric) { @@ -602,7 +609,7 @@ class ObservabilityService( val counter = counterCache.getOrPut(metric.name) { otelMeter.counterBuilder(metric.name).build() } - counter.add(metric.value.toLong(), metric.attributes.addSessionId()) + counter.add(metric.value.toLong(), metricAttributes(metric.attributes)) } override fun recordIncr(metric: Metric) { @@ -612,7 +619,7 @@ class ObservabilityService( otelMeter.counterBuilder(metric.name).build() } // It increments the value until the metric is exported, then it's reset. - counter.add(1, metric.attributes.addSessionId()) + counter.add(1, metricAttributes(metric.attributes)) } override fun recordHistogram(metric: Metric) { @@ -621,7 +628,7 @@ class ObservabilityService( val histogram = histogramCache.getOrPut(metric.name) { otelMeter.histogramBuilder(metric.name).build() } - histogram.record(metric.value, metric.attributes.addSessionId()) + histogram.record(metric.value, metricAttributes(metric.attributes)) } override fun recordUpDownCounter(metric: Metric) { @@ -630,7 +637,7 @@ class ObservabilityService( val upDownCounter = upDownCounterCache.getOrPut(metric.name) { otelMeter.upDownCounterBuilder(metric.name).build() } - upDownCounter.add(metric.value.toLong(), metric.attributes.addSessionId()) + upDownCounter.add(metric.value.toLong(), metricAttributes(metric.attributes)) } override fun recordLog( @@ -1016,6 +1023,10 @@ class ObservabilityService( } } + /** Global attributes, overridden by the per-call [attributes], plus the current session id. */ + private fun metricAttributes(attributes: Attributes): Attributes = + GlobalAttributesStore.merge(attributes).addSessionId() + private fun Attributes.addSessionId() = this.toBuilder().put(SESSION_ID_ATTRIBUTE, ldSessionManager.getSessionId()).build() companion object { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/interfaces/Observe.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/interfaces/Observe.kt index 9eb8fe197c..06359634d7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/interfaces/Observe.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/interfaces/Observe.kt @@ -1,6 +1,7 @@ package com.launchdarkly.observability.interfaces import com.launchdarkly.observability.bridge.toOtelAttributes +import com.launchdarkly.observability.client.GlobalAttributesStore import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity import io.opentelemetry.api.trace.Span @@ -96,6 +97,42 @@ interface TracesApi { * Provides methods for recording various types of information. */ interface Observe : MetricsApi, LogsApi, TracesApi { + /** + * Merges [attributes] by key into the process-wide global attributes. + * + * Global attributes are added to every span (manual and automatic, captured at span start), + * log record, and metric data point recorded after this call. They only fill keys that are not + * already present: attributes passed to an individual call (e.g. [startSpan], [recordLog], + * [recordMetric]) always win over globals, and globals win over resource attributes. + * + * Globals are kept in memory only: they are not persisted and are not cleared when the session + * rotates. Calls made before the SDK is initialized take effect once it is. SDK-managed session + * keys (`session.id`, `highlight.session_id`) are ignored. + * + * @param attributes The attributes to add or replace. + */ + fun setAttributes(attributes: Attributes) { + GlobalAttributesStore.set(attributes) + } + + /** + * Merges [properties] into the process-wide global attributes. Same as the [Attributes] + * overload, but values are supplied as a plain map and converted with the same rules as a + * `track` event's `properties`. + */ + fun setAttributes(properties: Map) { + setAttributes(properties.toOtelAttributes()) + } + + /** + * Removes the global attributes with the given [keys]. Unknown keys are ignored. + * + * @param keys The attribute key names to remove. + */ + fun removeAttributes(keys: Collection) { + GlobalAttributesStore.remove(keys) + } + /** * Requests a flush of all pending telemetry data (traces, logs, metrics). * diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/logs/GlobalAttributesLogRecordProcessor.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/logs/GlobalAttributesLogRecordProcessor.kt new file mode 100644 index 0000000000..64afa6b924 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/logs/GlobalAttributesLogRecordProcessor.kt @@ -0,0 +1,31 @@ +package com.launchdarkly.observability.logs + +import com.launchdarkly.observability.client.GlobalAttributesStore +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.context.Context +import io.opentelemetry.sdk.logs.LogRecordProcessor +import io.opentelemetry.sdk.logs.ReadWriteLogRecord + +/** + * Stamps the current global attributes onto every log record. + * + * Unlike the upstream `GlobalAttributesLogRecordAppender`, globals only fill keys the record does + * not already carry, so attributes supplied with the log always win. + */ +internal class GlobalAttributesLogRecordProcessor( + private val globals: () -> Attributes = GlobalAttributesStore::current, +) : LogRecordProcessor { + + override fun onEmit(context: Context, logRecord: ReadWriteLogRecord) { + val current = globals() + if (current.isEmpty) return + + val present = logRecord.attributes.asMap().keys.mapTo(HashSet()) { it.key } + for ((key, value) in current.asMap()) { + if (key.key in present) continue + @Suppress("UNCHECKED_CAST") + logRecord.setAttribute(key as AttributeKey, value) + } + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/traces/GlobalAttributesSpanProcessor.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/traces/GlobalAttributesSpanProcessor.kt new file mode 100644 index 0000000000..9802a76180 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/traces/GlobalAttributesSpanProcessor.kt @@ -0,0 +1,38 @@ +package com.launchdarkly.observability.traces + +import com.launchdarkly.observability.client.GlobalAttributesStore +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.context.Context +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.opentelemetry.sdk.trace.ReadableSpan +import io.opentelemetry.sdk.trace.SpanProcessor + +/** + * Stamps the global attributes current at span start onto every span. + * + * Unlike the upstream `GlobalAttributesSpanAppender`, globals only fill keys the span does not + * already carry, so attributes supplied when the span was built always win. + */ +internal class GlobalAttributesSpanProcessor( + private val globals: () -> Attributes = GlobalAttributesStore::current, +) : SpanProcessor { + + override fun onStart(parentContext: Context, span: ReadWriteSpan) { + val current = globals() + if (current.isEmpty) return + + val present = span.attributes.asMap().keys.mapTo(HashSet()) { it.key } + for ((key, value) in current.asMap()) { + if (key.key in present) continue + @Suppress("UNCHECKED_CAST") + span.setAttribute(key as AttributeKey, value) + } + } + + override fun isStartRequired(): Boolean = true + + override fun onEnd(span: ReadableSpan) {} + + override fun isEndRequired(): Boolean = false +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/GlobalAttributesPipelineTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/GlobalAttributesPipelineTest.kt new file mode 100644 index 0000000000..ad02797862 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/GlobalAttributesPipelineTest.kt @@ -0,0 +1,192 @@ +package com.launchdarkly.observability.client + +import com.launchdarkly.observability.context.ObserveLogger +import com.launchdarkly.observability.logs.EventLogRecordProcessor +import com.launchdarkly.observability.logs.GlobalAttributesLogRecordProcessor +import com.launchdarkly.observability.logs.LogItemPayload +import com.launchdarkly.observability.replay.transport.EventQueue +import com.launchdarkly.observability.sampling.utils.FakeExportSampler +import com.launchdarkly.observability.sdk.LDObserve +import com.launchdarkly.observability.traces.EventSpanProcessor +import com.launchdarkly.observability.traces.GlobalAttributesSpanProcessor +import com.launchdarkly.observability.traces.SpanItemPayload +import io.mockk.mockk +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.data.LogRecordData +import io.opentelemetry.sdk.metrics.SdkMeterProvider +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.data.SpanData +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Runs real OpenTelemetry providers wired like [ObservabilityService]: the global-attribute + * processor first, then the SDK's export processor feeding the shared [EventQueue]. + */ +class GlobalAttributesPipelineTest { + + private val logger = mockk(relaxed = true) + private val queue = EventQueue() + + @BeforeEach + fun setup() { + GlobalAttributesStore.clear() + GlobalAttributesStore.logger = logger + } + + @AfterEach + fun tearDown() { + GlobalAttributesStore.clear() + GlobalAttributesStore.logger = null + } + + // region spans + + @Test + fun `spans get global attributes set before initialization`() { + LDObserve.setAttributes(Attributes.of(TENANT, "acme")) + val tracerProvider = tracerProvider() + + tracerProvider.get("test").spanBuilder("manual").startSpan().end() + + assertEquals("acme", exportedSpans().single().attributes.get(TENANT)) + } + + @Test + fun `per-call span attributes win over globals and globals do not touch the resource`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "global", REGION, "us")) + val tracerProvider = tracerProvider(resource = Resource.create(Attributes.of(TENANT, "resource"))) + + tracerProvider.get("test").spanBuilder("manual") + .setAttribute(TENANT, "per-call") + .setAttribute(AttributeKey.longKey("region"), 7L) + .startSpan() + .end() + + val span = exportedSpans().single() + assertEquals("per-call", span.attributes.get(TENANT)) + assertEquals(7L, span.attributes.get(AttributeKey.longKey("region"))) + assertNull(span.attributes.get(REGION)) + assertEquals("resource", span.resource.attributes.get(TENANT)) + } + + @Test + fun `span keeps the global values current at span start`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "at-start")) + val tracerProvider = tracerProvider() + + val span = tracerProvider.get("test").spanBuilder("manual").startSpan() + GlobalAttributesStore.set(Attributes.of(TENANT, "later", REGION, "eu")) + span.end() + + val exported = exportedSpans().single() + assertEquals("at-start", exported.attributes.get(TENANT)) + assertNull(exported.attributes.get(REGION)) + } + + @Test + fun `removed globals are not applied to later spans`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "acme", REGION, "us")) + GlobalAttributesStore.remove(listOf("tenant")) + val tracerProvider = tracerProvider() + + tracerProvider.get("test").spanBuilder("manual").startSpan().end() + + val exported = exportedSpans().single() + assertNull(exported.attributes.get(TENANT)) + assertEquals("us", exported.attributes.get(REGION)) + } + + @Test + fun `reserved session keys never reach spans through globals`() { + GlobalAttributesStore.set(Attributes.builder().put("session.id", "spoofed").put("tenant", "acme").build()) + val tracerProvider = tracerProvider() + + tracerProvider.get("test").spanBuilder("manual").startSpan().end() + + val exported = exportedSpans().single() + assertNull(exported.attributes.get(AttributeKey.stringKey("session.id"))) + assertEquals("acme", exported.attributes.get(TENANT)) + } + + // endregion + + // region logs + + @Test + fun `logs get globals set before initialization`() { + LDObserve.setAttributes(mapOf("tenant" to "acme")) + val loggerProvider = loggerProvider() + + loggerProvider.get("test").logRecordBuilder().setBody("hello").emit() + + assertEquals("acme", exportedLogs().single().attributes.get(TENANT)) + } + + @Test + fun `logs get globals while per-call attributes win`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "global", REGION, "us")) + val loggerProvider = loggerProvider() + + loggerProvider.get("test").logRecordBuilder() + .setBody("hello") + .setAttribute(TENANT, "per-call") + .emit() + + val log = exportedLogs().single() + assertEquals("per-call", log.attributes.get(TENANT)) + assertEquals("us", log.attributes.get(REGION)) + } + + // endregion + + @Test + fun `metric data points get globals while per-call attributes win`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "global", REGION, "us")) + val reader = InMemoryMetricReader.create() + val meterProvider = SdkMeterProvider.builder().registerMetricReader(reader).build() + + meterProvider.get("test").counterBuilder("requests").build() + .add(1, GlobalAttributesStore.merge(Attributes.of(TENANT, "per-call"))) + + val point = reader.collectAllMetrics().single().longSumData.points.single() + assertEquals("per-call", point.attributes.get(TENANT)) + assertEquals("us", point.attributes.get(REGION)) + } + + private fun tracerProvider(resource: Resource = Resource.empty()): SdkTracerProvider = + SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(GlobalAttributesSpanProcessor()) + .addSpanProcessor(EventSpanProcessor(queue, FakeExportSampler())) + .build() + + private fun loggerProvider(): SdkLoggerProvider = + SdkLoggerProvider.builder() + .addLogRecordProcessor(GlobalAttributesLogRecordProcessor()) + .addLogRecordProcessor(EventLogRecordProcessor(queue)) + .build() + + private fun exportedSpans(): List = + queuedPayloads().filterIsInstance().map { it.spanData } + + private fun exportedLogs(): List = + queuedPayloads().filterIsInstance().map { it.logRecord } + + private fun queuedPayloads() = + queue.earliest(costBudget = Int.MAX_VALUE, limit = 100, except = emptySet()) + ?.items?.map { it.payload } + .orEmpty() + + private companion object { + val TENANT: AttributeKey = AttributeKey.stringKey("tenant") + val REGION: AttributeKey = AttributeKey.stringKey("region") + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/GlobalAttributesStoreTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/GlobalAttributesStoreTest.kt new file mode 100644 index 0000000000..e6fcfd2970 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/GlobalAttributesStoreTest.kt @@ -0,0 +1,129 @@ +package com.launchdarkly.observability.client + +import com.launchdarkly.observability.context.ObserveLogger +import com.launchdarkly.observability.sdk.LDObserve +import io.mockk.mockk +import io.mockk.verify +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class GlobalAttributesStoreTest { + + private val logger = mockk(relaxed = true) + + @BeforeEach + fun setup() { + GlobalAttributesStore.clear() + GlobalAttributesStore.logger = logger + } + + @AfterEach + fun tearDown() { + GlobalAttributesStore.clear() + GlobalAttributesStore.logger = null + } + + @Test + fun `setAttributes merges by key`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "acme", REGION, "us")) + GlobalAttributesStore.set(Attributes.of(TENANT, "globex", PLAN, "pro")) + + val current = GlobalAttributesStore.current() + assertEquals(3, current.size()) + assertEquals("globex", current.get(TENANT)) + assertEquals("us", current.get(REGION)) + assertEquals("pro", current.get(PLAN)) + } + + @Test + fun `setAttributes replaces a key re-set with a different value type`() { + GlobalAttributesStore.set(Attributes.of(AttributeKey.stringKey("build"), "42")) + GlobalAttributesStore.set(Attributes.of(AttributeKey.longKey("build"), 42L)) + + val current = GlobalAttributesStore.current() + assertEquals(1, current.size()) + assertEquals(42L, current.get(AttributeKey.longKey("build"))) + } + + @Test + fun `removeAttributes removes only the given keys`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "acme", REGION, "us", PLAN, "pro")) + + GlobalAttributesStore.remove(listOf("tenant", "plan", "unknown")) + + val current = GlobalAttributesStore.current() + assertEquals(1, current.size()) + assertEquals("us", current.get(REGION)) + } + + @Test + fun `reserved session keys are ignored and logged`() { + GlobalAttributesStore.set( + Attributes.builder() + .put("session.id", "spoofed") + .put("highlight.session_id", "spoofed") + .put("tenant", "acme") + .build() + ) + + val current = GlobalAttributesStore.current() + assertEquals(1, current.size()) + assertEquals("acme", current.get(TENANT)) + assertNull(current.get(AttributeKey.stringKey("session.id"))) + assertNull(current.get(AttributeKey.stringKey("highlight.session_id"))) + verify(exactly = 2) { logger.warn(any()) } + } + + @Test + fun `merge lets explicit attributes win over globals`() { + GlobalAttributesStore.set(Attributes.of(TENANT, "acme", REGION, "us")) + + val merged = GlobalAttributesStore.merge( + Attributes.of(TENANT, "per-call", AttributeKey.longKey("region"), 7L) + ) + + assertEquals(2, merged.size()) + assertEquals("per-call", merged.get(TENANT)) + assertEquals(7L, merged.get(AttributeKey.longKey("region"))) + } + + @Test + fun `LDObserve setAttributes before initialization updates the global store`() { + LDObserve.setAttributes(Attributes.of(TENANT, "acme")) + LDObserve.setAttributes(mapOf("plan" to "pro", "seats" to 5)) + + val current = GlobalAttributesStore.current() + assertEquals("acme", current.get(TENANT)) + assertEquals("pro", current.get(PLAN)) + assertEquals(5L, current.get(AttributeKey.longKey("seats"))) + + LDObserve.removeAttributes(listOf("tenant")) + assertNull(GlobalAttributesStore.current().get(TENANT)) + } + + @Test + fun `concurrent writers do not lose updates`() { + val threads = (0 until 8).map { t -> + Thread { + repeat(50) { i -> GlobalAttributesStore.set(Attributes.of(AttributeKey.longKey("k$t.$i"), i.toLong())) } + } + } + threads.forEach { it.start() } + threads.forEach { it.join() } + + assertEquals(400, GlobalAttributesStore.current().size()) + assertTrue(GlobalAttributesStore.current().get(AttributeKey.longKey("k7.49")) == 49L) + } + + private companion object { + val TENANT: AttributeKey = AttributeKey.stringKey("tenant") + val REGION: AttributeKey = AttributeKey.stringKey("region") + val PLAN: AttributeKey = AttributeKey.stringKey("plan") + } +}