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:
Bill Easton 2026-07-27 15:05:29 -05:00 committed by GitHub
commit 75b9f92504
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 494 additions and 45 deletions

View file

@ -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] = {}

View 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) != []