Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any?> = 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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
)
)
}
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions sdk/@launchdarkly/observability-android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String, Pair<AttributeKey<*>, 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<String>) {
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<Any>, value)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading