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/sampling/run.py b/fastmcp_slim/fastmcp/server/sampling/run.py index cb0a1e9ec..382bf8a47 100644 --- a/fastmcp_slim/fastmcp/server/sampling/run.py +++ b/fastmcp_slim/fastmcp/server/sampling/run.py @@ -313,10 +313,11 @@ async def execute_tools( with tracer.start_as_current_span( f"sampling tool {tool_use.name}", kind=SpanKind.INTERNAL, + attributes={ + "gen_ai.tool.name": tool_use.name, + "fastmcp.tool.use_id": tool_use.id, + }, ) as span: - if span.is_recording(): - span.set_attribute("gen_ai.tool.name", tool_use.name) - span.set_attribute("fastmcp.tool.use_id", tool_use.id) try: result_value = await tool.run(tool_use.input) return ToolResultContent( @@ -556,12 +557,13 @@ async def sample_step_impl( with tracer.start_as_current_span( "sampling create_message", kind=SpanKind.CLIENT, + attributes={ + "mcp.method.name": "sampling/createMessage", + "fastmcp.server.name": context.fastmcp.name, + }, record_exception=False, set_status_on_exception=False, ) as span: - if span.is_recording(): - span.set_attribute("mcp.method.name", "sampling/createMessage") - span.set_attribute("fastmcp.server.name", context.fastmcp.name) try: if use_fallback: response = await call_sampling_handler( diff --git a/fastmcp_slim/fastmcp/server/telemetry.py b/fastmcp_slim/fastmcp/server/telemetry.py index e9f6d4344..2b994b24d 100644 --- a/fastmcp_slim/fastmcp/server/telemetry.py +++ b/fastmcp_slim/fastmcp/server/telemetry.py @@ -135,23 +135,21 @@ 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_protocol_span_attributes(), + **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_protocol_span_attributes(), - **get_auth_span_attributes(), - **get_session_span_attributes(), - } - ) token = _active_seam_span.set(span) try: yield span @@ -218,9 +216,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: @@ -240,16 +237,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/server/telemetry/test_sampling_tracing.py b/tests/server/telemetry/test_sampling_tracing.py index db02b13bc..2a550ffb3 100644 --- a/tests/server/telemetry/test_sampling_tracing.py +++ b/tests/server/telemetry/test_sampling_tracing.py @@ -10,6 +10,9 @@ from __future__ import annotations import pytest from mcp_types import TextContent +from opentelemetry.context import Context as OTelContext +from opentelemetry.sdk.trace import Span, SpanProcessor, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.trace import StatusCode @@ -17,6 +20,28 @@ from fastmcp import Client, Context, FastMCP from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams +class OnStartRecorder(SpanProcessor): + def __init__(self) -> None: + self.attributes: dict[str, dict[str, object]] = {} + + def on_start(self, span: Span, parent_context: OTelContext | None = None) -> None: + self.attributes[span.name] = dict(span.attributes or {}) + + +@pytest.fixture +def on_start_recorder( + monkeypatch: pytest.MonkeyPatch, + trace_exporter: InMemorySpanExporter, +) -> OnStartRecorder: + recorder = OnStartRecorder() + provider = TracerProvider() + provider.add_span_processor(recorder) + provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) + tracer = provider.get_tracer("test") + monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) + return recorder + + def _spans_named(exporter: InMemorySpanExporter, name: str): return [s for s in exporter.get_finished_spans() if s.name == name] @@ -27,7 +52,9 @@ def _exception_events(span): class TestSamplingCreateMessageSpan: async def test_success_creates_span_with_attributes( - self, trace_exporter: InMemorySpanExporter + self, + trace_exporter: InMemorySpanExporter, + on_start_recorder: OnStartRecorder, ): def sampling_handler( messages: list[SamplingMessage], @@ -52,6 +79,10 @@ class TestSamplingCreateMessageSpan: assert span.attributes is not None assert span.attributes["mcp.method.name"] == "sampling/createMessage" assert span.attributes["fastmcp.server.name"] == "sampling-server" + assert on_start_recorder.attributes["sampling create_message"] == { + "mcp.method.name": "sampling/createMessage", + "fastmcp.server.name": "sampling-server", + } # Success path must not record any exception. assert _exception_events(span) == [] assert span.status.status_code != StatusCode.ERROR @@ -93,7 +124,9 @@ class TestSamplingCreateMessageSpan: class TestSamplingToolSpan: async def test_tool_error_span_records_exception_once( - self, trace_exporter: InMemorySpanExporter + self, + trace_exporter: InMemorySpanExporter, + on_start_recorder: OnStartRecorder, ): from mcp_types import CreateMessageResultWithTools, ToolUseContent @@ -146,6 +179,10 @@ class TestSamplingToolSpan: assert span.status.status_code == StatusCode.ERROR assert span.attributes is not None assert span.attributes["gen_ai.tool.name"] == "boom_tool" + assert on_start_recorder.attributes["sampling tool boom_tool"] == { + "gen_ai.tool.name": "boom_tool", + "fastmcp.tool.use_id": "call_1", + } assert "error.type" in span.attributes # Tool spans catch-and-convert (no re-raise), so OTel auto-recording # never fires; the manual record_exception must fire exactly once. 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", + }