diff --git a/README.md b/README.md index edc91f7..ccb440a 100644 --- a/README.md +++ b/README.md @@ -440,7 +440,7 @@ class YourSettings(BaseServiceSettings): opentelemetry_namespace: str | None = None opentelemetry_insecure: bool = True opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = [] - opentelemetry_exclude_urls: list[str] = [] + opentelemetry_exclude_urls: list[str] = ["/metrics"] opentelemetry_baggage_span_attributes: dict[str, str] = {} ... # Other settings here @@ -456,9 +456,9 @@ 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 ignored urls. +- `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_log_traces` - traces will be logged to stdout. -- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True` +- `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. These settings are subsequently passed to [opentelemetry](https://opentelemetry.io/), finalizing your Opentelemetry integration. @@ -494,6 +494,49 @@ class YourSettings(FastStreamSettings): ... ``` +#### FastMCP + +`FastMcpSettings` include all OpenTelemetry settings, so tracing is enabled with `opentelemetry_endpoint` alone: + +```python +from fastmcp import FastMCP +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + +from microbootstrap import FastMcpSettings +from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper +from microbootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrumentor + + +class YourSettings(FastMcpSettings): + opentelemetry_endpoint: str | None = "otel-collector:4317" + opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = [OpenTelemetryInstrumentor(HTTPXClientInstrumentor())] + + +application: FastMCP = FastMcpBootstrapper(YourSettings()).bootstrap() +http_application = application.http_app(path="/mcp") +``` + +FastMCP creates its ASGI application only when `http_app()` is called (directly or by `application.run(transport="http")`), +so `SERVER` spans are added to every application returned by `http_app()`. Each request to it is wrapped in +[`OpenTelemetryMiddleware`](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html) +and produces a span named like `POST /mcp` or `GET /health/` with the `http.route` attribute and the response status code. +Requests to unknown paths produce spans named after the HTTP method only, without `http.route`. + +- `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_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`. + +Applications already instrumented by `StarletteInstrumentor` are left as is, so the request is never traced twice. +To post-process every created ASGI application yourself, use `add_http_app_hook` on the bootstrapped application: + +```python +application = FastMcpBootstrapper(settings).bootstrap() +application.add_http_app_hook(lambda http_application: http_application) +``` + ### [Pyroscope](https://pyroscope.io) To integrate Pyroscope, specify the `pyroscope_endpoint`. diff --git a/microbootstrap/bootstrappers/fastmcp.py b/microbootstrap/bootstrappers/fastmcp.py index 656c0d1..8295dbd 100644 --- a/microbootstrap/bootstrappers/fastmcp.py +++ b/microbootstrap/bootstrappers/fastmcp.py @@ -4,10 +4,15 @@ import prometheus_client import typing_extensions from fastmcp import FastMCP +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware +from opentelemetry.util.http import ExcludeList, get_excluded_urls +from starlette.applications import Starlette from starlette.responses import JSONResponse, Response +from starlette.routing import Match, Mount, Route from microbootstrap.bootstrappers.base import ApplicationBootstrapper from microbootstrap.config.fastmcp import FastMcpConfig +from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict from microbootstrap.instruments.logging_instrument import LoggingInstrument from microbootstrap.instruments.prometheus_instrument import FastMcpPrometheusConfig, PrometheusInstrument @@ -18,16 +23,44 @@ if typing.TYPE_CHECKING: + from fastmcp.server.http import StarletteWithLifespan from starlette.requests import Request + from starlette.types import Scope + + +StarletteT = typing.TypeVar("StarletteT", bound=Starlette) class KwargsFastMCP(FastMCP[typing.Any]): def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401 super().__init__(**kwargs) + self.http_app_hooks: list[typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]] = [] + + def add_http_app_hook(self, hook: typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]) -> None: + self.http_app_hooks.append(hook) + + def http_app(self, *args: typing.Any, **kwargs: typing.Any) -> StarletteWithLifespan: # noqa: ANN401 + # ASGI application is created by the user after bootstrap, so instruments subscribe to its creation + http_application = super().http_app(*args, **kwargs) + for hook in self.http_app_hooks: + http_application = hook(http_application) + return http_application + + +def build_fastmcp_route_details_from_scope( + scope: Scope, + routes: typing.Iterable[typing.Any], +) -> tuple[str, dict[str, str]]: + method: typing.Final = str(scope.get("method", "HTTP")).strip() + for route in routes: + if isinstance(route, (Route, Mount)) and route.matches(scope)[0] == Match.FULL: + return opentelemetry_instrument.build_span_name(method, route.path), {"http.route": route.path} + # Unmatched paths get no `http.route` to keep its cardinality low + return method, {} class FastMcpBootstrapper( - ApplicationBootstrapper[FastMcpSettings, FastMCP[typing.Any], FastMcpConfig], + ApplicationBootstrapper[FastMcpSettings, KwargsFastMCP, FastMcpConfig], ): application_config = FastMcpConfig() application_type = KwargsFastMCP @@ -41,8 +74,8 @@ def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]: def bootstrap_before_instruments_after_app_created( self, - application: FastMCP[typing.Any], - ) -> FastMCP[typing.Any]: + application: KwargsFastMCP, + ) -> KwargsFastMCP: self.console_writer.print_bootstrap_table() return application @@ -51,6 +84,40 @@ def bootstrap_before_instruments_after_app_created( FastMcpBootstrapper.use_instrument()(PyroscopeInstrument) +@FastMcpBootstrapper.use_instrument() +class FastMcpOpentelemetryInstrument( + opentelemetry_instrument.BaseOpentelemetryInstrument[opentelemetry_instrument.OpentelemetryConfig] +): + def bootstrap_after(self, application: FastMCP[typing.Any]) -> FastMCP[typing.Any]: # type: ignore[override] + if isinstance(application, KwargsFastMCP): + application.add_http_app_hook(self.instrument_http_app) + return application + + def instrument_http_app(self, http_application: StarletteT) -> StarletteT: + # `StarletteInstrumentor` marks applications the same way, so each application is instrumented once + if getattr(http_application, "_is_instrumented_by_opentelemetry", False): + return http_application + + def build_route_details(scope: Scope) -> tuple[str, dict[str, str]]: + return build_fastmcp_route_details_from_scope(scope, http_application.routes) + + http_application.add_middleware( + OpenTelemetryMiddleware, + tracer_provider=self.tracer_provider, + default_span_details=build_route_details, + excluded_urls=opentelemetry_instrument.CombinedExcludeList( + ExcludeList(self.define_exclude_urls()), + get_excluded_urls("STARLETTE"), + ), + ) + http_application._is_instrumented_by_opentelemetry = True # type: ignore[attr-defined] # noqa: SLF001 + return http_application + + @classmethod + def get_config_type(cls) -> type[opentelemetry_instrument.OpentelemetryConfig]: + return opentelemetry_instrument.OpentelemetryConfig + + @FastMcpBootstrapper.use_instrument() class FastMcpLoggingInstrument(LoggingInstrument): def bootstrap_after(self, application: FastMCP[typing.Any]) -> FastMCP[typing.Any]: # type: ignore[override] diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index bf827a8..4fb249f 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -14,18 +14,18 @@ from litestar.types.asgi_types import ASGIApp, Scope from litestar_offline_docs import generate_static_files_config from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware -from opentelemetry.util.http import get_excluded_urls +from opentelemetry.util.http import ExcludeList, get_excluded_urls from sentry_sdk.integrations.litestar import LitestarIntegration from microbootstrap.bootstrappers.base import ApplicationBootstrapper from microbootstrap.config.litestar import LitestarConfig +from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.cors_instrument import CorsInstrument from microbootstrap.instruments.health_checks_instrument import ( HealthChecksInstrument, HealthCheckTypedDict, ) from microbootstrap.instruments.logging_instrument import LoggingInstrument -from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument from microbootstrap.instruments.prometheus_instrument import ( LitestarPrometheusConfig, PrometheusInstrument, @@ -122,12 +122,6 @@ def bootstrap_before(self) -> dict[str, typing.Any]: LitestarBootstrapper.use_instrument()(PyroscopeInstrument) -def build_span_name(method: str, route: str) -> str: - if not route: - return method - return f"{method} {route}" - - def build_litestar_route_details_from_scope( scope: Scope, ) -> tuple[str, dict[str, str]]: @@ -144,18 +138,23 @@ def build_litestar_route_details_from_scope( method: typing.Final = str(scope.get("method", "HTTP")).strip() if path_template is not None: path_template_stripped: typing.Final = path_template.strip() - return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped} + span_name: typing.Final = opentelemetry_instrument.build_span_name(method, path_template_stripped) + return span_name, {"http.route": path_template_stripped} path: typing.Final = scope.get("path") if path is not None: path_stripped: typing.Final = path.strip() - return build_span_name(method, path_stripped), {"http.route": path_stripped} + return opentelemetry_instrument.build_span_name(method, path_stripped), {"http.route": path_stripped} return method, {} class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - def __init__(self, config: OpenTelemetryConfig) -> None: + def __init__(self, config: OpenTelemetryConfig, exclude_urls: typing.Sequence[str] = ()) -> None: self.config = config + self.excluded_urls = opentelemetry_instrument.CombinedExcludeList( + ExcludeList(exclude_urls), + get_excluded_urls(self.config.exclude_urls_env_key), + ) def create_open_telemetry_middleware(self, app: ASGIApp) -> OpenTelemetryMiddleware: return OpenTelemetryMiddleware( @@ -163,7 +162,7 @@ def create_open_telemetry_middleware(self, app: ASGIApp) -> OpenTelemetryMiddlew client_request_hook=self.config.client_request_hook_handler, client_response_hook=self.config.client_response_hook_handler, default_span_details=build_litestar_route_details_from_scope, - excluded_urls=get_excluded_urls(self.config.exclude_urls_env_key), + excluded_urls=self.excluded_urls, meter=self.config.meter, meter_provider=self.config.meter_provider, server_request_hook=self.config.server_request_hook_handler, @@ -175,7 +174,7 @@ async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASG @LitestarBootstrapper.use_instrument() -class LitestarOpentelemetryInstrument(OpentelemetryInstrument): +class LitestarOpentelemetryInstrument(opentelemetry_instrument.OpentelemetryInstrument): def bootstrap_before(self) -> dict[str, typing.Any]: return { "middleware": [ @@ -183,7 +182,8 @@ def bootstrap_before(self) -> dict[str, typing.Any]: LitestarOpentelemetryConfig( tracer_provider=self.tracer_provider, middleware_class=LitestarOpenTelemetryInstrumentationMiddleware, # type: ignore[arg-type] - ) + ), + exclude_urls=self.define_exclude_urls(), ) ] } diff --git a/microbootstrap/instruments/opentelemetry_instrument.py b/microbootstrap/instruments/opentelemetry_instrument.py index dad1ebf..8946486 100644 --- a/microbootstrap/instruments/opentelemetry_instrument.py +++ b/microbootstrap/instruments/opentelemetry_instrument.py @@ -19,6 +19,7 @@ from opentelemetry.semconv.resource import ResourceAttributes from opentelemetry.trace import SpanKind, format_span_id, get_current_span, set_tracer_provider from opentelemetry.util._importlib_metadata import entry_points +from opentelemetry.util.http import ExcludeList from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument from microbootstrap.instruments.sentry_instrument import snapshot_sentry_opentelemetry_baggage @@ -70,6 +71,21 @@ def opentelemetry_baggage_scope( context.detach(token) +def build_span_name(method: str, route: str) -> str: + if not route: + return method + return f"{method} {route}" + + +class CombinedExcludeList(ExcludeList): + def __init__(self, *exclude_lists: ExcludeList) -> None: + super().__init__([]) + self.exclude_lists = exclude_lists + + def url_disabled(self, url: str) -> bool: + return any(one_exclude_list.url_disabled(url) for one_exclude_list in self.exclude_lists) + + @dataclasses.dataclass() class OpenTelemetryInstrumentor: instrumentor: BaseInstrumentor @@ -167,6 +183,16 @@ 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] + 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) + return exclude_urls + def is_ready(self) -> bool: return ( bool(self.instrument_config.opentelemetry_endpoint) @@ -222,16 +248,6 @@ def bootstrap(self) -> None: class OpentelemetryInstrument(BaseOpentelemetryInstrument[OpentelemetryConfig]): - def define_exclude_urls(self) -> list[str]: - exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls] - 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) - return exclude_urls - @classmethod def get_config_type(cls) -> type[OpentelemetryConfig]: return OpentelemetryConfig diff --git a/microbootstrap/settings.py b/microbootstrap/settings.py index 5f9bce8..f7f2400 100644 --- a/microbootstrap/settings.py +++ b/microbootstrap/settings.py @@ -107,6 +107,7 @@ class FastMcpSettings( # type: ignore[misc] BaseServiceSettings, ServerConfig, LoggingConfig, + OpentelemetryConfig, SentryConfig, FastMcpPrometheusConfig, HealthChecksConfig, diff --git a/pyproject.toml b/pyproject.toml index f1621b3..90786a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ dev = [ "anyio>=4.8.0", "httpx>=0.28.1", "mypy>=1.14.1", + "opentelemetry-instrumentation-starlette>=0.54b1", "pre-commit>=4.0.1", "pytest>=8.3.4", "pytest-cov>=6.0.0", diff --git a/tests/bootstrappers/test_fastmcp.py b/tests/bootstrappers/test_fastmcp.py index 22c6af3..dc67c69 100644 --- a/tests/bootstrappers/test_fastmcp.py +++ b/tests/bootstrappers/test_fastmcp.py @@ -1,14 +1,32 @@ +import importlib.metadata import typing +from unittest import mock import prometheus_client +import pytest from fastmcp import FastMCP +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.instrumentation.starlette import StarletteInstrumentor +from opentelemetry.sdk.trace import ReadableSpan +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 starlette import status +from starlette.applications import Starlette from starlette.testclient import TestClient -from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper +from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper, FastMcpOpentelemetryInstrument, KwargsFastMCP from microbootstrap.config.fastmcp import FastMcpConfig +from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig from microbootstrap.instruments.logging_instrument import LoggingConfig +from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrumentor from microbootstrap.instruments.prometheus_instrument import FastMcpPrometheusConfig from microbootstrap.middlewares.fastmcp import FastMcpLoggingMiddleware from microbootstrap.settings import FastMcpSettings @@ -148,3 +166,246 @@ def test_fastmcp_prometheus_route_can_be_disabled() -> None: response: typing.Final = TestClient(application.http_app()).get(test_metrics_path) assert response.status_code == status.HTTP_404_NOT_FOUND + + +@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", mock.Mock()) + monkeypatch.setattr(_OpenTelemetrySemanticConventionStability, "_initialized", False) + monkeypatch.delenv(OTEL_SEMCONV_STABILITY_OPT_IN, raising=False) + monkeypatch.delenv("OTEL_PYTHON_STARLETTE_EXCLUDED_URLS", raising=False) + monkeypatch.delenv("OTEL_PYTHON_EXCLUDED_URLS", raising=False) + return exporter + + +def build_fastmcp_application_with_opentelemetry(**opentelemetry_params: typing.Any) -> FastMCP[typing.Any]: # noqa: ANN401 + return FastMcpBootstrapper( + FastMcpSettings(service_debug=False, opentelemetry_log_traces=True, **opentelemetry_params), + ).bootstrap() + + +def find_server_spans(span_exporter: InMemorySpanExporter) -> list[ReadableSpan]: + return [span for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] + + +def count_opentelemetry_middlewares(http_application: Starlette) -> int: + return sum( + typing.cast("object", middleware.cls) is OpenTelemetryMiddleware + for middleware in http_application.user_middleware + ) + + +def test_fastmcp_settings_include_opentelemetry_config() -> None: + assert set(OpentelemetryConfig.model_fields) <= set(FastMcpSettings.model_fields) + + +def test_fastmcp_opentelemetry_creates_tracer_provider(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", mock.Mock()) + bootstrapper: typing.Final = FastMcpBootstrapper( + FastMcpSettings(service_debug=False, opentelemetry_log_traces=True), + ) + + bootstrapper.bootstrap() + + opentelemetry_instrument_obj: typing.Final = next( + instrument + for instrument in bootstrapper.instrument_box.instruments + if isinstance(instrument, FastMcpOpentelemetryInstrument) + ) + assert opentelemetry_instrument_obj.is_ready() + assert isinstance(opentelemetry_instrument_obj.tracer_provider, SdkTracerProvider) + + +def test_fastmcp_opentelemetry_is_not_ready_without_settings() -> None: + application: typing.Final = FastMcpBootstrapper(FastMcpSettings(service_debug=False)).bootstrap() + + assert isinstance(application, KwargsFastMCP) + assert application.http_app_hooks == [] + assert count_opentelemetry_middlewares(application.http_app()) == 0 + + +class TestFastMcpHttpOpentelemetry: + @pytest.mark.parametrize( + ("semconv_opt_in", "expected_status_attributes"), + [ + (None, {"http.status_code"}), + ("http", {"http.response.status_code"}), + ("http/dup", {"http.status_code", "http.response.status_code"}), + ], + ) + def test_health_check_server_span( + self, + monkeypatch: pytest.MonkeyPatch, + span_exporter: InMemorySpanExporter, + semconv_opt_in: str | None, + expected_status_attributes: set[str], + ) -> None: + if semconv_opt_in is not None: + monkeypatch.setenv(OTEL_SEMCONV_STABILITY_OPT_IN, semconv_opt_in) + application: typing.Final = build_fastmcp_application_with_opentelemetry() + + response: typing.Final = TestClient(application.http_app()).get("/health/") + + assert response.status_code == status.HTTP_200_OK + server_spans: typing.Final = find_server_spans(span_exporter) + assert [span.name for span in server_spans] == ["GET /health/"] + assert server_spans[0].attributes + assert server_spans[0].attributes["http.route"] == "/health/" + assert { + attribute_name: server_spans[0].attributes[attribute_name] + for attribute_name in ("http.status_code", "http.response.status_code") + if attribute_name in server_spans[0].attributes + } == dict.fromkeys(expected_status_attributes, status.HTTP_200_OK) + + def test_metrics_are_excluded_by_default(self, span_exporter: InMemorySpanExporter) -> None: + application: typing.Final = build_fastmcp_application_with_opentelemetry() + + response: typing.Final = TestClient(application.http_app()).get("/metrics") + + assert response.status_code == status.HTTP_200_OK + assert find_server_spans(span_exporter) == [] + + def test_custom_exclude_urls(self, span_exporter: InMemorySpanExporter) -> None: + application: typing.Final = build_fastmcp_application_with_opentelemetry( + opentelemetry_exclude_urls=["/custom"], + ) + client: typing.Final = TestClient(application.http_app()) + + client.get("/custom") + client.get("/metrics") + + assert [span.name for span in find_server_spans(span_exporter)] == ["GET /metrics"] + + 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=[]) + client: typing.Final = TestClient(application.http_app()) + + client.get("/health/") + client.get("/metrics") + + assert [span.name for span in find_server_spans(span_exporter)] == ["GET /metrics"] + + 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("/metrics") + + assert [span.name for span in find_server_spans(span_exporter)] == ["GET /metrics"] + + def test_unknown_path_has_no_route(self, span_exporter: InMemorySpanExporter) -> None: + application: typing.Final = build_fastmcp_application_with_opentelemetry() + + response: typing.Final = TestClient(application.http_app()).get("/wp-admin/setup.php") + + assert response.status_code == status.HTTP_404_NOT_FOUND + server_spans: typing.Final = find_server_spans(span_exporter) + assert [span.name for span in server_spans] == ["GET"] + assert server_spans[0].attributes + assert "http.route" not in server_spans[0].attributes + assert server_spans[0].attributes["http.status_code"] == status.HTTP_404_NOT_FOUND + + def test_mcp_endpoint_has_route(self, span_exporter: InMemorySpanExporter) -> None: + application: typing.Final = build_fastmcp_application_with_opentelemetry() + + with TestClient(application.http_app()) as client: + client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"accept": "application/json, text/event-stream"}, + ) + + server_spans: typing.Final = [span for span in find_server_spans(span_exporter) if span.name == "POST /mcp"] + assert len(server_spans) == 1 + assert server_spans[0].attributes + assert server_spans[0].attributes["http.route"] == "/mcp" + + def test_each_http_app_is_instrumented_once(self, span_exporter: InMemorySpanExporter) -> None: + application: typing.Final = build_fastmcp_application_with_opentelemetry() + + first_http_application: typing.Final = application.http_app() + second_http_application: typing.Final = application.http_app(path="/other-mcp") + + assert first_http_application is not second_http_application + for http_application in (first_http_application, second_http_application): + assert count_opentelemetry_middlewares(http_application) == 1 + assert getattr(http_application, "_is_instrumented_by_opentelemetry", False) + TestClient(http_application).get("/health/") + assert [span.name for span in find_server_spans(span_exporter)] == ["GET /health/", "GET /health/"] + + def test_starlette_instrumentor_entry_point_does_not_duplicate_middleware( + self, + monkeypatch: pytest.MonkeyPatch, + span_exporter: InMemorySpanExporter, + ) -> None: + starlette_entry_point: typing.Final = next( + entry_point + for entry_point in importlib.metadata.entry_points(group="opentelemetry_instrumentor") + if entry_point.name == "starlette" + ) + monkeypatch.setattr(opentelemetry_instrument, "entry_points", mock.Mock(return_value=[starlette_entry_point])) + try: + application: typing.Final = build_fastmcp_application_with_opentelemetry() + assert StarletteInstrumentor().is_instrumented_by_opentelemetry + + http_application: typing.Final = application.http_app() + TestClient(http_application).get("/health/") + finally: + StarletteInstrumentor().uninstrument() + + assert count_opentelemetry_middlewares(http_application) == 1 + assert getattr(http_application, "_is_instrumented_by_opentelemetry", False) + assert [span.name for span in find_server_spans(span_exporter)] == ["GET /health/"] + + def test_already_instrumented_http_app_is_skipped( + self, + monkeypatch: pytest.MonkeyPatch, + span_exporter: InMemorySpanExporter, + ) -> None: + original_http_app: typing.Final = FastMCP.http_app + + def instrumented_http_app(self: FastMCP[typing.Any], *args: typing.Any, **kwargs: typing.Any) -> Starlette: # noqa: ANN401 + http_application: typing.Final = original_http_app(self, *args, **kwargs) + StarletteInstrumentor.instrument_app(http_application) + return http_application + + monkeypatch.setattr(FastMCP, "http_app", instrumented_http_app) + application: typing.Final = build_fastmcp_application_with_opentelemetry() + + http_application: typing.Final = application.http_app() + + assert count_opentelemetry_middlewares(http_application) == 1 + assert span_exporter.get_finished_spans() == () + + def test_instrumentors_are_applied_and_torn_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", mock.Mock()) + httpx_instrumentor: typing.Final = HTTPXClientInstrumentor() + bootstrapper: typing.Final = FastMcpBootstrapper(FastMcpSettings(service_debug=False)).configure_instrument( + OpentelemetryConfig( + opentelemetry_log_traces=True, + opentelemetry_instrumentors=[OpenTelemetryInstrumentor(httpx_instrumentor)], + ), + ) + + bootstrapper.bootstrap() + assert httpx_instrumentor.is_instrumented_by_opentelemetry + + bootstrapper.teardown() + assert not httpx_instrumentor.is_instrumented_by_opentelemetry diff --git a/tests/bootstrappers/test_litestar_opentelemetry.py b/tests/bootstrappers/test_litestar_opentelemetry.py index 71e1be6..52cd72f 100644 --- a/tests/bootstrappers/test_litestar_opentelemetry.py +++ b/tests/bootstrappers/test_litestar_opentelemetry.py @@ -6,6 +6,10 @@ from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig from litestar.status_codes import HTTP_200_OK from litestar.testing 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 import LitestarSettings from microbootstrap.bootstrappers.litestar import ( @@ -15,6 +19,7 @@ build_litestar_route_details_from_scope, ) from microbootstrap.config.litestar import LitestarConfig +from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig @@ -182,3 +187,54 @@ def test_litestar_opentelemetry_middleware_initialization() -> None: assert middleware.config == mock_config otel_middleware = middleware.create_open_telemetry_middleware(mock_app) assert otel_middleware is not None + + +def test_litestar_opentelemetry_excludes_urls_from_settings( + monkeypatch: pytest.MonkeyPatch, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + for environment_variable in ("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"): + monkeypatch.delenv(environment_variable, raising=False) + span_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(span_exporter)) + return tracer_provider + + monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", build_tracer_provider) + minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/internal"] + + @litestar.get("/internal") + async def internal() -> None: ... + + @litestar.get("/public") + async def public() -> None: ... + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[internal, public])) + .bootstrap() + ) + + with TestClient(app=application) as client: + client.get("/internal") + assert not span_exporter.get_finished_spans() + + client.get("/public") + assert [span.name for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] == ["GET /public"] + + +def test_litestar_opentelemetry_excludes_urls_from_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "/from-env") + middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware( + LitestarOpentelemetryConfig(), + exclude_urls=["/from-settings"], + ) + + assert middleware.excluded_urls.url_disabled("/from-env") + assert middleware.excluded_urls.url_disabled("/from-settings") + assert not middleware.excluded_urls.url_disabled("/other")