From 5f8e27e7ed212e542e02ffa7524447ff3077f95b Mon Sep 17 00:00:00 2001 From: Alexander Niyazov Date: Mon, 5 Oct 2026 13:57:24 +0500 Subject: [PATCH] Honor opentelemetry_exclude_urls in the Litestar bootstrapper LitestarOpenTelemetryInstrumentationMiddleware read exclusions only from OTEL_PYTHON_LITESTAR_EXCLUDED_URLS, so the opentelemetry_exclude_urls setting was ignored for Litestar. Combine define_exclude_urls() with the env-based list (CombinedExcludeList) and build it once instead of per request. define_exclude_urls moves to BaseOpentelemetryInstrument and build_span_name to instruments/opentelemetry_instrument.py so other bootstrappers can reuse them without importing the Litestar module. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +- microbootstrap/bootstrappers/litestar.py | 28 +++++----- .../instruments/opentelemetry_instrument.py | 36 ++++++++---- .../test_litestar_opentelemetry.py | 56 +++++++++++++++++++ 4 files changed, 99 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index edc91f7..b19c7ba 100644 --- a/README.md +++ b/README.md @@ -440,7 +440,7 @@ class YourSettings(BaseServiceSettings): opentelemetry_namespace: str | None = None opentelemetry_insecure: bool = True opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = [] - opentelemetry_exclude_urls: list[str] = [] + opentelemetry_exclude_urls: list[str] = ["/metrics"] opentelemetry_baggage_span_attributes: dict[str, str] = {} ... # Other settings here @@ -456,9 +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` - list of ignored urls. +- `opentelemetry_exclude_urls` - list of url regexes that produce no server spans (`["/metrics"]` by default). For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`). - `opentelemetry_log_traces` - traces will be logged to stdout. -- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True` +- `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. These settings are subsequently passed to [opentelemetry](https://opentelemetry.io/), finalizing your Opentelemetry integration. diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index bf827a8..4fb249f 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -14,18 +14,18 @@ from litestar.types.asgi_types import ASGIApp, Scope from litestar_offline_docs import generate_static_files_config from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware -from opentelemetry.util.http import get_excluded_urls +from opentelemetry.util.http import ExcludeList, get_excluded_urls from sentry_sdk.integrations.litestar import LitestarIntegration from microbootstrap.bootstrappers.base import ApplicationBootstrapper from microbootstrap.config.litestar import LitestarConfig +from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.cors_instrument import CorsInstrument from microbootstrap.instruments.health_checks_instrument import ( HealthChecksInstrument, HealthCheckTypedDict, ) from microbootstrap.instruments.logging_instrument import LoggingInstrument -from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import ( LitestarPrometheusConfig, PrometheusInstrument, @@ -122,12 +122,6 @@ def bootstrap_before(self) -> dict[str, typing.Any]: LitestarBootstrapper.use_instrument()(PyroscopeInstrument) -def build_span_name(method: str, route: str) -> str: - if not route: - return method - return f"{method} {route}" - - def build_litestar_route_details_from_scope( scope: Scope, ) -> tuple[str, dict[str, str]]: @@ -144,18 +138,23 @@ def build_litestar_route_details_from_scope( method: typing.Final = str(scope.get("method", "HTTP")).strip() if path_template is not None: path_template_stripped: typing.Final = path_template.strip() - return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped} + 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 build_span_name(method, path_stripped), {"http.route": path_stripped} + return opentelemetry_instrument.build_span_name(method, path_stripped), {"http.route": path_stripped} return method, {} class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, config: OpenTelemetryConfig) -> None: + 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( @@ -163,7 +162,7 @@ def create_open_telemetry_middleware(self, app: ASGIApp) -> OpenTelemetryMiddlew 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=get_excluded_urls(self.config.exclude_urls_env_key), + excluded_urls=self.excluded_urls, meter=self.config.meter, meter_provider=self.config.meter_provider, server_request_hook=self.config.server_request_hook_handler, @@ -175,7 +174,7 @@ async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASG @LitestarBootstrapper.use_instrument() -class LitestarOpentelemetryInstrument(OpentelemetryInstrument): +class LitestarOpentelemetryInstrument(opentelemetry_instrument.OpentelemetryInstrument): def bootstrap_before(self) -> dict[str, typing.Any]: return { "middleware": [ @@ -183,7 +182,8 @@ def bootstrap_before(self) -> dict[str, typing.Any]: LitestarOpentelemetryConfig( tracer_provider=self.tracer_provider, middleware_class=LitestarOpenTelemetryInstrumentationMiddleware, # type: ignore[arg-type] - ) + ), + exclude_urls=self.define_exclude_urls(), ) ] } diff --git a/microbootstrap/instruments/opentelemetry_instrument.py b/microbootstrap/instruments/opentelemetry_instrument.py index dad1ebf..8946486 100644 --- a/microbootstrap/instruments/opentelemetry_instrument.py +++ b/microbootstrap/instruments/opentelemetry_instrument.py @@ -19,6 +19,7 @@ from opentelemetry.semconv.resource import ResourceAttributes from opentelemetry.trace import SpanKind, format_span_id, get_current_span, set_tracer_provider from opentelemetry.util._importlib_metadata import entry_points +from opentelemetry.util.http import ExcludeList from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument from microbootstrap.instruments.sentry_instrument import snapshot_sentry_opentelemetry_baggage @@ -70,6 +71,21 @@ def opentelemetry_baggage_scope( context.detach(token) +def build_span_name(method: str, route: str) -> str: + if not route: + return method + return f"{method} {route}" + + +class CombinedExcludeList(ExcludeList): + def __init__(self, *exclude_lists: ExcludeList) -> None: + super().__init__([]) + self.exclude_lists = exclude_lists + + def url_disabled(self, url: str) -> bool: + return any(one_exclude_list.url_disabled(url) for one_exclude_list in self.exclude_lists) + + @dataclasses.dataclass() class OpenTelemetryInstrumentor: instrumentor: BaseInstrumentor @@ -167,6 +183,16 @@ def _load_instrumentors(self) -> None: LOGGER_OBJ.debug("Instrumenting failed", entry_point_name=entry_point.name) raise + def define_exclude_urls(self) -> list[str]: + exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls] + if ( + not self.instrument_config.opentelemetry_generate_health_check_spans + and self.instrument_config.health_checks_path + and self.instrument_config.health_checks_path not in exclude_urls + ): + exclude_urls.append(self.instrument_config.health_checks_path) + return exclude_urls + def is_ready(self) -> bool: return ( bool(self.instrument_config.opentelemetry_endpoint) @@ -222,16 +248,6 @@ def bootstrap(self) -> None: class OpentelemetryInstrument(BaseOpentelemetryInstrument[OpentelemetryConfig]): - def define_exclude_urls(self) -> list[str]: - exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls] - if ( - not self.instrument_config.opentelemetry_generate_health_check_spans - and self.instrument_config.health_checks_path - and self.instrument_config.health_checks_path not in exclude_urls - ): - exclude_urls.append(self.instrument_config.health_checks_path) - return exclude_urls - @classmethod def get_config_type(cls) -> type[OpentelemetryConfig]: return OpentelemetryConfig diff --git a/tests/bootstrappers/test_litestar_opentelemetry.py b/tests/bootstrappers/test_litestar_opentelemetry.py index 71e1be6..52cd72f 100644 --- a/tests/bootstrappers/test_litestar_opentelemetry.py +++ b/tests/bootstrappers/test_litestar_opentelemetry.py @@ -6,6 +6,10 @@ from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig from litestar.status_codes import HTTP_200_OK from litestar.testing import TestClient +from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import SpanKind from microbootstrap import LitestarSettings from microbootstrap.bootstrappers.litestar import ( @@ -15,6 +19,7 @@ build_litestar_route_details_from_scope, ) from microbootstrap.config.litestar import LitestarConfig +from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig @@ -182,3 +187,54 @@ def test_litestar_opentelemetry_middleware_initialization() -> None: assert middleware.config == mock_config otel_middleware = middleware.create_open_telemetry_middleware(mock_app) assert otel_middleware is not None + + +def test_litestar_opentelemetry_excludes_urls_from_settings( + monkeypatch: pytest.MonkeyPatch, + 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() + + 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 + + monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", build_tracer_provider) + minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/internal"] + + @litestar.get("/internal") + async def internal() -> None: ... + + @litestar.get("/public") + async def public() -> None: ... + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[internal, public])) + .bootstrap() + ) + + with TestClient(app=application) as client: + client.get("/internal") + 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"] + + +def test_litestar_opentelemetry_excludes_urls_from_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "/from-env") + middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware( + LitestarOpentelemetryConfig(), + exclude_urls=["/from-settings"], + ) + + assert middleware.excluded_urls.url_disabled("/from-env") + assert middleware.excluded_urls.url_disabled("/from-settings") + assert not middleware.excluded_urls.url_disabled("/other")