Repository navigation
feat: built-in OpenTelemetry tracing and metrics #3115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mattzcarey
wants to merge
7
commits into
main
Choose a base branch
from
feat/protocol-instrumentation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d317d97
feat: add an experimental instrumentation option to Protocol
mattzcarey 8f56525
feat(otel): add @modelcontextprotocol/otel on top of the instrumentat…
mattzcarey 0df12e6
feat: built-in OpenTelemetry, no option and no separate package
mattzcarey 5cfd53b
fix: inject MCP trace context with the client span active
mattzcarey 28b2264
fix: bound OpenTelemetry cardinality, peer-depend on @opentelemetry/api
mattzcarey 3e2aefa
fix: keep a server tool name only when a handler answered
mattzcarey cfb8190
docs: OpenTelemetry opt-out via Sentry's integration, baggage and Wor…
mattzcarey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| --- | ||
| '@modelcontextprotocol/core-internal': minor | ||
| '@modelcontextprotocol/client': minor | ||
| '@modelcontextprotocol/server': minor | ||
| --- | ||
|
|
||
| `Client`, `Server` and `McpServer` now emit OpenTelemetry spans and `mcp.client.operation.duration` / `mcp.server.operation.duration` histograms for every MCP request and notification except `notifications/message`, following the OpenTelemetry semantic conventions for MCP, and propagate W3C trace context through `params._meta` (SEP-414). There is nothing to configure: register an OpenTelemetry SDK and MCP traffic shows up. Without one, the OpenTelemetry API is a no-op, nothing is recorded and messages are unchanged. | ||
|
|
||
| `@opentelemetry/api` (`^1.9.0`) is a new required peer dependency of `@modelcontextprotocol/client` and `@modelcontextprotocol/server`, so the SDK uses the same copy as the application's OpenTelemetry SDK. npm 7+, pnpm 8+ and Bun install it automatically. Setups that don't install peers must add `@opentelemetry/api` to their dependencies, or importing either package fails: Yarn (classic, and Berry with any linker), `npm --legacy-peer-deps`, and pnpm with `auto-install-peers=false`. | ||
|
|
||
| What changes for applications that already run an OpenTelemetry SDK: | ||
|
|
||
| - MCP spans and metrics appear under the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` instrumentation scopes. Sentry's `McpServer` integration also traces MCP; remove it to avoid duplicate spans. Filtering the MCP scopes instead leaves handler spans parented on spans that are never exported. | ||
| - Outgoing requests and notifications carry whatever your global propagator writes, in `params._meta`, as HTTP requests carry it in headers: `traceparent` and `tracestate`, plus `baggage` if the propagator includes W3C Baggage (the `NodeSDK` default), so baggage entries reach MCP peers, including third-party servers. Register `W3CTraceContextPropagator` alone to send trace context only. | ||
| - Servers that validate a custom method's params with a strict schema need an optional `_meta` member, as they already do for callers that pass `onprogress`. | ||
|
|
||
| Spans do not record tool arguments, tool results or prompt arguments. They do record JSON-RPC error messages, handler exception messages and resource URIs. The span and attribute names are experimental while the MCP semantic conventions are in development. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| --- | ||
| shape: how-to | ||
| --- | ||
|
|
||
| # OpenTelemetry | ||
|
|
||
| `Client`, `McpServer` and `Server` emit OpenTelemetry spans and metrics for every MCP request and notification (except `notifications/message`), following the [OpenTelemetry semantic conventions for MCP](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). Register an OpenTelemetry SDK and they show up. | ||
|
|
||
| ```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#sdk_setup" | ||
| import { context, metrics, propagation, trace } from '@opentelemetry/api'; | ||
| import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; | ||
| import { W3CTraceContextPropagator } from '@opentelemetry/core'; | ||
| import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; | ||
|
|
||
| // Swap the in-memory exporter for an OTLP exporter to send spans to a backend. | ||
| const exporter = new InMemorySpanExporter(); | ||
| const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); | ||
|
|
||
| context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); | ||
| propagation.setGlobalPropagator(new W3CTraceContextPropagator()); | ||
| trace.setGlobalTracerProvider(tracerProvider); | ||
| metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); | ||
| ``` | ||
|
|
||
| ::: warning Experimental | ||
| The MCP semantic conventions are still in development. Span and attribute names can change in a minor release. | ||
| ::: | ||
|
|
||
| ## Register an OpenTelemetry SDK | ||
|
|
||
| The SDK packages talk to the OpenTelemetry API, which does nothing until an application registers an SDK. Without one, nothing is recorded and nothing is added to `params._meta`. | ||
|
|
||
| ```sh | ||
| npm install @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/core | ||
| ``` | ||
|
|
||
| The server needs no telemetry configuration of its own. | ||
|
|
||
| ```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#server_traced" | ||
| import { McpServer } from '@modelcontextprotocol/server'; | ||
| import * as z from 'zod/v4'; | ||
|
|
||
| const server = new McpServer({ name: 'weather', version: '1.0.0' }); | ||
|
|
||
| server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }, async ({ city }) => { | ||
| // Spans started here are children of the "tools/call get-weather" server span. | ||
| const forecast = await trace.getTracer('weather').startActiveSpan('fetch forecast', async span => { | ||
| span.end(); | ||
| return 'sunny'; | ||
| }); | ||
| return { content: [{ type: 'text', text: `${city}: ${forecast}` }] }; | ||
| }); | ||
| ``` | ||
|
|
||
| The context manager is what makes the MCP span active inside your handlers, so spans you start there nest under it. | ||
|
|
||
| ## See the spans | ||
|
|
||
| Make a call inside a span of your own, here an agent run. | ||
|
|
||
| ```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#callTool_traced" | ||
| await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => { | ||
| await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } }); | ||
| agentSpan.end(); | ||
| }); | ||
| ``` | ||
|
|
||
| The client's span carries its trace context to the server in `params._meta`, so both sides land in one trace: | ||
|
|
||
| ``` | ||
| invoke_agent (internal) | ||
| tools/call get-weather (client) | ||
| tools/call get-weather (server) | ||
| fetch forecast (internal) | ||
| ``` | ||
|
|
||
| The server span comes from the `@modelcontextprotocol/server` instrumentation scope (client spans from `@modelcontextprotocol/client`) and carries the conventions' attributes, plus `jsonrpc.request.id`, left out here: | ||
|
|
||
| ``` | ||
| @modelcontextprotocol/server { | ||
| 'mcp.method.name': 'tools/call', | ||
| 'mcp.protocol.version': '2025-11-25', | ||
| 'gen_ai.operation.name': 'execute_tool', | ||
| 'gen_ai.tool.name': 'get-weather' | ||
| } | ||
| ``` | ||
|
|
||
| ::: warning | ||
| `params._meta` gets whatever your global propagator writes, as HTTP headers do. If it includes W3C Baggage (the `NodeSDK` default), baggage entries reach every MCP server you call, including third-party ones. Register `W3CTraceContextPropagator` on its own to send trace context only. | ||
|
|
||
| On Cloudflare Workers, a span's trace flags don't reflect sampling, so a W3C propagator sends `traceparent` with flags `00` and peers may treat the request as unsampled. | ||
| ::: | ||
|
|
||
| ::: info | ||
| Any SDK that follows [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta) reads the same `traceparent`, `tracestate` and `baggage` keys, so a Python or C# peer joins the trace too. | ||
| ::: | ||
|
|
||
| ## Read the duration metrics | ||
|
|
||
| Register a meter provider and each instance records `mcp.client.operation.duration` for what it sends and `mcp.server.operation.duration` for what it receives, in seconds, with the conventions' bucket boundaries. The tool call above recorded: | ||
|
|
||
| ``` | ||
| mcp.client.operation.duration (s) { | ||
| 'mcp.method.name': 'tools/call', | ||
| 'mcp.protocol.version': '2025-11-25', | ||
| 'gen_ai.operation.name': 'execute_tool', | ||
| 'gen_ai.tool.name': 'get-weather' | ||
| } | ||
| ``` | ||
|
|
||
| Metrics never carry session ids, request ids or resource URIs. A server records any method that is neither a spec method nor registered with its own handler as `_OTHER`, keeps a tool or prompt name only once its handler has recognised it, and only records protocol versions it supports, so a client can't grow your metric cardinality. | ||
|
|
||
| ## Know what counts as an error | ||
|
|
||
| Failed operations set `error.type` and an `ERROR` status: | ||
|
|
||
| - A JSON-RPC error records its code, such as `-32603`, in `error.type` and `rpc.response.status_code`. On the server, `-32700`, `-32600`, `-32601`, `-32602` and `-32002` mean the caller sent something unservable and are not errors. | ||
| - A tool result with `isError: true` records `tool_error`. Its content stays out of the span. | ||
| - Local failures record `timeout`, `cancelled`, `connection_closed`, or the SDK error code (`SEND_FAILED`, `INVALID_RESULT`). | ||
| - A handler that throws something other than a `ProtocolError` also gets an exception event on the server span. | ||
|
|
||
| Spans don't record tool arguments, tool results or prompt arguments. They do record JSON-RPC error messages, handler exception messages and resource URIs. | ||
|
|
||
| ## Serve over HTTP | ||
|
|
||
| With HTTP instrumentation active too, the MCP server span stays a child of the client's MCP span and links to the HTTP server span. `createMcpHandler` needs nothing extra. | ||
|
|
||
| ::: tip | ||
| Sentry's `McpServer` integration also traces MCP, so with Sentry you get two spans per message. Remove the integration with `integrations: defaults => defaults.filter(integration => integration.name !== 'McpServer')`. Don't filter the `@modelcontextprotocol/*` scopes in a span processor instead: handler spans and the peer's spans would point at parents that are never exported. | ||
| ::: | ||
|
|
||
| The `examples/opentelemetry` story runs a traced client and server in separate processes over stdio and Streamable HTTP, and checks the trace crosses the wire. | ||
|
|
||
| ## Recap | ||
|
|
||
| - Register an OpenTelemetry SDK; `Client`, `McpServer` and `Server` need no configuration. | ||
| - Without a registered SDK, nothing is recorded and `params._meta` is untouched. | ||
| - Trace context travels in `params._meta`, so client and server spans join one trace and handler spans nest under the server span. | ||
| - Each instance records `mcp.client.operation.duration` and `mcp.server.operation.duration` with low-cardinality attributes. | ||
| - Spans don't record tool arguments, tool results or prompt arguments. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| /** | ||
| * Companion example for `docs/advanced/opentelemetry.md`. | ||
| * | ||
| * Every `ts` fence on that page is synced from a `//#region` in this file | ||
| * (`pnpm sync:snippets --check`). The file also runs: the harness below the | ||
| * regions connects an in-memory client and server and prints the spans and | ||
| * metrics the page quotes verbatim. | ||
| * | ||
| * pnpm --filter @modelcontextprotocol/examples typecheck | ||
| * npx tsx guides/advanced/opentelemetry.examples.ts # from examples/ | ||
| * | ||
| * @module | ||
| */ | ||
| /* eslint-disable no-console */ | ||
| import type { DataPoint, Histogram } from '@opentelemetry/sdk-metrics'; | ||
| import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; | ||
| import type { ReadableSpan } from '@opentelemetry/sdk-trace'; | ||
|
|
||
| class CollectingMetricReader extends MetricReader { | ||
| protected async onShutdown(): Promise<void> {} | ||
| protected async onForceFlush(): Promise<void> {} | ||
| } | ||
| const metricReader = new CollectingMetricReader(); | ||
|
|
||
| // "Register an OpenTelemetry SDK" — the application's job, once, before any MCP traffic. | ||
| //#region sdk_setup | ||
| import { context, metrics, propagation, trace } from '@opentelemetry/api'; | ||
| import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; | ||
| import { W3CTraceContextPropagator } from '@opentelemetry/core'; | ||
| import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; | ||
|
|
||
| // Swap the in-memory exporter for an OTLP exporter to send spans to a backend. | ||
| const exporter = new InMemorySpanExporter(); | ||
| const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); | ||
|
|
||
| context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); | ||
| propagation.setGlobalPropagator(new W3CTraceContextPropagator()); | ||
| trace.setGlobalTracerProvider(tracerProvider); | ||
| metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); | ||
| //#endregion sdk_setup | ||
|
|
||
| // "Register an OpenTelemetry SDK" — the server needs no telemetry configuration. | ||
| //#region server_traced | ||
| import { McpServer } from '@modelcontextprotocol/server'; | ||
| import * as z from 'zod/v4'; | ||
|
|
||
| const server = new McpServer({ name: 'weather', version: '1.0.0' }); | ||
|
|
||
| server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }, async ({ city }) => { | ||
| // Spans started here are children of the "tools/call get-weather" server span. | ||
| const forecast = await trace.getTracer('weather').startActiveSpan('fetch forecast', async span => { | ||
| span.end(); | ||
| return 'sunny'; | ||
| }); | ||
| return { content: [{ type: 'text', text: `${city}: ${forecast}` }] }; | ||
| }); | ||
| //#endregion server_traced | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Harness (not shown on the page). Connects a client in memory, makes one tool | ||
| // call inside an agent span, and prints what was recorded. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const { SpanKind } = await import('@opentelemetry/api'); | ||
| const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); | ||
| const client = new Client({ name: 'agent', version: '1.0.0' }); | ||
| const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | ||
| await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); | ||
| exporter.reset(); | ||
|
|
||
| // "See the spans" — the call the page's span tree comes from. | ||
| //#region callTool_traced | ||
| await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => { | ||
| await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } }); | ||
| agentSpan.end(); | ||
| }); | ||
| //#endregion callTool_traced | ||
|
|
||
| const spans = exporter.getFinishedSpans(); | ||
| const kinds: Record<number, string> = { [SpanKind.INTERNAL]: 'internal', [SpanKind.SERVER]: 'server', [SpanKind.CLIENT]: 'client' }; | ||
| const printTree = (parent: ReadableSpan | undefined, depth: number) => { | ||
| const children = spans | ||
| .filter(span => span.parentSpanContext?.spanId === parent?.spanContext().spanId) | ||
| .toSorted((a, b) => a.startTime[0] - b.startTime[0] || a.startTime[1] - b.startTime[1]); | ||
| for (const child of children) { | ||
| console.log(`${' '.repeat(depth)}${child.name} (${kinds[child.kind]})`); | ||
| printTree(child, depth + 1); | ||
| } | ||
| }; | ||
| printTree(undefined, 0); | ||
|
|
||
| const toolServerSpan = spans.find(span => span.name === 'tools/call get-weather' && span.kind === SpanKind.SERVER); | ||
| if (toolServerSpan === undefined) throw new Error('opentelemetry.md claim failed: no tools/call server span'); | ||
| const { 'jsonrpc.request.id': _requestId, ...stableAttributes } = toolServerSpan.attributes; | ||
| console.log(toolServerSpan.instrumentationScope.name, stableAttributes); | ||
|
|
||
| const { resourceMetrics } = await metricReader.collect(); | ||
| const clientDuration = resourceMetrics.scopeMetrics | ||
| .filter(scope => scope.scope.name === '@modelcontextprotocol/client') | ||
| .flatMap(scope => scope.metrics) | ||
| .find(metric => metric.descriptor.name === 'mcp.client.operation.duration'); | ||
| const toolCallPoint = (clientDuration?.dataPoints as DataPoint<Histogram>[] | undefined)?.find( | ||
| point => point.attributes['mcp.method.name'] === 'tools/call' | ||
| ); | ||
| if (toolCallPoint === undefined) throw new Error('opentelemetry.md claim failed: no tools/call duration was recorded'); | ||
| console.log(`${clientDuration!.descriptor.name} (${clientDuration!.descriptor.unit})`, toolCallPoint.attributes); | ||
|
|
||
| await client.close(); | ||
| await tracerProvider.shutdown(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # opentelemetry | ||
|
|
||
| Trace and measure MCP traffic with OpenTelemetry. Both processes register an OpenTelemetry SDK and configure nothing on the MCP side; the client's `tools/call` span carries its trace context to the server in `params._meta`, so the server span and anything the tool does land in the same trace. The client asserts the trace crossed the wire, the span parenting on both sides, and that `mcp.client.operation.duration` was recorded. | ||
|
|
||
| ```bash | ||
| pnpm --filter @mcp-examples/opentelemetry client | ||
| ``` | ||
|
|
||
| Sample output (ids differ per run): | ||
|
|
||
| ``` | ||
| trace 4bf92f3577b34da6a3ce929d0e0e4736 | ||
| invoke_agent (client process) | ||
| tools/call trace-info (client process) | ||
| tools/call trace-info (server process) | ||
| ``` | ||
|
|
||
| The server and client keep spans in memory to stay self-contained. In an application, add a span processor with an exporter (for example `OTLPTraceExporter`) to the tracer provider. See [the OpenTelemetry guide](../../docs/advanced/opentelemetry.md). |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit (optional): Readers who run the OpenTelemetry JS SDK on Cloudflare Workers are told their
traceparentalways carries flags00, which is not true for that setup. docs/advanced/opentelemetry.md:91 says it for all of Workers. The guide never names the setup it applies to: Workers' own tracing behindregisterOpenTelemetry()fromagents/observability. With the SDK setup this page shows, the sampler sets the flags (test/integration/test/otel/sdkSetupOrder.test.ts:48 expects-01). Fix: scope the sentence to that bridge by name and say what a reader should do about it, outside the baggage warning.Why this was flagged
No runtime trigger; this is a wording check on a line this push adds. The only setup the guide documents is the JS SDK:
TracerProviderfrom@ opentelemetry/sdk-traceplusW3CTraceContextPropagator(docs/advanced/opentelemetry.md:13-21), and Cloudflare Workers appears nowhere else on the page. In that setup the span's trace flags come from the SDK sampler on any runtime, andsendinjects from that span context (packages/core-internal/src/shared/openTelemetry.ts:159-162), so a sampled span sends-01, as test/integration/test/otel/sdkSetupOrder.test.ts:48 asserts. Line 91 says "On Cloudflare Workers, a span's trace flags don't reflect sampling, so a W3C propagator sendstraceparentwith flags00" with no qualifier. A Workers reader on the documented SDK setup is told their peers see unsampled requests when they do not, and a reader on the bridge cannot tell the caveat is theirs or what to do. The base branch has no such sentence. No safeguard applies to doc prose.Verification: The sentence at docs/advanced/opentelemetry.md:91 is unconditional. test/integration/test/otel/sdkSetupOrder.test.ts:17-19 registers
TracerProviderfrom@ opentelemetry/sdk-traceplusW3CTraceContextPropagator, and line 48 expectstraceparent: 00-${traceId}-${spanId}-01. A reader on Workers with the JS SDK is told their requests go out unsampled when the code sends-01.Finding ID:
f-90d751b6