Skip to content

Add OpenTelemetry instrument for FastMCP - #152

Open
personage-hub wants to merge 4 commits into
community-of-python:mainfrom
personage-hub:fastmcp-opentelemetry
Open

personage-hub wants to merge 4 commits into
community-of-python:mainfrom
personage-hub:fastmcp-opentelemetry

Conversation

@personage-hub

Copy link
Copy Markdown

Motivation

FastMcpBootstrapper already wires logging, Sentry, Pyroscope, health checks and Prometheus, but has no OpenTelemetry:
FastMcpSettings does not include OpentelemetryConfig and no tracing instrument is registered. FastMCP services need the
same SERVER spans that Litestar, FastAPI and (after #151) FastStream already produce: http.route and the response status
code are what availability is computed from in APM. Today services work around this by mixing OpentelemetryConfig into
their settings, registering OpentelemetryInstrument by hand and calling StarletteInstrumentor().instrument_app() on the
result of http_app().

Autoloaded StarletteInstrumentor().instrument() does not help: it replaces starlette.applications.Starlette, but FastMCP
subclasses the original class at import time (StarletteWithLifespan), so apps from http_app() stay uninstrumented.

Stacked on #151 (Add SERVER spans for FastStream ASGI routes): reuses build_span_name, define_exclude_urls and
CombinedExcludeList moved to instruments/opentelemetry_instrument.py there. Only the last commit belongs to this PR.

Changes

  • FastMcpSettings now include OpentelemetryConfig. No new required settings.
  • KwargsFastMCP.http_app() calls the parent and passes the result through hooks registered with add_http_app_hook().
    The return type stays StarletteWithLifespan. This mirrors add_http_middleware from Add SERVER spans for FastStream ASGI routes #151, for the case where the
    ASGI app is created after bootstrap. It also covers run(transport="http"), which calls http_app() internally.
  • FastMcpOpentelemetryInstrument adds OpenTelemetryMiddleware to every created app:
    • http.route comes from app.routes (Route/Mount, Match.FULL only). Unknown paths produce a method-only span
      with no http.route, which keeps cardinality low.
    • excluded_urls = CombinedExcludeList(ExcludeList(define_exclude_urls()), get_excluded_urls("STARLETTE")).
    • Apps that already have _is_instrumented_by_opentelemetry are skipped. The flag is set the same way
      StarletteInstrumentor sets it, so requests are never traced twice.
    • Semantic conventions are left to OpenTelemetryMiddleware (OTEL_SEMCONV_STABILITY_OPT_IN).
  • FastMcpBootstrapper.bootstrap() is now typed as returning KwargsFastMCP (a FastMCP subclass, as in Add SERVER spans for FastStream ASGI routes #151 for
    FastStream), so add_http_app_hook type-checks.
  • Added opentelemetry-instrumentation-starlette to the dev group so the entry-point autoload test can run.
  • README: FastMCP OpenTelemetry section.

Tests

tests/bootstrappers/test_fastmcp.py:

  • settings fields and tracer provider creation; instrument inactive without the relevant settings
  • GET /health/ span name, http.route and status attribute for unset / http / http/dup semconv
  • /metrics excluded by default; opentelemetry_exclude_urls; OTEL_PYTHON_STARLETTE_EXCLUDED_URLS; opentelemetry_generate_health_check_spans=False
  • unknown path: GET span without http.route, status 404
  • POST /mcp has http.route == "/mcp"
  • autoloaded starlette entry point: one middleware, flag set; already-instrumented app is skipped
  • opentelemetry_instrumentors (httpx) applied and torn down
  • two http_app() calls produce two independently instrumented apps

just lint-ci and just test pass (253 passed).

🤖 Generated with Claude Code

Alexander Niyazov and others added 4 commits September 30, 2026 12:35
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>
super().__init__(**kwargs)
self.http_app_hooks: list[typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]] = []

def add_http_app_hook(self, hook: typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Короткое название слишком. Я так понимаю, это аналог миддлваря для обычных приложений?

application.add_http_app_hook(self.instrument_http_app)
return application

def instrument_http_app(self, http_application: StarletteT) -> StarletteT:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Давай этот метод хотя бы с __ в начале сделаем, тк он не должен вызываться за пределами самого класса

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А вот тут какие-то дубли из смежного ПРа пошли

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}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Коммент тот же, давай в фастстриме это поддерживать

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants