From 0b79077842b376544272875d5ea91793e1440fc6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 3 Nov 2026 08:39:49 +0300 Subject: [PATCH] Reject commas in FastAPI and FastMCP tracing exclusions Closes #393 Breaking changes: FastAPIConfig and FastMcpConfig now reject exclusion regexes containing commas at construction. --- docs/introduction/configuration.md | 2 +- lite_bootstrap/bootstrappers/fastapi_bootstrapper.py | 1 + lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py | 1 + .../instruments/opentelemetry_instrument.py | 12 +++++++++++- tests/test_fastapi_bootstrap.py | 12 +++++++++++- tests/test_litestar_bootstrap.py | 2 +- 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 2ead4ba..4ab949f 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -223,7 +223,7 @@ Additional parameters: health-check path. While it is `False`, OpenTelemetry's HTTP metrics also leave health-check requests out, because the instrumentation skips an excluded URL entirely. Set it to `True` to trace probes, for example to watch their latency. -- `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_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. FastAPI and FastMCP raise `ConfigurationError` at config construction if an entry contains a comma because OpenTelemetry uses commas to separate patterns. Litestar accepts commas in config entries. 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 ef652f8..8f0c0ea 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -79,6 +79,7 @@ class FastAPIConfig( prometheus_instrument_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) prometheus_expose_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) logging_access_log_enabled: bool = False + _opentelemetry_exclusions_use_comma_separator: typing.ClassVar[bool] = True _sentry_lean_replaced_integrations: typing.ClassVar[Mapping[str, str]] = { "starlette": "StarletteIntegration", "fastapi": "FastApiIntegration", diff --git a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py index dbc9942..44f0b50 100644 --- a/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py @@ -197,6 +197,7 @@ class FastMcpConfig( fastmcp_prometheus_tool_metrics_enabled: bool = True prometheus_instrumentator_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) prometheus_instrument_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + _opentelemetry_exclusions_use_comma_separator: typing.ClassVar[bool] = True _sentry_lean_replaced_integrations: typing.ClassVar[Mapping[str, str]] = {"starlette": "StarletteIntegration"} diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index 0d32f6e..ef8bb23 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -5,7 +5,7 @@ import urllib.parse from lite_bootstrap import import_checker -from lite_bootstrap.exceptions import InstrumentDependencyMissingWarning, collect_teardown_errors +from lite_bootstrap.exceptions import ConfigurationError, InstrumentDependencyMissingWarning, collect_teardown_errors from lite_bootstrap.helpers.warn import warn_at_caller from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument @@ -137,6 +137,16 @@ class OpenTelemetryAsgiConfig(OpenTelemetryConfig): default_factory=lambda: ["receive", "send"] ) + _opentelemetry_exclusions_use_comma_separator: typing.ClassVar[bool] = False + + def __post_init__(self) -> None: + super().__post_init__() + if self._opentelemetry_exclusions_use_comma_separator: + for pattern in self.opentelemetry_excluded_urls: + if "," in pattern: + msg = f"opentelemetry_excluded_urls entry {pattern!r} contains a comma, which separates patterns" + raise ConfigurationError(msg) + if import_checker.is_opentelemetry_sdk_installed and import_checker.is_pyroscope_installed: _OTEL_PROFILE_ID_KEY: typing.Final = "pyroscope.profile.id" diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index ba3316b..c448a8a 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -2,6 +2,7 @@ import dataclasses import json import logging +import re import typing import uuid import warnings @@ -20,7 +21,7 @@ from starlette import status from starlette.testclient import TestClient -from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig, import_checker +from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig, FastMcpConfig, import_checker from lite_bootstrap.bootstrappers import fastapi_bootstrapper from lite_bootstrap.exceptions import ConfigurationError, InstrumentDependencyMissingWarning from lite_bootstrap.instruments.healthchecks_instrument import HealthProbe @@ -45,6 +46,15 @@ std_logger = logging.getLogger() +@pytest.mark.parametrize("config_class", [FastAPIConfig, FastMcpConfig]) +def test_config_rejects_comma_in_opentelemetry_excluded_url( + config_class: type[FastAPIConfig] | type[FastMcpConfig], +) -> None: + pattern = r"/v\d{1,2}/ping$" + with pytest.raises(ConfigurationError, match=re.escape(repr(pattern))): + config_class(opentelemetry_excluded_urls=[pattern]) + + @pytest.fixture def fastapi_config() -> FastAPIConfig: return FastAPIConfig( diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 21ebc22..693a165 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -611,7 +611,7 @@ async def get_item(item_id: int) -> dict[str, int]: config = dataclasses.replace( litestar_config, application_config=AppConfig(route_handlers=[get_item]), - opentelemetry_excluded_urls=[r"/items/\d+$"], + opentelemetry_excluded_urls=[r"/items/\d{1,2}$"], ) application = LitestarBootstrapper(bootstrap_config=config).bootstrap()