From 75b9f9250443f1808fabe1193a1bf915cf6aef7e Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Mon, 27 Jul 2026 15:05:29 -0500 Subject: [PATCH] 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 Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- dev-docs/v4-notes/change-register.md | 8 +- dev-docs/v4-notes/feature-program.md | 2 +- dev-docs/v4-notes/protocol-2026.md | 2 +- docs/more/settings.mdx | 2 +- docs/servers/telemetry.mdx | 45 ++- fastmcp_slim/fastmcp/server/telemetry.py | 56 +++- fastmcp_slim/fastmcp/settings.py | 45 +-- fastmcp_slim/fastmcp/telemetry.py | 87 +++++- tests/server/telemetry/test_server_tracing.py | 12 +- tests/telemetry/test_interop.py | 280 ++++++++++++++++++ 10 files changed, 494 insertions(+), 45 deletions(-) create mode 100644 tests/telemetry/test_interop.py diff --git a/dev-docs/v4-notes/change-register.md b/dev-docs/v4-notes/change-register.md index 771a2234c..89762a6ef 100644 --- a/dev-docs/v4-notes/change-register.md +++ b/dev-docs/v4-notes/change-register.md @@ -176,11 +176,13 @@ SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each *Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`. -### Telemetry on by default, with an explicit off-switch — Absorbed +### Telemetry on by default, with a three-way mode setting — Absorbed -FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. The new `FASTMCP_ENABLE_TELEMETRY` setting (`fastmcp.settings.enable_telemetry`, default `true`) is the explicit off-switch: set it to `false` and `get_tracer()` returns a genuine no-op tracer, so no FastMCP spans are created even when an SDK is configured. The off-switch governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send ` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions. +FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. `FASTMCP_TELEMETRY_MODE` (`fastmcp.settings.telemetry_mode`, default `native`) controls how much is active: `native` emits spans and propagates trace context; `propagation_only` emits no FastMCP spans but still extracts the incoming `_meta` context and attaches it, so downstream spans are parented to the calling trace; `off` is a full pass-through that touches neither spans nor context. The setting governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send ` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a single block for library authors who own the MCP hierarchy for one operation rather than process-wide; it cannot override `off`. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions. -*Verify:* `fastmcp_slim/fastmcp/settings.py` (`enable_telemetry`); `fastmcp_slim/fastmcp/telemetry.py` (`get_tracer` off-switch); `fastmcp_slim/fastmcp/server/telemetry.py` (`get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`. +`propagation_only` is applied at the seam span, which is where the incoming `_meta` parent context is established for the whole request; suppressing only the deeper `server_span` would leave the per-request SERVER span intact and defeat the mode. + +*Verify:* `fastmcp_slim/fastmcp/settings.py` (`telemetry_mode`); `fastmcp_slim/fastmcp/telemetry.py` (`telemetry_mode`, `get_tracer`, `suppress_fastmcp_telemetry`); `fastmcp_slim/fastmcp/server/telemetry.py` (`_propagation_only_span`, `seam_span`, `get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`; `tests/telemetry/test_interop.py`. ### Spec-correct error codes via a central translator — Breaking (wire error code) diff --git a/dev-docs/v4-notes/feature-program.md b/dev-docs/v4-notes/feature-program.md index 4e185d6dd..8c7b9f022 100644 --- a/dev-docs/v4-notes/feature-program.md +++ b/dev-docs/v4-notes/feature-program.md @@ -103,7 +103,7 @@ This workstream also owns the server-side statelessness design holes — `ctx.se A cluster of protocol features tracked for v4. Their statuses have diverged: - **Cache hints — shipped (#4464).** Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`, SEP-2549) stamps every cacheable result, and the FastMCP client honors hints with an opt-in response cache. -- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_ENABLE_TELEMETRY=false` off-switch. +- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_TELEMETRY_MODE` setting (`native` / `propagation_only` / `off`). - **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The server side is a Designed workstream in its own right (see [FastMCP-native extension API](#fastmcp-native-extension-api)). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2). - **Subscriptions — not started.** A `subscriptions/listen` surface backed by a subscription bus. diff --git a/dev-docs/v4-notes/protocol-2026.md b/dev-docs/v4-notes/protocol-2026.md index 1c77683fc..fb3ef5428 100644 --- a/dev-docs/v4-notes/protocol-2026.md +++ b/dev-docs/v4-notes/protocol-2026.md @@ -45,7 +45,7 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026 | **Middleware** | Typed per-method hooks (`on_call_tool`, `on_list_tools`, …) and a suite of built-ins (auth, rate limiting, caching, error handling, logging, timing, and more). | | **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. | | **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. | -| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_ENABLE_TELEMETRY=false` disables cleanly. | +| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_TELEMETRY_MODE` selects `native`, `propagation_only` (interop with an outer MCP instrumentation layer), or `off`. | | **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](background-tasks.md) for the design and [servers/tasks](https://gofastmcp.com/servers/tasks) for usage. | ## Still in the program diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index 0fa4ca0e2..6ea808af7 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -77,7 +77,7 @@ These control how the server listens when running with an HTTP transport. | Environment Variable | Type | Default | Description | |---|---|---|---| -| `FASTMCP_ENABLE_TELEMETRY` | `bool` | `true` | Whether FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry) is active. Enabled by default; FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry SDK and exporter are configured. Set to `false` to turn instrumentation off entirely, in which case no FastMCP spans are created even when an SDK is configured. | +| `FASTMCP_TELEMETRY_MODE` | `Literal["native", "propagation_only", "off"]` | `native` | Controls FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry). `native` emits FastMCP's MCP spans and propagates trace context; because FastMCP uses only the OpenTelemetry API, this costs almost nothing unless an SDK and exporter are configured. `propagation_only` keeps `_meta` trace propagation and still parents downstream spans from the incoming context, but emits none of FastMCP's own spans, so another instrumentation layer can own the MCP span hierarchy. `off` is a full pass-through: no spans, and no trace context extracted or attached. | ## Tasks (Docket) diff --git a/docs/servers/telemetry.mdx b/docs/servers/telemetry.mdx index 2d3a96c30..9573f07bd 100644 --- a/docs/servers/telemetry.mdx +++ b/docs/servers/telemetry.mdx @@ -21,11 +21,21 @@ FastMCP uses the OpenTelemetry API for instrumentation. This means: Because FastMCP only depends on the OpenTelemetry API, span creation is a no-op until you configure an SDK and exporter — so being on by default costs nothing until you opt into collection. -### Turning Telemetry Off +### Telemetry Modes -To disable FastMCP's instrumentation entirely, set `FASTMCP_ENABLE_TELEMETRY=false` (or `fastmcp.settings.enable_telemetry = False`). When disabled, FastMCP creates no spans even if an SDK is configured. +`FASTMCP_TELEMETRY_MODE` (or `fastmcp.settings.telemetry_mode`) controls how much of the instrumentation is active: + +| Mode | FastMCP spans | Trace context | +|---|---|---| +| `native` (default) | Emitted | Propagated | +| `propagation_only` | Suppressed | Propagated | +| `off` | Suppressed | Untouched | + +Use `off` to disable FastMCP's instrumentation entirely. No spans are created even if an SDK is configured, and FastMCP leaves the surrounding OpenTelemetry context exactly as it found it. + +Use `propagation_only` when another instrumentation layer already owns the MCP span hierarchy — see [Interoperability](#interoperability) below. ## Enabling Telemetry @@ -148,6 +158,37 @@ trace.set_tracer_provider(provider) The name check must happen before `ParentBased` delegates. If the name-based sampler is nested inside `ParentBased`, it is not consulted for child spans whose parent was already sampled. +## Interoperability + + + +FastMCP assumes it owns the MCP span hierarchy. When something else already owns it — an MCP-aware OpenTelemetry instrumentation library, or a service mesh that understands the protocol — FastMCP's spans duplicate what that layer already emits, and the same request shows up twice in your traces. + +Setting `propagation_only` resolves the duplication in FastMCP's favor of the other layer: + +```bash +export FASTMCP_TELEMETRY_MODE=propagation_only +``` + +The distinction from `off` matters here. Both emit no FastMCP spans, but `off` is fully transparent, while `propagation_only` still extracts the trace context arriving in `_meta` and attaches it for the duration of the request. Spans created downstream — by your tool handlers, or by the instrumentation layer that owns the hierarchy — are parented to the calling trace rather than starting a new one. Outbound requests still carry `traceparent` and `tracestate` in `_meta`. + +### Suppressing spans for a single block + +Library authors embedding FastMCP inside their own instrumented stack often want to own the hierarchy for one specific operation rather than process-wide. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a block: + +```python +from fastmcp import Client +from fastmcp.telemetry import suppress_fastmcp_telemetry + +async def search(client: Client, query: str): + with suppress_fastmcp_telemetry(): + return await client.call_tool("search", {"query": query}) +``` + +This is narrower than OpenTelemetry's global instrumentation suppression: only FastMCP's spans are skipped, so nested instrumentation for HTTP clients, databases, and everything else keeps emitting normally. + +The context manager has no effect when `telemetry_mode` is already `off`. A request to skip FastMCP's spans cannot re-enable the context propagation that `off` deliberately omits. + ## Programmatic Configuration For more control, configure the SDK in your Python code before importing FastMCP: diff --git a/fastmcp_slim/fastmcp/server/telemetry.py b/fastmcp_slim/fastmcp/server/telemetry.py index 53350e8e8..4d073d45b 100644 --- a/fastmcp_slim/fastmcp/server/telemetry.py +++ b/fastmcp_slim/fastmcp/server/telemetry.py @@ -4,14 +4,23 @@ from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar +from opentelemetry import context as otel_context from opentelemetry.context import Context -from opentelemetry.trace import Span, SpanKind, Status, StatusCode, get_current_span +from opentelemetry.trace import ( + INVALID_SPAN, + Span, + SpanKind, + Status, + StatusCode, + get_current_span, +) from fastmcp.exceptions import ToolError as _ToolError from fastmcp.telemetry import ( extract_trace_context, get_tracer, restore_dropped_attributes, + telemetry_mode, ) # Marker attribute set on the SERVER span opened at the FastMCP middleware seam @@ -87,6 +96,32 @@ def _get_parent_trace_context() -> Context | None: return None +@contextmanager +def _propagation_only_span() -> Generator[Span, None, None]: + """Attach the incoming `_meta` trace context without creating a span. + + This is what separates `propagation_only` from `off`. Both create no + FastMCP spans, but `off` is fully transparent while `propagation_only` + still has to *parent* whatever the request goes on to do: without the + attach here, the trace context carried in `_meta` would be extracted and + then thrown away, and a span created inside a tool handler — by the user or + by the outer instrumentation layer that owns the MCP hierarchy — would + start a brand new trace instead of continuing the caller's. + + Yields `INVALID_SPAN`, which is non-recording, so callers' `is_recording()` + guards skip attribute and error bookkeeping on it. + """ + parent_context = _get_parent_trace_context() + if parent_context is None: + yield INVALID_SPAN + return + token = otel_context.attach(parent_context) + try: + yield INVALID_SPAN + finally: + otel_context.detach(token) + + def _build_server_span_attrs( method: str, server_name: str, @@ -138,7 +173,16 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]: opening a second one. Exceptions raised anywhere below the seam — including rejections *before* the high-level path (auth, not-found, middleware vetoes) that would otherwise produce no SERVER span at all — are recorded here. + + In `propagation_only` mode no span is opened at all — this is the one place + that has to know the difference, because the seam is where the incoming + `_meta` parent context is applied for the whole request. """ + if telemetry_mode() == "propagation_only": + with _propagation_only_span() as span: + yield span + return + attrs = { SEAM_SPAN_MARKER: True, "mcp.method.name": method, @@ -198,7 +242,17 @@ def server_span( new SERVER span as before. Automatically records any exception on the span and sets error status. + + In `propagation_only` mode no span is opened or enriched. The seam has + normally already attached the incoming parent context for this request; + doing it again here is a no-op, and covers the in-process callers that + bypass the dispatcher and so never reach the seam at all. """ + if telemetry_mode() == "propagation_only": + with _propagation_only_span() as span: + yield span + return + attrs = _build_server_span_attrs( method, server_name, diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index 352b383e2..d442d7218 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -20,6 +20,8 @@ ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +TELEMETRY_MODE = Literal["native", "propagation_only", "off"] + MCP_LOG_LEVEL = Literal[ "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" ] @@ -104,24 +106,6 @@ class Settings(BaseSettings): ), ] = True - enable_telemetry: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - Whether FastMCP's native OpenTelemetry instrumentation is active. - Enabled by default: FastMCP uses only the OpenTelemetry API, so - span creation is a no-op with negligible overhead unless an - OpenTelemetry SDK and exporter are configured. Set to False to - turn instrumentation off entirely, in which case FastMCP's span - helpers become a transparent pass-through: no FastMCP spans are - created even when an SDK is configured, and the surrounding OTel - trace context is left untouched. - """ - ) - ), - ] = True - deprecation_warnings: Annotated[ bool, Field( @@ -167,6 +151,31 @@ class Settings(BaseSettings): ), ] = True + telemetry_mode: Annotated[ + TELEMETRY_MODE, + Field( + description=inspect.cleandoc( + """ + Controls FastMCP's native OpenTelemetry instrumentation. + + - `native` (default): FastMCP creates MCP spans and propagates + trace context through request `_meta`. FastMCP uses only the + OpenTelemetry API, so span creation is a no-op with negligible + overhead unless an SDK and exporter are configured. + - `propagation_only`: FastMCP still injects and extracts trace + context, and still parents downstream spans from the incoming + `_meta` context, but creates none of its own MCP spans. Use + this when another instrumentation layer owns the MCP span + hierarchy and FastMCP's spans would duplicate it. + - `off`: FastMCP's span helpers become a transparent + pass-through. No spans are created even when an SDK is + configured, and the surrounding OTel context is left + untouched — no trace context is extracted or attached. + """ + ), + ), + ] = "native" + client_init_timeout: Annotated[ float | None, Field( diff --git a/fastmcp_slim/fastmcp/telemetry.py b/fastmcp_slim/fastmcp/telemetry.py index dc0edf32a..cf379818b 100644 --- a/fastmcp_slim/fastmcp/telemetry.py +++ b/fastmcp_slim/fastmcp/telemetry.py @@ -23,7 +23,7 @@ Example usage with SDK: from collections.abc import Iterator, Mapping from contextlib import contextmanager -from typing import Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from opentelemetry import context as otel_context from opentelemetry import propagate, trace @@ -40,6 +40,9 @@ from opentelemetry.trace import ( from opentelemetry.trace import get_tracer as otel_get_tracer from opentelemetry.util import types as otel_types +if TYPE_CHECKING: + from fastmcp.settings import TELEMETRY_MODE as TelemetryMode + INSTRUMENTATION_NAME = "fastmcp" TRACE_PARENT_KEY = "traceparent" @@ -77,28 +80,71 @@ class _DisabledTracer(NoOpTracer): _DISABLED_TRACER = _DisabledTracer() +_SUPPRESS_KEY = otel_context.create_key("fastmcp_suppress_telemetry") + + +def telemetry_mode() -> "TelemetryMode": + """Resolve the effective telemetry mode for the current context. + + This is `fastmcp.settings.telemetry_mode`, except that an active + `suppress_fastmcp_telemetry()` block downgrades `native` to + `propagation_only`. Suppression never upgrades or overrides `off`: `off` + means FastMCP touches nothing, and a narrower request to skip FastMCP's + spans cannot re-enable the context propagation `off` deliberately omits. + """ + import fastmcp + + mode: TelemetryMode = fastmcp.settings.telemetry_mode + if mode == "native" and otel_context.get_value(_SUPPRESS_KEY): + return "propagation_only" + return mode + + +def native_spans_enabled() -> bool: + """Whether FastMCP should create its own spans right now.""" + return telemetry_mode() == "native" + + +@contextmanager +def suppress_fastmcp_telemetry() -> Iterator[None]: + """Suppress FastMCP's own spans without disabling trace propagation. + + Scoped equivalent of `telemetry_mode="propagation_only"`, for callers that + embed FastMCP inside their own instrumented stack and want to own the MCP + span hierarchy for a specific block. Narrower than OpenTelemetry's global + instrumentation suppression: only FastMCP's spans are skipped, so nested + instrumentation (HTTP clients, databases) keeps emitting, and trace context + still flows through `_meta` so those spans are parented correctly. + + Has no effect when `telemetry_mode` is already `off`. + """ + token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True)) + try: + yield + finally: + otel_context.detach(token) + def get_tracer(version: str | None = None) -> Tracer: """Get the FastMCP tracer for creating spans. Instrumentation is on by default. FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry - SDK and exporter are configured. Set `fastmcp.settings.enable_telemetry` to - False (env `FASTMCP_ENABLE_TELEMETRY=false`) to turn instrumentation off - entirely, in which case this returns a pass-through tracer that leaves the - current OTel context untouched even when an SDK is configured. + SDK and exporter are configured. When `fastmcp.settings.telemetry_mode` is + `propagation_only` or `off` — or the caller is inside a + `suppress_fastmcp_telemetry()` block — this returns a pass-through tracer + that creates no spans and leaves the current OTel context untouched even + when an SDK is configured. Args: version: Optional version string for the instrumentation Returns: - A tracer instance. Returns a non-attaching pass-through tracer if - telemetry is disabled; span creation is otherwise a no-op unless an SDK - is configured. + A tracer instance. Returns a non-attaching pass-through tracer when + FastMCP's own spans are disabled; span creation is otherwise a no-op + unless an SDK is configured. """ - import fastmcp - - if not fastmcp.settings.enable_telemetry: + if not native_spans_enabled(): return _DISABLED_TRACER return otel_get_tracer(INSTRUMENTATION_NAME, version) @@ -115,6 +161,11 @@ def inject_trace_context( A new dict containing the original meta (if any) plus trace context keys, or None if no trace context to inject and meta was None """ + # `off` means FastMCP touches nothing, outbound propagation included. + # `propagation_only` still injects — carrying context is the whole point. + if telemetry_mode() == "off": + return meta + carrier: dict[str, str] = {} propagate.inject(carrier) @@ -222,6 +273,10 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context: An OpenTelemetry Context with the extracted trace context, or the current context if no trace context found or already in a trace """ + # `off` means FastMCP touches nothing, including the surrounding context. + if telemetry_mode() == "off": + return otel_context.get_current() + # Don't override existing trace context (e.g., from HTTP propagation) current_span = trace.get_current_span() if current_span.get_span_context().is_valid: @@ -237,7 +292,12 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context: carrier["tracestate"] = str(meta[TRACE_STATE_KEY]) if carrier: - return propagate.extract(carrier) + # Extract *onto the current context* rather than a fresh root, so the + # incoming parent is added without discarding context values the + # caller already established — active baggage, and FastMCP's own + # suppression marker, which would otherwise be dropped the moment the + # extracted context is attached. + return propagate.extract(carrier, context=otel_context.get_current()) return otel_context.get_current() @@ -248,6 +308,9 @@ __all__ = [ "extract_trace_context", "get_tracer", "inject_trace_context", + "native_spans_enabled", "record_span_error", "restore_dropped_attributes", + "suppress_fastmcp_telemetry", + "telemetry_mode", ] diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index 3454d93ab..252d92b70 100644 --- a/tests/server/telemetry/test_server_tracing.py +++ b/tests/server/telemetry/test_server_tracing.py @@ -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] = {} diff --git a/tests/telemetry/test_interop.py b/tests/telemetry/test_interop.py new file mode 100644 index 000000000..5befb5811 --- /dev/null +++ b/tests/telemetry/test_interop.py @@ -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) != []