Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/integrations/fastmcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down
2 changes: 1 addition & 1 deletion docs/introduction/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
21 changes: 1 addition & 20 deletions lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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(),
)
Expand Down
24 changes: 24 additions & 0 deletions lite_bootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import dataclasses
import logging
import os
import re
import typing
import urllib.parse

Expand Down Expand Up @@ -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", ""})


Expand Down Expand Up @@ -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_<environment_key>_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)
Expand Down
59 changes: 59 additions & 0 deletions tests/test_fastapi_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"]
15 changes: 15 additions & 0 deletions tests/test_fastmcp_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
]
Loading