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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion microbootstrap/bootstrappers/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion microbootstrap/bootstrappers/fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
)
Expand Down
31 changes: 26 additions & 5 deletions microbootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import dataclasses
import logging
import os
import re
import typing

import pydantic
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions tests/bootstrappers/test_fastapi.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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) == []
48 changes: 41 additions & 7 deletions tests/bootstrappers/test_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -280,35 +282,67 @@ 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,
monkeypatch: pytest.MonkeyPatch,
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()
Expand Down
Loading