From aeb4cc8c53fe0674000733773f070b8c9338a04f Mon Sep 17 00:00:00 2001 From: Alexander Niyazov Date: Wed, 7 Oct 2026 11:08:35 +0500 Subject: [PATCH 1/2] Use Litestar's OpenTelemetryPlugin in the Litestar bootstrapper Litestar ships its own OpenTelemetry plugin since the custom middleware was written, so the bootstrapper now registers OpenTelemetryPlugin instead of building an ASGI middleware around OpenTelemetryMiddleware by hand (which also re-created the OTel middleware on every request). opentelemetry_exclude_urls map to the plugin's exclude, OTEL_PYTHON_LITESTAR_EXCLUDED_URLS keeps working through the plugin's exclude_urls_env_key. Litestar applies the plugin middleware outside the router, before path_template is known, so a small LitestarOpentelemetryRouteMiddleware inside the handler stack renames the server span by the route template and sets http.route, keeping the span names unchanged. Requires litestar>=2.22.0, where litestar.plugins.opentelemetry appeared. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- microbootstrap/bootstrappers/litestar.py | 64 +++---- pyproject.toml | 2 +- .../test_litestar_opentelemetry.py | 165 +++++++++++++----- tests/instruments/test_opentelemetry.py | 27 +-- 5 files changed, 159 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 9d7d444..935a9b7 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,9 @@ Parameters description: - `opentelemetry_insecure` - is opentelemetry connection secure. - `opentelemetry_container_name` - will be passed to the `Resource`. - `opentelemetry_instrumentors` - a list of extra instrumentors. -- `opentelemetry_exclude_urls` - extra url regexes that produce no server spans (empty by default). `prometheus_metrics_path` is always excluded; for FastAPI and FastMCP it is matched as a whole path, so `/metrics` excludes neither `/api/metrics` nor `/metrics-report`. For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`), for FastMCP with `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`). +- `opentelemetry_exclude_urls` - extra url regexes that produce no server spans (empty by default). `prometheus_metrics_path` is always excluded and matched as a whole path on every framework, so `/metrics` excludes neither `/api/metrics` nor `/metrics-report`. For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`), for FastMCP with `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`). + +For Litestar the tracing is wired through Litestar's own [`OpenTelemetryPlugin`](https://docs.litestar.dev/2/reference/plugins/opentelemetry.html). Server spans are named by the route template, e.g. `GET /users/{user_id}`, and carry it in `http.route`; requests that match no route produce a span named after the HTTP method only, without `http.route`. - `opentelemetry_log_traces` - traces will be logged to stdout. - `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`; if `False`, `health_checks_path` is added to the excluded urls. - `opentelemetry_baggage_span_attributes` - maps allowed baggage keys to attributes added to local server and consumer spans. diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index ab129e3..26e85ba 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -6,16 +6,13 @@ import typing_extensions from litestar import openapi from litestar.config.cors import CORSConfig as LitestarCorsConfig -from litestar.contrib.opentelemetry.config import ( - OpenTelemetryConfig as LitestarOpentelemetryConfig, -) from litestar.contrib.prometheus import PrometheusConfig, PrometheusController from litestar.middleware import ASGIMiddleware from litestar.openapi.plugins import SwaggerRenderPlugin -from litestar.types.asgi_types import ASGIApp, Scope +from litestar.plugins.opentelemetry import OpenTelemetryConfig as LitestarOpentelemetryConfig +from litestar.plugins.opentelemetry import OpenTelemetryPlugin from litestar_offline_docs import generate_static_files_config -from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware -from opentelemetry.util.http import ExcludeList, get_excluded_urls +from opentelemetry import trace from sentry_sdk.integrations.litestar import LitestarIntegration from microbootstrap.bootstrappers.base import ApplicationBootstrapper @@ -59,9 +56,7 @@ class AcceptVersionedOperation(openapi.spec.Operation): if typing.TYPE_CHECKING: - from litestar.contrib.opentelemetry import OpenTelemetryConfig - from litestar.types import ASGIApp, Scope - from litestar.types.asgi_types import Receive, Send + from litestar.types import ASGIApp, Receive, Scope, Send class LitestarBootstrapper( @@ -265,46 +260,35 @@ def build_litestar_route_details_from_scope( return method, {} -class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, config: OpenTelemetryConfig, exclude_urls: typing.Sequence[str] = ()) -> None: - self.config = config - self.excluded_urls = opentelemetry_instrument.CombinedExcludeList( - ExcludeList(exclude_urls), - get_excluded_urls(self.config.exclude_urls_env_key), - ) - - def create_open_telemetry_middleware(self, app: ASGIApp) -> OpenTelemetryMiddleware: - return OpenTelemetryMiddleware( - app=app, - client_request_hook=self.config.client_request_hook_handler, - client_response_hook=self.config.client_response_hook_handler, - default_span_details=build_litestar_route_details_from_scope, - excluded_urls=self.excluded_urls, - meter=self.config.meter, - meter_provider=self.config.meter_provider, - server_request_hook=self.config.server_request_hook_handler, - tracer_provider=self.config.tracer_provider, - ) - +class LitestarOpentelemetryRouteMiddleware(ASGIMiddleware): + # `OpenTelemetryPlugin` wraps the whole application, so its span is created before routing with the raw path. + # This middleware runs inside the route handler stack, where `path_template` is known, and renames the span. async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None: - await self.create_open_telemetry_middleware(next_app)(scope, receive, send) # type: ignore[arg-type] + server_span: typing.Final = trace.get_current_span() + if server_span.is_recording(): + span_name, attributes = build_litestar_route_details_from_scope(scope) + server_span.update_name(span_name) + server_span.set_attributes(attributes) + await next_app(scope, receive, send) @LitestarBootstrapper.use_instrument() class LitestarOpentelemetryInstrument(opentelemetry_instrument.OpentelemetryInstrument): def bootstrap_before(self) -> dict[str, typing.Any]: return { - "middleware": [ - LitestarOpenTelemetryInstrumentationMiddleware( - LitestarOpentelemetryConfig( - tracer_provider=self.tracer_provider, - middleware_class=LitestarOpenTelemetryInstrumentationMiddleware, # type: ignore[arg-type] - ), - exclude_urls=self.define_exclude_urls(), - ) - ] + "plugins": [OpenTelemetryPlugin(self.build_litestar_opentelemetry_config())], + "middleware": [LitestarOpentelemetryRouteMiddleware()], } + def build_litestar_opentelemetry_config(self) -> LitestarOpentelemetryConfig: + # `exclude` bypasses the plugin middleware for the configured urls; the plugin itself still honors + # `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` through `exclude_urls_env_key`. + return LitestarOpentelemetryConfig( + tracer_provider=self.tracer_provider, + scope_span_details_extractor=build_litestar_route_details_from_scope, + exclude=self.define_exclude_urls() or None, + ) + @LitestarBootstrapper.use_instrument() class LitestarLoggingInstrument(LoggingInstrument): diff --git a/pyproject.toml b/pyproject.toml index b29a08d..6735d58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ fastapi = [ "prometheus-fastapi-instrumentator>=7.1", ] litestar = [ - "litestar>=2.21.1", + "litestar>=2.22.0", "litestar-offline-docs>=1", "prometheus-client>=0.20", ] diff --git a/tests/bootstrappers/test_litestar_opentelemetry.py b/tests/bootstrappers/test_litestar_opentelemetry.py index 52cd72f..ca6a98c 100644 --- a/tests/bootstrappers/test_litestar_opentelemetry.py +++ b/tests/bootstrappers/test_litestar_opentelemetry.py @@ -1,10 +1,12 @@ import typing -from unittest.mock import Mock, patch +from unittest.mock import patch import litestar import pytest -from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig -from litestar.status_codes import HTTP_200_OK +from litestar.config.app import AppConfig +from litestar.plugins import InitPluginProtocol +from litestar.plugins.opentelemetry import OpenTelemetryPlugin +from litestar.status_codes import HTTP_200_OK, HTTP_404_NOT_FOUND from litestar.testing import TestClient from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -15,7 +17,7 @@ from microbootstrap.bootstrappers.litestar import ( LitestarBootstrapper, LitestarOpentelemetryInstrument, - LitestarOpenTelemetryInstrumentationMiddleware, + LitestarOpentelemetryRouteMiddleware, build_litestar_route_details_from_scope, ) from microbootstrap.config.litestar import LitestarConfig @@ -23,6 +25,25 @@ from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig +@pytest.fixture +def span_exporter(monkeypatch: pytest.MonkeyPatch) -> InMemorySpanExporter: + for environment_variable in ("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"): + monkeypatch.delenv(environment_variable, raising=False) + exporter: typing.Final = InMemorySpanExporter() + + def build_tracer_provider(*args: typing.Any, **kwargs: typing.Any) -> SdkTracerProvider: # noqa: ANN401 + tracer_provider: typing.Final = SdkTracerProvider(*args, **kwargs) + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + return tracer_provider + + monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", build_tracer_provider) + return exporter + + +def server_span_names(span_exporter: InMemorySpanExporter) -> list[str]: + return [span.name for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] + + @pytest.mark.parametrize( ("scope", "expected_span_name", "expected_attributes"), [ @@ -109,19 +130,38 @@ def test_build_litestar_route_details_from_scope( assert attributes == expected_attributes -def test_litestar_opentelemetry_instrument_uses_custom_middleware( +def test_litestar_opentelemetry_instrument_uses_litestar_plugin( minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: + minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/metrics"] + minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = False + minimal_opentelemetry_config.health_checks_path = "/health/" opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) opentelemetry_instrument.bootstrap() bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before() - assert "middleware" in bootstrap_result - assert len(bootstrap_result["middleware"]) == 1 + assert [type(middleware) for middleware in bootstrap_result["middleware"]] == [LitestarOpentelemetryRouteMiddleware] + assert len(bootstrap_result["plugins"]) == 1 + plugin: typing.Final = bootstrap_result["plugins"][0] + assert isinstance(plugin, OpenTelemetryPlugin) + assert plugin.config.tracer_provider is opentelemetry_instrument.tracer_provider + assert plugin.config.scope_span_details_extractor is build_litestar_route_details_from_scope + assert plugin.config.exclude == ["/metrics", "/health/"] + - middleware_config: typing.Final = bootstrap_result["middleware"][0].config - assert middleware_config.middleware.middleware == LitestarOpenTelemetryInstrumentationMiddleware +def test_litestar_opentelemetry_instrument_does_not_exclude_anything_by_default( + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + minimal_opentelemetry_config.opentelemetry_exclude_urls = [] + minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = True + opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) + opentelemetry_instrument.bootstrap() + + plugin: typing.Final = opentelemetry_instrument.bootstrap_before()["plugins"][0] + + # An empty list must not be passed: Litestar would compile it into a pattern that matches every path + assert plugin.config.exclude is None @pytest.mark.parametrize( @@ -167,42 +207,62 @@ async def root() -> dict[str, str]: assert mock_function.call_args_list[0].args[0].get("path_template") == expected_path_template -def test_litestar_opentelemetry_middleware_initialization() -> None: - mock_app: typing.Final = Mock() +def test_litestar_opentelemetry_server_spans_use_path_template( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + @litestar.get("/users/{user_id:int}") + async def get_user(user_id: int) -> dict[str, int]: + return {"user_id": user_id} + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[get_user])) + .bootstrap() + ) - mock_config: typing.Final = Mock(spec=LitestarOpentelemetryConfig) - mock_config.scopes = ["http"] - mock_config.exclude = [] - mock_config.exclude_opt_key = None - mock_config.client_request_hook_handler = None - mock_config.client_response_hook_handler = None - mock_config.exclude_urls_env_key = None - mock_config.meter = None - mock_config.meter_provider = None - mock_config.server_request_hook_handler = None - mock_config.tracer_provider = None + with TestClient(app=application) as client: + assert client.get("/users/123").status_code == HTTP_200_OK - middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware(config=mock_config) + assert client.get("/missing").status_code == HTTP_404_NOT_FOUND - assert middleware.config == mock_config - otel_middleware = middleware.create_open_telemetry_middleware(mock_app) - assert otel_middleware is not None + server_spans: typing.Final = [span for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] + assert [span.name for span in server_spans] == ["GET /users/{user_id}", "GET /missing"] + assert server_spans[0].attributes is not None + assert server_spans[0].attributes["http.route"] == "/users/{user_id}" + assert server_spans[0].attributes["http.status_code"] == HTTP_200_OK -def test_litestar_opentelemetry_excludes_urls_from_settings( - monkeypatch: pytest.MonkeyPatch, +def test_litestar_opentelemetry_keeps_user_plugins( + span_exporter: InMemorySpanExporter, minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: - for environment_variable in ("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"): - monkeypatch.delenv(environment_variable, raising=False) - span_exporter: typing.Final = InMemorySpanExporter() + class UserPlugin(InitPluginProtocol): + def on_app_init(self, app_config: AppConfig) -> AppConfig: + return app_config - def build_tracer_provider(*args: typing.Any, **kwargs: typing.Any) -> SdkTracerProvider: # noqa: ANN401 - tracer_provider: typing.Final = SdkTracerProvider(*args, **kwargs) - tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) - return tracer_provider + @litestar.get("/public") + async def public() -> None: ... - monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", build_tracer_provider) + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[public], plugins=[UserPlugin()])) + .bootstrap() + ) + + assert any(isinstance(plugin, UserPlugin) for plugin in application.plugins.init) + assert any(isinstance(plugin, OpenTelemetryPlugin) for plugin in application.plugins.init) + with TestClient(app=application) as client: + client.get("/public") + assert server_span_names(span_exporter) == ["GET /public"] + + +def test_litestar_opentelemetry_excludes_urls_from_settings( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/internal"] @litestar.get("/internal") @@ -223,18 +283,37 @@ async def public() -> None: ... assert not span_exporter.get_finished_spans() client.get("/public") - assert [span.name for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] == ["GET /public"] + assert server_span_names(span_exporter) == ["GET /public"] def test_litestar_opentelemetry_excludes_urls_from_environment( + span_exporter: InMemorySpanExporter, monkeypatch: pytest.MonkeyPatch, + minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: monkeypatch.setenv("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "/from-env") - middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware( - LitestarOpentelemetryConfig(), - exclude_urls=["/from-settings"], + minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/from-settings"] + + @litestar.get("/from-env") + async def from_env() -> None: ... + + @litestar.get("/from-settings") + async def from_settings() -> None: ... + + @litestar.get("/other") + async def other() -> None: ... + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[from_env, from_settings, other])) + .bootstrap() ) - assert middleware.excluded_urls.url_disabled("/from-env") - assert middleware.excluded_urls.url_disabled("/from-settings") - assert not middleware.excluded_urls.url_disabled("/other") + with TestClient(app=application) as client: + client.get("/from-env") + client.get("/from-settings") + assert not span_exporter.get_finished_spans() + + client.get("/other") + assert server_span_names(span_exporter) == ["GET /other"] diff --git a/tests/instruments/test_opentelemetry.py b/tests/instruments/test_opentelemetry.py index 72dbe36..d88c02e 100644 --- a/tests/instruments/test_opentelemetry.py +++ b/tests/instruments/test_opentelemetry.py @@ -1,12 +1,12 @@ -import contextlib import typing from unittest import mock -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, patch import fastapi import litestar import pytest from fastapi.testclient import TestClient as FastAPITestClient +from litestar.plugins.opentelemetry import OpenTelemetryPlugin from litestar.testing import TestClient as LitestarTestClient from opentelemetry import baggage, context from opentelemetry.context import Context @@ -18,7 +18,6 @@ from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument from microbootstrap.bootstrappers.litestar import ( LitestarOpentelemetryInstrument, - LitestarOpenTelemetryInstrumentationMiddleware, ) from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.opentelemetry_instrument import BaggageSpanProcessor, OpentelemetryInstrument @@ -218,10 +217,10 @@ def test_litestar_opentelemetry_bootstrap( opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before() assert opentelemetry_bootstrap_result - assert "middleware" in opentelemetry_bootstrap_result - assert isinstance(opentelemetry_bootstrap_result["middleware"], list) - assert len(opentelemetry_bootstrap_result["middleware"]) == 1 - assert isinstance(opentelemetry_bootstrap_result["middleware"][0], LitestarOpenTelemetryInstrumentationMiddleware) + assert "plugins" in opentelemetry_bootstrap_result + assert isinstance(opentelemetry_bootstrap_result["plugins"], list) + assert len(opentelemetry_bootstrap_result["plugins"]) == 1 + assert isinstance(opentelemetry_bootstrap_result["plugins"][0], OpenTelemetryPlugin) def test_litestar_opentelemetry_teardown( @@ -236,17 +235,11 @@ def test_litestar_opentelemetry_teardown( def test_litestar_opentelemetry_bootstrap_working( minimal_opentelemetry_config: OpentelemetryConfig, - async_mock: AsyncMock, ) -> None: test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) test_opentelemetry_instrument.bootstrap() opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before() - opentelemetry_middleware = opentelemetry_bootstrap_result["middleware"][0] - assert isinstance(opentelemetry_middleware, LitestarOpenTelemetryInstrumentationMiddleware) - async_mock.__name__ = "test-name" - opentelemetry_middleware.handle = async_mock # type: ignore[method-assign] - @litestar.get("/test-handler") async def test_handler() -> None: return None @@ -255,11 +248,9 @@ async def test_handler() -> None: route_handlers=[test_handler], **opentelemetry_bootstrap_result, ) - with LitestarTestClient(app=litestar_application) as test_client: - # Silencing error, because we are mocking middleware call, so ASGI scope remains unchanged. - with contextlib.suppress(AssertionError): - test_client.get("/test-handler") - assert async_mock.called + with patch("opentelemetry.trace.use_span") as mock_use_span, LitestarTestClient(app=litestar_application) as client: + client.get("/test-handler") + assert mock_use_span.called def test_fastapi_opentelemetry_bootstrap_working( From d851cc743a9ea339309ce369eadb1d4d6ec626f8 Mon Sep 17 00:00:00 2001 From: Alexander Niyazov Date: Tue, 13 Oct 2026 19:29:11 +0500 Subject: [PATCH 2/2] Anchor Litestar trace exclusions and name unmatched spans by method only Litestar searches the plugin's exclude patterns unanchored in scope["path"], so the derived paths (prometheus_metrics_path, and health_checks_path when health spans are off) are now anchored as ^(?:/|$) by define_excluded_path_patterns(), the path-based counterpart of define_excluded_url_patterns(); user-supplied regexes stay verbatim. /health and /health/ are both excluded, /api/metrics-report and /api/health/details keep their spans. The plugin opens the span before routing, so the extractor no longer falls back to the raw path: an unmatched request gets a span named by the method alone and no http.route, keeping cardinality bounded under a 404 scan. Matched routes are still renamed by LitestarOpentelemetryRouteMiddleware. Co-Authored-By: Claude Fable 5.1 --- microbootstrap/bootstrappers/litestar.py | 21 +++-- .../instruments/opentelemetry_instrument.py | 7 ++ .../test_litestar_opentelemetry.py | 80 ++++++++++++++----- 3 files changed, 75 insertions(+), 33 deletions(-) diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index 26e85ba..c353698 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -248,16 +248,13 @@ def build_litestar_route_details_from_scope( """ path_template: typing.Final = scope.get("path_template") method: typing.Final = str(scope.get("method", "HTTP")).strip() - if path_template is not None: - path_template_stripped: typing.Final = path_template.strip() - span_name: typing.Final = opentelemetry_instrument.build_span_name(method, path_template_stripped) - return span_name, {"http.route": path_template_stripped} - - path: typing.Final = scope.get("path") - if path is not None: - path_stripped: typing.Final = path.strip() - return opentelemetry_instrument.build_span_name(method, path_stripped), {"http.route": path_stripped} - return method, {} + # Unmatched paths get only the method and no `http.route`: a raw path would let scanners explode span cardinality + if path_template is None: + return method, {} + path_template_stripped: typing.Final = path_template.strip() + return opentelemetry_instrument.build_span_name(method, path_template_stripped), { + "http.route": path_template_stripped + } class LitestarOpentelemetryRouteMiddleware(ASGIMiddleware): @@ -281,12 +278,12 @@ def bootstrap_before(self) -> dict[str, typing.Any]: } def build_litestar_opentelemetry_config(self) -> LitestarOpentelemetryConfig: - # `exclude` bypasses the plugin middleware for the configured urls; the plugin itself still honors + # `exclude` bypasses the plugin middleware for the configured paths; the plugin itself still honors # `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` through `exclude_urls_env_key`. return LitestarOpentelemetryConfig( tracer_provider=self.tracer_provider, scope_span_details_extractor=build_litestar_route_details_from_scope, - exclude=self.define_exclude_urls() or None, + exclude=self.define_excluded_path_patterns() or None, ) diff --git a/microbootstrap/instruments/opentelemetry_instrument.py b/microbootstrap/instruments/opentelemetry_instrument.py index 361257d..6d85a7e 100644 --- a/microbootstrap/instruments/opentelemetry_instrument.py +++ b/microbootstrap/instruments/opentelemetry_instrument.py @@ -214,6 +214,13 @@ def define_excluded_url_patterns(self) -> list[str]: ] return [*self.instrument_config.opentelemetry_exclude_urls, *anchored_paths] + def define_excluded_path_patterns(self) -> list[str]: + # The path-based counterpart of define_excluded_url_patterns() for middlewares that search a bare path + anchored_paths: typing.Final = [ + rf"^{re.escape(path.rstrip('/'))}(?:/|$)" for path in self.define_excluded_paths() if path.rstrip("/") + ] + return [*self.instrument_config.opentelemetry_exclude_urls, *anchored_paths] + def is_ready(self) -> bool: return ( bool(self.instrument_config.opentelemetry_endpoint) diff --git a/tests/bootstrappers/test_litestar_opentelemetry.py b/tests/bootstrappers/test_litestar_opentelemetry.py index ca6a98c..f47e7cf 100644 --- a/tests/bootstrappers/test_litestar_opentelemetry.py +++ b/tests/bootstrappers/test_litestar_opentelemetry.py @@ -61,29 +61,15 @@ def server_span_names(span_exporter: InMemorySpanExporter) -> list[str]: "path": "/users/123", "method": "POST", }, - "POST /users/123", - {"http.route": "/users/123"}, + "POST", + {}, ), ( { "path": "/test", }, - "HTTP /test", - {"http.route": "/test"}, - ), - ( - { - "path": "", - }, - "HTTP", - {"http.route": ""}, - ), - ( - { - "path": " ", - }, "HTTP", - {"http.route": ""}, + {}, ), ( { @@ -133,7 +119,8 @@ def test_build_litestar_route_details_from_scope( def test_litestar_opentelemetry_instrument_uses_litestar_plugin( minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: - minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/metrics"] + minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/internal.*"] + minimal_opentelemetry_config.prometheus_metrics_path = "/metrics" minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = False minimal_opentelemetry_config.health_checks_path = "/health/" opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) @@ -147,13 +134,15 @@ def test_litestar_opentelemetry_instrument_uses_litestar_plugin( assert isinstance(plugin, OpenTelemetryPlugin) assert plugin.config.tracer_provider is opentelemetry_instrument.tracer_provider assert plugin.config.scope_span_details_extractor is build_litestar_route_details_from_scope - assert plugin.config.exclude == ["/metrics", "/health/"] + # User regexes stay verbatim, derived paths are anchored with an optional trailing slash + assert plugin.config.exclude == ["/internal.*", "^/metrics(?:/|$)", "^/health(?:/|$)"] -def test_litestar_opentelemetry_instrument_does_not_exclude_anything_by_default( +def test_litestar_opentelemetry_instrument_passes_no_exclusions_when_nothing_is_derived( minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: minimal_opentelemetry_config.opentelemetry_exclude_urls = [] + minimal_opentelemetry_config.prometheus_metrics_path = "" minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = True opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) opentelemetry_instrument.bootstrap() @@ -228,10 +217,14 @@ async def get_user(user_id: int) -> dict[str, int]: assert client.get("/missing").status_code == HTTP_404_NOT_FOUND server_spans: typing.Final = [span for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] - assert [span.name for span in server_spans] == ["GET /users/{user_id}", "GET /missing"] + # An unmatched path keeps the method-only name without `http.route`, so a 404 scan yields one span name + assert [span.name for span in server_spans] == ["GET /users/{user_id}", "GET"] assert server_spans[0].attributes is not None assert server_spans[0].attributes["http.route"] == "/users/{user_id}" assert server_spans[0].attributes["http.status_code"] == HTTP_200_OK + assert server_spans[1].attributes is not None + assert "http.route" not in server_spans[1].attributes + assert server_spans[1].attributes["http.status_code"] == HTTP_404_NOT_FOUND def test_litestar_opentelemetry_keeps_user_plugins( @@ -317,3 +310,48 @@ async def other() -> None: ... client.get("/other") assert server_span_names(span_exporter) == ["GET /other"] + + +def test_litestar_opentelemetry_exclusions_do_not_silence_lookalike_routes( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = False + + @litestar.get("/api/metrics-report") + async def metrics_report() -> None: ... + + @litestar.get("/api/health/details") + async def health_details() -> None: ... + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[metrics_report, health_details])) + .bootstrap() + ) + + with TestClient(app=application) as client: + # Litestar strips the trailing slash from scope["path"], so both forms of the health path must be excluded + for path in ("/metrics", "/health/", "/health", "/api/metrics-report", "/api/health/details"): + assert client.get(path).status_code == HTTP_200_OK + + assert server_span_names(span_exporter) == ["GET /api/metrics-report", "GET /api/health/details"] + + +def test_litestar_opentelemetry_custom_metrics_path_is_excluded( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + minimal_opentelemetry_config.prometheus_metrics_path = "/custom-metrics" + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings(prometheus_metrics_path="/custom-metrics")) + .configure_instrument(minimal_opentelemetry_config) + .bootstrap() + ) + + with TestClient(app=application) as client: + assert client.get("/custom-metrics").status_code == HTTP_200_OK + + assert server_span_names(span_exporter) == []