Skip to content

Commit 19ae071

Browse files
feat: allow attach span context to logs (#460)
## Summary Adds optional `SpanContext` parameter to `recordLog` so log records can be correlated with traces even when OTel context propagation is lost (e.g. detached threads, cross-process boundaries, .NET MAUI bridge). ## Changes ### SDK (`observability-android`) - **Split `Observe` into `MetricsApi`, `LogsApi`, `TracesApi`** — `Observe` now composes all three via interface inheritance. This enables bridge layers to depend on narrower contracts. - **`LogsApi.recordLog` gains `spanContext: SpanContext?`** — the old 3-arg signature is preserved as an extension function for backward compatibility. - **`LogRecordEmitter`** — new shared extension `Logger.emitLog(...)` that attaches `SpanContext` to OTel log records via `Context.root().with(Span.wrap(...))`. Used by both `ObservabilityService` and `KotlinLogger`. - **`KotlinLogger`** — new bridge-layer wrapper that accepts raw `traceId`/`spanId` strings (for .NET MAUI interop), reconstructs `SpanContext`, and routes through either the internal OTel logger or the level-gated `LogsApi`. - **`LDObserveBridge.getKotlinLogger()`** — exposes `KotlinLogger` to bridge consumers. - **`ObservabilityService.getLogger()`** — exposes the raw OTel `Logger` instance. ### E2E app - **`ViewModel.triggerLogWithContext()`** — demonstrates capturing `SpanContext` on the originating thread, then emitting a log from a detached `Thread` where automatic context propagation is lost. - **`MainActivity`** — adds "Log with Context" button next to "Trigger Log". --- > [!NOTE] > **Medium Risk** > Changes the `recordLog` API surface to accept an optional `SpanContext`, which can impact downstream implementers/callers and affects trace-log correlation behavior. New bridge logger paths and context-setting logic could introduce subtle correlation or gating regressions if misused. > > **Overview** > Adds optional trace correlation to logs by extending `recordLog` to accept a `SpanContext?` and updating `LDObserve`/`ObservabilityService` to propagate it. > > Introduces shared OTel log emission helper (`emitLog`) plus a new bridge-facing `KotlinLogger` exposed via `LDObserveBridge.getKotlinLogger()` to allow non-Kotlin layers to record logs with explicit trace/span IDs. The e2e app UI and `ViewModel` add a demo action that records a log from a detached thread using a captured span context. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 784b97b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 08130bb commit 19ae071

9 files changed

Lines changed: 162 additions & 33 deletions

File tree

e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -433,12 +433,23 @@ private fun CustomerApiButtons(viewModel: ViewModel) {
433433
) {
434434
Text("Trigger Error")
435435
}
436-
Button(
437-
onClick = {
438-
viewModel.triggerLog()
439-
}
436+
Row(
437+
horizontalArrangement = Arrangement.spacedBy(8.dp)
440438
) {
441-
Text("Trigger Log")
439+
Button(
440+
onClick = {
441+
viewModel.triggerLog()
442+
}
443+
) {
444+
Text("Trigger Log")
445+
}
446+
Button(
447+
onClick = {
448+
viewModel.triggerLogWithContext(customLogText)
449+
}
450+
) {
451+
Text("Log with Context")
452+
}
442453
}
443454

444455
OutlinedTextField(

e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,34 @@ class ViewModel(application: Application) : AndroidViewModel(application) {
8181
}
8282
}
8383

84+
fun triggerLogWithContext(message: String) {
85+
val text = message.ifEmpty { "Log with span context" }
86+
viewModelScope.launch(Dispatchers.IO) {
87+
val span = LDObserve.startSpan(
88+
name = "log-context-demo",
89+
attributes = Attributes.of(
90+
AttributeKey.stringKey("demo"), "log-with-context"
91+
)
92+
)
93+
// Capture span context while still on the originating thread.
94+
val capturedContext = span.makeCurrent().use { span.spanContext }
95+
span.end()
96+
97+
// Simulate a detached thread where OTel context is lost automatically.
98+
// Span.current() here returns INVALID, so we pass the captured context explicitly.
99+
Thread {
100+
LDObserve.recordLog(
101+
message = text,
102+
severity = Severity.WARN,
103+
attributes = Attributes.of(
104+
AttributeKey.stringKey("source"), "detached-thread-demo"
105+
),
106+
spanContext = capturedContext
107+
)
108+
}.start()
109+
}
110+
}
111+
84112
fun triggerCustomSpan(spanName: String) {
85113
if (spanName.isNotEmpty()) {
86114
viewModelScope.launch(Dispatchers.IO) {

e2e/android/app/src/test/java/com/example/androidobservability/DisablingConfigOptionsE2ETest.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,7 @@ class DisablingConfigOptionsE2ETest {
239239
private fun triggerTestLog(severity: Severity = Severity.INFO) {
240240
LDObserve.recordLog(
241241
message = "test-log",
242-
severity = severity,
243-
attributes = Attributes.empty()
242+
severity = severity
244243
)
245244
}
246245

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.launchdarkly.observability.bridge
2+
3+
import com.launchdarkly.observability.interfaces.LogsApi
4+
import io.opentelemetry.api.logs.Logger
5+
import io.opentelemetry.api.logs.Severity
6+
import io.opentelemetry.api.trace.SpanContext
7+
import io.opentelemetry.api.trace.TraceFlags
8+
import io.opentelemetry.api.trace.TraceState
9+
10+
/**
11+
* Wraps the OTel [Logger] for bridge layers (e.g. .NET MAUI).
12+
* Mirrors [KotlinTracer] but for log records.
13+
*
14+
* Holds two loggers:
15+
* - [internalLogger]: raw OTel Logger, bypasses level-gating, supports span context.
16+
* - [customerLogger]: level-gated [LogsApi] delegate for customer-facing logs.
17+
*/
18+
class KotlinLogger internal constructor(
19+
private val internalLogger: Logger,
20+
private val customerLogger: LogsApi
21+
) {
22+
23+
fun recordLog(message: String, severityNumber: Int,
24+
traceId: String?, spanId: String?,
25+
isInternal: Boolean,
26+
attributes: Map<String, Any?>?) {
27+
val severity = Severity.values().firstOrNull { it.severityNumber == severityNumber }
28+
?: Severity.INFO
29+
val spanContext = if (!traceId.isNullOrEmpty() && !spanId.isNullOrEmpty()) {
30+
SpanContext.create(traceId, spanId, TraceFlags.getSampled(), TraceState.getDefault())
31+
} else null
32+
val attrs = AttributeConverter.convert(attributes)
33+
34+
if (isInternal) {
35+
internalLogger.emitLog(message, severity, attrs, spanContext)
36+
} else {
37+
customerLogger.recordLog(message, severity, attrs, spanContext)
38+
}
39+
}
40+
}

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/bridge/LDObserveBridge.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ object LDObserveBridge {
1010
fun getKotlinTracer(): KotlinTracer? {
1111
return LDObserve.observabilityClient?.let { KotlinTracer(it.getTracer()) }
1212
}
13+
14+
fun getKotlinLogger(): KotlinLogger? {
15+
return LDObserve.observabilityClient?.let {
16+
KotlinLogger(internalLogger = it.getLogger(), customerLogger = it)
17+
}
18+
}
1319
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.launchdarkly.observability.bridge
2+
3+
import io.opentelemetry.api.common.Attributes
4+
import io.opentelemetry.api.logs.Logger
5+
import io.opentelemetry.api.logs.Severity
6+
import io.opentelemetry.api.trace.SpanContext
7+
import io.opentelemetry.context.Context
8+
import java.util.concurrent.TimeUnit
9+
10+
/**
11+
* Emits a log record through the OTel [Logger], setting span context when provided.
12+
* Shared by [KotlinLogger] and ObservabilityService to avoid duplication.
13+
*/
14+
internal fun Logger.emitLog(
15+
message: String,
16+
severity: Severity,
17+
attributes: Attributes,
18+
spanContext: SpanContext?
19+
) {
20+
val builder = logRecordBuilder()
21+
.setBody(message)
22+
.setTimestamp(System.currentTimeMillis(), TimeUnit.MILLISECONDS)
23+
.setSeverity(severity)
24+
.setSeverityText(severity.toString())
25+
.setAllAttributes(attributes)
26+
27+
if (spanContext != null) {
28+
builder.setContext(Context.root().with(
29+
io.opentelemetry.api.trace.Span.wrap(spanContext)
30+
))
31+
}
32+
33+
builder.emit()
34+
}

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.launchdarkly.observability.client
33
import android.app.Application
44
import com.launchdarkly.logging.LDLogger
55
import com.launchdarkly.observability.api.ObservabilityOptions
6+
import com.launchdarkly.observability.bridge.emitLog
67
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
78
import com.launchdarkly.observability.interfaces.Metric
89
import com.launchdarkly.observability.interfaces.Observe
@@ -334,17 +335,11 @@ class ObservabilityService(
334335
override fun recordLog(
335336
message: String,
336337
severity: Severity,
337-
attributes: Attributes
338+
attributes: Attributes,
339+
spanContext: io.opentelemetry.api.trace.SpanContext?
338340
) {
339341
if (observabilityOptions.logsApiLevel.level > severity.severityNumber) return
340-
341-
otelLogger.logRecordBuilder()
342-
.setBody(message)
343-
.setTimestamp(System.currentTimeMillis(), TimeUnit.MILLISECONDS)
344-
.setSeverity(severity)
345-
.setSeverityText(severity.toString())
346-
.setAllAttributes(attributes)
347-
.emit()
342+
otelLogger.emitLog(message, severity, attributes, spanContext)
348343
}
349344

350345
override fun recordError(error: Error, attributes: Attributes) {
@@ -377,6 +372,13 @@ class ObservabilityService(
377372
*/
378373
fun getTracer(): Tracer = otelTracer
379374

375+
/**
376+
* Returns the logger instance for recording log records.
377+
*
378+
* @return Logger instance
379+
*/
380+
fun getLogger(): io.opentelemetry.api.logs.Logger = otelLogger
381+
380382
/**
381383
* Flushes all pending telemetry data (traces, logs, metrics).
382384
* @return true if all flush operations succeeded, false otherwise

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/interfaces/Observe.kt

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ package com.launchdarkly.observability.interfaces
33
import io.opentelemetry.api.common.Attributes
44
import io.opentelemetry.api.logs.Severity
55
import io.opentelemetry.api.trace.Span
6+
import io.opentelemetry.api.trace.SpanContext
67

7-
/**
8-
* Interface for observability operations in the LaunchDarkly Android SDK.
9-
* Provides methods for recording various types of information.
10-
*/
11-
interface Observe {
8+
interface MetricsApi {
129
/**
1310
* Record a metric value.
1411
* @param metric The metric to record
@@ -38,29 +35,40 @@ interface Observe {
3835
* @param metric The up/down counter metric to record
3936
*/
4037
fun recordUpDownCounter(metric: Metric)
38+
}
39+
40+
interface LogsApi {
41+
/**
42+
* Record a log message with optional span context for trace-log correlation.
43+
* @param message The log message to record
44+
* @param severity The severity of the log message
45+
* @param attributes The attributes to record with the log message
46+
* @param spanContext Optional span context for trace-log correlation
47+
*/
48+
fun recordLog(message: String, severity: Severity, attributes: Attributes = Attributes.empty(), spanContext: SpanContext? = null)
49+
}
4150

51+
interface TracesApi {
4252
/**
4353
* Record an error.
4454
* @param error The error to record
4555
* @param attributes The attributes to record with the error
4656
*/
4757
fun recordError(error: Error, attributes: Attributes)
4858

49-
/**
50-
* Record a log message.
51-
* @param message The log message to record
52-
* @param severity The severity of the log message
53-
* @param attributes The attributes to record with the log message
54-
*/
55-
fun recordLog(message: String, severity: Severity, attributes: Attributes)
56-
5759
/**
5860
* Start a span.
5961
* @param name The name of the span
6062
* @param attributes The attributes to record with the span
6163
*/
6264
fun startSpan(name: String, attributes: Attributes): Span
65+
}
6366

67+
/**
68+
* Interface for observability operations in the LaunchDarkly Android SDK.
69+
* Provides methods for recording various types of information.
70+
*/
71+
interface Observe : MetricsApi, LogsApi, TracesApi {
6472
/**
6573
* Flushes all pending telemetry data (traces, logs, metrics).
6674
* @return true if all flush operations succeeded, false otherwise

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.launchdarkly.observability.interfaces.Observe
88
import io.opentelemetry.api.common.Attributes
99
import io.opentelemetry.api.logs.Severity
1010
import io.opentelemetry.api.trace.Span
11+
import io.opentelemetry.api.trace.SpanContext
1112

1213
/**
1314
* LDObserve is the singleton entry point for recording observability data such as
@@ -43,8 +44,8 @@ class LDObserve(private val client: Observe) : Observe {
4344
client.recordError(error, attributes)
4445
}
4546

46-
override fun recordLog(message: String, severity: Severity, attributes: Attributes) {
47-
client.recordLog(message, severity, attributes)
47+
override fun recordLog(message: String, severity: Severity, attributes: Attributes, spanContext: SpanContext?) {
48+
client.recordLog(message, severity, attributes, spanContext)
4849
}
4950

5051
override fun startSpan(name: String, attributes: Attributes): Span {
@@ -66,7 +67,7 @@ class LDObserve(private val client: Observe) : Observe {
6667
override fun recordHistogram(metric: Metric) {}
6768
override fun recordUpDownCounter(metric: Metric) {}
6869
override fun recordError(error: Error, attributes: Attributes) {}
69-
override fun recordLog(message: String, severity: Severity, attributes: Attributes) {}
70+
override fun recordLog(message: String, severity: Severity, attributes: Attributes, spanContext: SpanContext?) {}
7071
override fun startSpan(name: String, attributes: Attributes): Span {
7172
return Span.getInvalid() // Observability plugin was not initialized before being used.
7273
}
@@ -97,7 +98,7 @@ class LDObserve(private val client: Observe) : Observe {
9798
override fun recordHistogram(metric: Metric) = delegate.recordHistogram(metric)
9899
override fun recordUpDownCounter(metric: Metric) = delegate.recordUpDownCounter(metric)
99100
override fun recordError(error: Error, attributes: Attributes) = delegate.recordError(error, attributes)
100-
override fun recordLog(message: String, severity: Severity, attributes: Attributes) = delegate.recordLog(message, severity, attributes)
101+
override fun recordLog(message: String, severity: Severity, attributes: Attributes, spanContext: SpanContext?) = delegate.recordLog(message, severity, attributes, spanContext)
101102
override fun startSpan(name: String, attributes: Attributes): Span = delegate.startSpan(name, attributes)
102103
override fun flush(): Boolean = delegate.flush()
103104

0 commit comments

Comments
 (0)