mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
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.
205 lines
6.8 KiB
Python
205 lines
6.8 KiB
Python
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
|