keys(io.jooby.Context ctx) {
+ // Allows OTel to iterate over all header names if needed
+ return ctx.headerMap().keySet();
+ }
+
+ @Override
+ public String get(io.jooby.Context ctx, String key) {
+ // Safely extract the header value, returning null if it doesn't exist
+ return ctx.header(key).valueOrNull();
+ }
+ }
+}
diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelContextExtractor.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelContextExtractor.java
new file mode 100644
index 0000000000..b49724f8b0
--- /dev/null
+++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelContextExtractor.java
@@ -0,0 +1,35 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.opentelemetry;
+
+import io.opentelemetry.context.Context;
+
+/**
+ * Strategy interface for retrieving an active OpenTelemetry {@link Context} from a {@link
+ * io.jooby.Context}.
+ *
+ * When a request is intercepted by the OpenTelemetry HTTP tracing module, the active distributed
+ * tracing context is captured and attached to the Jooby request lifecycle. This interface provides
+ * a decoupled mechanism to extract that context later in the pipeline.
+ *
+ *
This is particularly critical when execution crosses asynchronous boundaries or worker threads
+ * (such as in JSON-RPC or Model Context Protocol execution), where {@link Context#current()} would
+ * otherwise return empty. By retrieving the context via this interface, extensions can explicitly
+ * set the parent context for newly spawned child spans.
+ *
+ * @author edgar
+ * @since 4.3.1
+ */
+public interface OtelContextExtractor {
+ /**
+ * Retrieves the OpenTelemetry context associated with the given HTTP request.
+ *
+ * @param ctx The current Jooby HTTP context.
+ * @return The active OpenTelemetry {@link Context}, or {@code null} if no tracing context was
+ * initialized or attached to this request.
+ */
+ Context extract(io.jooby.Context ctx);
+}
diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java
index 8a57fa5d03..16e24d8fe0 100644
--- a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java
+++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java
@@ -5,14 +5,9 @@
*/
package io.jooby.opentelemetry;
-import static io.opentelemetry.context.Context.current;
-
-import io.jooby.Context;
import io.jooby.Route;
-import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.Tracer;
-import io.opentelemetry.context.propagation.TextMapGetter;
/**
* OpenTelemetry HTTP tracing filter for Jooby routes.
@@ -66,14 +61,12 @@ public Route.Handler apply(Route.Handler next) {
// Create a high-cardinality-safe span name: e.g., "GET /api/users/{id}"
var spanName = ctx.getMethod() + " " + ctx.getRoute().getPattern();
var tracer = ctx.require(Tracer.class);
- var otel = ctx.require(OpenTelemetry.class);
- var propagator = otel.getPropagators().getTextMapPropagator();
-
- var extractedContext = propagator.extract(current(), ctx, JoobyRequestGetter.INSTANCE);
+ var extractor = ctx.require(OtelContextExtractor.class);
+ var parent = extractor.extract(ctx);
var span =
tracer
.spanBuilder(spanName)
- .setParent(extractedContext)
+ .setParent(parent)
.setSpanKind(SpanKind.SERVER)
.setAttribute("http.request.method", ctx.getMethod())
.setAttribute("url.path", ctx.getRequestPath())
@@ -96,6 +89,12 @@ public Route.Handler apply(Route.Handler next) {
try (var scope = span.makeCurrent()) {
ctx.setAttribute("otel-span", span);
+ // Save the active OpenTelemetry context into Jooby's context
+ // so it survives thread boundaries (like WebSocket frames or async workers)
+ ctx.setAttribute(
+ io.opentelemetry.context.Context.class.getName(),
+ io.opentelemetry.context.Context.current());
+
return next.apply(ctx);
} catch (Throwable t) {
span.recordException(t);
@@ -104,24 +103,4 @@ public Route.Handler apply(Route.Handler next) {
}
};
}
-
- /**
- * A bridge implementation allowing OpenTelemetry to extract distributed tracing headers directly
- * from a Jooby {@link Context}.
- */
- enum JoobyRequestGetter implements TextMapGetter {
- INSTANCE;
-
- @Override
- public Iterable keys(io.jooby.Context ctx) {
- // Allows OTel to iterate over all header names if needed
- return ctx.headerMap().keySet();
- }
-
- @Override
- public String get(io.jooby.Context ctx, String key) {
- // Safely extract the header value, returning null if it doesn't exist
- return ctx.header(key).valueOrNull();
- }
- }
}
diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java
index 72a2b86d32..f6c533db56 100644
--- a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java
+++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java
@@ -16,6 +16,7 @@
import io.jooby.Extension;
import io.jooby.Jooby;
+import io.jooby.internal.opentelemetry.DefaultOtelContextExtractor;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
@@ -171,6 +172,7 @@ public void install(Jooby application) {
services.put(OpenTelemetry.class, otel);
services.put(Tracer.class, tracer);
services.put(Trace.class, trace(tracer));
+ services.putIfAbsent(OtelContextExtractor.class, new DefaultOtelContextExtractor(otel));
application.onStarting(
() -> extensions.forEach(throwingConsumer(ext -> ext.install(application, otel))));
diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/internal/opentelemetry/DefaultOtelContextExtractorTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/internal/opentelemetry/DefaultOtelContextExtractorTest.java
new file mode 100644
index 0000000000..32b1aee19e
--- /dev/null
+++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/internal/opentelemetry/DefaultOtelContextExtractorTest.java
@@ -0,0 +1,135 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.opentelemetry;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.jooby.Context;
+import io.jooby.value.Value;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.context.propagation.ContextPropagators;
+import io.opentelemetry.context.propagation.TextMapPropagator;
+
+class DefaultOtelContextExtractorTest {
+
+ private OpenTelemetry otel;
+ private ContextPropagators propagators;
+ private TextMapPropagator textMapPropagator;
+ private Context joobyCtx;
+ private io.opentelemetry.context.Context otelCtx;
+
+ private DefaultOtelContextExtractor extractor;
+
+ @BeforeEach
+ void setUp() {
+ otel = mock(OpenTelemetry.class);
+ propagators = mock(ContextPropagators.class);
+ textMapPropagator = mock(TextMapPropagator.class);
+ joobyCtx = mock(Context.class);
+ otelCtx = mock(io.opentelemetry.context.Context.class);
+
+ when(otel.getPropagators()).thenReturn(propagators);
+ when(propagators.getTextMapPropagator()).thenReturn(textMapPropagator);
+
+ extractor = new DefaultOtelContextExtractor(otel);
+ }
+
+ @Test
+ void shouldReturnCachedContextWithoutParsingHeaders() {
+ // Arrange: Simulate OtelHttpTracing already running and saving the context
+ when(joobyCtx.getAttribute(io.opentelemetry.context.Context.class.getName()))
+ .thenReturn(otelCtx);
+
+ // Act
+ io.opentelemetry.context.Context result = extractor.extract(joobyCtx);
+
+ // Assert
+ assertSame(otelCtx, result, "Should return the exact cached context");
+ // Verify we never touched the OpenTelemetry propagators (Fast Path success!)
+ verifyNoInteractions(otel);
+ }
+
+ @Test
+ void shouldExtractFromHeadersAndCacheResultWhenNotAlreadyCached() {
+ // Arrange: Simulate a raw request where OtelHttpTracing did NOT run
+ when(joobyCtx.getAttribute(io.opentelemetry.context.Context.class.getName())).thenReturn(null);
+
+ // Mock the OpenTelemetry propagator to return our fake extracted context.
+ // STRICT MATCH: Ensure the first argument is strictly Context.root() to prevent thread-local
+ // leakage.
+ when(textMapPropagator.extract(
+ eq(io.opentelemetry.context.Context.root()), eq(joobyCtx), any()))
+ .thenReturn(otelCtx);
+
+ // Act
+ io.opentelemetry.context.Context result = extractor.extract(joobyCtx);
+
+ // Assert
+ assertSame(otelCtx, result, "Should return the context extracted from headers");
+
+ // Verify it was explicitly called with the root context
+ verify(textMapPropagator)
+ .extract(eq(io.opentelemetry.context.Context.root()), eq(joobyCtx), any());
+
+ // Verify the extractor cached it for the next time someone asks in this request lifecycle
+ verify(joobyCtx).setAttribute(io.opentelemetry.context.Context.class.getName(), otelCtx);
+ }
+
+ @Test
+ void joobyRequestGetterShouldReturnHeaderKeys() {
+ // Arrange
+ Map fakeHeaders = Map.of("traceparent", "123", "tracestate", "456");
+ when(joobyCtx.headerMap()).thenReturn(fakeHeaders);
+
+ // Act
+ Iterable keys = DefaultOtelContextExtractor.Headers.INSTANCE.keys(joobyCtx);
+
+ // Assert
+ assertEquals(Set.of("traceparent", "tracestate"), keys);
+ }
+
+ @Test
+ void joobyRequestGetterShouldReturnHeaderValueOrNull() {
+ // Arrange
+ Value mockHeaderValue = mock(Value.class);
+ when(mockHeaderValue.valueOrNull())
+ .thenReturn("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01");
+ when(joobyCtx.header("traceparent")).thenReturn(mockHeaderValue);
+
+ // Act
+ String headerVal = DefaultOtelContextExtractor.Headers.INSTANCE.get(joobyCtx, "traceparent");
+
+ // Assert
+ assertEquals("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", headerVal);
+ }
+
+ @Test
+ void joobyRequestGetterShouldHandleMissingHeaderGracefully() {
+ // Arrange
+ Value mockMissingHeader = mock(Value.class);
+ when(mockMissingHeader.valueOrNull()).thenReturn(null);
+ when(joobyCtx.header("missing-header")).thenReturn(mockMissingHeader);
+
+ // Act
+ String headerVal = DefaultOtelContextExtractor.Headers.INSTANCE.get(joobyCtx, "missing-header");
+
+ // Assert
+ assertEquals(null, headerVal);
+ }
+}
diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java
index 1d34b687e5..5277497110 100644
--- a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java
+++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java
@@ -7,15 +7,15 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import java.util.Map;
-
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -27,8 +27,11 @@
import io.jooby.StatusCode;
import io.jooby.value.Value;
import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanBuilder;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Scope;
import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension;
import io.opentelemetry.sdk.trace.data.SpanData;
import io.opentelemetry.sdk.trace.data.StatusData;
@@ -62,6 +65,11 @@ void setUp() {
when(ctx.require(Tracer.class)).thenReturn(tracer);
when(ctx.require(OpenTelemetry.class)).thenReturn(otelTesting.getOpenTelemetry());
+ // OtelContextExtractor mock
+ OtelContextExtractor extractor = mock(OtelContextExtractor.class);
+ when(ctx.require(OtelContextExtractor.class)).thenReturn(extractor);
+ when(extractor.extract(ctx)).thenReturn(io.opentelemetry.context.Context.current());
+
// Header extraction mocks
Value missingHeader = mock(Value.class);
when(missingHeader.valueOrNull()).thenReturn(null);
@@ -87,7 +95,19 @@ void shouldTraceSuccessfulRequest() throws Throwable {
// Assert
assertEquals("Success", result);
- verify(ctx).setAttribute(any(String.class), any()); // Verifies span was put in context
+
+ // Verify both attributes were saved to the Jooby context
+ verify(ctx).setAttribute(eq("otel-span"), any(Span.class));
+
+ ArgumentCaptor otelCtxCaptor =
+ ArgumentCaptor.forClass(io.opentelemetry.context.Context.class);
+ verify(ctx)
+ .setAttribute(
+ eq(io.opentelemetry.context.Context.class.getName()), otelCtxCaptor.capture());
+
+ // Ensure the captured context actually contains the span we just created
+ io.opentelemetry.context.Context capturedContext = otelCtxCaptor.getValue();
+ assertNotNull(Span.fromContext(capturedContext));
java.util.List spans = otelTesting.getSpans();
assertEquals(1, spans.size());
@@ -121,11 +141,6 @@ void shouldRecordExceptionAndFailSpan() throws Throwable {
// Act & Assert Exception
assertThrows(RuntimeException.class, () -> wrapped.apply(ctx));
- // Notice we do NOT trigger onComplete here because Jooby handles exception propagation,
- // but the catch block in the filter records the exception immediately.
- // Span.end() relies on the container eventually triggering onComplete. For the sake of the
- // test,
- // we manually trigger it to finalize the span state as Jooby would.
when(ctx.getResponseCode()).thenReturn(StatusCode.SERVER_ERROR);
ArgumentCaptor onCompleteCaptor = ArgumentCaptor.forClass(Route.Complete.class);
verify(ctx).onComplete(onCompleteCaptor.capture());
@@ -173,23 +188,61 @@ void shouldMarkSpanAsErrorOn500StatusCode() throws Throwable {
}
@Test
- void joobyRequestGetterExtractsHeaders() {
- // Arrange
- when(ctx.headerMap())
- .thenReturn(
- Map.of("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"));
+ void shouldExtractContextAndCreateSpan() throws Throwable {
+ // 1. Arrange - Core Mocks
+ var ctx = mock(Context.class);
+ var route = mock(Route.class);
+ var next = mock(Route.Handler.class);
+
+ // 2. Arrange - OTel Mocks
+ var tracer = mock(Tracer.class);
+ var spanBuilder = mock(SpanBuilder.class);
+ var span = mock(Span.class);
+ var scope = mock(Scope.class);
+
+ // 3. Arrange - The new Extractor Mocks
+ var extractor = mock(OtelContextExtractor.class);
+ var parentOtelContext = mock(io.opentelemetry.context.Context.class);
+
+ // Mock Jooby Routing State
+ when(ctx.getMethod()).thenReturn("GET");
+ when(ctx.getRequestPath()).thenReturn("/api/users/123");
+ when(route.getPattern()).thenReturn("/api/users/{id}");
+ when(ctx.getRoute()).thenReturn(route);
+
+ // Wire up the registry requires
+ when(ctx.require(Tracer.class)).thenReturn(tracer);
+ when(ctx.require(OtelContextExtractor.class)).thenReturn(extractor);
- Value mockHeaderValue = mock(Value.class);
- when(mockHeaderValue.valueOrNull())
- .thenReturn("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01");
- when(ctx.header("traceparent")).thenReturn(mockHeaderValue);
+ // Mock the Extractor behavior
+ when(extractor.extract(ctx)).thenReturn(parentOtelContext);
+
+ // Mock the OpenTelemetry Builder Chain
+ when(tracer.spanBuilder("GET /api/users/{id}")).thenReturn(spanBuilder);
+ when(spanBuilder.setParent(parentOtelContext)).thenReturn(spanBuilder);
+ when(spanBuilder.setSpanKind(SpanKind.SERVER)).thenReturn(spanBuilder);
+ when(spanBuilder.setAttribute(anyString(), anyString())).thenReturn(spanBuilder);
+ when(spanBuilder.startSpan()).thenReturn(span);
+ when(span.makeCurrent()).thenReturn(scope);
// Act
- Iterable keys = OtelHttpTracing.JoobyRequestGetter.INSTANCE.keys(ctx);
- String headerVal = OtelHttpTracing.JoobyRequestGetter.INSTANCE.get(ctx, "traceparent");
+ var filter = new OtelHttpTracing();
+ filter.apply(next).apply(ctx);
// Assert
- assertThat(keys).containsExactly("traceparent");
- assertEquals("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", headerVal);
+ verify(extractor).extract(ctx);
+ verify(spanBuilder).setParent(parentOtelContext);
+
+ // Verify the span was stored in the jooby context
+ verify(ctx).setAttribute("otel-span", span);
+
+ // Safely verify the context was saved without accidentally evaluating Context.current() outside
+ // the scope
+ verify(ctx)
+ .setAttribute(
+ eq(io.opentelemetry.context.Context.class.getName()),
+ any(io.opentelemetry.context.Context.class));
+
+ verify(next).apply(ctx);
}
}