Compare commits

...

4 commits

Author SHA1 Message Date
Jeremiah Lowin
4207802920
Reapply span attributes after creation to survive non-forwarding samplers
Tracer.start_span builds the span from sampling_result.attributes, not
the attributes kwarg — a custom Sampler that returns
SamplingResult(RECORD_AND_SAMPLE) without forwarding attributes
silently drops everything FastMCP passed at creation time. Reapply the
same attributes immediately after span creation (guarded by
is_recording()) so on_start hooks and samplers still see them, while
the finished span is guaranteed to carry FastMCP's telemetry
regardless of sampler behavior.
2026-07-18 19:52:53 -04:00
Jeremiah Lowin
d0afccb028
Merge remote-tracking branch 'origin/main' into codex/otel-on-start-attributes
# Conflicts:
#	fastmcp_slim/fastmcp/server/telemetry.py
2026-07-18 19:34:04 -04:00
zzstoatzz
675a23128c Expose sampling attributes on span start
🤖 Generated with Codex
2026-07-09 15:33:30 -05:00
zzstoatzz
e3941e23b1 Expose telemetry attributes on span start
🤖 Generated with Codex
2026-07-09 15:16:05 -05:00
5 changed files with 473 additions and 38 deletions

View file

@ -23,23 +23,34 @@ def client_span(
Automatically records any exception on the span and sets error status. 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() tracer = get_tracer()
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span: with tracer.start_as_current_span(
name, kind=SpanKind.CLIENT, attributes=attrs
) as span:
# Reapply: `attributes=attrs` above lets on_start hooks and the
# sampler see these values at creation time. But OTel's
# Tracer.start_span builds the span from
# `sampling_result.attributes`, not the `attributes` kwarg directly —
# a custom Sampler whose SamplingResult.attributes defaults to None
# silently drops everything we passed. Reapplying here (additive,
# can't clobber anything a sampler legitimately added) guarantees
# FastMCP's attributes survive regardless of sampler behavior.
if span.is_recording(): 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) span.set_attributes(attrs)
try: try:
yield span yield span

View file

@ -310,13 +310,26 @@ async def execute_tools(
) )
tracer = get_tracer() tracer = get_tracer()
span_attrs = {
"gen_ai.tool.name": tool_use.name,
"fastmcp.tool.use_id": tool_use.id,
}
with tracer.start_as_current_span( with tracer.start_as_current_span(
f"sampling tool {tool_use.name}", f"sampling tool {tool_use.name}",
kind=SpanKind.INTERNAL, kind=SpanKind.INTERNAL,
attributes=span_attrs,
) as span: ) as span:
# Reapply: `attributes=span_attrs` above lets on_start hooks and
# the sampler see these values at creation time. But OTel's
# Tracer.start_span builds the span from
# `sampling_result.attributes`, not the `attributes` kwarg
# directly — a custom Sampler whose SamplingResult.attributes
# defaults to None silently drops everything we passed.
# Reapplying here (additive, can't clobber anything a sampler
# legitimately added) guarantees FastMCP's attributes survive
# regardless of sampler behavior.
if span.is_recording(): if span.is_recording():
span.set_attribute("gen_ai.tool.name", tool_use.name) span.set_attributes(span_attrs)
span.set_attribute("fastmcp.tool.use_id", tool_use.id)
try: try:
result_value = await tool.run(tool_use.input) result_value = await tool.run(tool_use.input)
return ToolResultContent( return ToolResultContent(
@ -553,15 +566,27 @@ async def sample_step_impl(
# Make the LLM call # Make the LLM call
tracer = get_tracer() tracer = get_tracer()
span_attrs = {
"mcp.method.name": "sampling/createMessage",
"fastmcp.server.name": context.fastmcp.name,
}
with tracer.start_as_current_span( with tracer.start_as_current_span(
"sampling create_message", "sampling create_message",
kind=SpanKind.CLIENT, kind=SpanKind.CLIENT,
attributes=span_attrs,
record_exception=False, record_exception=False,
set_status_on_exception=False, set_status_on_exception=False,
) as span: ) as span:
# Reapply: `attributes=span_attrs` above lets on_start hooks and the
# sampler see these values at creation time. But OTel's
# Tracer.start_span builds the span from
# `sampling_result.attributes`, not the `attributes` kwarg directly —
# a custom Sampler whose SamplingResult.attributes defaults to None
# silently drops everything we passed. Reapplying here (additive,
# can't clobber anything a sampler legitimately added) guarantees
# FastMCP's attributes survive regardless of sampler behavior.
if span.is_recording(): if span.is_recording():
span.set_attribute("mcp.method.name", "sampling/createMessage") span.set_attributes(span_attrs)
span.set_attribute("fastmcp.server.name", context.fastmcp.name)
try: try:
if use_fallback: if use_fallback:
response = await call_sampling_handler( response = await call_sampling_handler(

View file

@ -135,23 +135,31 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]:
rejections *before* the high-level path (auth, not-found, middleware vetoes) rejections *before* the high-level path (auth, not-found, middleware vetoes)
that would otherwise produce no SERVER span at all are recorded here. 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() tracer = get_tracer()
with tracer.start_as_current_span( with tracer.start_as_current_span(
method, method,
context=_get_parent_trace_context(), context=_get_parent_trace_context(),
kind=SpanKind.SERVER, kind=SpanKind.SERVER,
attributes=attrs,
) as span: ) as span:
# Reapply: `attributes=attrs` above is what makes on_start hooks and
# the sampler see these values at creation time (the whole point of
# this helper). But OTel's Tracer.start_span builds the span from
# `sampling_result.attributes`, not the `attributes` kwarg directly —
# a custom Sampler whose SamplingResult.attributes defaults to None
# silently drops everything we passed. Reapplying here (additive,
# can't clobber anything a sampler legitimately added) guarantees
# FastMCP's attributes survive regardless of sampler behavior.
if span.is_recording(): if span.is_recording():
span.set_attribute(SEAM_SPAN_MARKER, True) span.set_attributes(attrs)
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) token = _active_seam_span.set(span)
try: try:
yield span yield span
@ -218,7 +226,11 @@ def server_span(
name, name,
context=_get_parent_trace_context(), context=_get_parent_trace_context(),
kind=SpanKind.SERVER, kind=SpanKind.SERVER,
attributes=attrs,
) as span: ) as span:
# Reapply for the same reason as `seam_span`: OTel builds the span
# from `sampling_result.attributes`, which a custom Sampler may not
# forward even though it was handed `attributes=attrs` above.
if span.is_recording(): if span.is_recording():
span.set_attributes(attrs) span.set_attributes(attrs)
try: try:
@ -240,15 +252,19 @@ def delegate_span(
Used by FastMCPProvider when delegating to mounted servers. Used by FastMCPProvider when delegating to mounted servers.
Automatically records any exception on the span and sets error status. 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() tracer = get_tracer()
with tracer.start_as_current_span(f"delegate {name}") as span: with tracer.start_as_current_span(f"delegate {name}", attributes=attrs) as span:
# Reapply for the same reason as `seam_span`: OTel builds the span
# from `sampling_result.attributes`, which a custom Sampler may not
# forward even though it was handed `attributes=attrs` above.
if span.is_recording(): 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) span.set_attributes(attrs)
try: try:
yield span yield span

View file

@ -10,13 +10,66 @@ from __future__ import annotations
import pytest import pytest
from mcp_types import TextContent 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.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult
from opentelemetry.trace import StatusCode from opentelemetry.trace import StatusCode
from fastmcp import Client, Context, FastMCP from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams 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 {})
class NonForwardingSampler(Sampler):
"""Samples every span but never forwards the attributes it was handed.
See `tests/telemetry/test_span_attributes.py` for the full explanation:
OTel's `Tracer.start_span` builds the finished span from
`sampling_result.attributes`, not from the `attributes` kwarg passed to
`start_as_current_span`, so a custom sampler like this one reproduces the
regression where a non-forwarding sampler silently drops FastMCP's
attributes.
"""
def should_sample(
self,
parent_context: OTelContext | None,
trace_id: int,
name: str,
kind: object = None,
attributes: object = None,
links: object = None,
trace_state: object = None,
) -> SamplingResult:
return SamplingResult(Decision.RECORD_AND_SAMPLE)
def get_description(self) -> str:
return "NonForwardingSampler"
@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): def _spans_named(exporter: InMemorySpanExporter, name: str):
return [s for s in exporter.get_finished_spans() if s.name == name] return [s for s in exporter.get_finished_spans() if s.name == name]
@ -27,7 +80,9 @@ def _exception_events(span):
class TestSamplingCreateMessageSpan: class TestSamplingCreateMessageSpan:
async def test_success_creates_span_with_attributes( async def test_success_creates_span_with_attributes(
self, trace_exporter: InMemorySpanExporter self,
trace_exporter: InMemorySpanExporter,
on_start_recorder: OnStartRecorder,
): ):
def sampling_handler( def sampling_handler(
messages: list[SamplingMessage], messages: list[SamplingMessage],
@ -52,6 +107,10 @@ class TestSamplingCreateMessageSpan:
assert span.attributes is not None assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "sampling/createMessage" assert span.attributes["mcp.method.name"] == "sampling/createMessage"
assert span.attributes["fastmcp.server.name"] == "sampling-server" 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. # Success path must not record any exception.
assert _exception_events(span) == [] assert _exception_events(span) == []
assert span.status.status_code != StatusCode.ERROR assert span.status.status_code != StatusCode.ERROR
@ -93,7 +152,9 @@ class TestSamplingCreateMessageSpan:
class TestSamplingToolSpan: class TestSamplingToolSpan:
async def test_tool_error_span_records_exception_once( 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 from mcp_types import CreateMessageResultWithTools, ToolUseContent
@ -146,7 +207,124 @@ class TestSamplingToolSpan:
assert span.status.status_code == StatusCode.ERROR assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None assert span.attributes is not None
assert span.attributes["gen_ai.tool.name"] == "boom_tool" 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 assert "error.type" in span.attributes
# Tool spans catch-and-convert (no re-raise), so OTel auto-recording # Tool spans catch-and-convert (no re-raise), so OTel auto-recording
# never fires; the manual record_exception must fire exactly once. # never fires; the manual record_exception must fire exactly once.
assert len(_exception_events(span)) == 1 assert len(_exception_events(span)) == 1
class TestAttributesSurviveANonForwardingSampler:
"""Regression: `sampling create_message` and `sampling tool ...` spans
must keep FastMCP's attributes even when the configured Sampler doesn't
forward the `attributes` it was handed to its `SamplingResult`.
"""
@pytest.fixture
def non_forwarding_recorder(
self,
monkeypatch: pytest.MonkeyPatch,
trace_exporter: InMemorySpanExporter,
) -> OnStartRecorder:
recorder = OnStartRecorder()
provider = TracerProvider(sampler=NonForwardingSampler())
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
async def test_create_message_span_keeps_attributes(
self,
trace_exporter: InMemorySpanExporter,
non_forwarding_recorder: OnStartRecorder,
):
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return "sampled-text"
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
async with Client(mcp, sampling_handler=sampling_handler) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(trace_exporter, "sampling create_message")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "sampling/createMessage"
assert span.attributes["fastmcp.server.name"] == "sampling-server"
# The sampler never forwards attributes, so on_start legitimately sees
# none — this documents that limitation rather than asserting around it.
assert non_forwarding_recorder.attributes["sampling create_message"] == {}
async def test_sampling_tool_span_keeps_attributes(
self,
trace_exporter: InMemorySpanExporter,
non_forwarding_recorder: OnStartRecorder,
):
from mcp_types import CreateMessageResultWithTools, ToolUseContent
def echo_tool(text: str) -> str:
return text
call_count = 0
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="echo_tool",
input={"text": "hi"},
)
],
model="test-model",
stop_reason="toolUse",
)
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def driver(context: Context) -> str:
result = await context.sample(messages="go", tools=[echo_tool])
return result.text or ""
async with Client(mcp) as client:
await client.call_tool("driver", {})
spans = _spans_named(trace_exporter, "sampling tool echo_tool")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["gen_ai.tool.name"] == "echo_tool"
assert span.attributes["fastmcp.tool.use_id"] == "call_1"
# The sampler never forwards attributes, so on_start legitimately sees
# none — this documents that limitation rather than asserting around it.
assert non_forwarding_recorder.attributes["sampling tool echo_tool"] == {}

View file

@ -0,0 +1,205 @@
from collections.abc import Callable
from contextlib import AbstractContextManager
import pytest
from opentelemetry.context import Context
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.sdk.trace.sampling import Decision, Sampler, SamplingResult
from opentelemetry.trace import Span as APISpan
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 {})
class NonForwardingSampler(Sampler):
"""Samples every span but never forwards the attributes it was handed.
Mirrors a real-world custom sampler that builds its own `SamplingResult`
without threading through the `attributes` it received the
`attributes` parameter defaults to `None`, so `RECORD_AND_SAMPLE` with no
`attributes` argument reproduces the regression: OTel's `Tracer.start_span`
constructs the span from `sampling_result.attributes`, not from the
`attributes` kwarg passed to `start_as_current_span`.
"""
def should_sample(
self,
parent_context: Context | None,
trace_id: int,
name: str,
kind: object = None,
attributes: object = None,
links: object = None,
trace_state: object = None,
) -> SamplingResult:
return SamplingResult(Decision.RECORD_AND_SAMPLE)
def get_description(self) -> str:
return "NonForwardingSampler"
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",
}
SPAN_HELPER_CASES = [
pytest.param(
lambda: client_span(
"client test",
method="tools/call",
component_key="tool:echo@",
tool_name="echo",
),
"client test",
{
"mcp.method.name": "tools/call",
"fastmcp.component.key": "tool:echo@",
"gen_ai.tool.name": "echo",
},
id="client_span",
),
pytest.param(
lambda: server_span(
"server test",
method="tools/call",
server_name="test-server",
component_type="tool",
component_key="tool:echo@",
tool_name="echo",
),
"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",
},
id="server_span",
),
pytest.param(
lambda: seam_span("initialize test", server_name="test-server"),
"initialize test",
{
"fastmcp.span.seam": True,
"mcp.method.name": "initialize test",
"fastmcp.server.name": "test-server",
},
id="seam_span",
),
pytest.param(
lambda: delegate_span(
"delegate test",
provider_type="LocalProvider",
component_key="tool:echo@",
method="tools/call",
),
"delegate delegate test",
{
"fastmcp.provider.type": "LocalProvider",
"fastmcp.component.key": "tool:echo@",
"mcp.method.name": "tools/call",
},
id="delegate_span",
),
]
@pytest.mark.parametrize(
("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES
)
def test_attributes_survive_a_non_forwarding_sampler(
monkeypatch: pytest.MonkeyPatch,
span_factory: Callable[[], AbstractContextManager[APISpan]],
span_name: str,
expected_attrs: dict[str, object],
) -> None:
"""Regression: a custom Sampler that samples a span but doesn't forward
the `attributes` it was handed must not erase FastMCP's telemetry.
OTel's `Tracer.start_span` builds the finished span from
`sampling_result.attributes`, not from the `attributes` kwarg passed to
`start_as_current_span`. Built-in samplers forward what they're given, but
a custom sampler can legally return `SamplingResult(attributes=None)` and
silently drop everything FastMCP passed in. The span helpers must reapply
their attributes after span creation so this can't happen.
"""
exporter = InMemorySpanExporter()
provider = TracerProvider(sampler=NonForwardingSampler())
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer("test")
monkeypatch.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer)
monkeypatch.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer)
with span_factory():
pass
spans = [s for s in exporter.get_finished_spans() if s.name == span_name]
assert len(spans) == 1
assert dict(spans[0].attributes or {}) == expected_attrs