11import contextlib
22import dataclasses
3+ import functools
4+ import re
35import time
46import typing
57from collections .abc import AsyncGenerator
810from lite_bootstrap .bootstrappers .base import BaseBootstrapper
911from lite_bootstrap .instruments .healthchecks_instrument import HealthChecksConfig , HealthChecksInstrument
1012from lite_bootstrap .instruments .logging_instrument import LoggingConfig , LoggingInstrument
13+ from lite_bootstrap .instruments .opentelemetry_instrument import OpenTelemetryConfig , OpenTelemetryInstrument
1114from lite_bootstrap .instruments .prometheus_instrument import PrometheusConfig , PrometheusInstrument
1215from lite_bootstrap .instruments .pyroscope_instrument import PyroscopeConfig , PyroscopeInstrument
1316from lite_bootstrap .instruments .sentry_instrument import SentryConfig , SentryInstrument
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+
2335if import_checker .is_structlog_installed :
2436 import structlog
2537
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+
3251def _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+
3693if 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 )
106222class 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 ,
0 commit comments