Skip to content

Commit 3092c8f

Browse files
feat: O11Y-359 - Add custom sampling for OTLP logs and traces (#189)
## Summary This commit introduces a custom sampling mechanism for OpenTelemetry (OTLP) logs and traces within the Android observability SDK. This allows for more granular control over which telemetry data is exported, helping to manage data volume and focus on relevant information. The architecture and implementation are mostly based on the [observability-node](https://github.com/launchdarkly/observability-sdk/tree/main/sdk/%40launchdarkly/observability-node) project The key additions include: - `CustomSampler`: A class that implements sampling logic based on a `SamplingConfig`. - `SamplingConfig`: Data classes defining the structure for configuring sampling rules for spans and logs. - `ExportSampler`: An interface defining the contract for samplers that operate during the export phase. - `SamplingTraceExporter` and `SamplingLogExporter`: Decorator classes that wrap the standard `OtlpHttpSpanExporter` and `OtlpHttpLogRecordExporter` respectively. They use the `CustomSampler` to filter spans/logs before delegating to the underlying exporter. - `SamplingResult`: A data class to hold the outcome of a sampling decision (whether to sample and any additional attributes). - `sampleSpans` and `sampleLogs`: Utility functions that take a list of spans or logs and an `ExportSampler` to filter them based on sampling decisions. What is not included: - Fetching sampling configuration from the backend service - Setting sampling configuration to the CustomSampler ## How did you test this change? Unit testing ## Are there any deployment considerations? Since the sample configuration isn't being retrieved from the backend yet, null is currently passed as the config. This means no data will be sampled. --------- Co-authored-by: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com>
1 parent 51fc585 commit 3092c8f

File tree

19 files changed

+2397
-33
lines changed

19 files changed

+2397
-33
lines changed

sdk/@launchdarkly/observability-android/gradle/libs.versions.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
33

44
[versions]
5-
junit-jupiter = "5.12.1"
5+
junit-jupiter = "5.13.4"
66

77
[libraries]
8-
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
8+
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit-jupiter" }
9+
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
10+
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
911

1012
[plugins]
1113
kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.1.20" }

sdk/@launchdarkly/observability-android/lib/build.gradle.kts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,20 @@ dependencies {
3939
implementation("io.opentelemetry.android.instrumentation:crash:0.11.0-alpha")
4040

4141
// Use JUnit Jupiter for testing.
42-
testImplementation("org.junit.jupiter:junit-jupiter")
43-
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
42+
testImplementation(platform(libs.junit.bom))
43+
testImplementation(libs.junit.jupiter)
44+
testRuntimeOnly(libs.junit.platform.launcher)
45+
46+
// MockK for mocking in Kotlin tests
47+
testImplementation("io.mockk:mockk:1.14.5")
4448
}
4549

4650
val releaseVersion = version.toString()
4751

52+
tasks.withType<Test> {
53+
useJUnitPlatform()
54+
}
55+
4856
android {
4957
namespace = "com.launchdarkly.observability"
5058
compileSdk = 30

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

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import android.app.Application
44
import com.launchdarkly.logging.LDLogger
55
import com.launchdarkly.observability.api.Options
66
import com.launchdarkly.observability.interfaces.Metric
7+
import com.launchdarkly.observability.sampling.CompositeLogExporter
8+
import com.launchdarkly.observability.sampling.CustomSampler
9+
import com.launchdarkly.observability.sampling.SamplingLogExporter
10+
import com.launchdarkly.observability.sampling.SamplingTraceExporter
711
import io.opentelemetry.android.OpenTelemetryRum
812
import io.opentelemetry.android.config.OtelRumConfig
913
import io.opentelemetry.android.session.SessionConfig
@@ -17,7 +21,10 @@ import io.opentelemetry.context.Context
1721
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter
1822
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter
1923
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
24+
import io.opentelemetry.sdk.common.CompletableResultCode
25+
import io.opentelemetry.sdk.logs.data.LogRecordData
2026
import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor
27+
import io.opentelemetry.sdk.logs.export.LogRecordExporter
2128
import io.opentelemetry.sdk.metrics.export.MetricExporter
2229
import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader
2330
import io.opentelemetry.sdk.resources.Resource
@@ -45,63 +52,55 @@ class InstrumentationManager(
4552
private var otelMeter: Meter
4653
private var otelLogger: Logger
4754
private var otelTracer: Tracer
55+
private var customSampler = CustomSampler()
4856

4957
init {
5058
val otelRumConfig = OtelRumConfig().setSessionConfig(
5159
SessionConfig(backgroundInactivityTimeout = options.sessionBackgroundTimeout)
5260
)
61+
5362
otelRUM = OpenTelemetryRum.builder(application, otelRumConfig)
5463
.addLoggerProviderCustomizer { sdkLoggerProviderBuilder, application ->
5564
val logExporter = OtlpHttpLogRecordExporter.builder()
5665
.setEndpoint(options.otlpEndpoint + LOGS_PATH)
5766
.setHeaders { options.customHeaders }
5867
.build()
5968

60-
val processor = BatchLogRecordProcessor.builder(logExporter)
61-
.setMaxQueueSize(100)
62-
.setScheduleDelay(1000, TimeUnit.MILLISECONDS)
63-
.setExporterTimeout(5000, TimeUnit.MILLISECONDS)
64-
.setMaxExportBatchSize(10)
65-
.build()
66-
67-
sdkLoggerProviderBuilder
68-
.setResource(resources)
69-
.addLogRecordProcessor(processor)
69+
sdkLoggerProviderBuilder.setResource(resources)
7070

7171
if (options.debug) {
72-
val adapterLogExporter = object : io.opentelemetry.sdk.logs.export.LogRecordExporter {
73-
override fun export(logRecords: Collection<io.opentelemetry.sdk.logs.data.LogRecordData>): io.opentelemetry.sdk.common.CompletableResultCode {
72+
val debugLogExporter = object : LogRecordExporter {
73+
override fun export(logRecords: Collection<LogRecordData>): CompletableResultCode {
7474
for (record in logRecords) {
7575
logger.info(record.toString()) // TODO: Figure out why logger.debug is being blocked by Log.isLoggable is adapter.
7676
}
77-
return io.opentelemetry.sdk.common.CompletableResultCode.ofSuccess()
78-
}
79-
override fun flush(): io.opentelemetry.sdk.common.CompletableResultCode {
80-
return io.opentelemetry.sdk.common.CompletableResultCode.ofSuccess()
81-
}
82-
override fun shutdown(): io.opentelemetry.sdk.common.CompletableResultCode {
83-
return io.opentelemetry.sdk.common.CompletableResultCode.ofSuccess()
77+
return CompletableResultCode.ofSuccess()
8478
}
79+
override fun flush(): CompletableResultCode = CompletableResultCode.ofSuccess()
80+
override fun shutdown(): CompletableResultCode = CompletableResultCode.ofSuccess()
8581
}
8682

87-
val adapterProcessor = BatchLogRecordProcessor.builder(adapterLogExporter)
88-
.setMaxQueueSize(100)
89-
.setScheduleDelay(1000, TimeUnit.MILLISECONDS)
90-
.setExporterTimeout(5000, TimeUnit.MILLISECONDS)
91-
.setMaxExportBatchSize(10)
92-
.build()
93-
sdkLoggerProviderBuilder.addLogRecordProcessor(adapterProcessor)
94-
}
83+
val compositeExporter = CompositeLogExporter(logExporter, debugLogExporter)
84+
val samplingLogExporter = SamplingLogExporter(compositeExporter, customSampler)
85+
val logProcessor = getBatchLogRecordProcessor(samplingLogExporter)
86+
87+
sdkLoggerProviderBuilder.addLogRecordProcessor(logProcessor)
88+
} else {
89+
val samplingLogExporter = SamplingLogExporter(logExporter, customSampler)
90+
val logProcessor = getBatchLogRecordProcessor(samplingLogExporter)
9591

96-
sdkLoggerProviderBuilder
92+
sdkLoggerProviderBuilder.addLogRecordProcessor(logProcessor)
93+
}
9794
}
9895
.addTracerProviderCustomizer { sdkTracerProviderBuilder, application ->
9996
val spanExporter = OtlpHttpSpanExporter.builder()
10097
.setEndpoint(options.otlpEndpoint + TRACES_PATH)
10198
.setHeaders { options.customHeaders }
10299
.build()
103100

104-
val spanProcessor = BatchSpanProcessor.builder(spanExporter)
101+
val samplingTraceExporter = SamplingTraceExporter(spanExporter, customSampler)
102+
103+
val spanProcessor = BatchSpanProcessor.builder(samplingTraceExporter)
105104
.setMaxQueueSize(100)
106105
.setScheduleDelay(1000, TimeUnit.MILLISECONDS)
107106
.setExporterTimeout(5000, TimeUnit.MILLISECONDS)
@@ -135,6 +134,14 @@ class InstrumentationManager(
135134
otelTracer = otelRUM.openTelemetry.tracerProvider.get(INSTRUMENTATION_SCOPE_NAME)
136135
}
137136

137+
private fun getBatchLogRecordProcessor(logRecordExporter: LogRecordExporter): BatchLogRecordProcessor {
138+
return BatchLogRecordProcessor.builder(logRecordExporter)
139+
.setMaxQueueSize(100)
140+
.setScheduleDelay(1000, TimeUnit.MILLISECONDS)
141+
.setExporterTimeout(5000, TimeUnit.MILLISECONDS)
142+
.setMaxExportBatchSize(10)
143+
.build()
144+
}
138145

139146
fun recordMetric(metric: Metric) {
140147
otelMeter.gaugeBuilder(metric.name).build()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.launchdarkly.observability.sampling
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 composite log exporter that forwards log records to multiple underlying exporters.
9+
*
10+
* This allows sending the same log records to multiple destinations (e.g., OTLP endpoint
11+
* and local debug output) without duplicating the sampling logic. All operations
12+
* (export, flush, shutdown) are forwarded to all underlying exporters.
13+
*
14+
* The composite operation succeeds only if ALL underlying exporters succeed.
15+
*
16+
* @param exporters The list of underlying exporters to forward operations to
17+
*/
18+
class CompositeLogExporter(
19+
private val exporters: List<LogRecordExporter>
20+
) : LogRecordExporter {
21+
22+
/**
23+
* Convenience constructor that accepts a variable number of exporters.
24+
*
25+
* @param exporters The exporters to compose
26+
*/
27+
constructor(vararg exporters: LogRecordExporter) : this(exporters.toList())
28+
29+
/**
30+
* Exports log records to all underlying exporters.
31+
*
32+
* @param logRecords The log records to export
33+
* @return A CompletableResultCode that succeeds only if all underlying exports succeed
34+
*/
35+
override fun export(logRecords: Collection<LogRecordData>): CompletableResultCode {
36+
val results = exporters.map { exporter ->
37+
exporter.export(logRecords)
38+
}
39+
return CompletableResultCode.ofAll(results)
40+
}
41+
42+
/**
43+
* Flushes all underlying exporters.
44+
*
45+
* @return A CompletableResultCode that succeeds only if all underlying flushes succeed
46+
*/
47+
override fun flush(): CompletableResultCode {
48+
val results = exporters.map { exporter ->
49+
exporter.flush()
50+
}
51+
return CompletableResultCode.ofAll(results)
52+
}
53+
54+
/**
55+
* Shuts down all underlying exporters.
56+
*
57+
* @return A CompletableResultCode that succeeds only if all underlying shutdowns succeed
58+
*/
59+
override fun shutdown(): CompletableResultCode {
60+
val results = exporters.map { exporter ->
61+
exporter.shutdown()
62+
}
63+
return CompletableResultCode.ofAll(results)
64+
}
65+
}

0 commit comments

Comments
 (0)