Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/more/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env
| `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. |
| `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. |

## Telemetry

| Environment Variable | Type | Default | Description |
|---|---|---|---|
| `FASTMCP_TELEMETRY_MODE` | `Literal["native", "propagation_only"]` | `native` | Controls FastMCP's native OpenTelemetry span creation. `native` emits FastMCP MCP spans and propagates trace context. `propagation_only` keeps `_meta` trace propagation but suppresses FastMCP's own spans so another instrumentation layer can own the MCP span hierarchy. |

## Transport & HTTP

These control how the server listens when running with an HTTP transport.
Expand Down
27 changes: 24 additions & 3 deletions docs/servers/telemetry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ FastMCP includes native OpenTelemetry instrumentation for observability. Traces

FastMCP uses the OpenTelemetry API for instrumentation. This means:

- **Zero configuration required** - Instrumentation is always active
- **Zero configuration required** - Native FastMCP instrumentation is available by default
- **No overhead when unused** - Without an SDK, all operations are no-ops
- **Bring your own SDK** - You control collection, export, and sampling
- **Works with any OTEL backend** - Jaeger, Zipkin, Datadog, New Relic, etc.

FastMCP also propagates OpenTelemetry context through MCP `params._meta`, including `traceparent` and `tracestate` when present.

## Enabling Telemetry

The easiest way to export traces is using `opentelemetry-instrument`, which configures the SDK automatically:
Expand Down Expand Up @@ -52,11 +54,11 @@ This works with any OTLP-compatible backend (Jaeger, Zipkin, Grafana Tempo, Data

## Tracing

FastMCP creates spans for all MCP operations, providing end-to-end visibility into request handling.
FastMCP creates spans for the MCP operations it currently instruments, providing end-to-end visibility into request handling.

### Server Spans

The server creates spans for each operation using [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):
The server creates spans for each supported operation using [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):

| Span Name | Description |
|-----------|-------------|
Expand Down Expand Up @@ -119,6 +121,25 @@ def greet(name: str) -> str:
The SDK must be configured **before** importing FastMCP to ensure the tracer provider is set when FastMCP initializes.
</Tip>

## Interoperability Mode

If another MCP-aware instrumentation layer should own the MCP spans, switch FastMCP to propagation-only mode:

```bash
export FASTMCP_TELEMETRY_MODE=propagation_only
```

In this mode, FastMCP still injects and extracts trace context through MCP `_meta`, but it stops creating its own MCP request spans and `delegate` spans.

Library authors can suppress only FastMCP's native spans programmatically without disabling unrelated nested instrumentation:

```python
from fastmcp.telemetry import suppress_fastmcp_telemetry

with suppress_fastmcp_telemetry():
result = await client.call_tool("search", {"query": "otel"})
```

### Local Development

For quick local trace visualization, [otel-desktop-viewer](https://github.com/CtrlSpice/otel-desktop-viewer) is a lightweight single-binary tool:
Expand Down
6 changes: 5 additions & 1 deletion fastmcp_slim/fastmcp/client/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from opentelemetry.trace import Span, SpanKind, Status, StatusCode

from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import get_tracer
from fastmcp.telemetry import get_noop_span, get_tracer, native_telemetry_enabled


@contextmanager
Expand All @@ -23,6 +23,10 @@ def client_span(

Automatically records any exception on the span and sets error status.
"""
if not native_telemetry_enabled():
yield get_noop_span()
return

tracer = get_tracer()
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span:
if span.is_recording():
Expand Down
26 changes: 25 additions & 1 deletion fastmcp_slim/fastmcp/server/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
from contextlib import contextmanager

from mcp.server.lowlevel.server import request_ctx
from opentelemetry import context as otel_context
from opentelemetry.context import Context
from opentelemetry.trace import Span, SpanKind, Status, StatusCode

from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import extract_trace_context, get_tracer
from fastmcp.telemetry import (
extract_trace_context,
get_noop_span,
get_tracer,
native_telemetry_enabled,
)


def get_auth_span_attributes() -> dict[str, str]:
Expand Down Expand Up @@ -67,7 +73,21 @@ def server_span(
"""Create a SERVER span with standard MCP attributes and auth context.

Automatically records any exception on the span and sets error status.
When native telemetry is suppressed, incoming trace context is still
propagated so downstream user spans inherit the correct parent.
"""
if not native_telemetry_enabled():
parent_context = _get_parent_trace_context()
if parent_context is not None:
token = otel_context.attach(parent_context)
Comment thread
strawgate marked this conversation as resolved.
try:
yield get_noop_span()
finally:
otel_context.detach(token)
else:
yield get_noop_span()
return
Comment thread
strawgate marked this conversation as resolved.

tracer = get_tracer()
with tracer.start_as_current_span(
name,
Expand Down Expand Up @@ -117,6 +137,10 @@ def delegate_span(
Used by FastMCPProvider when delegating to mounted servers.
Automatically records any exception on the span and sets error status.
"""
if not native_telemetry_enabled():
yield get_noop_span()
return

tracer = get_tracer()
with tracer.start_as_current_span(f"delegate {name}") as span:
if span.is_recording():
Expand Down
19 changes: 19 additions & 0 deletions fastmcp_slim/fastmcp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]

TELEMETRY_MODE = Literal["native", "propagation_only"]

MCP_LOG_LEVEL = Literal[
"debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"
]
Expand Down Expand Up @@ -239,6 +241,23 @@ def normalize_log_level(cls, v):
),
] = True

telemetry_mode: Annotated[
TELEMETRY_MODE,
Field(
description=inspect.cleandoc(
"""
Controls FastMCP's native OpenTelemetry span creation.

- ``native`` (default): FastMCP creates MCP spans and propagates
trace context in request ``_meta``.
- ``propagation_only``: FastMCP still injects/extracts trace
context, but does not create its own MCP spans. Use this when
another instrumentation layer owns the MCP span hierarchy.
"""
),
),
] = "native"

client_init_timeout: Annotated[
float | None,
Field(
Expand Down
61 changes: 56 additions & 5 deletions fastmcp_slim/fastmcp/telemetry.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
"""OpenTelemetry instrumentation for FastMCP.

This module provides native OpenTelemetry integration for FastMCP servers and clients.
It uses only the opentelemetry-api package, so telemetry is a no-op unless the user
installs an OpenTelemetry SDK and configures exporters.
This module provides native OpenTelemetry integration for FastMCP servers and
clients. It uses only the opentelemetry-api package, so telemetry is a no-op
unless the user installs an OpenTelemetry SDK and configures exporters.

FastMCP always propagates OpenTelemetry context through MCP ``params._meta``.
Native FastMCP spans can be suppressed globally via
``FASTMCP_TELEMETRY_MODE=propagation_only`` or programmatically with
``suppress_fastmcp_telemetry()`` when another instrumentation layer owns the
MCP span hierarchy.

Example usage with SDK:
```python
Expand All @@ -21,19 +27,58 @@
```
"""

from collections.abc import Generator
from contextlib import contextmanager
from typing import Any

from opentelemetry import context as otel_context
from opentelemetry import propagate, trace
from opentelemetry.context import Context
from opentelemetry.trace import Span, Status, StatusCode, Tracer
from opentelemetry.trace import INVALID_SPAN, Span, Status, StatusCode, Tracer
from opentelemetry.trace import get_tracer as otel_get_tracer

INSTRUMENTATION_NAME = "fastmcp"

TRACE_PARENT_KEY = "traceparent"
TRACE_STATE_KEY = "tracestate"

_SUPPRESS_FASTMCP_TELEMETRY_KEY = otel_context.create_key("fastmcp_suppress_telemetry")


def native_telemetry_enabled() -> bool:
"""Return whether FastMCP should create native MCP spans.

False when the global setting is ``propagation_only`` or when
the current context has been suppressed via ``suppress_fastmcp_telemetry()``.
"""
import fastmcp

if fastmcp.settings.telemetry_mode != "native":
return False
return not otel_context.get_value(_SUPPRESS_FASTMCP_TELEMETRY_KEY)


@contextmanager
def suppress_fastmcp_telemetry() -> Generator[None, None, None]:
"""Suppress native FastMCP spans while preserving context propagation.

This is narrower than OpenTelemetry's global instrumentation suppression:
it disables only FastMCP's own spans, allowing unrelated nested
instrumentations (HTTP clients, databases, etc.) to continue emitting.
"""
token = otel_context.attach(
otel_context.set_value(_SUPPRESS_FASTMCP_TELEMETRY_KEY, True)
)
try:
yield
finally:
otel_context.detach(token)


def get_noop_span() -> Span:
"""Return the sentinel span used when native FastMCP telemetry is suppressed."""
return INVALID_SPAN


def get_tracer(version: str | None = None) -> Tracer:
"""Get the FastMCP tracer for creating spans.
Expand Down Expand Up @@ -107,7 +152,10 @@ 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 (not a fresh root) so the parent
# trace is added without dropping context values such as FastMCP's
# suppression marker or active baggage.
return propagate.extract(carrier, context=otel_context.get_current())
return otel_context.get_current()


Expand All @@ -116,7 +164,10 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context:
"TRACE_PARENT_KEY",
"TRACE_STATE_KEY",
"extract_trace_context",
"get_noop_span",
"get_tracer",
"inject_trace_context",
"native_telemetry_enabled",
"record_span_error",
"suppress_fastmcp_telemetry",
]
Loading
Loading