From 0d874c17bc39e6b9c1010c86728ce2bd86b7469a Mon Sep 17 00:00:00 2001 From: Mahadev Annabhimoju <219508079+Joosboy@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:56:23 +0530 Subject: [PATCH] docs: clarify OpenTelemetry spans for resources and prompts --- docs/run/opentelemetry.md | 22 ++++++++++++++++++---- docs_src/opentelemetry/tutorial001.py | 12 ++++++++++++ tests/docs_src/test_opentelemetry.py | 20 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/docs/run/opentelemetry.md b/docs/run/opentelemetry.md index c36f5ceaa4..5b88857149 100644 --- a/docs/run/opentelemetry.md +++ b/docs/run/opentelemetry.md @@ -11,12 +11,14 @@ call `MCPServer(...)`. ``` 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. +true when a client reads `catalog://featured` or renders `reading_prompt`. The low-level `Server` +is traced too. ## 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` +Every inbound message becomes a `SERVER` span named after the method, plus a target for named +operations. So a `tools/call` for `search_books` is the span `tools/call search_books`, +a `prompts/get` for `reading_prompt` is `prompts/get reading_prompt`, and a bare `tools/list` is just `tools/list`. Each span carries a few attributes: @@ -31,13 +33,25 @@ OpenTelemetry's [GenAI semantic conventions](https://opentelemetry.io/docs/specs * `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 +A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. `resources/read` spans are +created by the same middleware and carry the common `mcp.*` attributes; the SDK does not add the +resource URI to the span name or to a `gen_ai.*` attribute today. 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. +## Adding your own detail + +The SDK span wraps the request handler. That means a tool, resource, or prompt function runs with +the SDK-created span already current. + +If you need more detail than the default attributes provide, create child spans inside your handler +or in your own middleware. For example, a resource handler can add a child span for a database read, +and a prompt handler can add one for template assembly. Those spans nest under `resources/read` or +`prompts/get`, so a tracing backend still shows one connected request. + ## It costs nothing until you want it Here is the part that makes "on by default" a comfortable default. diff --git a/docs_src/opentelemetry/tutorial001.py b/docs_src/opentelemetry/tutorial001.py index 3e66e90844..e68fdd37ec 100644 --- a/docs_src/opentelemetry/tutorial001.py +++ b/docs_src/opentelemetry/tutorial001.py @@ -7,3 +7,15 @@ def search_books(query: str) -> str: """Search the catalog by title or author.""" return f"Found 3 books matching {query!r}." + + +@mcp.resource("catalog://featured") +def featured_books() -> str: + """The featured books shelf.""" + return "Dune\nThe Left Hand of Darkness\nA Wizard of Earthsea" + + +@mcp.prompt() +def reading_prompt(topic: str) -> str: + """Create a reading recommendation prompt.""" + return f"Recommend one book about {topic}." diff --git a/tests/docs_src/test_opentelemetry.py b/tests/docs_src/test_opentelemetry.py index 17b153c265..6b82d94101 100644 --- a/tests/docs_src/test_opentelemetry.py +++ b/tests/docs_src/test_opentelemetry.py @@ -24,6 +24,26 @@ async def test_a_plain_server_is_traced_with_no_extra_code(capfire: CaptureLogfi assert attributes["gen_ai.tool.name"] == "search_books" +async def test_resources_and_prompts_are_traced_at_the_request_level(capfire: CaptureLogfire) -> None: + """tutorial001: resource reads and prompt renders use request-level SERVER spans.""" + async with Client(tutorial001.mcp) as client: + await client.read_resource("catalog://featured") + await client.get_prompt("reading_prompt", {"topic": "Dune"}) + + spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()} + + resource_attributes = spans["resources/read"]["attributes"] + assert resource_attributes["mcp.method.name"] == "resources/read" + assert "gen_ai.operation.name" not in resource_attributes + assert "gen_ai.prompt.name" not in resource_attributes + assert "gen_ai.tool.name" not in resource_attributes + + prompt_attributes = spans["prompts/get reading_prompt"]["attributes"] + assert prompt_attributes["mcp.method.name"] == "prompts/get" + assert prompt_attributes["gen_ai.prompt.name"] == "reading_prompt" + assert "gen_ai.operation.name" not in prompt_attributes + + async def test_client_and_server_share_one_trace(capfire: CaptureLogfire) -> None: """When both sides run the SDK, the client and server spans land in one trace (SEP-414).""" async with Client(tutorial001.mcp, mode="legacy") as client: