Skip to content
Merged
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 @@ -9,6 +9,7 @@ import io.opentelemetry.api.common.AttributeKey
import io.opentelemetry.api.common.Attributes
import io.opentelemetry.api.logs.Severity
import com.example.androidobservability.TestUtils.TelemetryType
import com.launchdarkly.observability.api.Options
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
Expand All @@ -27,22 +28,23 @@ class DisablingConfigOptionsE2ETest {
private val application = ApplicationProvider.getApplicationContext<Application>() as TestApplication

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

triggerTestLog()
LDObserve.flush()
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems

assertNull(application.telemetryInspector?.logExporter)
assertTrue(logsExported?.isEmpty() == true)
assertFalse(requestsContainsUrl(logsUrl))
}

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

Expand All @@ -56,21 +58,23 @@ class DisablingConfigOptionsE2ETest {

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

triggerTestSpan()
LDObserve.flush()

waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.SPANS)
val spansExported = application.telemetryInspector?.spanExporter?.finishedSpanItems

assertNull(application.telemetryInspector?.spanExporter)
assertTrue(spansExported?.isEmpty() == true)
assertFalse(requestsContainsUrl(tracesUrl))
}

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

Expand All @@ -84,7 +88,7 @@ class DisablingConfigOptionsE2ETest {

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

Expand All @@ -98,7 +102,7 @@ class DisablingConfigOptionsE2ETest {

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

Expand All @@ -112,7 +116,7 @@ class DisablingConfigOptionsE2ETest {

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

Expand All @@ -127,8 +131,8 @@ class DisablingConfigOptionsE2ETest {
}

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

Expand All @@ -146,6 +150,41 @@ class DisablingConfigOptionsE2ETest {
)
}

@Test
fun `Crashes should NOT be exported when disableErrorTracking is set to true`() {
application.pluginOptions = getOptionsAllEnabled().copy(disableErrorTracking = true)
application.initForTest()
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"

Thread { throw RuntimeException("Exception for testing") }.start()

waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems

assertFalse(requestsContainsUrl(logsUrl))
assertEquals(0, logsExported?.size)
}

@Test
fun `Crashes should be exported as logs when disableErrorTracking is set to false and disableLogs set to true`() {
application.pluginOptions = getOptionsAllEnabled().copy(disableLogs = true, disableErrorTracking = false)
application.initForTest()
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"
val exceptionMessage = "Exception for testing"

Thread { throw RuntimeException(exceptionMessage) }.start()

waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems

assertTrue(requestsContainsUrl(logsUrl))
assertEquals(1, logsExported?.size)
assertEquals(
exceptionMessage,
logsExported?.get(0)?.attributes?.get(AttributeKey.stringKey("exception.message"))
)
}

private fun requestsContainsUrl(url: String): Boolean {
while (true) {
val request = application.mockWebServer?.takeRequest(100, TimeUnit.MILLISECONDS)
Expand Down Expand Up @@ -180,4 +219,14 @@ class DisablingConfigOptionsE2ETest {
private fun triggerTestMetric() {
LDObserve.recordMetric(Metric("test", 50.0))
}

private fun getOptionsAllEnabled(): Options {
return Options(
debug = true,
disableTraces = false,
disableLogs = false,
disableMetrics = false,
disableErrorTracking = false
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.launchdarkly.observability.client

import io.opentelemetry.sdk.common.CompletableResultCode
import io.opentelemetry.sdk.logs.data.LogRecordData
import io.opentelemetry.sdk.logs.export.LogRecordExporter

/**
* A log record exporter that conditionally forwards logs based on their source.
* This allows different filtering rules for crashes vs normal logs.
*
* @property delegate The underlying exporter to forward logs to
* @property allowNormalLogs Whether to allow normal application logs
* @property allowCrashes Whether to allow crash logs from OpenTelemetry's CrashReporter
*/
class ConditionalLogRecordExporter(
private val delegate: LogRecordExporter,
private val allowNormalLogs: Boolean,
private val allowCrashes: Boolean
) : LogRecordExporter {

override fun export(logs: Collection<LogRecordData>): CompletableResultCode {
val filteredLogs = logs.filter { logRecord ->
// Check if this is a crash log (from OpenTelemetry's CrashReporter)
val instrumentationScopeName = logRecord.instrumentationScopeInfo.name
val isCrashLog = instrumentationScopeName == "io.opentelemetry.crash"

when {
isCrashLog -> allowCrashes
else -> allowNormalLogs
}
}

return if (filteredLogs.isNotEmpty()) {
delegate.export(filteredLogs)
} else {
CompletableResultCode.ofSuccess()
}
}

override fun flush(): CompletableResultCode = delegate.flush()

override fun shutdown(): CompletableResultCode = delegate.shutdown()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.launchdarkly.observability.client

import io.opentelemetry.sdk.common.CompletableResultCode
import io.opentelemetry.sdk.trace.data.SpanData
import io.opentelemetry.sdk.trace.export.SpanExporter

/**
* A span exporter that conditionally forwards spans based on their source.
* This allows different filtering rules for error spans vs normal spans.
*
* @property delegate The underlying exporter to forward spans to
* @property allowNormalSpans Whether to allow normal application spans
* @property allowErrorSpans Whether to allow error spans created by recordError method
*/
class ConditionalSpanExporter(
private val delegate: SpanExporter,
private val allowNormalSpans: Boolean,
private val allowErrorSpans: Boolean
) : SpanExporter {

override fun export(spans: Collection<SpanData>): CompletableResultCode {
val filteredSpans = spans.filter { spanData ->
// Check if this is an error span created by recordError method
val spanName = spanData.name
val isErrorSpan = spanName == InstrumentationManager.ERROR_SPAN_NAME

when {
isErrorSpan -> allowErrorSpans
else -> allowNormalSpans
}
}

return if (filteredSpans.isNotEmpty()) {
delegate.export(filteredSpans)
} else {
CompletableResultCode.ofSuccess()
}
}

override fun flush(): CompletableResultCode = delegate.flush()

override fun shutdown(): CompletableResultCode = delegate.shutdown()
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import com.launchdarkly.observability.api.Options
import com.launchdarkly.observability.interfaces.Metric
import com.launchdarkly.observability.network.GraphQLClient
import com.launchdarkly.observability.network.SamplingApiService
import com.launchdarkly.observability.sampling.CompositeLogExporter
import com.launchdarkly.observability.sampling.CompositeSpanExporter
import com.launchdarkly.observability.sampling.CustomSampler
import com.launchdarkly.observability.sampling.SamplingConfig
import com.launchdarkly.observability.sampling.SamplingLogExporter
Expand Down Expand Up @@ -72,8 +70,7 @@ class InstrumentationManager(
private const val LOGS_PATH = "/v1/logs"
private const val TRACES_PATH = "/v1/traces"
private const val INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability"

// Batch processor configuration constants
const val ERROR_SPAN_NAME = "highlight.error"
private const val BATCH_MAX_QUEUE_SIZE = 100
private const val BATCH_SCHEDULE_DELAY_MS = 1000L
private const val BATCH_EXPORTER_TIMEOUT_MS = 5000L
Expand Down Expand Up @@ -109,14 +106,14 @@ class InstrumentationManager(

otelRUM = OpenTelemetryRum.builder(application, otelRumConfig)
.addLoggerProviderCustomizer { sdkLoggerProviderBuilder, _ ->
return@addLoggerProviderCustomizer if (options.disableLogs) {
return@addLoggerProviderCustomizer if (options.disableLogs && options.disableErrorTracking) {
sdkLoggerProviderBuilder
} else {
configureLoggerProvider(sdkLoggerProviderBuilder)
}
}
.addTracerProviderCustomizer { sdkTracerProviderBuilder, _ ->
return@addTracerProviderCustomizer if (options.disableTraces) {
return@addTracerProviderCustomizer if (options.disableTraces && options.disableErrorTracking) {
sdkTracerProviderBuilder
} else {
configureTracerProvider(sdkTracerProviderBuilder)
Expand All @@ -141,7 +138,12 @@ class InstrumentationManager(

private fun createOtelRumConfig(): OtelRumConfig {
val config = OtelRumConfig()
.setDiskBufferingConfig(DiskBufferingConfig.create(enabled = isAnySignalEnabled(options), debugEnabled = options.debug))
.setDiskBufferingConfig(
DiskBufferingConfig.create(
enabled = isAnySignalEnabled(options),
debugEnabled = options.debug
)
)
.setSessionConfig(SessionConfig(backgroundInactivityTimeout = options.sessionBackgroundTimeout))

if (options.disableErrorTracking) {
Expand Down Expand Up @@ -213,35 +215,58 @@ class InstrumentationManager(
}

private fun createLogExporter(primaryExporter: LogRecordExporter): LogRecordExporter {
return if (options.debug) {
val exporters = mutableListOf(primaryExporter, DebugLogExporter(logger))
inMemoryLogExporter = InMemoryLogRecordExporter.create().also { exporters.add(it) }

val compositeExporter = CompositeLogExporter(exporters)
SamplingLogExporter(compositeExporter, customSampler)
val baseExporter = if (options.debug) {
LogRecordExporter.composite(
buildList {
add(primaryExporter)
add(DebugLogExporter(logger))
add(InMemoryLogRecordExporter.create().also { inMemoryLogExporter = it })
}
)
} else {
SamplingLogExporter(primaryExporter, customSampler)
primaryExporter
}

val conditionalExporter = ConditionalLogRecordExporter(
delegate = baseExporter,
allowNormalLogs = !options.disableLogs,
allowCrashes = !options.disableErrorTracking
)

return SamplingLogExporter(conditionalExporter, customSampler)
}

private fun createSpanExporter(primaryExporter: SpanExporter): SpanExporter {
return if (options.debug) {
val exporters = mutableListOf(primaryExporter, DebugSpanExporter(logger))
inMemorySpanExporter = InMemorySpanExporter.create().also { exporters.add(it) }

val compositeExporter = CompositeSpanExporter(exporters)
SamplingTraceExporter(compositeExporter, customSampler)
val baseExporter = if (options.debug) {
SpanExporter.composite(
buildList {
add(primaryExporter)
add(DebugSpanExporter(logger))
add(InMemorySpanExporter.create().also { inMemorySpanExporter = it })
}
)
} else {
SamplingTraceExporter(primaryExporter, customSampler)
primaryExporter
}

val conditionalExporter = ConditionalSpanExporter(
delegate = baseExporter,
allowNormalSpans = !options.disableTraces,
allowErrorSpans = !options.disableErrorTracking
)

return SamplingTraceExporter(conditionalExporter, customSampler)
}

private fun createMetricExporter(primaryExporter: MetricExporter): MetricExporter {
return if (options.debug) {
val exporters = mutableListOf(primaryExporter, DebugMetricExporter(logger))
inMemoryMetricExporter = InMemoryMetricExporter.create().also { exporters.add(it) }

CompositeMetricExporter(exporters)
CompositeMetricExporter(
buildList {
add(primaryExporter)
add(DebugMetricExporter(logger))
add(InMemoryMetricExporter.create().also { inMemoryMetricExporter = it })
}
)
} else {
primaryExporter
}
Expand Down Expand Up @@ -337,7 +362,7 @@ class InstrumentationManager(
fun recordError(error: Error, attributes: Attributes) {
if (!options.disableErrorTracking) {
val span = otelTracer
.spanBuilder("highlight.error")
.spanBuilder(ERROR_SPAN_NAME)
.setParent(Context.current().with(Span.current()))
.startSpan()

Expand Down
Loading
Loading