OpenTelemetry MCP
View MarkdownStatus: Stable Package:
dev.vertique.mcp.opentelemetryArtifact:vertique-opentelemetry-mcpDepends on:vertique-mcp-core,vertique-coreAPI reference: javadoc.io
Observe-only OpenTelemetry span-enrichment adapter for the MCP server. When installed alongside
McpServerModule, it captures the current Vert.x HTTP server span in open(...), retains it across
asynchronous completion, and enriches that exact retained span with bounded MCP attributes. It also
adds at most one link for a client-supplied body trace reference — see “At most one body-trace link”
below — and never creates or renames a span.
The module compiles against the OpenTelemetry API only — no SDK dependency. Without an
OpenTelemetry SDK installed (or with a no-op OpenTelemetry instance and no exporter configured),
every span operation resolves to the API’s built-in no-op implementation: enrichment is skipped
entirely and MCP protocol behavior is unaffected either way.
When To Use It
Section titled “When To Use It”Install vertique-opentelemetry-mcp whenever an application exposes the MCP server via
McpServerModule and wants its HTTP server spans (produced by Vert.x’s own OpenTelemetry tracing
integration) enriched with MCP-specific facts. Omitting this module entirely is also supported:
open-core MCP has no dependency on it, and MCP protocol behavior is identical either way.
Core Concepts
Section titled “Core Concepts”Capture-at-open, never Span.current() at callback time. open(...) runs while the Vert.x HTTP
server span is current — before dispatch, exactly as REST’s ServerSpanEnrichmentContributor/
ServerSpanOutcomeInterceptor capture the span early for the same reason. The observer resolves
Span.current() exactly once, inside open, and retains that exact Span reference (and its
SpanContext) on the returned session. The terminal callback later enriches that retained reference
directly — it never re-resolves Span.current(). This matters because a terminal callback can be
delivered on a different thread or after the request-owning context has moved on, where
Span.current() would return a different span (or the no-op span) rather than the one this request
actually ran under.
No span is ever created or renamed. This observer calls no tracer and builds no span. It only
mutates the one span already current when open runs — and only when that span’s context is valid
(an installed OpenTelemetry SDK with active tracing). Transport status remains owned by Vert.x HTTP
tracing; this observer never calls Span#setStatus.
Enrichment happens at logical settlement (onTerminal), not at transport completion. The
observer implements onTerminal, not onCompleted: attributes are set once the request settles
logically, before the response is written, mirroring the contract’s “sets bounded logical outcome
before the terminal write.”
Bounded attributes only.
rpc.system.name— always the literaljsonrpc.mcp.method.name— anMcpMethodname, withMcpMethod.OTHERremapped to the underscore-prefixed literal_OTHER, mirroring the sibling Micrometer adapter’smethodtag convention.mcp.protocol.version— the terminal event’s negotiated protocol version, set only when non-null: a request whose protocol negotiation never completed (rejected at or before negotiation) sets no value here at all, rather than a hardcoded or default literal.vertique.mcp.outcome— the terminal event’sMcpOutcomeenum name.vertique.mcp.result.type— the terminal event’sMcpResultTypeenum name.gen_ai.tool.name— set only when the terminal event carries a resolved tool identity (never theUNKNOWNplaceholder).
rpc.system.name, mcp.method.name, mcp.protocol.version, and gen_ai.tool.name are experimental
OpenTelemetry semantic-convention names; rather than depending on an incubating semconv artifact, they
are declared as internal, Vertique-owned AttributeKey constants on McpServerSpanObserver.
At most one body-trace link, never a child span, and only under mcp.bodyTracePolicy=LINK.
When the request’s terminal observation carries a non-null linkedTrace (a
dev.vertique.core.correlation.TraceReference — the framework’s single trace-reference type,
replacing the deleted MCP-local McpTraceContext — extracted by McpProtocolCodec from the request
body’s params._meta.traceparent/tracestate and bound onto McpCompletionCoordinator by
McpRequestDispatcher), the observer converts it into an OpenTelemetry SpanContext and adds exactly
one Span#addLink when that context is valid and from a different trace than the HTTP span’s own
captured trace id. A body trace context sharing the HTTP span’s own trace id is suppressed as a
self-reference and adds no link — this is the shape a body reference identical to the HTTP
traceparent header actually takes: the header shares the captured span’s trace id (only the span id
differs, since the captured span’s own span id is freshly minted at open and never equals its
parent’s), so suppression compares trace id only, never full span identity. Malformed trace-state data
(not well-formed W3C key=value entries) is caught and logged at WARN with a bounded diagnostic — the
exception class name only, never the raw trace data — and adds no link; every other enrichment
attribute is still recorded.
This observer performs no extraction of its own — the policy lives in vertique-mcp-server.
The MCP server module’s mcp.bodyTracePolicy defaults to IGNORE
(mirroring Vert.x’s own TracingPolicy default-off posture): under IGNORE, linkedTrace is always
null on every terminal observation this observer receives, so no link is ever added and no
extraction cost is ever paid — regardless of whether this adapter is installed. Set
mcp.bodyTracePolicy=LINK on the MCP server configuration (not on this module) to opt into the
extraction and linking behavior described above; see vertique-mcp-server’s own module.md
(“Body trace-context extraction”) for the extraction and bounds mechanics.
Zero-overhead when unconfigured. Every operation is guarded by Span#getSpanContext().isValid()
(at open), Span#isRecording() (at enrichment — a late terminal callback can observe a span that
already ended between capture and enrichment), and try/catch, so a missing
OpenTelemetry SDK, a no-op OpenTelemetry instance, or a throwing OpenTelemetry implementation never
affects MCP request processing.
Key Classes
Section titled “Key Classes”McpOpenTelemetryModule
Section titled “McpOpenTelemetryModule”Dagger @Module. The only public type in this artifact. Install it explicitly to contribute one
McpRequestLifecycleObserver that captures the current HTTP server span at open and enriches that
retained span at logical settlement (bounded MCP attributes and at most one body-trace link when the
server’s mcp.bodyTracePolicy is LINK). The package-private observer session also implements
McpCompletionScope so co-installed Micrometer recording can run with a valid span current for
exemplars.
@Component(modules = { VertxModule.class, McpServerModule.class, McpOpenTelemetryModule.class, // ...})interface AppComponent { /* ... */ }Module Dagger Bindings
Section titled “Module Dagger Bindings”| Type | Qualifier | Description |
|---|---|---|
McpRequestLifecycleObserver |
@IntoSet |
HTTP server span capture + enrichment |
Owns no module-local configuration keys. Body-trace linking is gated by mcp.bodyTracePolicy on
vertique-mcp-server. Observe-only: failures never alter MCP request processing.
Dependencies
Section titled “Dependencies”io.opentelemetry:opentelemetry-api—Span,SpanContext,TraceFlags,TraceState,AttributeKey; no OpenTelemetry SDK dependency (NFR-TEL-002).dev.vertique:vertique-mcp-core—McpRequestLifecycleObserver,McpRequestObservation,McpRequestTerminalObservation,McpRequestTerminalEvent, and the bounded lifecycle enums.dev.vertique:vertique-core—dev.vertique.core.correlation.TraceReference, the framework’s single trace-reference type, referenced directly by this module’s main source rather than merely reachable transitively.com.google.dagger:dagger,jakarta.inject:jakarta.inject-apiorg.slf4j:slf4j-api,org.projectlombok:lombok(provided)