diff --git a/fastmcp_slim/fastmcp/client/telemetry.py b/fastmcp_slim/fastmcp/client/telemetry.py index e66cd7b47..d6a866098 100644 --- a/fastmcp_slim/fastmcp/client/telemetry.py +++ b/fastmcp_slim/fastmcp/client/telemetry.py @@ -23,24 +23,25 @@ def client_span( Automatically records any exception on the span and sets error status. """ + attrs: dict[str, str] = { + # MCP semantic conventions + "mcp.method.name": method, + # FastMCP-specific attributes + "fastmcp.component.key": component_key, + } + if session_id is not None: + attrs["mcp.session.id"] = session_id + if resource_uri: + attrs["mcp.resource.uri"] = resource_uri + if tool_name is not None: + attrs["gen_ai.tool.name"] = tool_name + if prompt_name is not None: + attrs["gen_ai.prompt.name"] = prompt_name + tracer = get_tracer() - with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span: - if span.is_recording(): - attrs: dict[str, str] = { - # MCP semantic conventions - "mcp.method.name": method, - # FastMCP-specific attributes - "fastmcp.component.key": component_key, - } - if session_id is not None: - attrs["mcp.session.id"] = session_id - if resource_uri: - attrs["mcp.resource.uri"] = resource_uri - if tool_name is not None: - attrs["gen_ai.tool.name"] = tool_name - if prompt_name is not None: - attrs["gen_ai.prompt.name"] = prompt_name - span.set_attributes(attrs) + with tracer.start_as_current_span( + name, kind=SpanKind.CLIENT, attributes=attrs + ) as span: try: yield span except Exception as e: diff --git a/fastmcp_slim/fastmcp/server/telemetry.py b/fastmcp_slim/fastmcp/server/telemetry.py index eeaa0aeb7..3b8455c8b 100644 --- a/fastmcp_slim/fastmcp/server/telemetry.py +++ b/fastmcp_slim/fastmcp/server/telemetry.py @@ -119,22 +119,20 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]: rejections *before* the high-level path (auth, not-found, middleware vetoes) that would otherwise produce no SERVER span at all — are recorded here. """ + attrs = { + SEAM_SPAN_MARKER: True, + "mcp.method.name": method, + "fastmcp.server.name": server_name, + **get_auth_span_attributes(), + **get_session_span_attributes(), + } tracer = get_tracer() with tracer.start_as_current_span( method, context=_get_parent_trace_context(), kind=SpanKind.SERVER, + attributes=attrs, ) as span: - if span.is_recording(): - span.set_attribute(SEAM_SPAN_MARKER, True) - span.set_attributes( - { - "mcp.method.name": method, - "fastmcp.server.name": server_name, - **get_auth_span_attributes(), - **get_session_span_attributes(), - } - ) token = _active_seam_span.set(span) try: yield span @@ -201,9 +199,8 @@ def server_span( name, context=_get_parent_trace_context(), kind=SpanKind.SERVER, + attributes=attrs, ) as span: - if span.is_recording(): - span.set_attributes(attrs) try: yield span except Exception as e: @@ -223,16 +220,15 @@ def delegate_span( Used by FastMCPProvider when delegating to mounted servers. Automatically records any exception on the span and sets error status. """ + attrs: dict[str, str] = { + "fastmcp.provider.type": provider_type, + "fastmcp.component.key": component_key, + } + if method is not None: + attrs["mcp.method.name"] = method + tracer = get_tracer() - with tracer.start_as_current_span(f"delegate {name}") as span: - if span.is_recording(): - attrs: dict[str, str] = { - "fastmcp.provider.type": provider_type, - "fastmcp.component.key": component_key, - } - if method is not None: - attrs["mcp.method.name"] = method - span.set_attributes(attrs) + with tracer.start_as_current_span(f"delegate {name}", attributes=attrs) as span: try: yield span except Exception as e: diff --git a/tests/telemetry/test_span_attributes.py b/tests/telemetry/test_span_attributes.py new file mode 100644 index 000000000..82fde174e --- /dev/null +++ b/tests/telemetry/test_span_attributes.py @@ -0,0 +1,74 @@ +import pytest +from opentelemetry.context import Context +from opentelemetry.sdk.trace import Span, SpanProcessor, TracerProvider + +from fastmcp.client.telemetry import client_span +from fastmcp.server.telemetry import delegate_span, seam_span, server_span + + +class OnStartRecorder(SpanProcessor): + def __init__(self) -> None: + self.attributes: dict[str, dict[str, object]] = {} + + def on_start(self, span: Span, parent_context: Context | None = None) -> None: + self.attributes[span.name] = dict(span.attributes or {}) + + +def test_known_span_attributes_are_available_on_start( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recorder = OnStartRecorder() + provider = TracerProvider() + provider.add_span_processor(recorder) + tracer = provider.get_tracer("test") + monkeypatch.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer) + monkeypatch.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer) + + with client_span( + "client test", + method="tools/call", + component_key="tool:echo@", + tool_name="echo", + ): + pass + with server_span( + "server test", + method="tools/call", + server_name="test-server", + component_type="tool", + component_key="tool:echo@", + tool_name="echo", + ): + pass + with seam_span("initialize test", server_name="test-server"): + pass + with delegate_span( + "delegate test", + provider_type="LocalProvider", + component_key="tool:echo@", + method="tools/call", + ): + pass + + assert recorder.attributes["client test"] == { + "mcp.method.name": "tools/call", + "fastmcp.component.key": "tool:echo@", + "gen_ai.tool.name": "echo", + } + assert recorder.attributes["server test"] == { + "mcp.method.name": "tools/call", + "fastmcp.server.name": "test-server", + "fastmcp.component.type": "tool", + "fastmcp.component.key": "tool:echo@", + "gen_ai.tool.name": "echo", + } + assert recorder.attributes["initialize test"] == { + "fastmcp.span.seam": True, + "mcp.method.name": "initialize test", + "fastmcp.server.name": "test-server", + } + assert recorder.attributes["delegate delegate test"] == { + "fastmcp.provider.type": "LocalProvider", + "fastmcp.component.key": "tool:echo@", + "mcp.method.name": "tools/call", + }