diff --git a/docs/asciidoc/modules/opentelemetry.adoc b/docs/asciidoc/modules/opentelemetry.adoc index 8b8820be2d..85a7db2866 100644 --- a/docs/asciidoc/modules/opentelemetry.adoc +++ b/docs/asciidoc/modules/opentelemetry.adoc @@ -324,6 +324,46 @@ import io.opentelemetry.api.OpenTelemetry } ---- +==== Model Context Protocol (MCP) + +Provides automatic tracing for your MCP (Model Context Protocol) servers. By adding the `OtelMcpTracing` invoker to your MCP module pipeline, it generates a dedicated OpenTelemetry span for every MCP operation (tools, prompts, resources, and completions). + +It strictly follows the official **OpenTelemetry GenAI and RPC Semantic Conventions**, ensuring seamless integration with modern APM and specialized AI observability dashboards. It prevents metric cardinality explosion by intelligently handling span names, and accurately records both protocol failures and MCP tool errors (which return `isError = true` rather than throwing exceptions). + +.MCP Integration +[source, java, role = "primary"] +---- +import io.jooby.mcp.McpModule; +import io.jooby.mcp.instrumentation.OtelMcpTracing; +import io.opentelemetry.api.OpenTelemetry; + +{ + install(new OtelModule()); + + // Register the MCP module and attach the tracing invoker + install(new McpModule(new CalculatorServiceMcp_()) + .invoker(new OtelMcpTracing(require(OpenTelemetry.class))) + ); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.mcp.McpModule +import io.jooby.mcp.instrumentation.OtelMcpTracing +import io.opentelemetry.api.OpenTelemetry + +{ + install(OtelModule()) + + // Register the MCP module and attach the tracing invoker + install(McpModule(CalculatorServiceMcp_()) + .invoker(OtelMcpTracing(require(OpenTelemetry::class.java))) + ) +} +---- + ==== Log4j2 Seamlessly exports all application logs to your OpenTelemetry backend, automatically correlated with active trace and span IDs using a dynamic appender. diff --git a/modules/jooby-jsonrpc/pom.xml b/modules/jooby-jsonrpc/pom.xml index 9262825d9c..2f0bdabd31 100644 --- a/modules/jooby-jsonrpc/pom.xml +++ b/modules/jooby-jsonrpc/pom.xml @@ -20,9 +20,9 @@ - io.opentelemetry - opentelemetry-api - ${opentelemetry.version} + io.jooby + jooby-opentelemetry + ${jooby.version} true diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java index 5e1c1fa3e0..d20b35f7ef 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java @@ -14,6 +14,7 @@ import io.jooby.Context; import io.jooby.SneakyThrows; import io.jooby.jsonrpc.*; +import io.jooby.opentelemetry.OtelContextExtractor; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; @@ -44,6 +45,7 @@ * @since 4.5.0 */ public class OtelJsonRcpTracing implements JsonRpcInvoker { + private final OpenTelemetry otel; private final Tracer tracer; @@ -57,6 +59,7 @@ public class OtelJsonRcpTracing implements JsonRpcInvoker { * @param otel The OpenTelemetry instance used to obtain the tracer. */ public OtelJsonRcpTracing(OpenTelemetry otel) { + this.otel = otel; tracer = otel.getTracer("io.jooby.jsonrpc"); } @@ -101,6 +104,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3 invoke( @NonNull Context ctx, @NonNull JsonRpcRequest request, @NonNull JsonRpcChain chain) { var method = Optional.ofNullable(request.getMethod()).orElse("unknown_method"); + var parent = ctx.require(OtelContextExtractor.class).extract(ctx); var span = tracer .spanBuilder(method) @@ -109,6 +113,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3 * - * @author Edgar Espina + * @author edgar * @since 4.0.17 */ module io.jooby.jsonrpc { @@ -38,6 +38,7 @@ requires static org.jspecify; requires typesafe.config; requires org.slf4j; + requires static io.jooby.opentelemetry; requires static io.opentelemetry.api; requires static io.opentelemetry.context; } diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml index 6dbc202b84..a2fda1542f 100644 --- a/modules/jooby-mcp/pom.xml +++ b/modules/jooby-mcp/pom.xml @@ -22,6 +22,14 @@ io.modelcontextprotocol.sdk mcp-core + + + io.jooby + jooby-opentelemetry + ${jooby.version} + true + + diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpDefaultInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpDefaultInvoker.java deleted file mode 100644 index 39177e2a64..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpDefaultInvoker.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.LoggerFactory; - -import io.jooby.Jooby; -import io.jooby.StatusCode; -import io.jooby.mcp.McpChain; -import io.jooby.mcp.McpInvoker; -import io.jooby.mcp.McpOperation; -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; - -public class McpDefaultInvoker implements McpInvoker { - private final Jooby application; - - public McpDefaultInvoker(Jooby application) { - this.application = application; - } - - @SuppressWarnings("unchecked") - public @NonNull Object invoke( - @Nullable McpSyncServerExchange exchange, - @NonNull McpTransportContext transportContext, - @NonNull McpOperation operation, - @NonNull McpChain next) { - try { - return next.proceed(exchange, transportContext, operation); - } catch (McpError mcpError) { - throw mcpError; - } catch (Throwable cause) { - var log = LoggerFactory.getLogger(operation.getClassName()); - if (operation.isTool()) { - // Tool error - var errorMessage = cause.getMessage() != null ? cause.getMessage() : cause.toString(); - return McpSchema.CallToolResult.builder() - .addTextContent(errorMessage) - .isError(true) - .build(); - } - var statusCode = application.getRouter().errorCode(cause); - if (statusCode.value() >= 500) { - log.error("execution of {} resulted in exception", operation.getId(), cause); - } else { - log.debug("execution of {} resulted in exception", operation.getId(), cause); - } - var mcpErrorCode = toMcpErrorCode(statusCode); - throw new McpError( - new McpSchema.JSONRPCResponse.JSONRPCError(mcpErrorCode, cause.getMessage(), null)); - } - } - - private int toMcpErrorCode(StatusCode statusCode) { - return switch (statusCode.value()) { - case StatusCode.BAD_REQUEST_CODE, StatusCode.CONFLICT_CODE -> - McpSchema.ErrorCodes.INVALID_PARAMS; - case StatusCode.NOT_FOUND_CODE -> McpSchema.ErrorCodes.RESOURCE_NOT_FOUND; - - default -> McpSchema.ErrorCodes.INTERNAL_ERROR; - }; - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpExecutor.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpExecutor.java new file mode 100644 index 0000000000..e6e85693ea --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpExecutor.java @@ -0,0 +1,90 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.LoggerFactory; + +import io.jooby.Jooby; +import io.jooby.SneakyThrows; +import io.jooby.StatusCode; +import io.jooby.mcp.McpChain; +import io.jooby.mcp.McpInvoker; +import io.jooby.mcp.McpOperation; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; + +public class McpExecutor implements McpInvoker { + private final Jooby application; + + public McpExecutor(Jooby application) { + this.application = application; + } + + @SuppressWarnings("unchecked") + public @NonNull Object invoke( + @Nullable McpSyncServerExchange exchange, + @NonNull McpTransportContext transportContext, + @NonNull McpOperation operation, + @NonNull McpChain next) { + try { + return next.proceed(exchange, transportContext, operation); + } catch (Throwable cause) { + operation.exception(cause); + log(operation, cause); + if (SneakyThrows.isFatal(cause)) { + throw SneakyThrows.propagate(cause); + } + var code = toMcpErrorCode(cause); + if (operation.isTool()) { + // Tool error + var errorMessage = + cause.getMessage() != null ? cause.getMessage() : "Unknown error occurred"; + var textContent = new McpSchema.TextContent(errorMessage); + return McpSchema.CallToolResult.builder().addContent(textContent).isError(true).build(); + } + if (cause instanceof McpError mcpError) { + throw mcpError; + } else { + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError(code, cause.getMessage(), null)); + } + } + } + + private void log(McpOperation operation, Throwable cause) { + var log = LoggerFactory.getLogger(operation.getClassName()); + var code = toMcpErrorCode(cause); + if (isServerError(code)) { + log.error("execution of {} resulted in exception", operation.getId(), cause); + } else { + log.debug("execution of {} resulted in exception", operation.getId(), cause); + } + } + + static boolean isServerError(int code) { + // -32603 is Internal Error. Custom server errors usually fall outside the -32600 to -32699 + // reserved range. + return code == McpSchema.ErrorCodes.INTERNAL_ERROR || code < -32700; + } + + private int toMcpErrorCode(Throwable cause) { + if (cause instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + return mcpError.getJsonRpcError().code(); + } + var statusCode = application.getRouter().errorCode(cause); + return switch (statusCode.value()) { + case StatusCode.BAD_REQUEST_CODE, StatusCode.CONFLICT_CODE -> + McpSchema.ErrorCodes.INVALID_PARAMS; + case StatusCode.NOT_FOUND_CODE -> McpSchema.ErrorCodes.RESOURCE_NOT_FOUND; + + default -> McpSchema.ErrorCodes.INTERNAL_ERROR; + }; + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java index ee21d65d9e..18cae37ca0 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java @@ -115,7 +115,6 @@ public McpInspectorModule defaultServer(String mcpServerName) { @Override public void install(Jooby app) { this.indexHtml = buildIndexHtml(); - this.mcpSrvConfig = resolveMcpServerConfig(app); app.assets(inspectorEndpoint + "/static/*", "/mcpInspector/assets/"); @@ -128,6 +127,11 @@ public void install(Jooby app) { var configJson = buildConfigJson(mcpSrvConfig, location); return ctx.setResponseType(MediaType.json).render(configJson); }); + + app.onStarting( + () -> { + this.mcpSrvConfig = resolveMcpServerConfig(app); + }); } private String buildIndexHtml() { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index a3ac6ee810..d96991661b 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -20,12 +20,13 @@ import io.jooby.Jooby; import io.jooby.ServiceKey; import io.jooby.exception.StartupException; -import io.jooby.internal.mcp.McpDefaultInvoker; +import io.jooby.internal.mcp.McpExecutor; import io.jooby.internal.mcp.McpServerConfig; import io.jooby.internal.mcp.transport.SseTransportProvider; import io.jooby.internal.mcp.transport.StatelessTransportProvider; import io.jooby.internal.mcp.transport.StreamableTransportProvider; import io.jooby.internal.mcp.transport.WebSocketTransportProvider; +import io.jooby.mcp.instrumentation.OtelMcpTracing; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.*; @@ -153,8 +154,9 @@ public class McpModule implements Extension { private final List mcpServices = new ArrayList<>(); private @Nullable McpInvoker invoker; + private @Nullable OtelMcpTracing head; - private Boolean generateOutputSchema = null; + private @Nullable Boolean generateOutputSchema; /** * Creates a new MCP module initialized with the provided generated services. @@ -200,10 +202,15 @@ public McpModule transport(Transport transport) { * @return This module instance for method chaining. */ public McpModule invoker(McpInvoker invoker) { - if (this.invoker != null) { - this.invoker = invoker.then(this.invoker); + if (invoker instanceof OtelMcpTracing otel) { + // otel goes first: + this.head = otel; } else { - this.invoker = invoker; + if (this.invoker != null) { + this.invoker = invoker.then(this.invoker); + } else { + this.invoker = invoker; + } } return this; } @@ -229,9 +236,14 @@ public void install(Jooby app) { ? app.getConfig().getBoolean("mcp.generateOutputSchema") : Optional.ofNullable(this.generateOutputSchema).orElse(Boolean.FALSE); // invoker - McpInvoker pipeline = new McpDefaultInvoker(app); + McpInvoker pipeline = new McpExecutor(app); + // Otel tracing goes first: + if (head != null) { + invoker = invoker == null ? head : head.then(invoker); + } + // Default invoker: if (this.invoker != null) { - pipeline = pipeline.then(this.invoker); + pipeline = this.invoker.then(pipeline); } services.put(McpInvoker.class, pipeline); // Group services by server diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java index 37c5355acd..ed25f5e2d9 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java @@ -9,6 +9,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.Nullable; + import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; @@ -30,6 +32,7 @@ public class McpOperation { private final String methodName; private final McpSchema.Request request; private final ConcurrentMap arguments; + private @Nullable Throwable exception; private McpOperation(String id, String className, String methodName, McpSchema.Request request) { this.id = id; @@ -142,6 +145,27 @@ public void setArgument(String name, Object value) { this.arguments.put(name, value); } + /** + * Retrieves the exception associated with the current operation. Internal use only. This + * exception is set by the default MCP executor in case of an error. It makes sense for a tool + * error only bc it must generate a tool errored response and the exception is dropped. + * + * @return The {@code Throwable} object representing the exception associated with this operation, + * or {@code null} if no exception is set. + */ + public @Nullable Throwable exception() { + return exception; + } + + /** + * Sets the exception associated with this operation. Internal use only. + * + * @param exception The {@code Throwable} object representing the exception to set. Can be null. + */ + public void exception(@Nullable Throwable exception) { + this.exception = exception; + } + /** * Creates an operation context for a Tool invocation. * diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/instrumentation/OtelMcpTracing.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/instrumentation/OtelMcpTracing.java new file mode 100644 index 0000000000..7ba879e985 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/instrumentation/OtelMcpTracing.java @@ -0,0 +1,142 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.instrumentation; + +import java.util.List; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import io.jooby.Context; +import io.jooby.mcp.McpChain; +import io.jooby.mcp.McpInvoker; +import io.jooby.mcp.McpOperation; +import io.jooby.opentelemetry.OtelContextExtractor; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +public class OtelMcpTracing implements McpInvoker { + + private final Tracer tracer; + + public OtelMcpTracing(OpenTelemetry openTelemetry) { + this.tracer = openTelemetry.getTracer("io.jooby.mcp"); + } + + @Override + public R invoke( + @Nullable McpSyncServerExchange exchange, + @NonNull McpTransportContext transportContext, + @NonNull McpOperation operation, + @NonNull McpChain chain) + throws Exception { + + // operation.getId() looks like: "tools/add_numbers" or "resources/calculator://history/{user}" + var rawId = operation.getId(); + + // Split "tools/add_numbers" into type="tools" and target="add_numbers" + int slashIdx = rawId.indexOf('/'); + var type = slashIdx > 0 ? rawId.substring(0, slashIdx) : rawId; + var target = slashIdx > 0 ? rawId.substring(slashIdx + 1) : null; + + // Map your prefix to the official JSON-RPC method names + var rpcMethod = + switch (type) { + case "tools" -> "tools/call"; + case "prompts" -> "prompts/get"; + case "resources" -> "resources/read"; + case "completions" -> "completion/complete"; + default -> type; + }; + + // Format OTel Span Name: {mcp.method.name} {target} + // Example: "tools/call add_numbers" or "resources/read calculator://history/{user}" + var spanName = target != null ? rpcMethod + " " + target : rpcMethod; + Context ctx = (Context) transportContext.get("CTX"); + var parent = ctx.require(OtelContextExtractor.class).extract(ctx); + var builder = + tracer + .spanBuilder(spanName) + .setSpanKind(SpanKind.SERVER) + .setParent(parent) + .setAttribute("rpc.system", "mcp") + .setAttribute("rpc.method", rpcMethod) + .setAttribute("mcp.method.name", rpcMethod) // Fixed: mcp.method.name + .setAttribute("rpc.service", operation.getClassName()); + + if (target != null) { + builder.setAttribute("gen_ai.operation.name", target); // Good fallback tracking + } + + if (exchange != null && exchange.sessionId() != null) { + builder.setAttribute("mcp.session.id", exchange.sessionId()); + } + + var request = operation.getRequest(); + + // Set specific semantic attributes based on the payload + switch (request) { + case McpSchema.CallToolRequest callToolRequest -> + builder.setAttribute("gen_ai.tool.name", target); + case McpSchema.GetPromptRequest getPromptRequest -> + builder.setAttribute("mcp.prompt.name", target); + case McpSchema.ReadResourceRequest resourceReq -> + builder.setAttribute("mcp.resource.uri", resourceReq.uri()); + case McpSchema.CompleteRequest completeRequest -> + builder.setAttribute("mcp.completion.ref", target); + default -> {} + } + + var span = builder.startSpan(); + + try (var scope = span.makeCurrent()) { + R rsp = chain.proceed(exchange, transportContext, operation); + if (rsp instanceof McpSchema.CallToolResult callToolResult && callToolResult.isError()) { + traceError(operation.exception(), span); + } else { + span.setStatus(StatusCode.OK); + } + return rsp; + } catch (Throwable cause) { + traceError(cause, span); + throw cause; + } finally { + span.end(); + } + } + + private static void traceError(Throwable cause, Span span) { + var message = cause != null ? cause.getMessage() : "Tool execution failed"; + span.setStatus(StatusCode.ERROR, message); + if (cause != null) { + span.recordException(cause); + span.setAttribute("error.type", cause.getClass().getName()); + } + } + + private String extractErrorMessage(List contentList) { + if (contentList == null || contentList.isEmpty()) { + return "Tool execution failed (no content provided)"; + } + + McpSchema.Content first = contentList.getFirst(); + + return switch (first) { + case McpSchema.TextContent text -> text.text(); + case McpSchema.ImageContent img -> "[Image: " + img.mimeType() + "]"; + case McpSchema.AudioContent audio -> "[Audio]"; + case McpSchema.EmbeddedResource embedded -> + "[Embedded Resource: " + embedded.resource().uri() + "]"; + case McpSchema.ResourceLink link -> "[Resource Link: " + link.uri() + "]"; + }; + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/internal/opentelemetry/DefaultOtelContextExtractor.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/internal/opentelemetry/DefaultOtelContextExtractor.java new file mode 100644 index 0000000000..c8f54802f3 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/internal/opentelemetry/DefaultOtelContextExtractor.java @@ -0,0 +1,59 @@ +/* + * 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 io.opentelemetry.context.Context.root; + +import org.jspecify.annotations.NonNull; + +import io.jooby.opentelemetry.OtelContextExtractor; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; + +public class DefaultOtelContextExtractor implements OtelContextExtractor { + + private final OpenTelemetry otel; + + public DefaultOtelContextExtractor(OpenTelemetry otel) { + this.otel = otel; + } + + @Override + public @NonNull Context extract(io.jooby.@NonNull Context ctx) { + // 1. Primary: Check if the OtelHttpTracing middleware already saved it + Context result = ctx.getAttribute(Context.class.getName()); + if (result == null) { + // 2. Secondary: If middleware is missing, manually parse the W3C headers + var propagator = otel.getPropagators().getTextMapPropagator(); + // Extracts W3C headers (if present) or returns Context.current() as a safe fallback + result = propagator.extract(root(), ctx, Headers.INSTANCE); + // Cache it to avoid re-parsing headers on subsequent calls in the same request + ctx.setAttribute(Context.class.getName(), result); + } + return result; + } + + /** + * A bridge implementation allowing OpenTelemetry to extract distributed tracing headers directly + * from a Jooby {@link io.jooby.Context}. + */ + enum Headers 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/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); } }