mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +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
|
|
@ -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 <method>` 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 <method>` 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
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
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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