mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-15 18:19:10 +02:00
469 lines
17 KiB
Python
469 lines
17 KiB
Python
from collections.abc import Callable
|
|
from contextlib import AbstractContextManager
|
|
|
|
import pytest
|
|
from opentelemetry.context import Context
|
|
from opentelemetry.sdk.trace import (
|
|
ReadableSpan,
|
|
Span,
|
|
SpanLimits,
|
|
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 opentelemetry.util import types as otel_types
|
|
|
|
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"
|
|
|
|
|
|
class RedactingSampler(Sampler):
|
|
"""Forwards the attributes it receives, but replaces `mcp.method.name`.
|
|
|
|
Mirrors a real-world sampler that deliberately alters a FastMCP attribute
|
|
(e.g. redacting the method name for privacy) rather than failing to
|
|
forward attributes at all. The restore-missing-attributes helper must
|
|
respect this decision: `mcp.method.name` is present on the span, just not
|
|
with FastMCP's original value, so it must not be overwritten.
|
|
"""
|
|
|
|
def should_sample(
|
|
self,
|
|
parent_context: Context | None,
|
|
trace_id: int,
|
|
name: str,
|
|
kind: object = None,
|
|
attributes: otel_types.Attributes = None,
|
|
links: object = None,
|
|
trace_state: object = None,
|
|
) -> SamplingResult:
|
|
forwarded = dict(attributes or {})
|
|
forwarded["mcp.method.name"] = "REDACTED"
|
|
return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded)
|
|
|
|
def get_description(self) -> str:
|
|
return "RedactingSampler"
|
|
|
|
|
|
class AttributeAddingSampler(Sampler):
|
|
"""Forwards the attributes it receives unchanged and adds its own.
|
|
|
|
Mirrors a sampler that annotates spans with sampling-policy metadata.
|
|
Both the sampler's own attribute and FastMCP's attributes must survive.
|
|
"""
|
|
|
|
def should_sample(
|
|
self,
|
|
parent_context: Context | None,
|
|
trace_id: int,
|
|
name: str,
|
|
kind: object = None,
|
|
attributes: otel_types.Attributes = None,
|
|
links: object = None,
|
|
trace_state: object = None,
|
|
) -> SamplingResult:
|
|
forwarded = dict(attributes or {})
|
|
forwarded["sampling.policy"] = "always_on"
|
|
return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded)
|
|
|
|
def get_description(self) -> str:
|
|
return "AttributeAddingSampler"
|
|
|
|
|
|
class FilteringSampler(Sampler):
|
|
"""Discards every attribute it receives and substitutes its own.
|
|
|
|
Mirrors a real-world sampler that strips component names or resource
|
|
URIs for privacy or cardinality control by returning a `SamplingResult`
|
|
with only its own attribute, ignoring what it was handed entirely. None
|
|
of FastMCP's attributes may survive on the span, and the restore helper
|
|
must not reintroduce them — that would defeat the filter.
|
|
"""
|
|
|
|
def should_sample(
|
|
self,
|
|
parent_context: Context | None,
|
|
trace_id: int,
|
|
name: str,
|
|
kind: object = None,
|
|
attributes: otel_types.Attributes = None,
|
|
links: object = None,
|
|
trace_state: object = None,
|
|
) -> SamplingResult:
|
|
return SamplingResult(
|
|
Decision.RECORD_AND_SAMPLE, attributes={"sampling.policy": "filtered"}
|
|
)
|
|
|
|
def get_description(self) -> str:
|
|
return "FilteringSampler"
|
|
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES
|
|
)
|
|
def test_redacted_attribute_survives_a_redacting_sampler(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
span_factory: Callable[[], AbstractContextManager[APISpan]],
|
|
span_name: str,
|
|
expected_attrs: dict[str, object],
|
|
) -> None:
|
|
"""A sampler that deliberately replaces one of FastMCP's attributes (e.g.
|
|
redacting `mcp.method.name` for privacy) must have that decision survive.
|
|
|
|
Restoring must only fill in attributes the sampler dropped, never
|
|
overwrite attributes the sampler kept and intentionally changed.
|
|
"""
|
|
exporter = InMemorySpanExporter()
|
|
provider = TracerProvider(sampler=RedactingSampler())
|
|
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
|
|
attrs = dict(spans[0].attributes or {})
|
|
|
|
# The redaction must survive — not be clobbered by a blanket reapply.
|
|
assert attrs["mcp.method.name"] == "REDACTED"
|
|
|
|
# Every other FastMCP attribute the sampler forwarded unchanged is
|
|
# untouched, and any it dropped are still restored.
|
|
for key, value in expected_attrs.items():
|
|
if key == "mcp.method.name":
|
|
continue
|
|
assert attrs[key] == value
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES
|
|
)
|
|
def test_sampler_added_attribute_survives_alongside_fastmcp_attributes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
span_factory: Callable[[], AbstractContextManager[APISpan]],
|
|
span_name: str,
|
|
expected_attrs: dict[str, object],
|
|
) -> None:
|
|
"""A sampler that forwards attributes unchanged and adds its own must
|
|
keep both: its own attribute and every FastMCP attribute."""
|
|
exporter = InMemorySpanExporter()
|
|
provider = TracerProvider(sampler=AttributeAddingSampler())
|
|
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
|
|
attrs = dict(spans[0].attributes or {})
|
|
|
|
assert attrs["sampling.policy"] == "always_on"
|
|
for key, value in expected_attrs.items():
|
|
assert attrs[key] == value
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES
|
|
)
|
|
def test_filtered_attributes_are_not_restored(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
span_factory: Callable[[], AbstractContextManager[APISpan]],
|
|
span_name: str,
|
|
expected_attrs: dict[str, object],
|
|
) -> None:
|
|
"""Regression: a sampler that intentionally supplies only its own
|
|
attributes (e.g. to strip component names or resource URIs for privacy
|
|
or cardinality control) must not have FastMCP's attributes restored.
|
|
|
|
A gate keyed off "none of our keys are present" can't tell this apart
|
|
from a bare non-forwarding sampler — both leave none of FastMCP's keys
|
|
on the span — so it would restore everything and defeat the filter. The
|
|
fix keys off the span having no attributes at all: a filtering sampler
|
|
leaves the span non-empty (its own attribute is there), which a bare
|
|
non-forwarding sampler never does.
|
|
"""
|
|
exporter = InMemorySpanExporter()
|
|
provider = TracerProvider(sampler=FilteringSampler())
|
|
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
|
|
attrs = dict(spans[0].attributes or {})
|
|
|
|
assert attrs == {"sampling.policy": "filtered"}
|
|
for key in expected_attrs:
|
|
assert key not in attrs
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES
|
|
)
|
|
def test_restore_does_not_churn_sdk_attribute_limit_evictions(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
span_factory: Callable[[], AbstractContextManager[APISpan]],
|
|
span_name: str,
|
|
expected_attrs: dict[str, object],
|
|
) -> None:
|
|
"""Regression: under a low `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`, restoring
|
|
must not reinsert a key the SDK's bounded attribute map already evicted.
|
|
|
|
An evicted key is indistinguishable from a sampler-omitted one from
|
|
inside the restore helper, so a per-key "reinsert what's missing"
|
|
strategy would cycle an evicted key back onto the span, which evicts a
|
|
*different* retained key and inflates `dropped_attributes` beyond what
|
|
the SDK's own eviction already cost. The fix gates the restore on the
|
|
span having no attributes at all (plus `dropped_attributes == 0`), so a
|
|
normal forwarding sampler colliding with a low limit is left exactly as
|
|
the SDK computed it — this test proves that by diffing against a
|
|
baseline with the restore step stubbed out entirely.
|
|
"""
|
|
monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "2")
|
|
# SpanLimits reads the env var at construction time, so build it now
|
|
# (after setting the env var) rather than relying on TracerProvider to
|
|
# pick it up implicitly.
|
|
span_limits = SpanLimits()
|
|
assert span_limits.max_span_attributes == 2
|
|
|
|
def run(*, stub_restore: bool) -> ReadableSpan:
|
|
# Each run gets its own MonkeyPatch context so patches from one run
|
|
# (e.g. stubbing the restore step for the baseline) don't leak into
|
|
# the other — both runs must exercise their own code path.
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
exporter = InMemorySpanExporter()
|
|
provider = TracerProvider(span_limits=span_limits)
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
tracer = provider.get_tracer("test")
|
|
mp.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer)
|
|
mp.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer)
|
|
if stub_restore:
|
|
mp.setattr(
|
|
"fastmcp.client.telemetry.restore_dropped_attributes",
|
|
lambda span, attrs: None,
|
|
)
|
|
mp.setattr(
|
|
"fastmcp.server.telemetry.restore_dropped_attributes",
|
|
lambda span, attrs: None,
|
|
)
|
|
|
|
with span_factory():
|
|
pass
|
|
|
|
spans = [s for s in exporter.get_finished_spans() if s.name == span_name]
|
|
assert len(spans) == 1
|
|
return spans[0]
|
|
|
|
baseline = run(stub_restore=True)
|
|
# The whole test is moot if the limit didn't actually bind.
|
|
assert baseline.dropped_attributes > 0
|
|
|
|
with_restore = run(stub_restore=False)
|
|
|
|
assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {})
|
|
assert with_restore.dropped_attributes == baseline.dropped_attributes
|