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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}


Expand Down Expand Up @@ -74,4 +80,4 @@ centralPortal {
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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<Void> =
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String>()

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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading