From cf95374b418d62f9072c490d002a0a0f35f9e5f9 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 9 Oct 2026 22:56:35 +0300 Subject: [PATCH] fix: anchor FastAPI trace exclusions and honor the exclusion environment variables FastAPI handed OpenTelemetry the derived metrics and health paths unanchored, and ExcludeList searches them anywhere in the full URL, so /custom-metrics/ also silenced /api/custom-metrics/report. The anchoring FastMCP already used moves to OpenTelemetryInstrument._build_excluded_url_patterns and FastAPI uses it too. Passing an exclude list made the FastAPI and FastMCP instrumentation ignore OTEL_PYTHON_FASTAPI_EXCLUDED_URLS / OTEL_PYTHON_STARLETTE_EXCLUDED_URLS (and OTEL_PYTHON_EXCLUDED_URLS); their entries are now added to the list. --- docs/integrations/fastmcp.md | 2 +- docs/introduction/configuration.md | 2 +- .../bootstrappers/fastapi_bootstrapper.py | 2 +- .../bootstrappers/fastmcp_bootstrapper.py | 21 +------ .../instruments/opentelemetry_instrument.py | 24 ++++++++ tests/test_fastapi_bootstrap.py | 59 +++++++++++++++++++ tests/test_fastmcp_bootstrap.py | 15 +++++ 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/docs/integrations/fastmcp.md b/docs/integrations/fastmcp.md index ed292a4..3316f77 100644 --- a/docs/integrations/fastmcp.md +++ b/docs/integrations/fastmcp.md @@ -80,7 +80,7 @@ route, such as `POST /mcp` or `GET /health/`, with the route in `http.route`. A unknown path produces a span named after the HTTP method alone, without `http.route`. The metrics path, the health-check path (unless `opentelemetry_generate_health_check_spans` is on) -and `opentelemetry_excluded_urls` produce no spans. An application already instrumented by +`opentelemetry_excluded_urls` and `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` produce no spans. An application already instrumented by `StarletteInstrumentor` is left as is, so no request is traced twice. Teardown is wired through FastMCP's provider lifecycle — `bootstrapper.teardown()` diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 0cdc4ab..57c040f 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -134,7 +134,7 @@ Additional parameters: - `opentelemetry_log_traces` - traces will be logged to stdout. - `opentelemetry_sampler` - an `opentelemetry.sdk.trace.sampling.Sampler` deciding which traces are recorded. Unset, the SDK's own default applies: `parentbased_always_on`, which records every trace that is not the child of a non-recording remote parent, unless `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` say otherwise. - `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`. -- `opentelemetry_excluded_urls` - extra regexes for URLs excluded from tracing; the metrics path and (unless health-check spans are enabled) the health-check path are excluded automatically. Litestar searches them in the request path and also honors `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS`; FastAPI searches them in the full URL. +- `opentelemetry_excluded_urls` - extra regexes for URLs excluded from tracing; the metrics path and (unless health-check spans are enabled) the health-check path are excluded automatically. Litestar searches them in the request path, FastAPI and FastMCP in the full URL. The environment variable OpenTelemetry defines for the framework (`OTEL_PYTHON_LITESTAR_EXCLUDED_URLS`, `OTEL_PYTHON_FASTAPI_EXCLUDED_URLS` or `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` for FastMCP), falling back to `OTEL_PYTHON_EXCLUDED_URLS`, adds to them. - `opentelemetry_metrics_endpoint` - will be passed to `OTLPMetricExporter` as endpoint, and turns on the metrics signal. Its own field rather than a flag on `opentelemetry_endpoint`, so upgrading never starts exporting metrics you did not ask for. Under `opentelemetry_exporter_protocol="http"` this is a full URL (e.g. `http://collector:4318/v1/metrics`); `opentelemetry_exporter_protocol` and `opentelemetry_insecure` are shared with traces. ### Metrics diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 177ea8b..9dc3b6f 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -203,7 +203,7 @@ def bootstrap(self) -> None: app=self.bootstrap_config.app, tracer_provider=get_tracer_provider(), meter_provider=get_meter_provider(), - excluded_urls=",".join(self._build_excluded_urls()), + excluded_urls=",".join(self._build_excluded_url_patterns("FASTAPI")), exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans, ) diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index 06c04b2..ef263ec 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -48,9 +48,6 @@ _DEFAULT_METRICS_PARAMETERS: typing.Final = frozenset(inspect.signature(instrumentator_metrics.default).parameters) -# OpenTelemetryMiddleware matches its patterns against a full URL, not a bare path. -_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*" - _TOOL_CALL_STATUS_SUCCESS: typing.Final = "success" _TOOL_CALL_STATUS_ERROR: typing.Final = "error" @@ -230,22 +227,6 @@ class FastMcpOpenTelemetryInstrument(OpenTelemetryInstrument): def dependencies_installed() -> bool: return OpenTelemetryInstrument.dependencies_installed() and import_checker.is_fastmcp_opentelemetry_installed - def _build_excluded_url_patterns(self) -> list[str]: - """Anchored patterns for the derived paths, plus the caller's own entries verbatim. - - The trailing slash is stripped and matched optionally, so ``/health/`` excludes the path with - or without it. Anchoring is needed because ``ExcludeList`` searches unanchored: a bare - ``/health`` would also silence ``/healthy``. Caller-supplied entries stay untouched because - OpenTelemetry documents them as 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("/")) - } - return sorted(anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls)) - def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "StarletteWithLifespan": if getattr(http_application, _OPENTELEMETRY_INSTRUMENTED_MARKER, False): return http_application @@ -255,7 +236,7 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St build_fastmcp_route_details_from_scope, routes=http_application.routes ), # OpenTelemetryMiddleware only parses a raw string from 0.56b0; the floor is 0.49b0. - excluded_urls=parse_excluded_urls(",".join(self._build_excluded_url_patterns())), + excluded_urls=parse_excluded_urls(",".join(self._build_excluded_url_patterns("STARLETTE"))), tracer_provider=get_tracer_provider(), meter_provider=get_meter_provider(), ) diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index 221f1aa..9da3961 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -1,6 +1,7 @@ import dataclasses import logging import os +import re import typing import urllib.parse @@ -60,6 +61,9 @@ 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", ""}) @@ -199,6 +203,26 @@ def _build_infrastructure_excluded_paths(self) -> set[str]: 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("/")) + } + 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) + def _silence_otel_loggers(self) -> None: for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"): otel_logger = logging.getLogger(logger_name) diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index 329b480..dfda088 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -13,6 +13,10 @@ import structlog from opentelemetry.metrics import get_meter_provider from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider +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, get_tracer_provider from starlette import status from starlette.testclient import TestClient @@ -442,3 +446,58 @@ def test_fastapi_otel_hands_the_instrumentor_the_meter_provider(fastapi_config: assert mock_instrument_app.call_args.kwargs["meter_provider"] is get_meter_provider() assert isinstance(get_meter_provider(), SDKMeterProvider) + + +def _server_span_names_for(config: FastAPIConfig, paths: typing.Iterable[str]) -> list[str]: + application = config.app + + @application.get("/api/custom-metrics/report") + async def metrics_report() -> str: + return "ok" + + @application.get("/custom-healthy") + async def custom_healthy() -> str: + return "ok" + + @application.get("/from-env") + async def from_env() -> str: + return "ok" + + bootstrapper = FastAPIBootstrapper(bootstrap_config=config) + bootstrapped_application = bootstrapper.bootstrap() + tracer_provider = get_tracer_provider() + assert isinstance(tracer_provider, SDKTracerProvider) + exporter = InMemorySpanExporter() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + try: + with TestClient(bootstrapped_application) as client: + for path in paths: + client.get(path, follow_redirects=False) + finally: + bootstrapper.teardown() + return [span.name for span in exporter.get_finished_spans() if span.kind == SpanKind.SERVER] + + +def test_fastapi_otel_excludes_derived_paths_only_where_they_match_whole_segments( + fastapi_config: FastAPIConfig, +) -> None: + """REGRESSION: `/custom-metrics/` was searched anywhere in the URL, silencing `/api/custom-metrics/report`.""" + span_names = _server_span_names_for( + fastapi_config, + ["/custom-metrics/", "/custom-health/", "/api/custom-metrics/report", "/custom-healthy"], + ) + + assert sorted(span_names) == ["GET /api/custom-metrics/report", "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 +) -> None: + for one_variable in ("OTEL_PYTHON_FASTAPI_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"): + monkeypatch.delenv(one_variable, raising=False) + monkeypatch.setenv(environment_variable, "/from-env") + + span_names = _server_span_names_for(fastapi_config, ["/from-env", "/custom-healthy"]) + + assert span_names == ["GET /custom-healthy"] diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 287acc2..45d4d78 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -626,3 +626,18 @@ def postprocess(http_application: StarletteWithLifespan) -> StarletteWithLifespa restore_first() assert "http_app" not in vars(application) + + +def test_fastmcp_otel_honors_excluded_urls_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OTEL_PYTHON_EXCLUDED_URLS", raising=False) + monkeypatch.setenv("OTEL_PYTHON_STARLETTE_EXCLUDED_URLS", "/mcp") + with ( + _bootstrapped_with_span_exporter() as (bootstrapper, application, exporter), + TestClient(application.http_app()) as client, + ): + client.post("/mcp", json={}) + client.get(bootstrapper.bootstrap_config.health_checks_path) + + assert [span.name for span in _server_spans(exporter)] == [ + f"GET {bootstrapper.bootstrap_config.health_checks_path}" + ]