Make OpenTelemetry tracing the single default middleware (#2995)
This commit is contained in:
parent
cc596195bb
commit
b31d95a429
2 changed files with 260 additions and 238 deletions
|
|
@ -79,33 +79,12 @@ In increasing order of how much you should hesitate:
|
|||
an elicitation) while handling `initialize` therefore **deadlocks the connection**: the
|
||||
response you are waiting for can never be read. Fire-and-forget notifications are fine.
|
||||
|
||||
## `OpenTelemetryMiddleware`
|
||||
## The one middleware that ships on by default
|
||||
|
||||
The SDK ships one middleware: `OpenTelemetryMiddleware`. Construct it and append it
|
||||
(`server.middleware.append(OpenTelemetryMiddleware())`), exactly the line you already wrote
|
||||
for `log_timing`.
|
||||
|
||||
Every inbound message becomes a `SERVER` span named after the method and its target, so a
|
||||
`tools/call` for `search_books` is the span `tools/call search_books`.
|
||||
|
||||
* Every span carries `mcp.method.name` and `mcp.protocol.version`; a request's span also
|
||||
carries its JSON-RPC request id (a notification has none).
|
||||
* A `tools/call` span gets OpenTelemetry's GenAI semantic conventions,
|
||||
`gen_ai.operation.name` (`"execute_tool"`) and `gen_ai.tool.name`, so a tracing UI groups
|
||||
your tool calls the way it groups any other agent's. A `prompts/get` span gets
|
||||
`gen_ai.prompt.name`. The list methods carry no `gen_ai.*` keys.
|
||||
* A handler that raises sets the span's status to error. So does a tool result with
|
||||
`is_error=True`.
|
||||
|
||||
!!! tip
|
||||
The SDK depends only on `opentelemetry-api`. With no exporter installed those spans are
|
||||
no-ops, so appending this middleware costs you nothing. Install `opentelemetry-sdk` plus an
|
||||
exporter and everything lights up, with no server change.
|
||||
|
||||
The import is the catch. The class lives at `from mcp.server._otel import OpenTelemetryMiddleware`
|
||||
today, and the leading underscore is not an accident: it is the same provisional flag this whole
|
||||
page opened with. The SDK has not given it a public spelling yet, so the import path is the one
|
||||
line here you should expect to change.
|
||||
The SDK ships exactly one middleware, and it is already on your server's list: the one that
|
||||
emits an OpenTelemetry span for every message. You don't append it, and most of the time you
|
||||
don't think about it. It is a no-op until you install an exporter, and it has its own page:
|
||||
**OpenTelemetry**.
|
||||
|
||||
!!! info
|
||||
If you have written ASGI middleware, you already know this shape. Starlette's
|
||||
|
|
@ -121,9 +100,8 @@ line here you should expect to change.
|
|||
unknown methods) and runs outermost-first.
|
||||
* `ctx.request_id is None` is how you tell a notification from a request.
|
||||
* Raise instead of calling `call_next` to refuse one message; the connection survives.
|
||||
* `OpenTelemetryMiddleware` turns each message into a span (with GenAI attributes on tool
|
||||
calls and prompt gets) for the price of one `append`, and costs nothing until you install
|
||||
an exporter.
|
||||
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
|
||||
**OpenTelemetry**.
|
||||
* The whole surface is provisional. Observe with it; don't build on it.
|
||||
|
||||
That is everything that wraps a request. **Authorization** is what decides whether the request
|
||||
|
|
|
|||
107
docs/advanced/opentelemetry.md
Normal file
107
docs/advanced/opentelemetry.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# OpenTelemetry
|
||||
|
||||
Your server is already traced. You don't have to add anything.
|
||||
|
||||
Every server you create emits an [OpenTelemetry](https://opentelemetry.io/) span for every
|
||||
message it handles. You didn't write that, and you don't import it. It is there the moment you
|
||||
call `MCPServer(...)`.
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/opentelemetry/tutorial001.py"
|
||||
```
|
||||
|
||||
That is a complete, traced server. Call `search_books` and a span is created for it. The same is
|
||||
true for the low-level `Server`: the tracing lives on both.
|
||||
|
||||
## What you get
|
||||
|
||||
Every inbound message becomes a `SERVER` span named after the method and its target. So a
|
||||
`tools/call` for `search_books` is the span `tools/call search_books`, and a bare `tools/list`
|
||||
is just `tools/list`.
|
||||
|
||||
Each span carries a few attributes:
|
||||
|
||||
* `mcp.method.name` and `mcp.protocol.version`, on every span.
|
||||
* `jsonrpc.request.id`, on a request (a notification has none).
|
||||
* A handler that raises sets the span status to error. So does a tool result with `is_error=True`.
|
||||
|
||||
And because tracing a tool call is such a common thing to want, `tools/call` spans speak
|
||||
OpenTelemetry's [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/):
|
||||
|
||||
* `gen_ai.operation.name`, set to `"execute_tool"`.
|
||||
* `gen_ai.tool.name`, set to the tool being called.
|
||||
|
||||
A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. The list methods carry no
|
||||
`gen_ai.*` keys, because there is nothing to name.
|
||||
|
||||
!!! tip
|
||||
Those GenAI attributes are the reason a tracing UI groups your tool calls the way it groups
|
||||
any other agent's. You get that grouping for free, with no extra code.
|
||||
|
||||
## It costs nothing until you want it
|
||||
|
||||
Here is the part that makes "on by default" a comfortable default.
|
||||
|
||||
The SDK depends only on `opentelemetry-api`, the lightweight half of OpenTelemetry. With no SDK
|
||||
and no exporter installed, creating a span is a no-op. So the spans your server is emitting right
|
||||
now cost you almost nothing, and nobody is collecting them.
|
||||
|
||||
The day you want to *see* them, you install the other half and point it somewhere:
|
||||
|
||||
```console
|
||||
uv add opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
```
|
||||
|
||||
Configure an exporter the usual OpenTelemetry way, and every span the SDK has been quietly
|
||||
creating lights up. Your server code does not change. Not one line.
|
||||
|
||||
!!! info
|
||||
[Pydantic Logfire](https://logfire.pydantic.dev/) is one such backend, and it does the
|
||||
configuration for you: `pip install logfire`, `logfire.configure()`, and your MCP spans show
|
||||
up in the live view. It is built on OpenTelemetry, so anything below applies to it too.
|
||||
|
||||
## Traces that cross the wire
|
||||
|
||||
A trace is most useful when it follows a request from the client into the server, in one
|
||||
connected picture.
|
||||
|
||||
When the client and the server both run the SDK, that connection is automatic. The client injects
|
||||
the [W3C trace context](https://www.w3.org/TR/trace-context/) into the request, and the server
|
||||
reads it back out, so the server span nests under the client span in the same trace. This is
|
||||
[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), and you get it without
|
||||
asking.
|
||||
|
||||
If the inbound message carries no trace context, for example a request from a client that is not
|
||||
the SDK, the server span simply parents to whatever span is already current on the server, rather
|
||||
than starting a brand-new orphan trace.
|
||||
|
||||
## Turning it off
|
||||
|
||||
Tracing is a middleware, the first one on your server's list. If you really want a server that
|
||||
emits no spans, take it off:
|
||||
|
||||
```python
|
||||
from mcp.server._otel import OpenTelemetryMiddleware
|
||||
|
||||
mcp._lowlevel_server.middleware[:] = [
|
||||
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
|
||||
]
|
||||
```
|
||||
|
||||
!!! warning
|
||||
That import has a leading underscore, and that is on purpose. The class is provisional, the
|
||||
same way [`Server.middleware`](middleware.md) is provisional, so the import path is something
|
||||
you should expect to change. You almost never need this: with no exporter installed the spans
|
||||
are free, so the usual answer is to leave them on and not install an exporter.
|
||||
|
||||
## Recap
|
||||
|
||||
* Every `MCPServer` and every low-level `Server` emits one `SERVER` span per inbound message, out
|
||||
of the box. You write nothing.
|
||||
* Spans carry `mcp.method.name` and `mcp.protocol.version`; `tools/call` and `prompts/get` also
|
||||
carry GenAI attributes so your tool calls group like any other agent's.
|
||||
* It costs nothing until you install an OpenTelemetry SDK and an exporter, and then it lights up
|
||||
with no change to your server.
|
||||
* Client-to-server trace context propagates automatically when both sides run the SDK.
|
||||
|
||||
Next, the thing that decides whether a request runs at all: **Authorization**.
|
||||
Loading…
Reference in a new issue