mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
feat: Add telemetry interop mode for FastMCP (#4046)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
parent
c3cbe8b9a3
commit
75b9f92504
10 changed files with 494 additions and 45 deletions
|
|
@ -587,14 +587,14 @@ class TestTelemetryEnabledByDefault:
|
|||
"""Instrumentation is on by default and controllable via the off-switch.
|
||||
|
||||
FastMCP uses only the OpenTelemetry API, so spans are created unconditionally
|
||||
and light up when an SDK is configured. `FASTMCP_ENABLE_TELEMETRY=false`
|
||||
(`fastmcp.settings.enable_telemetry`) turns span creation off entirely, so no
|
||||
and light up when an SDK is configured. `FASTMCP_TELEMETRY_MODE=off`
|
||||
(`fastmcp.settings.telemetry_mode`) turns span creation off entirely, so no
|
||||
FastMCP spans are exported even with an SDK configured.
|
||||
"""
|
||||
|
||||
async def test_spans_fire_by_default(self, trace_exporter: InMemorySpanExporter):
|
||||
"""No opt-in required: a tool call produces a span out of the box."""
|
||||
assert fastmcp.settings.enable_telemetry is True
|
||||
assert fastmcp.settings.telemetry_mode == "native"
|
||||
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
|
|
@ -615,7 +615,7 @@ class TestTelemetryEnabledByDefault:
|
|||
):
|
||||
"""With telemetry disabled, no spans are created even with an SDK
|
||||
configured (the exporter fixture installs one)."""
|
||||
monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False)
|
||||
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off")
|
||||
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
|
|
@ -641,7 +641,7 @@ class TestTelemetryEnabledByDefault:
|
|||
are governed by the user's OpenTelemetry SDK, not FastMCP's off-switch,
|
||||
so they may still appear — the assertion filters them out.
|
||||
"""
|
||||
monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False)
|
||||
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off")
|
||||
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
|
|
@ -676,7 +676,7 @@ class TestTelemetryEnabledByDefault:
|
|||
`trace.get_current_span()` inside a handler must still return the
|
||||
caller's enclosing span, and attributes written there must land on it.
|
||||
"""
|
||||
monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False)
|
||||
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off")
|
||||
|
||||
tracer = trace.get_tracer("test-enclosing")
|
||||
captured: dict[str, Span] = {}
|
||||
|
|
|
|||
280
tests/telemetry/test_interop.py
Normal file
280
tests/telemetry/test_interop.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""Tests for telemetry interoperability modes.
|
||||
|
||||
Validates that FastMCP's own spans can be suppressed — globally via
|
||||
`telemetry_mode` or per-block via `suppress_fastmcp_telemetry()` — while trace
|
||||
context propagation keeps working in `propagation_only` mode and is fully
|
||||
disabled in `off` mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from opentelemetry import context as otel_context
|
||||
from opentelemetry import trace as otel_trace
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from opentelemetry.trace import INVALID_SPAN, SpanKind
|
||||
|
||||
import fastmcp
|
||||
from fastmcp import Client, Context, FastMCP
|
||||
from fastmcp.client.telemetry import client_span
|
||||
from fastmcp.server.telemetry import delegate_span, server_span
|
||||
from fastmcp.telemetry import (
|
||||
extract_trace_context,
|
||||
inject_trace_context,
|
||||
native_spans_enabled,
|
||||
suppress_fastmcp_telemetry,
|
||||
telemetry_mode,
|
||||
)
|
||||
|
||||
# A well-formed W3C traceparent for extraction tests.
|
||||
TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mode(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Set `fastmcp.settings.telemetry_mode` for the duration of a test."""
|
||||
|
||||
def _set(value: str) -> None:
|
||||
monkeypatch.setattr(fastmcp.settings, "telemetry_mode", value)
|
||||
|
||||
return _set
|
||||
|
||||
|
||||
def fastmcp_spans(exporter: InMemorySpanExporter) -> list[str]:
|
||||
"""Names of spans emitted by FastMCP's own instrumentation scope."""
|
||||
return [
|
||||
s.name
|
||||
for s in exporter.get_finished_spans()
|
||||
if s.instrumentation_scope is not None
|
||||
and s.instrumentation_scope.name == "fastmcp"
|
||||
]
|
||||
|
||||
|
||||
class TestTelemetryModeResolution:
|
||||
def test_native_by_default(self):
|
||||
assert telemetry_mode() == "native"
|
||||
assert native_spans_enabled()
|
||||
|
||||
@pytest.mark.parametrize("value", ["propagation_only", "off"])
|
||||
def test_setting_disables_native_spans(self, value: str, mode):
|
||||
mode(value)
|
||||
assert telemetry_mode() == value
|
||||
assert not native_spans_enabled()
|
||||
|
||||
def test_suppress_downgrades_native_to_propagation_only(self):
|
||||
with suppress_fastmcp_telemetry():
|
||||
assert telemetry_mode() == "propagation_only"
|
||||
assert not native_spans_enabled()
|
||||
assert telemetry_mode() == "native"
|
||||
|
||||
def test_suppress_cannot_override_off(self, mode):
|
||||
"""`off` means FastMCP touches nothing. A narrower request to skip
|
||||
FastMCP's spans must not re-enable the propagation `off` omits."""
|
||||
mode("off")
|
||||
with suppress_fastmcp_telemetry():
|
||||
assert telemetry_mode() == "off"
|
||||
|
||||
def test_suppress_nests(self):
|
||||
with suppress_fastmcp_telemetry():
|
||||
with suppress_fastmcp_telemetry():
|
||||
assert not native_spans_enabled()
|
||||
# Outer suppression still active after the inner block exits.
|
||||
assert not native_spans_enabled()
|
||||
assert native_spans_enabled()
|
||||
|
||||
def test_suppress_restores_on_exception(self):
|
||||
with pytest.raises(RuntimeError):
|
||||
with suppress_fastmcp_telemetry():
|
||||
raise RuntimeError("boom")
|
||||
assert native_spans_enabled()
|
||||
|
||||
|
||||
class TestSpanHelperSuppression:
|
||||
"""Every FastMCP span helper goes quiet when its own spans are disabled."""
|
||||
|
||||
@pytest.fixture
|
||||
def helpers(self):
|
||||
return {
|
||||
"server": lambda: server_span(
|
||||
name="test_op",
|
||||
method="tools/call",
|
||||
server_name="test-server",
|
||||
component_type="tool",
|
||||
component_key="tool://test",
|
||||
),
|
||||
"client": lambda: client_span(
|
||||
name="test_client",
|
||||
method="tools/call",
|
||||
component_key="tool://test",
|
||||
),
|
||||
"delegate": lambda: delegate_span(
|
||||
name="test_delegate",
|
||||
provider_type="FastMCPProvider",
|
||||
component_key="tool://test",
|
||||
),
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("helper", ["server", "client", "delegate"])
|
||||
@pytest.mark.parametrize("value", ["propagation_only", "off"])
|
||||
def test_helper_emits_nothing(
|
||||
self,
|
||||
helper: str,
|
||||
value: str,
|
||||
helpers,
|
||||
mode,
|
||||
trace_exporter: InMemorySpanExporter,
|
||||
):
|
||||
mode(value)
|
||||
with helpers[helper]() as span:
|
||||
assert span is INVALID_SPAN
|
||||
assert trace_exporter.get_finished_spans() == ()
|
||||
|
||||
@pytest.mark.parametrize("helper", ["server", "client", "delegate"])
|
||||
def test_helper_emits_nothing_under_suppress(
|
||||
self, helper: str, helpers, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
with suppress_fastmcp_telemetry():
|
||||
with helpers[helper]() as span:
|
||||
assert span is INVALID_SPAN
|
||||
assert trace_exporter.get_finished_spans() == ()
|
||||
|
||||
@pytest.mark.parametrize("helper", ["server", "client", "delegate"])
|
||||
def test_helper_emits_by_default(
|
||||
self, helper: str, helpers, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
with helpers[helper]():
|
||||
pass
|
||||
assert len(trace_exporter.get_finished_spans()) == 1
|
||||
|
||||
|
||||
class TestContextPropagation:
|
||||
"""`propagation_only` keeps trace context flowing; `off` does not."""
|
||||
|
||||
def test_extract_preserves_current_context_values(self):
|
||||
"""Regression: extracting the incoming traceparent must not discard
|
||||
context values the caller already established. Extracting onto a fresh
|
||||
root would drop FastMCP's own suppression marker (and any baggage), so
|
||||
attaching the result would silently re-enable FastMCP's spans.
|
||||
"""
|
||||
with suppress_fastmcp_telemetry():
|
||||
parent = extract_trace_context({"traceparent": TRACEPARENT})
|
||||
token = otel_context.attach(parent)
|
||||
try:
|
||||
assert telemetry_mode() == "propagation_only"
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
|
||||
def test_extract_applies_incoming_parent(self, mode):
|
||||
mode("propagation_only")
|
||||
parent = extract_trace_context({"traceparent": TRACEPARENT})
|
||||
token = otel_context.attach(parent)
|
||||
try:
|
||||
span_context = otel_trace.get_current_span().get_span_context()
|
||||
assert format(span_context.trace_id, "032x") == (
|
||||
"4bf92f3577b34da6a3ce929d0e0e4736"
|
||||
)
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
|
||||
def test_off_ignores_incoming_parent(self, mode):
|
||||
"""`off` is a full pass-through: the incoming context is not applied."""
|
||||
mode("off")
|
||||
parent = extract_trace_context({"traceparent": TRACEPARENT})
|
||||
assert parent is otel_context.get_current()
|
||||
|
||||
def test_off_does_not_inject(self, mode, trace_exporter: InMemorySpanExporter):
|
||||
mode("off")
|
||||
with otel_trace.get_tracer("test").start_as_current_span("root"):
|
||||
assert inject_trace_context({"existing": 1}) == {"existing": 1}
|
||||
|
||||
def test_propagation_only_still_injects(
|
||||
self, mode, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
mode("propagation_only")
|
||||
with otel_trace.get_tracer("test").start_as_current_span("root"):
|
||||
meta = inject_trace_context()
|
||||
assert meta is not None and "traceparent" in meta
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
"""A real in-process client drives a real server — nothing monkeypatched
|
||||
beyond the setting itself."""
|
||||
|
||||
async def test_propagation_only_parents_downstream_user_spans(
|
||||
self, mode, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
mode("propagation_only")
|
||||
captured: dict[str, int] = {}
|
||||
|
||||
server = FastMCP("interop-server")
|
||||
|
||||
@server.tool
|
||||
async def work(ctx: Context) -> str:
|
||||
# A span the *user* creates inside their handler.
|
||||
tracer = otel_trace.get_tracer("user-code")
|
||||
with tracer.start_as_current_span("user-span") as span:
|
||||
captured["downstream"] = span.get_span_context().trace_id
|
||||
return "done"
|
||||
|
||||
async with Client(server) as client:
|
||||
tracer = otel_trace.get_tracer("client-code")
|
||||
with tracer.start_as_current_span("client-root") as root:
|
||||
captured["client"] = root.get_span_context().trace_id
|
||||
await client.call_tool("work", {})
|
||||
|
||||
names = [s.name for s in trace_exporter.get_finished_spans()]
|
||||
assert "user-span" in names and "client-root" in names
|
||||
# FastMCP emitted none of its own spans — including the per-request
|
||||
# SERVER span opened at the middleware seam, which is the whole point.
|
||||
assert fastmcp_spans(trace_exporter) == []
|
||||
assert [
|
||||
s for s in trace_exporter.get_finished_spans() if s.kind == SpanKind.SERVER
|
||||
] == []
|
||||
# ...yet the user's span inherited the incoming distributed trace.
|
||||
assert captured["client"] == captured["downstream"]
|
||||
|
||||
async def test_propagation_only_without_incoming_trace(
|
||||
self, mode, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""With no surrounding client span there is no incoming trace. The call
|
||||
must still succeed and emit no FastMCP spans."""
|
||||
mode("propagation_only")
|
||||
captured: dict[str, int] = {}
|
||||
|
||||
server = FastMCP("interop-server")
|
||||
|
||||
@server.tool
|
||||
async def work(ctx: Context) -> str:
|
||||
tracer = otel_trace.get_tracer("user-code")
|
||||
with tracer.start_as_current_span("user-span") as span:
|
||||
captured["downstream"] = span.get_span_context().trace_id
|
||||
return "done"
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("work", {})
|
||||
|
||||
assert result.data == "done"
|
||||
assert fastmcp_spans(trace_exporter) == []
|
||||
# A self-rooted trace was created (no incoming parent to inherit).
|
||||
assert "downstream" in captured
|
||||
|
||||
async def test_suppress_block_silences_a_single_call(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""The scoped form suppresses one call and leaves the next instrumented."""
|
||||
server = FastMCP("interop-server")
|
||||
|
||||
@server.tool
|
||||
async def work() -> str:
|
||||
return "done"
|
||||
|
||||
async with Client(server) as client:
|
||||
# Drop the spans the connection handshake already emitted.
|
||||
trace_exporter.clear()
|
||||
|
||||
with suppress_fastmcp_telemetry():
|
||||
await client.call_tool("work", {})
|
||||
assert fastmcp_spans(trace_exporter) == []
|
||||
|
||||
await client.call_tool("work", {})
|
||||
assert fastmcp_spans(trace_exporter) != []
|
||||
Loading…
Add table
Add a link
Reference in a new issue