Skip to content

Commit 38f6c45

Browse files
authored
feat(android): Add conditional exporters for logs and traces (#254)
## Summary This change introduces conditional exporters for both logs and spans on the Android SDK. These new exporters, `ConditionalLogRecordExporter` and `ConditionalSpanExporter`, allow for separate filtering of normal application telemetry versus error and crash reports. Key changes: - Added `ConditionalLogRecordExporter.kt` to filter logs, distinguishing between normal logs and crash reports from OpenTelemetry's CrashReporter. - Added `ConditionalSpanExporter.kt` to filter spans, distinguishing between normal traces and error spans created by `recordError`. - Updated `InstrumentationManager.kt` to use these conditional exporters, enabling independent control over sending normal logs/traces and error/crash data based on the configuration options (`disableLogs`, `disableTraces`, `disableErrorTracking`). - `CompositeLogExporter` and `CompositeSpanExporter` classes were replaced by using the built-in `LogRecordExporter.composite()` and `SpanExporter.composite()` static methods from the OpenTelemetry SDK. ## How did you test this change? <!-- Frontend - Leave a screencast or a screenshot to visually describe the changes. --> ## Are there any deployment considerations? <!-- Backend - Do we need to consider migrations or backfilling data? --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce conditional exporters to independently control normal logs/traces vs error/crash data; replace custom composites with built-in composites and update tests accordingly. > > - **SDK (Android)**: > - **Conditional Exporters**: Add `ConditionalLogRecordExporter` and `ConditionalSpanExporter` to filter normal telemetry vs crash/error (`highlight.error`) data. > - **InstrumentationManager**: > - Use `LogRecordExporter.composite`/`SpanExporter.composite` in debug; remove `CompositeLogExporter`/`CompositeSpanExporter` usage. > - Wrap exporters with conditional filters allowing errors/crashes when `disableErrorTracking` is false, regardless of `disableLogs`/`disableTraces`. > - Adjust provider customizers to disable logs/traces only when both the signal and error tracking are disabled; add `ERROR_SPAN_NAME` constant. > - **Tests**: > - Add unit tests: `ConditionalLogRecordExporterTest`, `ConditionalSpanExporterTest`. > - Update E2E `DisablingConfigOptionsE2ETest` to validate filtered exports, error-as-span when traces disabled, crash-as-log when logs disabled; add `getOptionsAllEnabled()` helper. > - **Cleanup**: > - Remove `CompositeLogExporter`/`CompositeSpanExporter` and their tests; switch to built-in composites. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 137ae73. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1b801be commit 38f6c45

File tree

9 files changed

+692
-391
lines changed

9 files changed

+692
-391
lines changed

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

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import io.opentelemetry.api.common.AttributeKey
99
import io.opentelemetry.api.common.Attributes
1010
import io.opentelemetry.api.logs.Severity
1111
import com.example.androidobservability.TestUtils.TelemetryType
12+
import com.launchdarkly.observability.api.Options
1213
import junit.framework.TestCase.assertEquals
1314
import junit.framework.TestCase.assertFalse
1415
import junit.framework.TestCase.assertNotNull
@@ -27,22 +28,23 @@ class DisablingConfigOptionsE2ETest {
2728
private val application = ApplicationProvider.getApplicationContext<Application>() as TestApplication
2829

2930
@Test
30-
fun `Logs should not be exported when disableLogs is set to true`() {
31-
application.pluginOptions = application.pluginOptions.copy(disableLogs = true)
31+
fun `Logs should NOT be exported when disableLogs is set to true`() {
32+
application.pluginOptions = getOptionsAllEnabled().copy(disableLogs = true)
3233
application.initForTest()
3334
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"
3435

3536
triggerTestLog()
3637
LDObserve.flush()
3738
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
39+
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems
3840

39-
assertNull(application.telemetryInspector?.logExporter)
41+
assertTrue(logsExported?.isEmpty() == true)
4042
assertFalse(requestsContainsUrl(logsUrl))
4143
}
4244

4345
@Test
4446
fun `Logs should be exported when disableLogs is set to false`() {
45-
application.pluginOptions = application.pluginOptions.copy(disableLogs = false)
47+
application.pluginOptions = getOptionsAllEnabled().copy(disableLogs = false)
4648
application.initForTest()
4749
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"
4850

@@ -56,21 +58,23 @@ class DisablingConfigOptionsE2ETest {
5658

5759
@Test
5860
fun `Spans should NOT be exported when disableTraces is set to true`() {
59-
application.pluginOptions = application.pluginOptions.copy(disableTraces = true)
61+
application.pluginOptions = getOptionsAllEnabled().copy(disableTraces = true)
6062
application.initForTest()
6163
val tracesUrl = "http://localhost:${application.mockWebServer?.port}/v1/traces"
6264

6365
triggerTestSpan()
6466
LDObserve.flush()
67+
6568
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.SPANS)
69+
val spansExported = application.telemetryInspector?.spanExporter?.finishedSpanItems
6670

67-
assertNull(application.telemetryInspector?.spanExporter)
71+
assertTrue(spansExported?.isEmpty() == true)
6872
assertFalse(requestsContainsUrl(tracesUrl))
6973
}
7074

7175
@Test
7276
fun `Spans should be exported when disableTraces is set to false`() {
73-
application.pluginOptions = application.pluginOptions.copy(disableTraces = false)
77+
application.pluginOptions = getOptionsAllEnabled().copy(disableTraces = false)
7478
application.initForTest()
7579
val tracesUrl = "http://localhost:${application.mockWebServer?.port}/v1/traces"
7680

@@ -84,7 +88,7 @@ class DisablingConfigOptionsE2ETest {
8488

8589
@Test
8690
fun `Metrics should NOT be exported when disableMetrics is set to true`() {
87-
application.pluginOptions = application.pluginOptions.copy(disableMetrics = true)
91+
application.pluginOptions = getOptionsAllEnabled().copy(disableMetrics = true)
8892
application.initForTest()
8993
val metricsUrl = "http://localhost:${application.mockWebServer?.port}/v1/metrics"
9094

@@ -98,7 +102,7 @@ class DisablingConfigOptionsE2ETest {
98102

99103
@Test
100104
fun `Metrics should be exported when disableMetrics is set to false`() {
101-
application.pluginOptions = application.pluginOptions.copy(disableMetrics = false)
105+
application.pluginOptions = getOptionsAllEnabled().copy(disableMetrics = false)
102106
application.initForTest()
103107
val metricsUrl = "http://localhost:${application.mockWebServer?.port}/v1/metrics"
104108

@@ -112,7 +116,7 @@ class DisablingConfigOptionsE2ETest {
112116

113117
@Test
114118
fun `Errors should NOT be exported when disableErrorTracking is set to true`() {
115-
application.pluginOptions = application.pluginOptions.copy(disableErrorTracking = true)
119+
application.pluginOptions = getOptionsAllEnabled().copy(disableErrorTracking = true)
116120
application.initForTest()
117121
val tracesUrl = "http://localhost:${application.mockWebServer?.port}/v1/traces"
118122

@@ -127,8 +131,8 @@ class DisablingConfigOptionsE2ETest {
127131
}
128132

129133
@Test
130-
fun `Errors should be exported when disableErrorTracking is set to false`() {
131-
application.pluginOptions = application.pluginOptions.copy(disableErrorTracking = false)
134+
fun `Errors should be exported as spans when disableErrorTracking is set to false and disableTraces set to true`() {
135+
application.pluginOptions = getOptionsAllEnabled().copy(disableTraces = true, disableErrorTracking = false)
132136
application.initForTest()
133137
val tracesUrl = "http://localhost:${application.mockWebServer?.port}/v1/traces"
134138

@@ -146,6 +150,41 @@ class DisablingConfigOptionsE2ETest {
146150
)
147151
}
148152

153+
@Test
154+
fun `Crashes should NOT be exported when disableErrorTracking is set to true`() {
155+
application.pluginOptions = getOptionsAllEnabled().copy(disableErrorTracking = true)
156+
application.initForTest()
157+
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"
158+
159+
Thread { throw RuntimeException("Exception for testing") }.start()
160+
161+
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
162+
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems
163+
164+
assertFalse(requestsContainsUrl(logsUrl))
165+
assertEquals(0, logsExported?.size)
166+
}
167+
168+
@Test
169+
fun `Crashes should be exported as logs when disableErrorTracking is set to false and disableLogs set to true`() {
170+
application.pluginOptions = getOptionsAllEnabled().copy(disableLogs = true, disableErrorTracking = false)
171+
application.initForTest()
172+
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"
173+
val exceptionMessage = "Exception for testing"
174+
175+
Thread { throw RuntimeException(exceptionMessage) }.start()
176+
177+
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
178+
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems
179+
180+
assertTrue(requestsContainsUrl(logsUrl))
181+
assertEquals(1, logsExported?.size)
182+
assertEquals(
183+
exceptionMessage,
184+
logsExported?.get(0)?.attributes?.get(AttributeKey.stringKey("exception.message"))
185+
)
186+
}
187+
149188
private fun requestsContainsUrl(url: String): Boolean {
150189
while (true) {
151190
val request = application.mockWebServer?.takeRequest(100, TimeUnit.MILLISECONDS)
@@ -180,4 +219,14 @@ class DisablingConfigOptionsE2ETest {
180219
private fun triggerTestMetric() {
181220
LDObserve.recordMetric(Metric("test", 50.0))
182221
}
222+
223+
private fun getOptionsAllEnabled(): Options {
224+
return Options(
225+
debug = true,
226+
disableTraces = false,
227+
disableLogs = false,
228+
disableMetrics = false,
229+
disableErrorTracking = false
230+
)
231+
}
183232
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.launchdarkly.observability.client
2+
3+
import io.opentelemetry.sdk.common.CompletableResultCode
4+
import io.opentelemetry.sdk.logs.data.LogRecordData
5+
import io.opentelemetry.sdk.logs.export.LogRecordExporter
6+
7+
/**
8+
* A log record exporter that conditionally forwards logs based on their source.
9+
* This allows different filtering rules for crashes vs normal logs.
10+
*
11+
* @property delegate The underlying exporter to forward logs to
12+
* @property allowNormalLogs Whether to allow normal application logs
13+
* @property allowCrashes Whether to allow crash logs from OpenTelemetry's CrashReporter
14+
*/
15+
class ConditionalLogRecordExporter(
16+
private val delegate: LogRecordExporter,
17+
private val allowNormalLogs: Boolean,
18+
private val allowCrashes: Boolean
19+
) : LogRecordExporter {
20+
21+
override fun export(logs: Collection<LogRecordData>): CompletableResultCode {
22+
val filteredLogs = logs.filter { logRecord ->
23+
// Check if this is a crash log (from OpenTelemetry's CrashReporter)
24+
val instrumentationScopeName = logRecord.instrumentationScopeInfo.name
25+
val isCrashLog = instrumentationScopeName == "io.opentelemetry.crash"
26+
27+
when {
28+
isCrashLog -> allowCrashes
29+
else -> allowNormalLogs
30+
}
31+
}
32+
33+
return if (filteredLogs.isNotEmpty()) {
34+
delegate.export(filteredLogs)
35+
} else {
36+
CompletableResultCode.ofSuccess()
37+
}
38+
}
39+
40+
override fun flush(): CompletableResultCode = delegate.flush()
41+
42+
override fun shutdown(): CompletableResultCode = delegate.shutdown()
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.launchdarkly.observability.client
2+
3+
import io.opentelemetry.sdk.common.CompletableResultCode
4+
import io.opentelemetry.sdk.trace.data.SpanData
5+
import io.opentelemetry.sdk.trace.export.SpanExporter
6+
7+
/**
8+
* A span exporter that conditionally forwards spans based on their source.
9+
* This allows different filtering rules for error spans vs normal spans.
10+
*
11+
* @property delegate The underlying exporter to forward spans to
12+
* @property allowNormalSpans Whether to allow normal application spans
13+
* @property allowErrorSpans Whether to allow error spans created by recordError method
14+
*/
15+
class ConditionalSpanExporter(
16+
private val delegate: SpanExporter,
17+
private val allowNormalSpans: Boolean,
18+
private val allowErrorSpans: Boolean
19+
) : SpanExporter {
20+
21+
override fun export(spans: Collection<SpanData>): CompletableResultCode {
22+
val filteredSpans = spans.filter { spanData ->
23+
// Check if this is an error span created by recordError method
24+
val spanName = spanData.name
25+
val isErrorSpan = spanName == InstrumentationManager.ERROR_SPAN_NAME
26+
27+
when {
28+
isErrorSpan -> allowErrorSpans
29+
else -> allowNormalSpans
30+
}
31+
}
32+
33+
return if (filteredSpans.isNotEmpty()) {
34+
delegate.export(filteredSpans)
35+
} else {
36+
CompletableResultCode.ofSuccess()
37+
}
38+
}
39+
40+
override fun flush(): CompletableResultCode = delegate.flush()
41+
42+
override fun shutdown(): CompletableResultCode = delegate.shutdown()
43+
}

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

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import com.launchdarkly.observability.api.Options
66
import com.launchdarkly.observability.interfaces.Metric
77
import com.launchdarkly.observability.network.GraphQLClient
88
import com.launchdarkly.observability.network.SamplingApiService
9-
import com.launchdarkly.observability.sampling.CompositeLogExporter
10-
import com.launchdarkly.observability.sampling.CompositeSpanExporter
119
import com.launchdarkly.observability.sampling.CustomSampler
1210
import com.launchdarkly.observability.sampling.SamplingConfig
1311
import com.launchdarkly.observability.sampling.SamplingLogExporter
@@ -72,8 +70,7 @@ class InstrumentationManager(
7270
private const val LOGS_PATH = "/v1/logs"
7371
private const val TRACES_PATH = "/v1/traces"
7472
private const val INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability"
75-
76-
// Batch processor configuration constants
73+
const val ERROR_SPAN_NAME = "highlight.error"
7774
private const val BATCH_MAX_QUEUE_SIZE = 100
7875
private const val BATCH_SCHEDULE_DELAY_MS = 1000L
7976
private const val BATCH_EXPORTER_TIMEOUT_MS = 5000L
@@ -109,14 +106,14 @@ class InstrumentationManager(
109106

110107
otelRUM = OpenTelemetryRum.builder(application, otelRumConfig)
111108
.addLoggerProviderCustomizer { sdkLoggerProviderBuilder, _ ->
112-
return@addLoggerProviderCustomizer if (options.disableLogs) {
109+
return@addLoggerProviderCustomizer if (options.disableLogs && options.disableErrorTracking) {
113110
sdkLoggerProviderBuilder
114111
} else {
115112
configureLoggerProvider(sdkLoggerProviderBuilder)
116113
}
117114
}
118115
.addTracerProviderCustomizer { sdkTracerProviderBuilder, _ ->
119-
return@addTracerProviderCustomizer if (options.disableTraces) {
116+
return@addTracerProviderCustomizer if (options.disableTraces && options.disableErrorTracking) {
120117
sdkTracerProviderBuilder
121118
} else {
122119
configureTracerProvider(sdkTracerProviderBuilder)
@@ -141,7 +138,12 @@ class InstrumentationManager(
141138

142139
private fun createOtelRumConfig(): OtelRumConfig {
143140
val config = OtelRumConfig()
144-
.setDiskBufferingConfig(DiskBufferingConfig.create(enabled = isAnySignalEnabled(options), debugEnabled = options.debug))
141+
.setDiskBufferingConfig(
142+
DiskBufferingConfig.create(
143+
enabled = isAnySignalEnabled(options),
144+
debugEnabled = options.debug
145+
)
146+
)
145147
.setSessionConfig(SessionConfig(backgroundInactivityTimeout = options.sessionBackgroundTimeout))
146148

147149
if (options.disableErrorTracking) {
@@ -213,35 +215,58 @@ class InstrumentationManager(
213215
}
214216

215217
private fun createLogExporter(primaryExporter: LogRecordExporter): LogRecordExporter {
216-
return if (options.debug) {
217-
val exporters = mutableListOf(primaryExporter, DebugLogExporter(logger))
218-
inMemoryLogExporter = InMemoryLogRecordExporter.create().also { exporters.add(it) }
219-
220-
val compositeExporter = CompositeLogExporter(exporters)
221-
SamplingLogExporter(compositeExporter, customSampler)
218+
val baseExporter = if (options.debug) {
219+
LogRecordExporter.composite(
220+
buildList {
221+
add(primaryExporter)
222+
add(DebugLogExporter(logger))
223+
add(InMemoryLogRecordExporter.create().also { inMemoryLogExporter = it })
224+
}
225+
)
222226
} else {
223-
SamplingLogExporter(primaryExporter, customSampler)
227+
primaryExporter
224228
}
229+
230+
val conditionalExporter = ConditionalLogRecordExporter(
231+
delegate = baseExporter,
232+
allowNormalLogs = !options.disableLogs,
233+
allowCrashes = !options.disableErrorTracking
234+
)
235+
236+
return SamplingLogExporter(conditionalExporter, customSampler)
225237
}
226238

227239
private fun createSpanExporter(primaryExporter: SpanExporter): SpanExporter {
228-
return if (options.debug) {
229-
val exporters = mutableListOf(primaryExporter, DebugSpanExporter(logger))
230-
inMemorySpanExporter = InMemorySpanExporter.create().also { exporters.add(it) }
231-
232-
val compositeExporter = CompositeSpanExporter(exporters)
233-
SamplingTraceExporter(compositeExporter, customSampler)
240+
val baseExporter = if (options.debug) {
241+
SpanExporter.composite(
242+
buildList {
243+
add(primaryExporter)
244+
add(DebugSpanExporter(logger))
245+
add(InMemorySpanExporter.create().also { inMemorySpanExporter = it })
246+
}
247+
)
234248
} else {
235-
SamplingTraceExporter(primaryExporter, customSampler)
249+
primaryExporter
236250
}
251+
252+
val conditionalExporter = ConditionalSpanExporter(
253+
delegate = baseExporter,
254+
allowNormalSpans = !options.disableTraces,
255+
allowErrorSpans = !options.disableErrorTracking
256+
)
257+
258+
return SamplingTraceExporter(conditionalExporter, customSampler)
237259
}
238260

239261
private fun createMetricExporter(primaryExporter: MetricExporter): MetricExporter {
240262
return if (options.debug) {
241-
val exporters = mutableListOf(primaryExporter, DebugMetricExporter(logger))
242-
inMemoryMetricExporter = InMemoryMetricExporter.create().also { exporters.add(it) }
243-
244-
CompositeMetricExporter(exporters)
263+
CompositeMetricExporter(
264+
buildList {
265+
add(primaryExporter)
266+
add(DebugMetricExporter(logger))
267+
add(InMemoryMetricExporter.create().also { inMemoryMetricExporter = it })
268+
}
269+
)
245270
} else {
246271
primaryExporter
247272
}
@@ -337,7 +362,7 @@ class InstrumentationManager(
337362
fun recordError(error: Error, attributes: Attributes) {
338363
if (!options.disableErrorTracking) {
339364
val span = otelTracer
340-
.spanBuilder("highlight.error")
365+
.spanBuilder(ERROR_SPAN_NAME)
341366
.setParent(Context.current().with(Span.current()))
342367
.startSpan()
343368

0 commit comments

Comments
 (0)