From 2e211e7185e498be1ab3e6dd5a2a83a8d13348ce Mon Sep 17 00:00:00 2001 From: Okan Okumusoglu Date: Thu, 6 Aug 2026 13:50:35 +0300 Subject: [PATCH] add trace id --- README.md | 32 +++++++++++++++ library/build.gradle.kts | 8 +++- ...aceIdResponseWebFilterAutoConfiguration.kt | 36 +++++++++++++++++ .../coroutine/CoroutineTracing.kt | 21 ++++++++++ .../webflux/TraceIdResponseWebFilter.kt | 33 +++++++++++++++ ...itional-spring-configuration-metadata.json | 12 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...dResponseWebFilterAutoConfigurationTest.kt | 30 ++++++++++++++ .../coroutine/CoroutineTracingTest.kt | 38 ++++++++++++++++++ .../webflux/TraceIdResponseWebFilterTest.kt | 40 +++++++++++++++++++ 10 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 library/src/main/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfiguration.kt create mode 100644 library/src/main/kotlin/com/valensas/observability/coroutine/CoroutineTracing.kt create mode 100644 library/src/main/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilter.kt create mode 100644 library/src/test/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfigurationTest.kt create mode 100644 library/src/test/kotlin/com/valensas/observability/coroutine/CoroutineTracingTest.kt create mode 100644 library/src/test/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilterTest.kt diff --git a/README.md b/README.md index 19d904d..7dbb07e 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,38 @@ Allows for customization of [B3 header propagation](https://github.com/openzipki is enabled when `management.tracing.propagation.type=B3` and the header format can be configured using `management.tracing.propagation.format=SINGLE/MULTI/SINGLE_NO_PARENT`. The default value is `SINGLE`. +### Coroutine trace context + +Detached coroutine scopes do not automatically inherit the trace context of the request that launches them. Use +`launchWithCurrentTrace` when detached work must remain correlated with the active OpenTelemetry trace: + +```kotlin +import com.valensas.observability.coroutine.launchWithCurrentTrace + +CoroutineScope(Dispatchers.IO).launchWithCurrentTrace { + logger.info("Detached work started") +} +``` + +The helper remains non-blocking and returns a regular `Job`. Structured coroutine code that already inherits its +parent context does not need this helper. + +### Trace ID response header + +Reactive applications expose the active OpenTelemetry trace ID to API callers by default. The header name can be +customized or the feature can be disabled explicitly: + +```yaml +valensas: + observability: + trace-response-header: + enabled: false + name: X-Trace-Id +``` + +The feature only exposes the current trace ID; service-to-service propagation continues to use the configured +OpenTelemetry propagator, such as the W3C `traceparent` header. + ### Version metrics This feature allows to expose you application's dependencies' versions to Micrometer. This feature diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 492f359..d49da15 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -24,6 +24,12 @@ dependencies { api(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.28.0")) api("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter") api("io.opentelemetry:opentelemetry-extension-trace-propagators") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core") + implementation("io.opentelemetry:opentelemetry-extension-kotlin") + + testImplementation("io.opentelemetry:opentelemetry-sdk") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webflux") } @@ -74,4 +80,4 @@ centralPortal { } } } -} \ No newline at end of file +} diff --git a/library/src/main/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfiguration.kt b/library/src/main/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfiguration.kt new file mode 100644 index 0000000..2d58f1b --- /dev/null +++ b/library/src/main/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfiguration.kt @@ -0,0 +1,36 @@ +package com.valensas.observability.config + +import com.valensas.observability.webflux.TraceIdResponseWebFilter +import io.opentelemetry.api.trace.Span +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.web.server.WebFilter + +@AutoConfiguration +@ConditionalOnClass(WebFilter::class, Span::class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnProperty( + prefix = "valensas.observability.trace-response-header", + name = ["enabled"], + havingValue = "true", + matchIfMissing = true +) +@EnableConfigurationProperties(TraceResponseHeaderProperties::class) +open class TraceIdResponseWebFilterAutoConfiguration { + @Bean + @ConditionalOnMissingBean(TraceIdResponseWebFilter::class) + open fun traceIdResponseWebFilter(properties: TraceResponseHeaderProperties): TraceIdResponseWebFilter = + TraceIdResponseWebFilter(properties.name) +} + +@ConfigurationProperties("valensas.observability.trace-response-header") +data class TraceResponseHeaderProperties( + var enabled: Boolean = true, + var name: String = "X-Trace-Id" +) diff --git a/library/src/main/kotlin/com/valensas/observability/coroutine/CoroutineTracing.kt b/library/src/main/kotlin/com/valensas/observability/coroutine/CoroutineTracing.kt new file mode 100644 index 0000000..39696bf --- /dev/null +++ b/library/src/main/kotlin/com/valensas/observability/coroutine/CoroutineTracing.kt @@ -0,0 +1,21 @@ +package com.valensas.observability.coroutine + +import io.opentelemetry.context.Context +import io.opentelemetry.extension.kotlin.asContextElement +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Launches a coroutine with the OpenTelemetry context that is active at the call site. + * + * This is intended for detached scopes that do not inherit the request's coroutine context. + */ +fun CoroutineScope.launchWithCurrentTrace( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit +): Job = launch(context + Context.current().asContextElement(), start, block) diff --git a/library/src/main/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilter.kt b/library/src/main/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilter.kt new file mode 100644 index 0000000..6949fc4 --- /dev/null +++ b/library/src/main/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilter.kt @@ -0,0 +1,33 @@ +package com.valensas.observability.webflux + +import io.opentelemetry.api.trace.Span +import org.springframework.core.Ordered +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +/** Exposes the active OpenTelemetry trace ID as an HTTP response header. */ +class TraceIdResponseWebFilter( + private val headerName: String +) : WebFilter, + Ordered { + override fun getOrder(): Int = FILTER_ORDER + + override fun filter( + exchange: ServerWebExchange, + chain: WebFilterChain + ): Mono = + Mono.defer { + val spanContext = Span.current().spanContext + if (spanContext.isValid) { + exchange.response.headers.set(headerName, spanContext.traceId) + } + chain.filter(exchange) + } + + private companion object { + // OpenTelemetry's WebFlux filter uses HIGHEST_PRECEDENCE + 1 in instrumentation 2.28.0. + const val FILTER_ORDER = Ordered.HIGHEST_PRECEDENCE + 2 + } +} diff --git a/library/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/library/src/main/resources/META-INF/additional-spring-configuration-metadata.json index f745a13..7f7d653 100644 --- a/library/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/library/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -30,6 +30,18 @@ "description": "Enable or disable build info metrics.", "type": "java.lang.Boolean", "defaultValue": "true" + }, + { + "name": "valensas.observability.trace-response-header.enabled", + "description": "Expose the active OpenTelemetry trace ID as an HTTP response header for reactive applications.", + "type": "java.lang.Boolean", + "defaultValue": "true" + }, + { + "name": "valensas.observability.trace-response-header.name", + "description": "HTTP response header used to expose the active OpenTelemetry trace ID.", + "type": "java.lang.String", + "defaultValue": "X-Trace-Id" } ] } diff --git a/library/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/library/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 836c182..a1c45d0 100644 --- a/library/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/library/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ com.valensas.observability.config.TracingAutoConfiguration +com.valensas.observability.config.TraceIdResponseWebFilterAutoConfiguration com.valensas.observability.config.VersionMetricsAutoConfiguration com.valensas.observability.config.BuildInfoAutoConfiguration com.valensas.observability.config.FeignMicrometerAutoConfiguration diff --git a/library/src/test/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfigurationTest.kt b/library/src/test/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfigurationTest.kt new file mode 100644 index 0000000..dba9c0d --- /dev/null +++ b/library/src/test/kotlin/com/valensas/observability/config/TraceIdResponseWebFilterAutoConfigurationTest.kt @@ -0,0 +1,30 @@ +package com.valensas.observability.config + +import com.valensas.observability.webflux.TraceIdResponseWebFilter +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TraceIdResponseWebFilterAutoConfigurationTest { + private val contextRunner = + ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TraceIdResponseWebFilterAutoConfiguration::class.java)) + + @Test + fun `filter is registered by default`() { + contextRunner.run { context -> + assertEquals(1, context.getBeansOfType(TraceIdResponseWebFilter::class.java).size) + } + } + + @Test + fun `filter is not registered when disabled`() { + contextRunner + .withPropertyValues("valensas.observability.trace-response-header.enabled=false") + .run { context -> + assertTrue(context.getBeansOfType(TraceIdResponseWebFilter::class.java).isEmpty()) + } + } +} diff --git a/library/src/test/kotlin/com/valensas/observability/coroutine/CoroutineTracingTest.kt b/library/src/test/kotlin/com/valensas/observability/coroutine/CoroutineTracingTest.kt new file mode 100644 index 0000000..0337071 --- /dev/null +++ b/library/src/test/kotlin/com/valensas/observability/coroutine/CoroutineTracingTest.kt @@ -0,0 +1,38 @@ +package com.valensas.observability.coroutine + +import io.opentelemetry.api.trace.Span +import io.opentelemetry.sdk.trace.SdkTracerProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals + +class CoroutineTracingTest { + @Test + fun `detached coroutine inherits current trace`() = + runBlocking { + val tracerProvider = SdkTracerProvider.builder().build() + val span = tracerProvider.get("test").spanBuilder("parent").startSpan() + val detachedScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val observedTraceId = CompletableDeferred() + + try { + span.makeCurrent().use { + detachedScope.launchWithCurrentTrace { + observedTraceId.complete(Span.current().spanContext.traceId) + } + } + + assertEquals(span.spanContext.traceId, withTimeout(5_000) { observedTraceId.await() }) + } finally { + detachedScope.cancel() + span.end() + tracerProvider.close() + } + } +} diff --git a/library/src/test/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilterTest.kt b/library/src/test/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilterTest.kt new file mode 100644 index 0000000..38bf915 --- /dev/null +++ b/library/src/test/kotlin/com/valensas/observability/webflux/TraceIdResponseWebFilterTest.kt @@ -0,0 +1,40 @@ +package com.valensas.observability.webflux + +import io.opentelemetry.sdk.trace.SdkTracerProvider +import org.springframework.mock.http.server.reactive.MockServerHttpRequest +import org.springframework.mock.web.server.MockServerWebExchange +import reactor.core.publisher.Mono +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TraceIdResponseWebFilterTest { + private val filter = TraceIdResponseWebFilter("X-Trace-Id") + + @Test + fun `adds active trace id to response`() { + val tracerProvider = SdkTracerProvider.builder().build() + val span = tracerProvider.get("test").spanBuilder("request").startSpan() + val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test")) + + try { + span.makeCurrent().use { + filter.filter(exchange) { Mono.empty() }.block() + } + + assertEquals(span.spanContext.traceId, exchange.response.headers.getFirst("X-Trace-Id")) + } finally { + span.end() + tracerProvider.close() + } + } + + @Test + fun `does not add header without an active trace`() { + val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test")) + + filter.filter(exchange) { Mono.empty() }.block() + + assertNull(exchange.response.headers.getFirst("X-Trace-Id")) + } +}