From 793458e258c69bd59b831c5d930309a6355fde31 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 19 Sep 2026 14:29:47 +0300 Subject: [PATCH] feat: make the ASGI receive/send spans opt-out on FastAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every request produces three spans today: the server span plus one each for the ASGI receive and send events. #185 benchmarked dropping the latter two at 33 µs/request, a quarter of the instrument's cost. The field is FastAPI-scoped because the parameter is: Litestar's instrumentor has no counterpart, so a shared OpenTelemetryConfig field would be inert everywhere but one bootstrapper. The default stays empty, since dropping spans changes what an existing service sees in Jaeger or Tempo. --- docs/introduction/configuration.md | 7 ++++++ .../bootstrappers/fastapi_bootstrapper.py | 3 +++ tests/test_fastapi_bootstrap.py | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs/introduction/configuration.md b/docs/introduction/configuration.md index 0e35807..e562731 100644 --- a/docs/introduction/configuration.md +++ b/docs/introduction/configuration.md @@ -95,6 +95,13 @@ config = FastAPIConfig( ) ``` +For FastAPI 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 ~33 µs per + request on the benchmark endpoint, at the cost of two thirds of the spans disappearing from your + trace view. + For FastStream you must provide additionally: - `opentelemetry_middleware_cls` diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 78c6ca3..f4a27db 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -50,6 +50,8 @@ 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. + 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) prometheus_expose_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) @@ -119,6 +121,7 @@ def bootstrap(self) -> None: app=self.bootstrap_config.app, tracer_provider=get_tracer_provider(), excluded_urls=",".join(self._build_excluded_urls()), + exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans, ) def teardown(self) -> None: diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index 7dd78ed..cf4c7e9 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -1,5 +1,6 @@ import dataclasses import logging +import typing import warnings from unittest.mock import patch @@ -10,6 +11,7 @@ from starlette.testclient import TestClient from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig, import_checker +from lite_bootstrap.bootstrappers import fastapi_bootstrapper from lite_bootstrap.exceptions import ConfigurationError, InstrumentDependencyMissingWarning from lite_bootstrap.types import UNSET from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing, warning_source_files @@ -39,6 +41,28 @@ def fastapi_config() -> FastAPIConfig: ) +def _instrument_app_kwargs(config: FastAPIConfig) -> dict[str, typing.Any]: + """Bootstrap with FastAPIInstrumentor stubbed, and return what it was handed.""" + with patch.object(fastapi_bootstrapper, "FastAPIInstrumentor") as mock_instrumentor: + bootstrapper = FastAPIBootstrapper(bootstrap_config=config) + bootstrapper.bootstrap() + try: + return mock_instrumentor.instrument_app.call_args.kwargs + finally: + bootstrapper.teardown() + + +def test_fastapi_opentelemetry_exclude_spans_defaults_to_recording_them(fastapi_config: FastAPIConfig) -> None: + """The default keeps every span a trace view shows today; dropping two of three is opt-in.""" + assert _instrument_app_kwargs(fastapi_config)["exclude_spans"] == [] + + +def test_fastapi_opentelemetry_exclude_spans_reaches_the_instrumentor(fastapi_config: FastAPIConfig) -> None: + config = dataclasses.replace(fastapi_config, opentelemetry_exclude_spans=["receive", "send"]) + + assert _instrument_app_kwargs(config)["exclude_spans"] == ["receive", "send"] + + def test_fastapi_bootstrap(fastapi_config: FastAPIConfig) -> None: bootstrapper = FastAPIBootstrapper(bootstrap_config=fastapi_config) application = bootstrapper.bootstrap()