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
19 changes: 19 additions & 0 deletions lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@
from opentelemetry.trace import get_tracer_provider

if import_checker.is_fastapi_opentelemetry_installed:
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from starlette.middleware.errors import ServerErrorMiddleware

from lite_bootstrap.helpers.opentelemetry_asgi import ExcludeInfrastructureTelemetry

if import_checker.is_prometheus_fastapi_instrumentator_installed:
from prometheus_fastapi_instrumentator import Instrumentator
Expand Down Expand Up @@ -213,6 +217,21 @@ def bootstrap(self) -> None:
excluded_urls=",".join(self._build_excluded_url_patterns("FASTAPI")),
exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans,
)
self._install_infrastructure_exclusions()

def _install_infrastructure_exclusions(self) -> None:
app = self.bootstrap_config.app
build_stack = app.build_middleware_stack
excluded_paths = self._build_infrastructure_excluded_paths()

def build_middleware_stack() -> "ASGIApp":
stack = build_stack()
# FastAPIInstrumentor places its OTel middleware inside an outer error handler.
if isinstance(stack, ServerErrorMiddleware) and isinstance(stack.app, OpenTelemetryMiddleware):
stack.app = ExcludeInfrastructureTelemetry(stack.app, excluded_paths)
return stack

app.build_middleware_stack = build_middleware_stack # ty: ignore[invalid-assignment]

def teardown(self) -> None:
FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.app)
Expand Down
6 changes: 4 additions & 2 deletions lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@
from starlette.responses import JSONResponse, Response

if import_checker.is_fastmcp_opentelemetry_installed:
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from opentelemetry.metrics import get_meter_provider
from opentelemetry.trace import get_tracer_provider
from starlette.routing import BaseRoute, Match, Mount, Route
from starlette.types import Scope

from lite_bootstrap.helpers.opentelemetry_asgi import build_opentelemetry_middleware

if import_checker.is_fastmcp_installed and import_checker.is_sentry_installed:
from sentry_sdk.integrations.starlette import StarletteIntegration

Expand Down Expand Up @@ -233,7 +234,8 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St
if getattr(http_application, _OPENTELEMETRY_INSTRUMENTED_MARKER, False):
return http_application
http_application.add_middleware(
OpenTelemetryMiddleware,
build_opentelemetry_middleware,
infrastructure_paths=self._build_infrastructure_excluded_paths(),
default_span_details=functools.partial(
build_fastmcp_route_details_from_scope, routes=http_application.routes
),
Expand Down
34 changes: 34 additions & 0 deletions lite_bootstrap/helpers/opentelemetry_asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import typing

from lite_bootstrap import import_checker


if import_checker.is_fastapi_opentelemetry_installed or import_checker.is_fastmcp_opentelemetry_installed:
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from starlette._utils import get_route_path

if typing.TYPE_CHECKING:
from starlette.types import ASGIApp, Receive, Scope, Send


class ExcludeInfrastructureTelemetry:
def __init__(self, app: "OpenTelemetryMiddleware", excluded_paths: set[str]) -> None:
self.app = app
self.excluded_paths = tuple(path for value in excluded_paths if (path := value.rstrip("/")))

async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
if scope["type"] in ("http", "websocket"):
path = get_route_path(scope)
if any(path == excluded or path.startswith(excluded + "/") for excluded in self.excluded_paths):
await self.app.app(scope, receive, send)
return
await self.app(scope, receive, send)


def build_opentelemetry_middleware(
app: "ASGIApp",
*,
infrastructure_paths: set[str],
**kwargs: typing.Any, # noqa: ANN401
) -> ExcludeInfrastructureTelemetry:
return ExcludeInfrastructureTelemetry(OpenTelemetryMiddleware(app, **kwargs), infrastructure_paths)
20 changes: 2 additions & 18 deletions lite_bootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import dataclasses
import logging
import os
import re
import typing
import urllib.parse

Expand Down Expand Up @@ -61,9 +60,6 @@ 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 @@ -235,24 +231,12 @@ 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("/"))
}
"""Combine caller-supplied URL regexes with the OpenTelemetry environment defaults."""
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)
return sorted(set(self.bootstrap_config.opentelemetry_excluded_urls) | environment_patterns)

def _silence_otel_loggers(self) -> None:
for logger_name in ("opentelemetry.instrumentation.instrumentor", "opentelemetry.trace"):
Expand Down
20 changes: 17 additions & 3 deletions tests/test_fastapi_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,9 @@ def test_fastapi_prometheus_still_serves_metrics_while_opentelemetry_records_htt
bootstrapper.teardown()


def _server_span_names_for(config: FastAPIConfig, paths: typing.Iterable[str]) -> list[str]:
def _server_span_names_for(
config: FastAPIConfig, paths: typing.Iterable[str], *, host: str = "testserver", root_path: str = ""
) -> list[str]:
application = config.app

@application.get("/api/custom-metrics/report")
Expand All @@ -619,9 +621,9 @@ async def from_env() -> str:
exporter = InMemorySpanExporter()
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
try:
with TestClient(bootstrapped_application) as client:
with TestClient(bootstrapped_application, root_path=root_path) as client:
for path in paths:
client.get(path, follow_redirects=False)
client.get(path, headers={"host": host}, follow_redirects=False)
finally:
bootstrapper.teardown()
return [span.name for span in exporter.get_finished_spans() if span.kind == SpanKind.SERVER]
Expand Down Expand Up @@ -649,6 +651,18 @@ def test_fastapi_otel_leaves_the_health_check_untraced_by_default() -> None:
assert _server_span_names_for(config, ["/custom-health/", "/custom-healthy"]) == ["GET /custom-healthy"]


def test_fastapi_otel_host_header_cannot_exclude_a_work_request(fastapi_config: FastAPIConfig) -> None:
assert _server_span_names_for(fastapi_config, ["/custom-healthy"], host="x/custom-health") == [
"GET /custom-healthy"
]


def test_fastapi_otel_excludes_health_under_root_path(fastapi_config: FastAPIConfig) -> None:
assert _server_span_names_for(fastapi_config, ["/api/custom-health/", "/api/custom-healthy"], root_path="/api") == [
"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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_fastmcp_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from fastmcp.server.context import Context
from fastmcp.server.http import StarletteWithLifespan
from fastmcp.server.middleware import MiddlewareContext
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
Expand All @@ -31,6 +30,7 @@
_postprocess_http_apps,
)
from lite_bootstrap.exceptions import ConfigurationError
from lite_bootstrap.helpers.opentelemetry_asgi import build_opentelemetry_middleware
from lite_bootstrap.instruments.healthchecks_instrument import HealthProbe
from tests.conftest import (
HEALTH_PROBE_CASES,
Expand Down Expand Up @@ -435,7 +435,7 @@ def test_second_fastmcp_bootstrapper_bootstrap_raises() -> None:


def _count_opentelemetry_middlewares(http_application: Starlette) -> int:
return sum(middleware.cls is OpenTelemetryMiddleware for middleware in http_application.user_middleware)
return sum(middleware.cls is build_opentelemetry_middleware for middleware in http_application.user_middleware)


def _server_spans(exporter: InMemorySpanExporter) -> list[ReadableSpan]:
Expand Down
Loading