Repository navigation
Add OpenTelemetry instrument for FastMCP - #152
Conversation
| super().__init__(**kwargs) | ||
| self.http_app_hooks: list[typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]] = [] | ||
|
|
||
| def add_http_app_hook(self, hook: typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]) -> None: |
There was a problem hiding this comment.
Короткое название слишком. Я так понимаю, это аналог миддлваря для обычных приложений?
There was a problem hiding this comment.
Переименовал в add_http_application_postprocessor. Это не совсем аналог миддлваря: функция получает созданное FastMCP ASGI-приложение и возвращает его модифицированным (например, с навешенным ASGI-миддлварем), поэтому назвал postprocessor.
| application.add_http_app_hook(self.instrument_http_app) | ||
| return application | ||
|
|
||
| def instrument_http_app(self, http_application: StarletteT) -> StarletteT: |
There was a problem hiding this comment.
Давай этот метод хотя бы с __ в начале сделаем, тк он не должен вызываться за пределами самого класса
There was a problem hiding this comment.
Сделал __instrument_http_app.
| self.http_app: ASGIApp = super().__call__ | ||
|
|
||
| def add_http_middleware(self, build_middleware: typing.Callable[[ASGIApp], ASGIApp]) -> None: | ||
| self.http_app = build_middleware(self.http_app) | ||
|
|
||
| async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: | ||
| # Lifespan and websocket scopes bypass HTTP middlewares | ||
| if scope["type"] == "http": | ||
| await self.http_app(scope, receive, send) | ||
| return | ||
| await super().__call__(scope, receive, send) |
There was a problem hiding this comment.
А вот тут какие-то дубли из смежного ПРа пошли
There was a problem hiding this comment.
Неактуально: дубли из смежного ПРа убраны, faststream.py больше не входит в этот ПР.
| def build_faststream_route_details_from_scope( | ||
| scope: Scope, | ||
| routes: typing.Iterable[tuple[str, ASGIApp]], | ||
| ) -> tuple[str, dict[str, str]]: | ||
| method: typing.Final = str(scope.get("method", "HTTP")).strip() | ||
| path: typing.Final = scope.get("path") | ||
| # FastStream matches ASGI routes by exact path, unmatched paths get no `http.route` to keep its cardinality low | ||
| if path is None or all(path != route_path for route_path, _ in routes): | ||
| return method, {} | ||
| return build_span_name(method, path), {"http.route": path} |
There was a problem hiding this comment.
Коммент тот же, давай в фастстриме это поддерживать
There was a problem hiding this comment.
Неактуально: изменения faststream.py убраны из этого ПРа, поддержка в FastStream живёт в смежном ПРе.
b0f6bd8 to
43ccc29
Compare
FastMcpSettings now include OpentelemetryConfig, and FastMcpOpentelemetryInstrument wraps every ASGI application returned by http_app() in OpenTelemetryMiddleware. Server spans get http.route from Starlette route matching, exclusions combine opentelemetry_exclude_urls with OTEL_PYTHON_STARLETTE_EXCLUDED_URLS, and applications already instrumented by StarletteInstrumentor are skipped. KwargsFastMCP gets add_http_app_hook, since the ASGI application is created by the user after bootstrap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ethod Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f3abe5c to
bc2cef1
Compare
Motivation
FastMcpBootstrapperalready wires logging, Sentry, Pyroscope, health checks and Prometheus, but has no OpenTelemetry:FastMcpSettingsdoes not includeOpentelemetryConfigand no tracing instrument is registered. FastMCP services need thesame
SERVERspans that Litestar and FastAPI already produce:http.routeand the response statuscode are what availability is computed from in APM. Today services work around this by mixing
OpentelemetryConfigintotheir settings, registering
OpentelemetryInstrumentby hand and callingStarletteInstrumentor().instrument_app()on theresult of
http_app().Autoloaded
StarletteInstrumentor().instrument()does not help: it replacesstarlette.applications.Starlette, but FastMCPsubclasses the original class at import time (
StarletteWithLifespan), so apps fromhttp_app()stay uninstrumented.Stacked on #151 (Honor
opentelemetry_exclude_urlsin the Litestar bootstrapper): reusesbuild_span_name,define_exclude_urlsandCombinedExcludeListmoved toinstruments/opentelemetry_instrument.pythere. Only the last commit belongs to this PR.Changes
FastMcpSettingsnow includeOpentelemetryConfig. No new required settings.KwargsFastMCP.http_app()calls the parent and passes the result through hooks registered withadd_http_app_hook().The return type stays
StarletteWithLifespan. This handles the case where theASGI app is created after bootstrap, and it also covers
run(transport="http"), which callshttp_app()internally.FastMcpOpentelemetryInstrumentaddsOpenTelemetryMiddlewareto every created app:http.routecomes fromapp.routes(Route/Mount,Match.FULLonly). Unknown paths produce a method-only spanwith no
http.route, which keeps cardinality low.excluded_urls = CombinedExcludeList(ExcludeList(define_exclude_urls()), get_excluded_urls("STARLETTE"))._is_instrumented_by_opentelemetryare skipped. The flag is set the same wayStarletteInstrumentorsets it, so requests are never traced twice.OpenTelemetryMiddleware(OTEL_SEMCONV_STABILITY_OPT_IN).FastMcpBootstrapper.bootstrap()is now typed as returningKwargsFastMCP(aFastMCPsubclass), soadd_http_app_hooktype-checks.opentelemetry-instrumentation-starletteto the dev group so the entry-point autoload test can run.Tests
tests/bootstrappers/test_fastmcp.py:GET /health/span name,http.routeand status attribute for unset /http/http/dupsemconv/metricsexcluded by default;opentelemetry_exclude_urls;OTEL_PYTHON_STARLETTE_EXCLUDED_URLS;opentelemetry_generate_health_check_spans=FalseGETspan withouthttp.route, status 404POST /mcphashttp.route == "/mcp"opentelemetry_instrumentors(httpx) applied and torn downhttp_app()calls produce two independently instrumented appsjust lint-ciandjust testpass (238 passed).🤖 Generated with Claude Code