Skip to content

Add SERVER spans for FastStream ASGI routes - #151

Open
personage-hub wants to merge 3 commits into
community-of-python:mainfrom
personage-hub:faststream-http-server-spans
Open

personage-hub wants to merge 3 commits into
community-of-python:mainfrom
personage-hub:faststream-http-server-spans

Conversation

@personage-hub

Copy link
Copy Markdown

Motivation

Availability SLIs are computed from health-check spans: they must be SERVER spans with http.route and http.response.status_code. The Litestar bootstrapper already produces them via LitestarOpenTelemetryInstrumentationMiddleware, which wraps the app in OpenTelemetryMiddleware from opentelemetry-instrumentation-asgi. The FastStream bootstrapper wrapped the health handler in tracer.start_as_current_span(...), which produced an INTERNAL span with no route or status, so FastStream workers could not be monitored the same way.

Changes

  • KwargsAsgiFastStream routes http scopes through http_app, which can be wrapped via the public add_http_middleware() (documented in the README). Lifespan and websocket scopes go straight to AsgiFastStream.__call__. The application stays an AsgiFastStream, so application.broker and the rest of the public API are unchanged. FastStreamBootstrapper.bootstrap() is now typed as KwargsAsgiFastStream, a subclass, so existing AsgiFastStream annotations keep working.
  • FastStreamOpentelemetryInstrument.bootstrap_after wraps HTTP handling in OpenTelemetryMiddleware with the instrument's tracer_provider, build_faststream_route_details_from_scope and ExcludeList(define_exclude_urls()). The status attribute follows OTEL_SEMCONV_STABILITY_OPT_IN just like in Litestar: http.status_code, http.response.status_code or both.
  • The manual health-check span is removed. opentelemetry_generate_health_check_spans keeps working: when False, the health path is added to the excluded urls.
  • build_span_name moves to instruments/opentelemetry_instrument.py so the FastStream bootstrapper doesn't import the Litestar module (the litestar extra may be missing). define_exclude_urls moves to BaseOpentelemetryInstrument.
  • http.route is set only when the request path matches an entry of application.routes (FastStream matches routes by exact path). Unknown paths get a span named after the method only, without http.route, so scans like /wp-admin/... don't blow up the cardinality of span-derived metrics.
  • FastStreamOpentelemetryInstrument.is_ready no longer requires opentelemetry_middleware_cls: HTTP server spans work without a broker telemetry middleware, which is still added only when configured.

Related fix: Litestar ignored opentelemetry_exclude_urls

LitestarOpenTelemetryInstrumentationMiddleware read exclusions only from OTEL_PYTHON_LITESTAR_EXCLUDED_URLS. It now combines define_exclude_urls() with the env-based list (CombinedExcludeList) and builds the list once instead of per request.

Breaking

  • Litestar: opentelemetry_exclude_urls used to be ignored. Now the default /metrics exclusion applies, so /metrics stops producing spans, and opentelemetry_generate_health_check_spans=False suppresses health-check spans (the default is True). This affects every Litestar user and should go into the release notes.
  • FastStream: the OpenTelemetry instrument no longer needs opentelemetry_middleware_cls to activate. It follows the same readiness rule as the other bootstrappers (opentelemetry_endpoint, service_debug or opentelemetry_log_traces), so FastStream apps without a broker middleware now get a tracer provider and HTTP server spans.

Tests

  • GET /health/ gives exactly one SERVER span GET /health/ with http.route == "/health/", and the status attribute matches the semconv mode (unset / http / http/dup).
  • A failed broker.ping gives a 500 status attribute and StatusCode.ERROR.
  • Unknown paths produce a span named after the method, without http.route.
  • HTTP server spans are produced without opentelemetry_middleware_cls, and no broker middlewares are added in that case.
  • /metrics produces no spans. With opentelemetry_generate_health_check_spans=False health has no span while other routes still do.
  • Broker spans are not duplicated: one telemetry middleware, one create / process / publish.
  • Lifespan passes through the wrapper, and application.broker still works.
  • Litestar: opentelemetry_exclude_urls=["/internal"] suppresses spans without env vars, and settings and env exclusions combine.

just lint and just test pass (237 tests).

🤖 Generated with Claude Code

Alexander Niyazov and others added 3 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>
@@ -20,6 +21,7 @@
from microbootstrap.instruments.opentelemetry_instrument import (

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.

Давай тут импортнем сразу instrument, чтобы импорт не разрастался

def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401
# `broker` argument is positional-only
super().__init__(kwargs.pop("broker", None), **kwargs)
self.http_app: ASGIApp = super().__call__

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.

Выглядит как-то оч странно, почему метод - это объект asgiapp?

super().__init__(kwargs.pop("broker", None), **kwargs)
self.http_app: ASGIApp = super().__call__

def add_http_middleware(self, build_middleware: typing.Callable[[ASGIApp], ASGIApp]) -> 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.

Поч функцию извне прокидываем? Разве нет какой-то функции, добаляющей миддлварь в фастстриме для веб приложения?

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:

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.

Короче, мы тут как будто начинаем делать работу за фастстрим. Это не целевое решение. Давай закинем им в чат, что очень надо это поддержать. Пока предлагаю это не вливать и если очень надо сделать именно так, то это можно переопределить в конкретной репе (или сделать как у нас в репах)

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