Skip to content

Commit caa79ab

Browse files
authored
fix: anchor FastAPI trace exclusions and honor the exclusion environment variables (#282)
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.
1 parent 41db5bd commit caa79ab

7 files changed

Lines changed: 102 additions & 23 deletions

File tree

‎docs/integrations/fastmcp.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ route, such as `POST /mcp` or `GET /health/`, with the route in `http.route`. A
8080
unknown path produces a span named after the HTTP method alone, without `http.route`.
8181

8282
The metrics path, the health-check path (unless `opentelemetry_generate_health_check_spans` is on)
83-
and `opentelemetry_excluded_urls` produce no spans. An application already instrumented by
83+
`opentelemetry_excluded_urls` and `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` produce no spans. An application already instrumented by
8484
`StarletteInstrumentor` is left as is, so no request is traced twice.
8585

8686
Teardown is wired through FastMCP's provider lifecycle — `bootstrapper.teardown()`

‎docs/introduction/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Additional parameters:
134134
- `opentelemetry_log_traces` - traces will be logged to stdout.
135135
- `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.
136136
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`.
137-
- `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.
137+
- `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.
138138
- `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.
139139

140140
### Metrics

‎lite_bootstrap/bootstrappers/fastapi_bootstrapper.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def bootstrap(self) -> None:
203203
app=self.bootstrap_config.app,
204204
tracer_provider=get_tracer_provider(),
205205
meter_provider=get_meter_provider(),
206-
excluded_urls=",".join(self._build_excluded_urls()),
206+
excluded_urls=",".join(self._build_excluded_url_patterns("FASTAPI")),
207207
exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans,
208208
)
209209

‎lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,6 @@
4848
_DEFAULT_METRICS_PARAMETERS: typing.Final = frozenset(inspect.signature(instrumentator_metrics.default).parameters)
4949

5050

51-
# OpenTelemetryMiddleware matches its patterns against a full URL, not a bare path.
52-
_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*"
53-
5451
_TOOL_CALL_STATUS_SUCCESS: typing.Final = "success"
5552
_TOOL_CALL_STATUS_ERROR: typing.Final = "error"
5653

@@ -230,22 +227,6 @@ class FastMcpOpenTelemetryInstrument(OpenTelemetryInstrument):
230227
def dependencies_installed() -> bool:
231228
return OpenTelemetryInstrument.dependencies_installed() and import_checker.is_fastmcp_opentelemetry_installed
232229

233-
def _build_excluded_url_patterns(self) -> list[str]:
234-
"""Anchored patterns for the derived paths, plus the caller's own entries verbatim.
235-
236-
The trailing slash is stripped and matched optionally, so ``/health/`` excludes the path with
237-
or without it. Anchoring is needed because ``ExcludeList`` searches unanchored: a bare
238-
``/health`` would also silence ``/healthy``. Caller-supplied entries stay untouched because
239-
OpenTelemetry documents them as regexes.
240-
"""
241-
anchored_patterns: typing.Final = {
242-
rf"{_EXCLUDED_URL_SCHEME_AND_HOST}{re.escape(normalized_path)}(?:/|$)"
243-
for excluded_path in self._build_infrastructure_excluded_paths()
244-
# A bare "/" would anchor to every URL, so it is dropped along with empty values.
245-
if (normalized_path := excluded_path.rstrip("/"))
246-
}
247-
return sorted(anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls))
248-
249230
def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "StarletteWithLifespan":
250231
if getattr(http_application, _OPENTELEMETRY_INSTRUMENTED_MARKER, False):
251232
return http_application
@@ -255,7 +236,7 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St
255236
build_fastmcp_route_details_from_scope, routes=http_application.routes
256237
),
257238
# OpenTelemetryMiddleware only parses a raw string from 0.56b0; the floor is 0.49b0.
258-
excluded_urls=parse_excluded_urls(",".join(self._build_excluded_url_patterns())),
239+
excluded_urls=parse_excluded_urls(",".join(self._build_excluded_url_patterns("STARLETTE"))),
259240
tracer_provider=get_tracer_provider(),
260241
meter_provider=get_meter_provider(),
261242
)

‎lite_bootstrap/instruments/opentelemetry_instrument.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import dataclasses
22
import logging
33
import os
4+
import re
45
import typing
56
import urllib.parse
67

@@ -60,6 +61,9 @@ class OpenTelemetryServiceFieldsConfig(BaseConfig):
6061
opentelemetry_namespace: str | None = None
6162

6263

64+
# OpenTelemetry's ExcludeList searches its patterns in a full URL, not a bare path.
65+
_EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*"
66+
6367
_LOCAL_HOSTS: typing.Final[frozenset[str]] = frozenset({"localhost", "127.0.0.1", "::1", ""})
6468

6569

@@ -199,6 +203,26 @@ def _build_infrastructure_excluded_paths(self) -> set[str]:
199203
def _build_excluded_urls(self) -> set[str]:
200204
return set(self.bootstrap_config.opentelemetry_excluded_urls) | self._build_infrastructure_excluded_paths()
201205

206+
def _build_excluded_url_patterns(self, environment_key: str) -> list[str]:
207+
"""Patterns for OpenTelemetry's ``ExcludeList``, which searches them unanchored in the full URL.
208+
209+
Derived paths are anchored to the start of the path and to a segment end, with the trailing slash
210+
optional, so ``/metrics/`` excludes ``/metrics`` but neither ``/api/metrics/`` nor ``/metricsx``.
211+
Caller-supplied entries and ``OTEL_PYTHON_<environment_key>_EXCLUDED_URLS``, falling back to
212+
``OTEL_PYTHON_EXCLUDED_URLS`` as OpenTelemetry does, stay verbatim because they are regexes.
213+
"""
214+
anchored_patterns: typing.Final = {
215+
rf"{_EXCLUDED_URL_SCHEME_AND_HOST}{re.escape(normalized_path)}(?:/|$)"
216+
for excluded_path in self._build_infrastructure_excluded_paths()
217+
# A bare "/" would anchor to every URL, so it is dropped along with empty values.
218+
if (normalized_path := excluded_path.rstrip("/"))
219+
}
220+
environment_urls: typing.Final = os.environ.get(
221+
f"OTEL_PYTHON_{environment_key}_EXCLUDED_URLS", os.environ.get("OTEL_PYTHON_EXCLUDED_URLS", "")
222+
)
223+
environment_patterns: typing.Final = {url.strip() for url in environment_urls.split(",") if url.strip()}
224+
return sorted(anchored_patterns | set(self.bootstrap_config.opentelemetry_excluded_urls) | environment_patterns)
225+
202226
def _silence_otel_loggers(self) -> None:
203227
for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"):
204228
otel_logger = logging.getLogger(logger_name)

‎tests/test_fastapi_bootstrap.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import structlog
1414
from opentelemetry.metrics import get_meter_provider
1515
from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider
16+
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
17+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
18+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
19+
from opentelemetry.trace import SpanKind, get_tracer_provider
1620
from starlette import status
1721
from starlette.testclient import TestClient
1822

@@ -442,3 +446,58 @@ def test_fastapi_otel_hands_the_instrumentor_the_meter_provider(fastapi_config:
442446

443447
assert mock_instrument_app.call_args.kwargs["meter_provider"] is get_meter_provider()
444448
assert isinstance(get_meter_provider(), SDKMeterProvider)
449+
450+
451+
def _server_span_names_for(config: FastAPIConfig, paths: typing.Iterable[str]) -> list[str]:
452+
application = config.app
453+
454+
@application.get("/api/custom-metrics/report")
455+
async def metrics_report() -> str:
456+
return "ok"
457+
458+
@application.get("/custom-healthy")
459+
async def custom_healthy() -> str:
460+
return "ok"
461+
462+
@application.get("/from-env")
463+
async def from_env() -> str:
464+
return "ok"
465+
466+
bootstrapper = FastAPIBootstrapper(bootstrap_config=config)
467+
bootstrapped_application = bootstrapper.bootstrap()
468+
tracer_provider = get_tracer_provider()
469+
assert isinstance(tracer_provider, SDKTracerProvider)
470+
exporter = InMemorySpanExporter()
471+
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
472+
try:
473+
with TestClient(bootstrapped_application) as client:
474+
for path in paths:
475+
client.get(path, follow_redirects=False)
476+
finally:
477+
bootstrapper.teardown()
478+
return [span.name for span in exporter.get_finished_spans() if span.kind == SpanKind.SERVER]
479+
480+
481+
def test_fastapi_otel_excludes_derived_paths_only_where_they_match_whole_segments(
482+
fastapi_config: FastAPIConfig,
483+
) -> None:
484+
"""REGRESSION: `/custom-metrics/` was searched anywhere in the URL, silencing `/api/custom-metrics/report`."""
485+
span_names = _server_span_names_for(
486+
fastapi_config,
487+
["/custom-metrics/", "/custom-health/", "/api/custom-metrics/report", "/custom-healthy"],
488+
)
489+
490+
assert sorted(span_names) == ["GET /api/custom-metrics/report", "GET /custom-healthy"]
491+
492+
493+
@pytest.mark.parametrize("environment_variable", ["OTEL_PYTHON_FASTAPI_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"])
494+
def test_fastapi_otel_honors_excluded_urls_from_environment(
495+
fastapi_config: FastAPIConfig, monkeypatch: pytest.MonkeyPatch, environment_variable: str
496+
) -> None:
497+
for one_variable in ("OTEL_PYTHON_FASTAPI_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"):
498+
monkeypatch.delenv(one_variable, raising=False)
499+
monkeypatch.setenv(environment_variable, "/from-env")
500+
501+
span_names = _server_span_names_for(fastapi_config, ["/from-env", "/custom-healthy"])
502+
503+
assert span_names == ["GET /custom-healthy"]

‎tests/test_fastmcp_bootstrap.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,3 +626,18 @@ def postprocess(http_application: StarletteWithLifespan) -> StarletteWithLifespa
626626

627627
restore_first()
628628
assert "http_app" not in vars(application)
629+
630+
631+
def test_fastmcp_otel_honors_excluded_urls_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
632+
monkeypatch.delenv("OTEL_PYTHON_EXCLUDED_URLS", raising=False)
633+
monkeypatch.setenv("OTEL_PYTHON_STARLETTE_EXCLUDED_URLS", "/mcp")
634+
with (
635+
_bootstrapped_with_span_exporter() as (bootstrapper, application, exporter),
636+
TestClient(application.http_app()) as client,
637+
):
638+
client.post("/mcp", json={})
639+
client.get(bootstrapper.bootstrap_config.health_checks_path)
640+
641+
assert [span.name for span in _server_spans(exporter)] == [
642+
f"GET {bootstrapper.bootstrap_config.health_checks_path}"
643+
]

0 commit comments

Comments
 (0)