From bd0767db291391ec0d3fc7ebb11cbd41208261dc Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 3 Nov 2026 08:32:16 +0300 Subject: [PATCH] Match derived OpenTelemetry exclusions against ASGI route paths Fixes #392 --- .../bootstrappers/fastapi_bootstrapper.py | 19 +++++++++++ .../bootstrappers/fastmcp_bootstrapper.py | 6 ++-- lite_bootstrap/helpers/opentelemetry_asgi.py | 34 +++++++++++++++++++ .../instruments/opentelemetry_instrument.py | 20 ++--------- tests/test_fastapi_bootstrap.py | 20 +++++++++-- tests/test_fastmcp_bootstrap.py | 4 +-- 6 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 lite_bootstrap/helpers/opentelemetry_asgi.py diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 8f706ba..ef652f8 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -52,7 +52,11 @@ from opentelemetry.trace import get_tracer_provider if import_checker.is_fastapi_opentelemetry_installed: + from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + from starlette.middleware.errors import ServerErrorMiddleware + + from lite_bootstrap.helpers.opentelemetry_asgi import ExcludeInfrastructureTelemetry if import_checker.is_prometheus_fastapi_instrumentator_installed: from prometheus_fastapi_instrumentator import Instrumentator @@ -213,6 +217,21 @@ def bootstrap(self) -> None: excluded_urls=",".join(self._build_excluded_url_patterns("FASTAPI")), exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans, ) + self._install_infrastructure_exclusions() + + def _install_infrastructure_exclusions(self) -> None: + app = self.bootstrap_config.app + build_stack = app.build_middleware_stack + excluded_paths = self._build_infrastructure_excluded_paths() + + def build_middleware_stack() -> "ASGIApp": + stack = build_stack() + # FastAPIInstrumentor places its OTel middleware inside an outer error handler. + if isinstance(stack, ServerErrorMiddleware) and isinstance(stack.app, OpenTelemetryMiddleware): + stack.app = ExcludeInfrastructureTelemetry(stack.app, excluded_paths) + return stack + + app.build_middleware_stack = build_middleware_stack # ty: ignore[invalid-assignment] def teardown(self) -> None: FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.app) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 225fd46..dbc9942 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -31,12 +31,13 @@ from starlette.responses import JSONResponse, Response if import_checker.is_fastmcp_opentelemetry_installed: - from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware from opentelemetry.metrics import get_meter_provider from opentelemetry.trace import get_tracer_provider from starlette.routing import BaseRoute, Match, Mount, Route from starlette.types import Scope + from lite_bootstrap.helpers.opentelemetry_asgi import build_opentelemetry_middleware + if import_checker.is_fastmcp_installed and import_checker.is_sentry_installed: from sentry_sdk.integrations.starlette import StarletteIntegration @@ -233,7 +234,8 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St if getattr(http_application, _OPENTELEMETRY_INSTRUMENTED_MARKER, False): return http_application http_application.add_middleware( - OpenTelemetryMiddleware, + build_opentelemetry_middleware, + infrastructure_paths=self._build_infrastructure_excluded_paths(), default_span_details=functools.partial( build_fastmcp_route_details_from_scope, routes=http_application.routes ), diff --git a/lite_bootstrap/helpers/opentelemetry_asgi.py b/lite_bootstrap/helpers/opentelemetry_asgi.py new file mode 100644 index 0000000..99016dd --- /dev/null +++ b/lite_bootstrap/helpers/opentelemetry_asgi.py @@ -0,0 +1,34 @@ +import typing + +from lite_bootstrap import import_checker + + +if import_checker.is_fastapi_opentelemetry_installed or import_checker.is_fastmcp_opentelemetry_installed: + from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware + from starlette._utils import get_route_path + +if typing.TYPE_CHECKING: + from starlette.types import ASGIApp, Receive, Scope, Send + + +class ExcludeInfrastructureTelemetry: + def __init__(self, app: "OpenTelemetryMiddleware", excluded_paths: set[str]) -> None: + self.app = app + self.excluded_paths = tuple(path for value in excluded_paths if (path := value.rstrip("/"))) + + async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None: + if scope["type"] in ("http", "websocket"): + path = get_route_path(scope) + if any(path == excluded or path.startswith(excluded + "/") for excluded in self.excluded_paths): + await self.app.app(scope, receive, send) + return + await self.app(scope, receive, send) + + +def build_opentelemetry_middleware( + app: "ASGIApp", + *, + infrastructure_paths: set[str], + **kwargs: typing.Any, # noqa: ANN401 +) -> ExcludeInfrastructureTelemetry: + return ExcludeInfrastructureTelemetry(OpenTelemetryMiddleware(app, **kwargs), infrastructure_paths) diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index c9218b0..0d32f6e 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -1,7 +1,6 @@ import dataclasses import logging import os -import re import typing import urllib.parse @@ -61,9 +60,6 @@ class OpenTelemetryServiceFieldsConfig(BaseConfig): opentelemetry_namespace: str | None = None -# OpenTelemetry's ExcludeList searches its patterns in a full URL, not a bare path. -_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*" - _LOCAL_HOSTS: typing.Final[frozenset[str]] = frozenset({"localhost", "127.0.0.1", "::1", ""}) @@ -235,24 +231,12 @@ def _build_excluded_urls(self) -> set[str]: return set(self.bootstrap_config.opentelemetry_excluded_urls) | self._build_infrastructure_excluded_paths() def _build_excluded_url_patterns(self, environment_key: str) -> list[str]: - """Patterns for OpenTelemetry's ``ExcludeList``, which searches them unanchored in the full URL. - - Derived paths are anchored to the start of the path and to a segment end, with the trailing slash - optional, so ``/metrics/`` excludes ``/metrics`` but neither ``/api/metrics/`` nor ``/metricsx``. - Caller-supplied entries and ``OTEL_PYTHON__EXCLUDED_URLS``, falling back to - ``OTEL_PYTHON_EXCLUDED_URLS`` as OpenTelemetry does, stay verbatim because they are regexes. - """ - anchored_patterns: typing.Final = { - rf"{_EXCLUDED_URL_SCHEME_AND_HOST}{re.escape(normalized_path)}(?:/|$)" - for excluded_path in self._build_infrastructure_excluded_paths() - # A bare "/" would anchor to every URL, so it is dropped along with empty values. - if (normalized_path := excluded_path.rstrip("/")) - } + """Combine caller-supplied URL regexes with the OpenTelemetry environment defaults.""" environment_urls: typing.Final = os.environ.get( f"OTEL_PYTHON_{environment_key}_EXCLUDED_URLS", os.environ.get("OTEL_PYTHON_EXCLUDED_URLS", "") ) environment_patterns: typing.Final = {url.strip() for url in environment_urls.split(",") if url.strip()} - return sorted(anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls) | environment_patterns) + return sorted(set(self.bootstrap_config.opentelemetry_excluded_urls) | environment_patterns) def _silence_otel_loggers(self) -> None: for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"): diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index c9e647a..ba3316b 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -597,7 +597,9 @@ def test_fastapi_prometheus_still_serves_metrics_while_opentelemetry_records_htt bootstrapper.teardown() -def _server_span_names_for(config: FastAPIConfig, paths: typing.Iterable[str]) -> list[str]: +def _server_span_names_for( + config: FastAPIConfig, paths: typing.Iterable[str], *, host: str = "testserver", root_path: str = "" +) -> list[str]: application = config.app @application.get("/api/custom-metrics/report") @@ -619,9 +621,9 @@ async def from_env() -> str: exporter = InMemorySpanExporter() tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) try: - with TestClient(bootstrapped_application) as client: + with TestClient(bootstrapped_application, root_path=root_path) as client: for path in paths: - client.get(path, follow_redirects=False) + client.get(path, headers={"host": host}, follow_redirects=False) finally: bootstrapper.teardown() return [span.name for span in exporter.get_finished_spans() if span.kind == SpanKind.SERVER] @@ -649,6 +651,18 @@ def test_fastapi_otel_leaves_the_health_check_untraced_by_default() -> None: assert _server_span_names_for(config, ["/custom-health/", "/custom-healthy"]) == ["GET /custom-healthy"] +def test_fastapi_otel_host_header_cannot_exclude_a_work_request(fastapi_config: FastAPIConfig) -> None: + assert _server_span_names_for(fastapi_config, ["/custom-healthy"], host="x/custom-health") == [ + "GET /custom-healthy" + ] + + +def test_fastapi_otel_excludes_health_under_root_path(fastapi_config: FastAPIConfig) -> None: + assert _server_span_names_for(fastapi_config, ["/api/custom-health/", "/api/custom-healthy"], root_path="/api") == [ + "GET /custom-healthy" + ] + + @pytest.mark.parametrize("environment_variable", ["OTEL_PYTHON_FASTAPI_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"]) def test_fastapi_otel_honors_excluded_urls_from_environment( fastapi_config: FastAPIConfig, monkeypatch: pytest.MonkeyPatch, environment_variable: str diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 26d2a7c..6e60e5b 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -12,7 +12,6 @@ from fastmcp.server.context import Context from fastmcp.server.http import StarletteWithLifespan from fastmcp.server.middleware import MiddlewareContext -from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -31,6 +30,7 @@ _postprocess_http_apps, ) from lite_bootstrap.exceptions import ConfigurationError +from lite_bootstrap.helpers.opentelemetry_asgi import build_opentelemetry_middleware from lite_bootstrap.instruments.healthchecks_instrument import HealthProbe from tests.conftest import ( HEALTH_PROBE_CASES, @@ -435,7 +435,7 @@ def test_second_fastmcp_bootstrapper_bootstrap_raises() -> None: def _count_opentelemetry_middlewares(http_application: Starlette) -> int: - return sum(middleware.cls is OpenTelemetryMiddleware for middleware in http_application.user_middleware) + return sum(middleware.cls is build_opentelemetry_middleware for middleware in http_application.user_middleware) def _server_spans(exporter: InMemorySpanExporter) -> list[ReadableSpan]: