Skip to content

Add OpenTelemetry instrument for FastMCP - #152

Merged
vrslev merged 2 commits into
community-of-python:mainfrom
personage-hub:fastmcp-opentelemetry
Oct 6, 2026
Merged

vrslev merged 2 commits into
community-of-python:mainfrom
personage-hub:fastmcp-opentelemetry

Conversation

@personage-hub

@personage-hub personage-hub commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

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 and FastAPI 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 (Honor opentelemetry_exclude_urls in the Litestar bootstrapper): 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 handles the case where the
    ASGI app is created after bootstrap, and 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), 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 (238 passed).

🤖 Generated with Claude Code

Comment thread microbootstrap/bootstrappers/fastmcp.py Outdated
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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Переименовал в add_http_application_postprocessor. Это не совсем аналог миддлваря: функция получает созданное FastMCP ASGI-приложение и возвращает его модифицированным (например, с навешенным ASGI-миддлварем), поэтому назвал postprocessor.

Comment thread microbootstrap/bootstrappers/fastmcp.py Outdated
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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Сделал __instrument_http_app.

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Неактуально: дубли из смежного ПРа убраны, faststream.py больше не входит в этот ПР.

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Неактуально: изменения faststream.py убраны из этого ПРа, поддержка в FastStream живёт в смежном ПРе.

Alexander Niyazov and others added 2 commits October 6, 2026 13:43
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>
@personage-hub
personage-hub force-pushed the fastmcp-opentelemetry branch from f3abe5c to bc2cef1 Compare October 6, 2026 08:44
@vrslev
vrslev merged commit 2e3c8e2 into community-of-python:main Oct 6, 2026
17 checks passed
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.

3 participants