diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 57c040f..0dcafd2 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -173,7 +173,7 @@ config = FastAPIConfig( ) ``` -For FastAPI there is additionally: +For FastAPI and Litestar there is additionally: - `opentelemetry_exclude_spans` - drops the ASGI `receive` and/or `send` spans, leaving only the server span. Empty by default, which records all three. `["receive", "send"]` is worth ~34 µs per diff --git a/docs/introduction/performance.md b/docs/introduction/performance.md index f4e93db..f725ca5 100644 --- a/docs/introduction/performance.md +++ b/docs/introduction/performance.md @@ -46,7 +46,7 @@ In rough order of what they return: |---|---|---| | [`opentelemetry_sampler`](configuration.md#opentelemetry), 1% ratio | ~55 µs/req | 99% of your traces | | `http_methods_to_capture=()` on the Sentry ASGI integrations | ~35 µs/req | Sentry-side trace correlation | -| [`opentelemetry_exclude_spans`](configuration.md#opentelemetry), FastAPI only | ~34 µs/req | the two ASGI event spans | +| [`opentelemetry_exclude_spans`](configuration.md#opentelemetry), FastAPI and Litestar | ~34 µs/req | the two ASGI event spans | | [`sentry_auto_session_tracking=False`](configuration.md#sentry) | ~7.7 µs/req | Sentry release health | | [`sentry_logging_breadcrumb_level=None`](configuration.md#sentry-logging-integration) | ~7 µs per log record | log breadcrumbs on errors | diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 8f68348..0701f7f 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -61,7 +61,7 @@ class FastAPIConfig( ): application: "fastapi.FastAPI | UnsetType" = UNSET application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict) - # Not on OpenTelemetryConfig: `exclude_spans` is this instrumentor's parameter, and Litestar's has none. + # Not on OpenTelemetryConfig: only the FastAPI and Litestar instrumentors take `exclude_spans`. opentelemetry_exclude_spans: list[typing.Literal["receive", "send"]] = dataclasses.field(default_factory=list) prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) prometheus_instrument_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index c2c73d0..839c605 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -123,6 +123,7 @@ class LitestarConfig( application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108 litestar_logging_middleware_config: "LoggingMiddlewareConfig | None" = None litestar_logging_middleware_enabled: bool = False + opentelemetry_exclude_spans: list[typing.Literal["receive", "send"]] = dataclasses.field(default_factory=list) prometheus_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) # Bounds path-label cardinality (Litestar defaults False -> raw URLs leak memory). See litestar#4891. prometheus_group_path: bool = True @@ -237,6 +238,7 @@ def bootstrap(self) -> None: scope_span_details_extractor=build_litestar_route_details_from_scope, # An empty list compiles to a pattern that matches, and so skips, every path exclude=self._build_excluded_path_patterns() or None, + exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans, ) ) ) diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index b3f55de..81073b3 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -605,6 +605,31 @@ async def get_user(user_id: int) -> dict[str, int]: assert server_spans[0].attributes["http.route"] == "/users/{user_id}" +def _span_kinds_for_one_request(config: LitestarConfig) -> list[SpanKind]: + @litestar.get("/items") + async def get_items() -> list[int]: + return [1] + + config = dataclasses.replace(config, application_config=AppConfig(route_handlers=[get_items])) + application, exporter = _bootstrap_with_span_exporter(config) + + with TestClient(app=application) as client: + assert client.get("/items").status_code == status_codes.HTTP_200_OK + + return sorted((span.kind for span in exporter.get_finished_spans()), key=lambda kind: kind.value) + + +def test_litestar_otel_exclude_spans_defaults_to_recording_them(litestar_config: LitestarConfig) -> None: + """The default keeps the ASGI event spans a trace view shows today; dropping them is opt-in.""" + assert SpanKind.INTERNAL in _span_kinds_for_one_request(litestar_config) + + +def test_litestar_otel_exclude_spans_leaves_only_the_server_span(litestar_config: LitestarConfig) -> None: + config = dataclasses.replace(litestar_config, opentelemetry_exclude_spans=["receive", "send"]) + + assert _span_kinds_for_one_request(config) == [SpanKind.SERVER] + + def test_litestar_otel_unmatched_path_span_is_named_by_method_only(litestar_config: LitestarConfig) -> None: """A 404 never reaches the route stack, so its span must not carry the raw path that scanners vary.""" application, exporter = _bootstrap_with_span_exporter(litestar_config)