Add OpenTelemetry instrument for FastMCP - #152
Open
personage-hub wants to merge 4 commits into
Open
personage-hub wants to merge 4 commits into
personage-hub wants to merge 4 commits into
Conversation
Wrap HTTP requests to the FastStream ASGI application in OpenTelemetryMiddleware, the same way the Litestar bootstrapper does, so health checks produce SERVER spans with http.route and the response status code. Lifespan events bypass the middleware. Drop the manual health-check span, which was INTERNAL and had no attributes; opentelemetry_generate_health_check_spans now controls the excluded urls. Apply opentelemetry_exclude_urls in the Litestar bootstrapper, combined with OTEL_PYTHON_LITESTAR_EXCLUDED_URLS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s from broker middleware - Match the request path against application.routes and omit http.route on unknown paths - Drop the opentelemetry_middleware_cls requirement from FastStreamOpentelemetryInstrument.is_ready - Type FastStreamBootstrapper as KwargsAsgiFastStream and document add_http_middleware Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
kek0vi4
reviewed
Oct 1, 2026
| super().__init__(**kwargs) | ||
| self.http_app_hooks: list[typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]] = [] | ||
|
|
||
| def add_http_app_hook(self, hook: typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]) -> None: |
Member
There was a problem hiding this comment.
Короткое название слишком. Я так понимаю, это аналог миддлваря для обычных приложений?
| application.add_http_app_hook(self.instrument_http_app) | ||
| return application | ||
|
|
||
| def instrument_http_app(self, http_application: StarletteT) -> StarletteT: |
Member
There was a problem hiding this comment.
Давай этот метод хотя бы с __ в начале сделаем, тк он не должен вызываться за пределами самого класса
Comment on lines
+66
to
+76
| 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) |
Member
There was a problem hiding this comment.
А вот тут какие-то дубли из смежного ПРа пошли
Comment on lines
+79
to
+88
| 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} |
Member
There was a problem hiding this comment.
Коммент тот же, давай в фастстриме это поддерживать
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, FastAPI and (after #151) FastStream 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 (Add SERVER spans for FastStream ASGI routes): reuses
build_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 mirrorsadd_http_middlewarefrom Add SERVER spans for FastStream ASGI routes #151, for the case where theASGI app is created after bootstrap. 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, as in Add SERVER spans for FastStream ASGI routes #151 forFastStream), so
add_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 (253 passed).🤖 Generated with Claude Code