Skip to content

Commit cc33819

Browse files
authored
feat: trace FastMCP HTTP requests with OpenTelemetry (#280)
* feat: trace FastMCP HTTP requests with OpenTelemetry FastMcpConfig now includes OpenTelemetryConfig. FastMcpOpenTelemetryInstrument wraps the application's http_app() so every ASGI application it builds, including the one run(transport="http") builds, gets OpenTelemetry's ASGI middleware with spans named by route. Teardown restores http_app. New fastmcp-otl extra, part of fastmcp-all. * test: cover chained http_app overrides and annotate the context manager with Generator
1 parent 14a4a63 commit cc33819

9 files changed

Lines changed: 305 additions & 8 deletions

File tree

‎.github/workflows/_checks.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ jobs:
105105
fastapi fastapi-sentry fastapi-otl fastapi-logging fastapi-metrics fastapi-all \
106106
litestar litestar-sentry litestar-otl litestar-logging litestar-metrics litestar-all \
107107
faststream faststream-sentry faststream-otl faststream-logging faststream-metrics faststream-all \
108-
fastmcp fastmcp-metrics fastmcp-all"
108+
fastmcp fastmcp-otl fastmcp-metrics fastmcp-all"
109109
failed=""
110110
uv venv --python 3.11 .v-bare >/dev/null
111111
if uv pip install --python .v-bare/bin/python . >/dev/null 2>&1 \

‎docs/integrations/fastmcp.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ nanoseconds. A message that raises is logged at exception level and the exceptio
6464

6565
Set `health_checks_enabled=False` to omit the health route.
6666

67+
## Tracing
68+
69+
With `opentelemetry_endpoint` set (and the `fastmcp-otl` extra installed), every ASGI application
70+
`application.http_app()` builds, including the one `application.run(transport="http")` builds, is
71+
wrapped in OpenTelemetry's ASGI middleware. Each request produces a server span named after its
72+
route, such as `POST /mcp` or `GET /health/`, with the route in `http.route`. A request to an
73+
unknown path produces a span named after the HTTP method alone, without `http.route`.
74+
75+
The metrics path, the health-check path (unless `opentelemetry_generate_health_check_spans` is on)
76+
and `opentelemetry_excluded_urls` produce no spans. An application already instrumented by
77+
`StarletteInstrumentor` is left as is, so no request is traced twice.
78+
6779
Teardown is wired through FastMCP's provider lifecycle — `bootstrapper.teardown()`
6880
runs automatically when the FastMCP server's ASGI lifespan shuts down (i.e. when
6981
the application that serves `application.http_app()` shuts down).

‎docs/introduction/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Additional parameters:
122122
### Metrics
123123

124124
Left unset, no `MeterProvider` is installed and the metrics signal stays off. Set it, and the
125-
FastAPI, Litestar and FastStream instrumentations start recording their duration histograms
125+
FastAPI, Litestar, FastStream and FastMCP instrumentations start recording their duration histograms
126126
against it; until then they build those instruments against a no-op provider and the data goes
127127
nowhere.
128128

‎docs/introduction/installation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ You can choose required framework and instruments using this table:
88
|---------------|--------------------|----------------------|-------------------|---------------------|--------------------------------------|
99
| sentry | `litestar-sentry` | `faststream-sentry` | `fastapi-sentry` | `sentry` (compose) | `sentry` |
1010
| prometheus | `litestar-metrics` | `faststream-metrics` | `fastapi-metrics` | `fastmcp-metrics` | not used |
11-
| opentelemetry | `litestar-otl` | `faststream-otl` | `fastapi-otl` | not used | `otl` |
11+
| opentelemetry | `litestar-otl` | `faststream-otl` | `fastapi-otl` | `fastmcp-otl` | `otl` |
1212
| pyroscope | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` | `pyroscope` |
1313
| structlog | `litestar-logging` | `faststream-logging` | `fastapi-logging` | `logging` (compose) | `logging` |
1414
| cors | no extra | not used | no extra | not used | not used |

‎lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py‎

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import contextlib
22
import dataclasses
3+
import functools
4+
import re
35
import time
46
import typing
57
from collections.abc import AsyncGenerator
@@ -8,6 +10,7 @@
810
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
911
from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument
1012
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
13+
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument
1114
from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument
1215
from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
1316
from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument
@@ -20,6 +23,15 @@
2023
from starlette.requests import Request
2124
from starlette.responses import JSONResponse, Response
2225

26+
if import_checker.is_fastmcp_opentelemetry_installed:
27+
from fastmcp.server.http import StarletteWithLifespan
28+
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
29+
from opentelemetry.metrics import get_meter_provider
30+
from opentelemetry.trace import get_tracer_provider
31+
from opentelemetry.util.http import parse_excluded_urls
32+
from starlette.routing import BaseRoute, Match, Mount, Route
33+
from starlette.types import Scope
34+
2335
if import_checker.is_structlog_installed:
2436
import structlog
2537

@@ -29,10 +41,55 @@
2941
import prometheus_client
3042

3143

44+
# OpenTelemetryMiddleware matches its patterns against a full URL, not a bare path.
45+
_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*"
46+
47+
# Set by StarletteInstrumentor too, so an application is never traced twice
48+
_OPENTELEMETRY_INSTRUMENTED_MARKER: typing.Final = "_is_instrumented_by_opentelemetry"
49+
50+
3251
def _make_fastmcp() -> "FastMCP[typing.Any]":
3352
return FastMCP()
3453

3554

55+
def _postprocess_http_apps(
56+
application: "FastMCP[typing.Any]",
57+
postprocess: "typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]",
58+
) -> typing.Callable[[], None]:
59+
"""Pass every ASGI application ``http_app()`` builds through ``postprocess``; return the undo.
60+
61+
FastMCP builds its ASGI application lazily, when the user calls ``http_app()`` after bootstrap or
62+
``run(transport="http")`` calls it, so there is nothing to instrument at bootstrap time.
63+
"""
64+
previous_override: typing.Final = vars(application).get("http_app")
65+
build_http_app: typing.Final = application.http_app
66+
67+
def http_app(*args: typing.Any, **kwargs: typing.Any) -> "StarletteWithLifespan": # noqa: ANN401
68+
return postprocess(build_http_app(*args, **kwargs))
69+
70+
application.http_app = http_app # ty: ignore[invalid-assignment]
71+
72+
def restore() -> None:
73+
if previous_override is None:
74+
del application.http_app
75+
else:
76+
application.http_app = previous_override
77+
78+
return restore
79+
80+
81+
def build_fastmcp_route_details_from_scope(
82+
scope: "Scope",
83+
routes: "typing.Iterable[BaseRoute]",
84+
) -> tuple[str, dict[str, str]]:
85+
method: typing.Final = str(scope.get("method", "HTTP")).strip()
86+
for route in routes:
87+
if isinstance(route, (Route, Mount)) and route.path and route.matches(scope)[0] == Match.FULL:
88+
return f"{method} {route.path}", {"http.route": route.path}
89+
# Unmatched paths get no `http.route`: a raw path would let scanners explode span cardinality
90+
return method, {}
91+
92+
3693
if import_checker.is_fastmcp_installed:
3794

3895
class _TeardownProvider(Provider):
@@ -80,7 +137,9 @@ async def on_message(
80137

81138

82139
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
83-
class FastMcpConfig(HealthChecksConfig, LoggingConfig, PrometheusConfig, PyroscopeConfig, SentryConfig):
140+
class FastMcpConfig(
141+
HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig
142+
):
84143
application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp)
85144
fastmcp_logging_middleware_enabled: bool = False
86145

@@ -102,6 +161,63 @@ async def health_check_handler(_: "Request") -> "JSONResponse":
102161
return JSONResponse(dict(self.render_health_check_data()))
103162

104163

164+
@dataclasses.dataclass(kw_only=True)
165+
class FastMcpOpenTelemetryInstrument(OpenTelemetryInstrument):
166+
bootstrap_config: FastMcpConfig
167+
missing_dependency_message = "opentelemetry-instrumentation-asgi is not installed"
168+
_restore_http_app: typing.Callable[[], None] | None = dataclasses.field(
169+
default=None, init=False, repr=False, compare=False
170+
)
171+
172+
@staticmethod
173+
def dependencies_installed() -> bool:
174+
return OpenTelemetryInstrument.dependencies_installed() and import_checker.is_fastmcp_opentelemetry_installed
175+
176+
def _build_excluded_url_patterns(self) -> list[str]:
177+
"""Anchored patterns for the derived paths, plus the caller's own entries verbatim.
178+
179+
The trailing slash is stripped and matched optionally, so ``/health/`` excludes the path with
180+
or without it. Anchoring is needed because ``ExcludeList`` searches unanchored: a bare
181+
``/health`` would also silence ``/healthy``. Caller-supplied entries stay untouched because
182+
OpenTelemetry documents them as regexes.
183+
"""
184+
anchored_patterns: typing.Final = {
185+
rf"{_EXCLUDED_URL_SCHEME_AND_HOST}{re.escape(normalized_path)}(?:/|$)"
186+
for excluded_path in self._build_infrastructure_excluded_paths()
187+
# A bare "/" would anchor to every URL, so it is dropped along with empty values.
188+
if (normalized_path := excluded_path.rstrip("/"))
189+
}
190+
return sorted(anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls))
191+
192+
def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "StarletteWithLifespan":
193+
if getattr(http_application, _OPENTELEMETRY_INSTRUMENTED_MARKER, False):
194+
return http_application
195+
http_application.add_middleware(
196+
OpenTelemetryMiddleware,
197+
default_span_details=functools.partial(
198+
build_fastmcp_route_details_from_scope, routes=http_application.routes
199+
),
200+
# OpenTelemetryMiddleware only parses a raw string from 0.56b0; the floor is 0.49b0.
201+
excluded_urls=parse_excluded_urls(",".join(self._build_excluded_url_patterns())),
202+
tracer_provider=get_tracer_provider(),
203+
meter_provider=get_meter_provider(),
204+
)
205+
setattr(http_application, _OPENTELEMETRY_INSTRUMENTED_MARKER, True)
206+
return http_application
207+
208+
def bootstrap(self) -> None:
209+
super().bootstrap()
210+
self._restore_http_app = _postprocess_http_apps(self.bootstrap_config.application, self._instrument_http_app)
211+
212+
def teardown(self) -> None:
213+
try:
214+
super().teardown()
215+
finally:
216+
if self._restore_http_app is not None:
217+
self._restore_http_app()
218+
self._restore_http_app = None
219+
220+
105221
@dataclasses.dataclass(kw_only=True)
106222
class FastMcpPrometheusInstrument(PrometheusInstrument):
107223
bootstrap_config: FastMcpConfig
@@ -142,6 +258,7 @@ class FastMcpBootstrapper(BaseBootstrapper["FastMCP[typing.Any]"]):
142258
__slots__ = "bootstrap_config", "instruments"
143259

144260
instruments_types: typing.ClassVar = [
261+
FastMcpOpenTelemetryInstrument,
145262
PyroscopeInstrument,
146263
SentryInstrument,
147264
FastMcpHealthChecksInstrument,

‎lite_bootstrap/import_checker.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@ def _safe_find_spec(module_name: str) -> bool:
4040
is_otlp_http_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.http.trace_exporter")
4141
is_pyroscope_installed = find_spec("pyroscope") is not None
4242
is_fastmcp_installed = find_spec("fastmcp") is not None
43+
is_fastmcp_opentelemetry_installed = (
44+
is_opentelemetry_installed and is_fastmcp_installed and _safe_find_spec("opentelemetry.instrumentation.asgi")
45+
)
4346
is_orjson_installed = find_spec("orjson") is not None

‎pyproject.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,16 @@ fastmcp = [
198198
# fastmcp 3.0 pulls py-key-value-aio, which requires typing-extensions>=4.15.
199199
"typing-extensions>=4.15",
200200
]
201+
fastmcp-otl = [
202+
"lite-bootstrap[fastmcp,otl]",
203+
"opentelemetry-instrumentation-asgi>=0.49b0",
204+
]
201205
fastmcp-metrics = [
202206
"lite-bootstrap[fastmcp]",
203207
"prometheus-client>=0.20",
204208
]
205209
fastmcp-all = [
206-
"lite-bootstrap[fastmcp,fastmcp-metrics,sentry,logging,pyroscope]",
210+
"lite-bootstrap[fastmcp,fastmcp-otl,fastmcp-metrics,sentry,logging,pyroscope]",
207211
]
208212

209213
[dependency-groups]

‎scripts/floor_smoke.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,18 @@ def _fastmcp() -> None:
198198
sentry_dsn=SENTRY_DSN,
199199
sentry_additional_params=SENTRY_PARAMS,
200200
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
201+
opentelemetry_endpoint=OTLP_ENDPOINT,
202+
opentelemetry_metrics_endpoint=OTLP_ENDPOINT,
203+
opentelemetry_log_traces=True,
201204
)
202205
)
203-
bootstrapper.bootstrap()
204-
bootstrapper.teardown()
206+
application = bootstrapper.bootstrap()
207+
try:
208+
# FastMcpOpenTelemetryInstrument adds its middleware to the application http_app() builds.
209+
application.http_app()
210+
_emit_span()
211+
finally:
212+
bootstrapper.teardown()
205213

206214

207215
TARGETS: typing.Final = {

0 commit comments

Comments
 (0)