Add GenAI semantic-convention attributes to OpenTelemetryMiddleware (#2970)
This commit is contained in:
parent
f226d00d0a
commit
1b1abf6ab6
2 changed files with 137 additions and 15 deletions
|
|
@ -8,28 +8,32 @@ from pydantic import ValidationError
|
|||
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
|
||||
from mcp.shared._otel import extract_trace_context, otel_span
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import INVALID_PARAMS, CallToolResult
|
||||
|
||||
|
||||
class OpenTelemetryMiddleware(ServerMiddleware[Any]):
|
||||
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span.
|
||||
|
||||
Span name `"MCP handle <method> [<target>]"`, `mcp.method.name` attribute, W3C
|
||||
trace context extracted from `params._meta` (SEP-414), and an ERROR status if
|
||||
the handler raises. Requests and notifications both get a span;
|
||||
`jsonrpc.request.id` is set only when `ctx.request_id` is present (notifications
|
||||
have none).
|
||||
"""
|
||||
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span."""
|
||||
|
||||
async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
|
||||
name = ctx.params.get("name") if ctx.params else None
|
||||
target = name if isinstance(name, str) else None
|
||||
|
||||
attributes: dict[str, Any] = {"mcp.method.name": ctx.method}
|
||||
attributes: dict[str, Any] = {
|
||||
"mcp.method.name": ctx.method,
|
||||
"mcp.protocol.version": ctx.protocol_version,
|
||||
}
|
||||
if ctx.request_id is not None:
|
||||
attributes["jsonrpc.request.id"] = str(ctx.request_id)
|
||||
|
||||
if ctx.method == "tools/call":
|
||||
attributes["gen_ai.operation.name"] = "execute_tool"
|
||||
if target is not None:
|
||||
attributes["gen_ai.tool.name"] = target
|
||||
elif ctx.method == "prompts/get" and target is not None:
|
||||
attributes["gen_ai.prompt.name"] = target
|
||||
|
||||
with otel_span(
|
||||
name=f"MCP handle {ctx.method}{f' {target}' if target else ''}",
|
||||
name=f"{ctx.method}{f' {target}' if target else ''}",
|
||||
kind=SpanKind.SERVER,
|
||||
attributes=attributes,
|
||||
context=extract_trace_context(ctx.meta),
|
||||
|
|
@ -37,15 +41,28 @@ class OpenTelemetryMiddleware(ServerMiddleware[Any]):
|
|||
set_status_on_exception=False,
|
||||
) as span:
|
||||
try:
|
||||
return await call_next(ctx)
|
||||
result = await call_next(ctx)
|
||||
except MCPError as e:
|
||||
code = str(e.error.code)
|
||||
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
|
||||
span.set_status(StatusCode.ERROR, e.error.message)
|
||||
raise
|
||||
except ValidationError:
|
||||
# Mirror the sanitized wire response; pydantic messages carry client input.
|
||||
code = str(INVALID_PARAMS)
|
||||
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
|
||||
span.set_status(StatusCode.ERROR, "Invalid request parameters")
|
||||
raise
|
||||
except Exception as e:
|
||||
span.set_attribute("error.type", type(e).__qualname__)
|
||||
span.record_exception(e)
|
||||
span.set_status(StatusCode.ERROR, str(e))
|
||||
raise
|
||||
if ctx.method == "tools/call":
|
||||
match result:
|
||||
case CallToolResult(is_error=True) | {"isError": True} | {"is_error": True}:
|
||||
span.set_attribute("error.type", "tool_error")
|
||||
span.set_status(StatusCode.ERROR)
|
||||
case _:
|
||||
pass
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -13,7 +13,17 @@ from mcp.server.lowlevel.server import Server
|
|||
from mcp.server.runner import otel_middleware
|
||||
from mcp.shared._otel import inject_trace_context
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.types import CallToolRequestParams, ListToolsResult, NotificationParams, PaginatedRequestParams, Tool
|
||||
from mcp.types import (
|
||||
INVALID_PARAMS,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
GetPromptRequestParams,
|
||||
GetPromptResult,
|
||||
ListToolsResult,
|
||||
NotificationParams,
|
||||
PaginatedRequestParams,
|
||||
Tool,
|
||||
)
|
||||
|
||||
from .conftest import SpanCapture
|
||||
from .test_runner import Ctx, SrvT, connected_runner
|
||||
|
|
@ -40,13 +50,98 @@ async def test_emits_server_span_with_method_and_target(server: SrvT, spans: Spa
|
|||
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
|
||||
assert result == {"content": [], "isError": False}
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.name == "MCP handle tools/call mytool"
|
||||
assert span.name == "tools/call mytool"
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["mcp.method.name"] == "tools/call"
|
||||
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
|
||||
assert span.attributes["gen_ai.tool.name"] == "mytool"
|
||||
assert isinstance(span.attributes["jsonrpc.request.id"], str)
|
||||
assert span.status.status_code == StatusCode.UNSET
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_error_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
|
||||
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
|
||||
return {"content": [], "isError": True}
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["error.type"] == "tool_error"
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_error_model_result_sets_error_type(server: SrvT, spans: SpanCapture):
|
||||
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult:
|
||||
return CallToolResult(content=[], is_error=True)
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["error.type"] == "tool_error"
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_error_snake_case_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
|
||||
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
|
||||
return {"content": [], "is_error": True}
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["error.type"] == "tool_error"
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_named_non_tool_prompt_method_omits_gen_ai_attrs(server: SrvT, spans: SpanCapture):
|
||||
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
|
||||
return {"content": [], "isError": False}
|
||||
|
||||
server.add_request_handler("custom/op", CallToolRequestParams, custom)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("custom/op", {"name": "thing", "arguments": {}})
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.name == "custom/op thing"
|
||||
assert span.attributes is not None
|
||||
assert "gen_ai.operation.name" not in span.attributes
|
||||
assert "gen_ai.tool.name" not in span.attributes
|
||||
assert "gen_ai.prompt.name" not in span.attributes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prompt_get_sets_prompt_name(server: SrvT, spans: SpanCapture):
|
||||
async def get_prompt(ctx: Ctx, params: GetPromptRequestParams) -> GetPromptResult:
|
||||
return GetPromptResult(messages=[])
|
||||
|
||||
server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("prompts/get", {"name": "myprompt"})
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.name == "prompts/get myprompt"
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["gen_ai.prompt.name"] == "myprompt"
|
||||
assert "gen_ai.operation.name" not in span.attributes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCapture):
|
||||
async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
|
||||
|
|
@ -59,7 +154,7 @@ async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCaptu
|
|||
await client.notify("notifications/roots/list_changed", None)
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.name == "MCP handle notifications/roots/list_changed"
|
||||
assert span.name == "notifications/roots/list_changed"
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["mcp.method.name"] == "notifications/roots/list_changed"
|
||||
assert "jsonrpc.request.id" not in span.attributes
|
||||
|
|
@ -146,6 +241,9 @@ async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCaptur
|
|||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.status.description == "Method not found"
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["error.type"] == str(exc.value.error.code)
|
||||
assert span.attributes["rpc.response.status_code"] == str(exc.value.error.code)
|
||||
assert not [e for e in span.events if e.name == "exception"]
|
||||
|
||||
|
||||
|
|
@ -160,6 +258,11 @@ async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: Spa
|
|||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.status.description == "Invalid request parameters"
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["error.type"] == str(INVALID_PARAMS)
|
||||
assert span.attributes["rpc.response.status_code"] == str(INVALID_PARAMS)
|
||||
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
|
||||
assert "gen_ai.tool.name" not in span.attributes
|
||||
assert not span.events
|
||||
|
||||
|
||||
|
|
@ -177,6 +280,8 @@ async def test_records_error_status_on_handler_exception(server: SrvT, spans: Sp
|
|||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.status.description == "handler blew up"
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["error.type"] == "ValueError"
|
||||
[event] = [e for e in span.events if e.name == "exception"]
|
||||
assert event.attributes is not None
|
||||
assert event.attributes["exception.type"] == "ValueError"
|
||||
|
|
@ -202,4 +307,4 @@ async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture
|
|||
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}})
|
||||
assert seen_arguments == {"x": 1, "injected": True}
|
||||
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
|
||||
assert span.name == "MCP handle tools/call mytool"
|
||||
assert span.name == "tools/call mytool"
|
||||
|
|
|
|||
Loading…
Reference in a new issue