diff --git a/README.md b/README.md index 9d7d444..935a9b7 100644 --- a/README.md +++ b/README.md @@ -456,7 +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` - 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_exclude_urls` - extra url regexes that produce no server spans (empty by default). `prometheus_metrics_path` is always excluded and matched as a whole path on every framework, 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`). + +For Litestar the tracing is wired through Litestar's own [`OpenTelemetryPlugin`](https://docs.litestar.dev/2/reference/plugins/opentelemetry.html). Server spans are named by the route template, e.g. `GET /users/{user_id}`, and carry it in `http.route`; requests that match no route produce a span named after the HTTP method only, without `http.route`. - `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. diff --git a/microbootstrap/bootstrappers/litestar.py b/microbootstrap/bootstrappers/litestar.py index ab129e3..c353698 100644 --- a/microbootstrap/bootstrappers/litestar.py +++ b/microbootstrap/bootstrappers/litestar.py @@ -6,16 +6,13 @@ import typing_extensions from litestar import openapi from litestar.config.cors import CORSConfig as LitestarCorsConfig -from litestar.contrib.opentelemetry.config import ( - OpenTelemetryConfig as LitestarOpentelemetryConfig, -) from litestar.contrib.prometheus import PrometheusConfig, PrometheusController from litestar.middleware import ASGIMiddleware from litestar.openapi.plugins import SwaggerRenderPlugin -from litestar.types.asgi_types import ASGIApp, Scope +from litestar.plugins.opentelemetry import OpenTelemetryConfig as LitestarOpentelemetryConfig +from litestar.plugins.opentelemetry import OpenTelemetryPlugin from litestar_offline_docs import generate_static_files_config -from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware -from opentelemetry.util.http import ExcludeList, get_excluded_urls +from opentelemetry import trace from sentry_sdk.integrations.litestar import LitestarIntegration from microbootstrap.bootstrappers.base import ApplicationBootstrapper @@ -59,9 +56,7 @@ class AcceptVersionedOperation(openapi.spec.Operation): if typing.TYPE_CHECKING: - from litestar.contrib.opentelemetry import OpenTelemetryConfig - from litestar.types import ASGIApp, Scope - from litestar.types.asgi_types import Receive, Send + from litestar.types import ASGIApp, Receive, Scope, Send class LitestarBootstrapper( @@ -253,58 +248,44 @@ def build_litestar_route_details_from_scope( """ path_template: typing.Final = scope.get("path_template") method: typing.Final = str(scope.get("method", "HTTP")).strip() - if path_template is not None: - path_template_stripped: typing.Final = path_template.strip() - 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 opentelemetry_instrument.build_span_name(method, path_stripped), {"http.route": path_stripped} - return method, {} - - -class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware): - 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( - app=app, - 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=self.excluded_urls, - meter=self.config.meter, - meter_provider=self.config.meter_provider, - server_request_hook=self.config.server_request_hook_handler, - tracer_provider=self.config.tracer_provider, - ) - + # Unmatched paths get only the method and no `http.route`: a raw path would let scanners explode span cardinality + if path_template is None: + return method, {} + path_template_stripped: typing.Final = path_template.strip() + return opentelemetry_instrument.build_span_name(method, path_template_stripped), { + "http.route": path_template_stripped + } + + +class LitestarOpentelemetryRouteMiddleware(ASGIMiddleware): + # `OpenTelemetryPlugin` wraps the whole application, so its span is created before routing with the raw path. + # This middleware runs inside the route handler stack, where `path_template` is known, and renames the span. async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None: - await self.create_open_telemetry_middleware(next_app)(scope, receive, send) # type: ignore[arg-type] + server_span: typing.Final = trace.get_current_span() + if server_span.is_recording(): + span_name, attributes = build_litestar_route_details_from_scope(scope) + server_span.update_name(span_name) + server_span.set_attributes(attributes) + await next_app(scope, receive, send) @LitestarBootstrapper.use_instrument() class LitestarOpentelemetryInstrument(opentelemetry_instrument.OpentelemetryInstrument): def bootstrap_before(self) -> dict[str, typing.Any]: return { - "middleware": [ - LitestarOpenTelemetryInstrumentationMiddleware( - LitestarOpentelemetryConfig( - tracer_provider=self.tracer_provider, - middleware_class=LitestarOpenTelemetryInstrumentationMiddleware, # type: ignore[arg-type] - ), - exclude_urls=self.define_exclude_urls(), - ) - ] + "plugins": [OpenTelemetryPlugin(self.build_litestar_opentelemetry_config())], + "middleware": [LitestarOpentelemetryRouteMiddleware()], } + def build_litestar_opentelemetry_config(self) -> LitestarOpentelemetryConfig: + # `exclude` bypasses the plugin middleware for the configured paths; the plugin itself still honors + # `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` through `exclude_urls_env_key`. + return LitestarOpentelemetryConfig( + tracer_provider=self.tracer_provider, + scope_span_details_extractor=build_litestar_route_details_from_scope, + exclude=self.define_excluded_path_patterns() or None, + ) + @LitestarBootstrapper.use_instrument() class LitestarLoggingInstrument(LoggingInstrument): diff --git a/microbootstrap/instruments/opentelemetry_instrument.py b/microbootstrap/instruments/opentelemetry_instrument.py index 361257d..6d85a7e 100644 --- a/microbootstrap/instruments/opentelemetry_instrument.py +++ b/microbootstrap/instruments/opentelemetry_instrument.py @@ -214,6 +214,13 @@ def define_excluded_url_patterns(self) -> list[str]: ] return [*self.instrument_config.opentelemetry_exclude_urls, *anchored_paths] + def define_excluded_path_patterns(self) -> list[str]: + # The path-based counterpart of define_excluded_url_patterns() for middlewares that search a bare path + anchored_paths: typing.Final = [ + rf"^{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/pyproject.toml b/pyproject.toml index b29a08d..6735d58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ fastapi = [ "prometheus-fastapi-instrumentator>=7.1", ] litestar = [ - "litestar>=2.21.1", + "litestar>=2.22.0", "litestar-offline-docs>=1", "prometheus-client>=0.20", ] diff --git a/tests/bootstrappers/test_litestar_opentelemetry.py b/tests/bootstrappers/test_litestar_opentelemetry.py index 52cd72f..f47e7cf 100644 --- a/tests/bootstrappers/test_litestar_opentelemetry.py +++ b/tests/bootstrappers/test_litestar_opentelemetry.py @@ -1,10 +1,12 @@ import typing -from unittest.mock import Mock, patch +from unittest.mock import patch import litestar import pytest -from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig -from litestar.status_codes import HTTP_200_OK +from litestar.config.app import AppConfig +from litestar.plugins import InitPluginProtocol +from litestar.plugins.opentelemetry import OpenTelemetryPlugin +from litestar.status_codes import HTTP_200_OK, HTTP_404_NOT_FOUND from litestar.testing import TestClient from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -15,7 +17,7 @@ from microbootstrap.bootstrappers.litestar import ( LitestarBootstrapper, LitestarOpentelemetryInstrument, - LitestarOpenTelemetryInstrumentationMiddleware, + LitestarOpentelemetryRouteMiddleware, build_litestar_route_details_from_scope, ) from microbootstrap.config.litestar import LitestarConfig @@ -23,6 +25,25 @@ from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig +@pytest.fixture +def span_exporter(monkeypatch: pytest.MonkeyPatch) -> InMemorySpanExporter: + for environment_variable in ("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"): + monkeypatch.delenv(environment_variable, raising=False) + 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) + return exporter + + +def server_span_names(span_exporter: InMemorySpanExporter) -> list[str]: + return [span.name for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] + + @pytest.mark.parametrize( ("scope", "expected_span_name", "expected_attributes"), [ @@ -40,29 +61,15 @@ "path": "/users/123", "method": "POST", }, - "POST /users/123", - {"http.route": "/users/123"}, + "POST", + {}, ), ( { "path": "/test", }, - "HTTP /test", - {"http.route": "/test"}, - ), - ( - { - "path": "", - }, "HTTP", - {"http.route": ""}, - ), - ( - { - "path": " ", - }, - "HTTP", - {"http.route": ""}, + {}, ), ( { @@ -109,19 +116,41 @@ def test_build_litestar_route_details_from_scope( assert attributes == expected_attributes -def test_litestar_opentelemetry_instrument_uses_custom_middleware( +def test_litestar_opentelemetry_instrument_uses_litestar_plugin( minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: + minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/internal.*"] + minimal_opentelemetry_config.prometheus_metrics_path = "/metrics" + minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = False + minimal_opentelemetry_config.health_checks_path = "/health/" opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) opentelemetry_instrument.bootstrap() bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before() - assert "middleware" in bootstrap_result - assert len(bootstrap_result["middleware"]) == 1 + assert [type(middleware) for middleware in bootstrap_result["middleware"]] == [LitestarOpentelemetryRouteMiddleware] + assert len(bootstrap_result["plugins"]) == 1 + plugin: typing.Final = bootstrap_result["plugins"][0] + assert isinstance(plugin, OpenTelemetryPlugin) + assert plugin.config.tracer_provider is opentelemetry_instrument.tracer_provider + assert plugin.config.scope_span_details_extractor is build_litestar_route_details_from_scope + # User regexes stay verbatim, derived paths are anchored with an optional trailing slash + assert plugin.config.exclude == ["/internal.*", "^/metrics(?:/|$)", "^/health(?:/|$)"] - middleware_config: typing.Final = bootstrap_result["middleware"][0].config - assert middleware_config.middleware.middleware == LitestarOpenTelemetryInstrumentationMiddleware + +def test_litestar_opentelemetry_instrument_passes_no_exclusions_when_nothing_is_derived( + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + minimal_opentelemetry_config.opentelemetry_exclude_urls = [] + minimal_opentelemetry_config.prometheus_metrics_path = "" + minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = True + opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) + opentelemetry_instrument.bootstrap() + + plugin: typing.Final = opentelemetry_instrument.bootstrap_before()["plugins"][0] + + # An empty list must not be passed: Litestar would compile it into a pattern that matches every path + assert plugin.config.exclude is None @pytest.mark.parametrize( @@ -167,42 +196,66 @@ async def root() -> dict[str, str]: assert mock_function.call_args_list[0].args[0].get("path_template") == expected_path_template -def test_litestar_opentelemetry_middleware_initialization() -> None: - mock_app: typing.Final = Mock() +def test_litestar_opentelemetry_server_spans_use_path_template( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + @litestar.get("/users/{user_id:int}") + async def get_user(user_id: int) -> dict[str, int]: + return {"user_id": user_id} + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[get_user])) + .bootstrap() + ) - mock_config: typing.Final = Mock(spec=LitestarOpentelemetryConfig) - mock_config.scopes = ["http"] - mock_config.exclude = [] - mock_config.exclude_opt_key = None - mock_config.client_request_hook_handler = None - mock_config.client_response_hook_handler = None - mock_config.exclude_urls_env_key = None - mock_config.meter = None - mock_config.meter_provider = None - mock_config.server_request_hook_handler = None - mock_config.tracer_provider = None + with TestClient(app=application) as client: + assert client.get("/users/123").status_code == HTTP_200_OK - middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware(config=mock_config) + assert client.get("/missing").status_code == HTTP_404_NOT_FOUND - assert middleware.config == mock_config - otel_middleware = middleware.create_open_telemetry_middleware(mock_app) - assert otel_middleware is not None + server_spans: typing.Final = [span for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] + # An unmatched path keeps the method-only name without `http.route`, so a 404 scan yields one span name + assert [span.name for span in server_spans] == ["GET /users/{user_id}", "GET"] + assert server_spans[0].attributes is not None + assert server_spans[0].attributes["http.route"] == "/users/{user_id}" + assert server_spans[0].attributes["http.status_code"] == HTTP_200_OK + assert server_spans[1].attributes is not None + assert "http.route" not in server_spans[1].attributes + assert server_spans[1].attributes["http.status_code"] == HTTP_404_NOT_FOUND -def test_litestar_opentelemetry_excludes_urls_from_settings( - monkeypatch: pytest.MonkeyPatch, +def test_litestar_opentelemetry_keeps_user_plugins( + span_exporter: InMemorySpanExporter, 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() + class UserPlugin(InitPluginProtocol): + def on_app_init(self, app_config: AppConfig) -> AppConfig: + return app_config - 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 + @litestar.get("/public") + async def public() -> None: ... - monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", build_tracer_provider) + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[public], plugins=[UserPlugin()])) + .bootstrap() + ) + + assert any(isinstance(plugin, UserPlugin) for plugin in application.plugins.init) + assert any(isinstance(plugin, OpenTelemetryPlugin) for plugin in application.plugins.init) + with TestClient(app=application) as client: + client.get("/public") + assert server_span_names(span_exporter) == ["GET /public"] + + +def test_litestar_opentelemetry_excludes_urls_from_settings( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/internal"] @litestar.get("/internal") @@ -223,18 +276,82 @@ async def public() -> None: ... 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"] + assert server_span_names(span_exporter) == ["GET /public"] def test_litestar_opentelemetry_excludes_urls_from_environment( + span_exporter: InMemorySpanExporter, monkeypatch: pytest.MonkeyPatch, + minimal_opentelemetry_config: OpentelemetryConfig, ) -> None: monkeypatch.setenv("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "/from-env") - middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware( - LitestarOpentelemetryConfig(), - exclude_urls=["/from-settings"], + minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/from-settings"] + + @litestar.get("/from-env") + async def from_env() -> None: ... + + @litestar.get("/from-settings") + async def from_settings() -> None: ... + + @litestar.get("/other") + async def other() -> None: ... + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[from_env, from_settings, other])) + .bootstrap() ) - assert middleware.excluded_urls.url_disabled("/from-env") - assert middleware.excluded_urls.url_disabled("/from-settings") - assert not middleware.excluded_urls.url_disabled("/other") + with TestClient(app=application) as client: + client.get("/from-env") + client.get("/from-settings") + assert not span_exporter.get_finished_spans() + + client.get("/other") + assert server_span_names(span_exporter) == ["GET /other"] + + +def test_litestar_opentelemetry_exclusions_do_not_silence_lookalike_routes( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + minimal_opentelemetry_config.opentelemetry_generate_health_check_spans = False + + @litestar.get("/api/metrics-report") + async def metrics_report() -> None: ... + + @litestar.get("/api/health/details") + async def health_details() -> None: ... + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings()) + .configure_instrument(minimal_opentelemetry_config) + .configure_application(LitestarConfig(route_handlers=[metrics_report, health_details])) + .bootstrap() + ) + + with TestClient(app=application) as client: + # Litestar strips the trailing slash from scope["path"], so both forms of the health path must be excluded + for path in ("/metrics", "/health/", "/health", "/api/metrics-report", "/api/health/details"): + assert client.get(path).status_code == HTTP_200_OK + + assert server_span_names(span_exporter) == ["GET /api/metrics-report", "GET /api/health/details"] + + +def test_litestar_opentelemetry_custom_metrics_path_is_excluded( + span_exporter: InMemorySpanExporter, + minimal_opentelemetry_config: OpentelemetryConfig, +) -> None: + minimal_opentelemetry_config.prometheus_metrics_path = "/custom-metrics" + + application: typing.Final = ( + LitestarBootstrapper(LitestarSettings(prometheus_metrics_path="/custom-metrics")) + .configure_instrument(minimal_opentelemetry_config) + .bootstrap() + ) + + with TestClient(app=application) as client: + assert client.get("/custom-metrics").status_code == HTTP_200_OK + + assert server_span_names(span_exporter) == [] diff --git a/tests/instruments/test_opentelemetry.py b/tests/instruments/test_opentelemetry.py index 72dbe36..d88c02e 100644 --- a/tests/instruments/test_opentelemetry.py +++ b/tests/instruments/test_opentelemetry.py @@ -1,12 +1,12 @@ -import contextlib import typing from unittest import mock -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, patch import fastapi import litestar import pytest from fastapi.testclient import TestClient as FastAPITestClient +from litestar.plugins.opentelemetry import OpenTelemetryPlugin from litestar.testing import TestClient as LitestarTestClient from opentelemetry import baggage, context from opentelemetry.context import Context @@ -18,7 +18,6 @@ from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument from microbootstrap.bootstrappers.litestar import ( LitestarOpentelemetryInstrument, - LitestarOpenTelemetryInstrumentationMiddleware, ) from microbootstrap.instruments import opentelemetry_instrument from microbootstrap.instruments.opentelemetry_instrument import BaggageSpanProcessor, OpentelemetryInstrument @@ -218,10 +217,10 @@ def test_litestar_opentelemetry_bootstrap( opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before() assert opentelemetry_bootstrap_result - assert "middleware" in opentelemetry_bootstrap_result - assert isinstance(opentelemetry_bootstrap_result["middleware"], list) - assert len(opentelemetry_bootstrap_result["middleware"]) == 1 - assert isinstance(opentelemetry_bootstrap_result["middleware"][0], LitestarOpenTelemetryInstrumentationMiddleware) + assert "plugins" in opentelemetry_bootstrap_result + assert isinstance(opentelemetry_bootstrap_result["plugins"], list) + assert len(opentelemetry_bootstrap_result["plugins"]) == 1 + assert isinstance(opentelemetry_bootstrap_result["plugins"][0], OpenTelemetryPlugin) def test_litestar_opentelemetry_teardown( @@ -236,17 +235,11 @@ def test_litestar_opentelemetry_teardown( def test_litestar_opentelemetry_bootstrap_working( minimal_opentelemetry_config: OpentelemetryConfig, - async_mock: AsyncMock, ) -> None: test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config) test_opentelemetry_instrument.bootstrap() opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before() - opentelemetry_middleware = opentelemetry_bootstrap_result["middleware"][0] - assert isinstance(opentelemetry_middleware, LitestarOpenTelemetryInstrumentationMiddleware) - async_mock.__name__ = "test-name" - opentelemetry_middleware.handle = async_mock # type: ignore[method-assign] - @litestar.get("/test-handler") async def test_handler() -> None: return None @@ -255,11 +248,9 @@ async def test_handler() -> None: route_handlers=[test_handler], **opentelemetry_bootstrap_result, ) - with LitestarTestClient(app=litestar_application) as test_client: - # Silencing error, because we are mocking middleware call, so ASGI scope remains unchanged. - with contextlib.suppress(AssertionError): - test_client.get("/test-handler") - assert async_mock.called + with patch("opentelemetry.trace.use_span") as mock_use_span, LitestarTestClient(app=litestar_application) as client: + client.get("/test-handler") + assert mock_use_span.called def test_fastapi_opentelemetry_bootstrap_working(