diff --git a/README.md b/README.md index afa10eb..9d7d444 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ Parameters description: - `opentelemetry_insecure` - is opentelemetry connection secure. - `opentelemetry_container_name` - will be passed to the `Resource`. - `opentelemetry_instrumentors` - a list of extra instrumentors. -- `opentelemetry_exclude_urls` - list of url regexes that produce no server spans (`["/metrics"]` by default). For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`), for FastMCP with `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`). +- `opentelemetry_exclude_urls` - extra url regexes that produce no server spans (empty by default). `prometheus_metrics_path` is always excluded; for FastAPI and FastMCP it is matched as a whole path, so `/metrics` excludes neither `/api/metrics` nor `/metrics-report`. For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`), for FastMCP with `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`). - `opentelemetry_log_traces` - traces will be logged to stdout. - `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`; if `False`, `health_checks_path` is added to the excluded urls. - `opentelemetry_baggage_span_attributes` - maps allowed baggage keys to attributes added to local server and consumer spans. @@ -524,7 +524,7 @@ Requests to unknown paths produce spans named after the HTTP method only, withou - `opentelemetry_endpoint` - OTLP endpoint for exported traces. - `opentelemetry_instrumentors` - extra instrumentors, e.g. for HTTP clients used by your tools. -- `opentelemetry_exclude_urls` - urls without spans, `["/metrics"]` by default. Combined with `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS`. +- `opentelemetry_exclude_urls` - extra url regexes without spans, empty by default; `prometheus_metrics_path` is always excluded. Combined with `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS`. - `opentelemetry_generate_health_check_spans` - set to `False` to skip spans for `health_checks_path`. - The status code attribute name depends on `OTEL_SEMCONV_STABILITY_OPT_IN`: `http.status_code` when unset, `http.response.status_code` for `http`, both for `http/dup`. diff --git a/microbootstrap/bootstrappers/fastapi.py b/microbootstrap/bootstrappers/fastapi.py index 07aab1b..48988ea 100644 --- a/microbootstrap/bootstrappers/fastapi.py +++ b/microbootstrap/bootstrappers/fastapi.py @@ -137,7 +137,7 @@ def bootstrap_after(self, application: ApplicationT) -> ApplicationT: FastAPIInstrumentor.instrument_app( application, tracer_provider=self.tracer_provider, - excluded_urls=",".join(self.define_exclude_urls()), + excluded_urls=",".join(self.define_excluded_url_patterns()), ) return application diff --git a/microbootstrap/bootstrappers/fastmcp.py b/microbootstrap/bootstrappers/fastmcp.py index a12df0c..3b7c691 100644 --- a/microbootstrap/bootstrappers/fastmcp.py +++ b/microbootstrap/bootstrappers/fastmcp.py @@ -108,7 +108,7 @@ def build_route_details(scope: Scope) -> tuple[str, dict[str, str]]: tracer_provider=self.tracer_provider, default_span_details=build_route_details, excluded_urls=opentelemetry_instrument.CombinedExcludeList( - ExcludeList(self.define_exclude_urls()), + ExcludeList(self.define_excluded_url_patterns()), get_excluded_urls("STARLETTE"), ), ) diff --git a/microbootstrap/instruments/opentelemetry_instrument.py b/microbootstrap/instruments/opentelemetry_instrument.py index 8946486..361257d 100644 --- a/microbootstrap/instruments/opentelemetry_instrument.py +++ b/microbootstrap/instruments/opentelemetry_instrument.py @@ -3,6 +3,7 @@ import dataclasses import logging import os +import re import typing import pydantic @@ -71,6 +72,10 @@ def opentelemetry_baggage_scope( context.detach(token) +# OpenTelemetry's `ExcludeList` searches its patterns in the full url, not in a bare path +EXCLUDED_URL_SCHEME_AND_HOST: typing.Final = r"^\w+://[^/]*" + + def build_span_name(method: str, route: str) -> str: if not route: return method @@ -97,6 +102,7 @@ class OpentelemetryConfig(BaseInstrumentConfig): service_name: str = "micro-service" service_version: str = "1.0.0" health_checks_path: str = "/health/" + prometheus_metrics_path: str = "/metrics" pyroscope_endpoint: pydantic.HttpUrl | None = None opentelemetry_service_name: str | None = None @@ -105,7 +111,7 @@ class OpentelemetryConfig(BaseInstrumentConfig): opentelemetry_namespace: str | None = None opentelemetry_insecure: bool = pydantic.Field(default=True) opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = pydantic.Field(default_factory=list) - opentelemetry_exclude_urls: list[str] = pydantic.Field(default=["/metrics"]) + opentelemetry_exclude_urls: list[str] = pydantic.Field(default_factory=list) opentelemetry_disabled_instrumentations: list[str] = pydantic.Field( default=[ one_package_to_exclude.strip() @@ -183,16 +189,31 @@ def _load_instrumentors(self) -> None: LOGGER_OBJ.debug("Instrumenting failed", entry_point_name=entry_point.name) raise - def define_exclude_urls(self) -> list[str]: - exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls] + def define_excluded_paths(self) -> list[str]: + excluded_paths: typing.Final = [] + if self.instrument_config.prometheus_metrics_path: + excluded_paths.append(self.instrument_config.prometheus_metrics_path) if ( not self.instrument_config.opentelemetry_generate_health_check_spans and self.instrument_config.health_checks_path - and self.instrument_config.health_checks_path not in exclude_urls ): - exclude_urls.append(self.instrument_config.health_checks_path) + excluded_paths.append(self.instrument_config.health_checks_path) + return excluded_paths + + def define_exclude_urls(self) -> list[str]: + exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls] + exclude_urls.extend(path for path in self.define_excluded_paths() if path not in exclude_urls) return exclude_urls + def define_excluded_url_patterns(self) -> list[str]: + # Only derived paths are anchored: user-supplied entries are regexes and stay verbatim + anchored_paths: typing.Final = [ + rf"{EXCLUDED_URL_SCHEME_AND_HOST}{re.escape(path.rstrip('/'))}(?:/|$)" + for path in self.define_excluded_paths() + if path.rstrip("/") + ] + return [*self.instrument_config.opentelemetry_exclude_urls, *anchored_paths] + def is_ready(self) -> bool: return ( bool(self.instrument_config.opentelemetry_endpoint) diff --git a/tests/bootstrappers/test_fastapi.py b/tests/bootstrappers/test_fastapi.py index 902ea6e..7a785a4 100644 --- a/tests/bootstrappers/test_fastapi.py +++ b/tests/bootstrappers/test_fastapi.py @@ -1,11 +1,17 @@ import typing from unittest.mock import MagicMock +import pytest from fastapi import status from fastapi.testclient import TestClient +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 from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper from microbootstrap.config.fastapi import FastApiConfig +from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig from microbootstrap.settings import FastApiSettings @@ -56,3 +62,60 @@ def test_fastapi_configure_application_lifespan(magic_mock: MagicMock) -> None: with TestClient(app=application): assert magic_mock.called + + +@pytest.fixture +def span_exporter(monkeypatch: pytest.MonkeyPatch) -> InMemorySpanExporter: + exporter: typing.Final = InMemorySpanExporter() + + def build_tracer_provider(*args: typing.Any, **kwargs: typing.Any) -> SdkTracerProvider: # noqa: ANN401 + tracer_provider: typing.Final = SdkTracerProvider(*args, **kwargs) + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + return tracer_provider + + monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", build_tracer_provider) + monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", MagicMock()) + return exporter + + +def find_server_span_names(span_exporter: InMemorySpanExporter) -> list[str]: + return [span.name for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] + + +def test_fastapi_opentelemetry_exclusions_do_not_silence_lookalike_routes( + span_exporter: InMemorySpanExporter, +) -> None: + application: typing.Final = FastApiBootstrapper( + FastApiSettings( + service_debug=False, + opentelemetry_log_traces=True, + opentelemetry_generate_health_check_spans=False, + ), + ).bootstrap() + + @application.get("/api/metrics-report") + async def metrics_report() -> str: + return "ok" + + @application.get("/api/health/details") + async def health_details() -> str: + return "ok" + + with TestClient(app=application) as client: + for path in ("/metrics", "/health/", "/api/metrics-report", "/api/health/details"): + assert client.get(path).status_code == status.HTTP_200_OK + + assert find_server_span_names(span_exporter) == ["GET /api/metrics-report", "GET /api/health/details"] + + +def test_fastapi_opentelemetry_metrics_path_is_derived_from_prometheus_settings( + span_exporter: InMemorySpanExporter, +) -> None: + application: typing.Final = FastApiBootstrapper( + FastApiSettings(service_debug=False, opentelemetry_log_traces=True, prometheus_metrics_path="/custom-metrics"), + ).bootstrap() + + with TestClient(app=application) as client: + assert client.get("/custom-metrics").status_code == status.HTTP_200_OK + + assert find_server_span_names(span_exporter) == [] diff --git a/tests/bootstrappers/test_fastmcp.py b/tests/bootstrappers/test_fastmcp.py index 7845727..afbf454 100644 --- a/tests/bootstrappers/test_fastmcp.py +++ b/tests/bootstrappers/test_fastmcp.py @@ -19,6 +19,8 @@ from opentelemetry.trace import SpanKind from starlette import status from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import PlainTextResponse from starlette.testclient import TestClient from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper, FastMcpOpentelemetryInstrument, KwargsFastMCP @@ -280,9 +282,9 @@ def test_custom_exclude_urls(self, span_exporter: InMemorySpanExporter) -> None: client: typing.Final = TestClient(application.http_app()) client.get("/custom") - client.get("/metrics") + client.get("/health/") - assert [span.name for span in find_server_spans(span_exporter)] == ["GET /metrics"] + assert [span.name for span in find_server_spans(span_exporter)] == ["GET /health/"] def test_exclude_urls_from_environment( self, @@ -290,25 +292,57 @@ def test_exclude_urls_from_environment( span_exporter: InMemorySpanExporter, ) -> None: monkeypatch.setenv("OTEL_PYTHON_STARLETTE_EXCLUDED_URLS", "/health/") - application: typing.Final = build_fastmcp_application_with_opentelemetry(opentelemetry_exclude_urls=[]) + application: typing.Final = build_fastmcp_application_with_opentelemetry() client: typing.Final = TestClient(application.http_app()) client.get("/health/") - client.get("/metrics") + client.get("/missing") - assert [span.name for span in find_server_spans(span_exporter)] == ["GET /metrics"] + assert [span.name for span in find_server_spans(span_exporter)] == ["GET"] def test_health_check_spans_can_be_disabled(self, span_exporter: InMemorySpanExporter) -> None: application: typing.Final = build_fastmcp_application_with_opentelemetry( opentelemetry_generate_health_check_spans=False, - opentelemetry_exclude_urls=[], ) client: typing.Final = TestClient(application.http_app()) client.get("/health/") + client.get("/missing") + + assert [span.name for span in find_server_spans(span_exporter)] == ["GET"] + + def test_exclusions_do_not_silence_lookalike_routes(self, span_exporter: InMemorySpanExporter) -> None: + application: typing.Final = build_fastmcp_application_with_opentelemetry( + opentelemetry_generate_health_check_spans=False, + ) + + @application.custom_route("/api/metrics-report", methods=["GET"]) + async def metrics_report(_: Request) -> PlainTextResponse: + return PlainTextResponse("ok") + + @application.custom_route("/api/health/details", methods=["GET"]) + async def health_details(_: Request) -> PlainTextResponse: + return PlainTextResponse("ok") + + client: typing.Final = TestClient(application.http_app()) + for path in ("/metrics", "/health/", "/health", "/api/metrics-report", "/api/health/details"): + client.get(path, follow_redirects=False) + + assert [span.name for span in find_server_spans(span_exporter)] == [ + "GET /api/metrics-report", + "GET /api/health/details", + ] + + def test_metrics_path_is_derived_from_prometheus_settings(self, span_exporter: InMemorySpanExporter) -> None: + application: typing.Final = build_fastmcp_application_with_opentelemetry( + prometheus_metrics_path="/custom-metrics", + ) + client: typing.Final = TestClient(application.http_app()) + + assert client.get("/custom-metrics").status_code == status.HTTP_200_OK client.get("/metrics") - assert [span.name for span in find_server_spans(span_exporter)] == ["GET /metrics"] + assert [span.name for span in find_server_spans(span_exporter)] == ["GET"] def test_unknown_path_has_no_route(self, span_exporter: InMemorySpanExporter) -> None: application: typing.Final = build_fastmcp_application_with_opentelemetry()