Skip to content

Commit fc71cb7

Browse files
authored
Default opentelemetry_exclude_spans to ["receive", "send"] and support it on FastMCP (#364)
1 parent 05346fb commit fc71cb7

14 files changed

Lines changed: 97 additions & 39 deletions

‎benchmarks/README.md‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,21 +101,35 @@ Sentry +57.1, OpenTelemetry +122.4 and all four +228.2. The table holds.
101101

102102
| scenario | RPS | µs/req | gain |
103103
|---|---:|---:|---|
104-
| `otel` as lite-bootstrap configures it | 7267 | 137.6 | - |
104+
| `otel` as lite-bootstrap 1.x configured it | 7267 | 137.6 | - |
105105
| `+ opentelemetry_exclude_spans=["receive", "send"]` | 9651 | 103.6 | −34.0 µs |
106106
| `+ opentelemetry_sampler=ParentBased(TraceIdRatioBased(0.01))` | 12154 | 82.3 | −55.3 µs |
107107
| both | 15518 | 64.4 | **2.14x** |
108108

109109
1. `FastAPIInstrumentor.instrument_app` accepts `exclude_spans: list[Literal["receive","send"]]`,
110-
which `FastAPIConfig.opentelemetry_exclude_spans` passes through. It is empty by default, so
111-
**every request still produces three spans** - the server span plus one each for the ASGI
112-
`receive` and `send` events. Two thirds of the spans, one quarter of the cost, and almost
113-
nobody looks at them. Set it to `["receive", "send"]` to drop the two event spans.
110+
which `FastAPIConfig.opentelemetry_exclude_spans` passes through. In 1.x it was empty by
111+
default, so every request produced three spans: the server span plus one each for the ASGI
112+
`receive` and `send` events. Those two were two thirds of the spans and a quarter of the cost.
113+
Since 2.0 it defaults to `["receive", "send"]` on FastAPI, Litestar and FastMCP.
114114
2. `OpenTelemetryConfig.opentelemetry_sampler` is passed to the `TracerProvider`. Left unset, the
115115
SDK default `parentbased_always_on` applies and every request is recorded, serialized and
116116
shipped. A 1% ratio sampler is worth 55 µs/req here. The default stays always-on deliberately:
117117
sampling rate is a user decision, not something to pick on a service's behalf.
118118

119+
Rerun for 2.0 in-process on the M2 host (macOS, CPython 3.14.7, opentelemetry-sdk 1.45.1,
120+
opentelemetry-instrumentation-asgi 0.66b1, fastapi 0.162.0, litestar 2.25.0, fastmcp 4.1.4),
121+
100% sampling, with `bench.py <suite> bare,otel,otel_all_spans --repeat 3` (FastMCP with
122+
`--ops 1500 --warmup 300`). `otel` is the 2.0 default and `otel_all_spans` sets
123+
`opentelemetry_exclude_spans=[]`, the 1.x default:
124+
125+
| suite | `otel` | `otel_all_spans` | saved |
126+
|---|---:|---:|---:|
127+
| FastAPI, per request | +91.8 | +127.8 | 36.0 |
128+
| Litestar, per request | +79.3 | +116.1 | 36.8 |
129+
| FastMCP, per `tools/call` | +175.4 | +273.8 | 98.4 |
130+
131+
FastMCP's rounds differed by up to 9%, FastAPI's `otel` row by 17%.
132+
119133
### 4b. Sentry: the cost is one thing, and it is not the one people tune
120134

121135
In-process ablation, Sentry only, baseline 16.5 µs/req:
@@ -233,7 +247,8 @@ Since 2.0, lite-bootstrap reports FastAPI request errors itself while Sentry tra
233247
| `sentry_integration_sampled_trace` | +59.2 | +113.2 |
234248

235249
The last row on 3.0.0b2 varied by 40% between rounds. The table above was measured before 2.0. Its
236-
`sentry_nosess` row now has the same config as `sentry`, and `sentry_noauto` no longer stops request
250+
OpenTelemetry rows record the `receive` and `send` spans, which 2.0 leaves out by default (section
251+
4a). Its `sentry_nosess` row now has the same config as `sentry`, and `sentry_noauto` no longer stops request
237252
capture, because lean capture needs no auto-enabling integration.
238253

239254
FastAPI real-server runs under uvicorn in the container were unstable (bare repeat medians of 8235
@@ -281,13 +296,13 @@ FastAPIConfig(
281296
],
282297
sentry_logging_breadcrumb_level=None,
283298
# OpenTelemetry: sampling is the single biggest saving here
284-
opentelemetry_exclude_spans=["receive", "send"],
285299
opentelemetry_sampler=ParentBased(TraceIdRatioBased(0.01)),
286300
)
287301
```
288302
<!-- --8<-- [end:tuned] -->
289303

290-
The tuned runs also set `sentry_auto_session_tracking=False`, which is the default since 2.0.
304+
The tuned runs also set `sentry_auto_session_tracking=False` and
305+
`opentelemetry_exclude_spans=["receive", "send"]`, which are the defaults since 2.0.
291306

292307
Trade-offs, in order of what you give up: log breadcrumbs on Sentry errors, Sentry release health,
293308
Sentry-side trace correlation, 99% of OTel traces, ASGI event spans.
@@ -418,7 +433,8 @@ On 3.0 an incoming sampled `sentry-trace` makes `LitestarIntegration` record a r
418433
tracing off. `sentry_nointegration`, which disables `LitestarIntegration` and turns lean capture off,
419434
so nothing captures request errors, measured +0.3 and +0.0 in a later run that put `sentry` at
420435
+6.7 and +6.8. The table above was measured before 2.0. Its `sentry_nosess` scenario now has the same
421-
config as `sentry`, because sessions are off by default since 2.0.
436+
config as `sentry`, because sessions are off by default since 2.0. Its OpenTelemetry rows record the
437+
`receive` and `send` spans, which 2.0 leaves out by default (section 4a).
422438

423439
lite-bootstrap's Litestar defaults with no endpoint configured (Prometheus, health, logging and
424440
Swagger on) cost +11.7 µs on `/ping`, all of it Prometheus. The access-log middleware writes each record
@@ -555,6 +571,9 @@ over a baseline of about 620 µs per tool call:
555571

556572
Rounds differed by up to 15%, so the lean rows are not resolved from each other.
557573

574+
The OpenTelemetry rows in the first table record the ASGI `receive` and `send` spans, which 2.0
575+
leaves out by default: section 4a has the rerun.
576+
558577
### 8f. Logging, per call
559578

560579
The per-call chain is the same on every framework: structlog's stdlib `BoundLogger`, lite-bootstrap's

‎benchmarks/frameworks/common.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def sentry(**extra_params: object) -> dict[str, typing.Any]:
163163
OTEL: typing.Final = {"opentelemetry_endpoint": OTLP_TRACES, "opentelemetry_exporter_protocol": "http"}
164164
OTEL_METRICS: typing.Final = {"opentelemetry_metrics_endpoint": OTLP_METRICS}
165165
EXCL: typing.Final = {"opentelemetry_exclude_spans": ["receive", "send"]}
166+
ALL_SPANS: typing.Final = {"opentelemetry_exclude_spans": []}
166167
PROM: typing.Final = {"prometheus_metrics_path": "/metrics"}
167168
LOG: typing.Final = {"logging_enabled": True}
168169
CORS: typing.Final = {"cors_allowed_origins": ["https://example.com"], "cors_allowed_methods": ["GET"]}

‎benchmarks/frameworks/fastapi_suite.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import typing
44

55
from common import (
6+
ALL_SPANS,
67
CORS,
78
EXCL,
89
LOG,
@@ -37,6 +38,7 @@
3738
"log_mw": Scenario(config=base_config(**LOG, logging_access_log_enabled=True)),
3839
"prom": Scenario(config=base_config(**PROM)),
3940
"otel": Scenario(config=base_config(**OTEL)),
41+
"otel_all_spans": Scenario(config=base_config(**OTEL, **ALL_SPANS)),
4042
"otel_excl": Scenario(config=base_config(**OTEL, **EXCL)),
4143
"otel_10": Scenario(config=base_config(**OTEL, **ratio(0.1))),
4244
"otel_10_excl": Scenario(config=base_config(**OTEL, **ratio(0.1), **EXCL)),

‎benchmarks/frameworks/fastmcp_suite.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import fastmcp.server.telemetry
1111
from common import (
12+
ALL_SPANS,
1213
LOG,
1314
OTEL,
1415
PROM,
@@ -46,6 +47,7 @@ def no_fastmcp_spans() -> None:
4647
"log_mw": Scenario(config=base_config(**LOG, logging_access_log_enabled=True)),
4748
"prom": Scenario(config=base_config(**PROM)),
4849
"otel": Scenario(config=base_config(**OTEL)),
50+
"otel_all_spans": Scenario(config=base_config(**OTEL, **ALL_SPANS)),
4951
"otel_nofastmcp_spans": Scenario(config=base_config(**OTEL), patch=no_fastmcp_spans),
5052
"otel_10": Scenario(config=base_config(**OTEL, **ratio(0.1))),
5153
"sentry": Scenario(config=base_config(**sentry())),

‎benchmarks/frameworks/litestar_suite.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import litestar
1010
import structlog
1111
from common import (
12+
ALL_SPANS,
1213
CORS,
1314
EXCL,
1415
LOG,
@@ -50,6 +51,7 @@
5051
"prom": Scenario(config=base_config(**PROM)),
5152
"prom_nogroup": Scenario(config=base_config(**PROM, prometheus_group_path=False)),
5253
"otel": Scenario(config=base_config(**OTEL)),
54+
"otel_all_spans": Scenario(config=base_config(**OTEL, **ALL_SPANS)),
5355
"otel_excl": Scenario(config=base_config(**OTEL, **EXCL)),
5456
"otel_10": Scenario(config=base_config(**OTEL, **ratio(0.1))),
5557
"otel_10_excl": Scenario(config=base_config(**OTEL, **ratio(0.1), **EXCL)),

‎docs/introduction/configuration.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,13 @@ config = FastAPIConfig(
233233
)
234234
```
235235

236-
For FastAPI and Litestar there is additionally:
236+
For FastAPI, Litestar and FastMCP there is additionally:
237237

238-
- `opentelemetry_exclude_spans` - drops the ASGI `receive` and/or `send` spans, leaving only the
239-
server span. Empty by default, which records all three. `["receive", "send"]` is worth ~34 µs per
240-
request on the benchmark endpoint, at the cost of two thirds of the spans disappearing from your
241-
trace view.
238+
- `opentelemetry_exclude_spans` - the ASGI events that get no span of their own. Defaults to
239+
`["receive", "send"]` since 2.0, so each request records only its server span. At 100% sampling
240+
the two event spans cost about 36 to 66 µs per request on FastAPI and Litestar, and about 98 µs
241+
per FastMCP tool call. Set it to `[]` to record them again, or to `["receive"]` or `["send"]` to
242+
keep one kind.
242243

243244
For FastStream there is additionally:
244245

‎docs/introduction/performance.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ In rough order of what they return:
5252
|---|---|---|
5353
| [`opentelemetry_sampler`](configuration.md#opentelemetry), 1% ratio | ~55 µs/req | 99% of your traces |
5454
| `http_methods_to_capture=()` on the Sentry ASGI integrations | ~35 µs/req | Sentry-side trace correlation |
55-
| [`opentelemetry_exclude_spans`](configuration.md#opentelemetry), FastAPI and Litestar | ~34 µs/req | the two ASGI event spans |
55+
| [`opentelemetry_exclude_spans=["receive", "send"]`](configuration.md#opentelemetry), the default since 2.0 | ~34 µs/req | the two ASGI event spans |
5656
| [`sentry_auto_session_tracking=False`](configuration.md#sentry), the default since 2.0 | ~7.7 µs/req | Sentry release health |
5757
| [`sentry_logging_breadcrumb_level=None`](configuration.md#sentry-logging-integration) | ~7 µs per log record | log breadcrumbs on errors |
5858

@@ -68,7 +68,7 @@ request, every request then pays the per-record cost measured here:
6868

6969
--8<-- "benchmarks/README.md:logging"
7070

71-
lite-bootstrap already applies five savings for you. It turns Sentry Logs off in the logging
71+
lite-bootstrap already applies six savings for you. It turns Sentry Logs off in the logging
7272
integration, because it never enables them and on 2.x the handler formats every record before
7373
checking whether they are enabled; that one costs nothing. Since 2.0 it also leaves release-health
7474
sessions off and starts breadcrumbs at `WARNING`, so an `INFO` record skips the breadcrumb handler.
@@ -78,8 +78,10 @@ back. And while Sentry tracing is off it reports request errors without sentry-s
7878
integration, which costs you request transactions and bodies the handler never read; set `sentry_lean_error_capture=False` to keep the integration. With tracing off it also turns
7979
sentry-sdk's [auto-enabling integrations](configuration.md#sentry-auto-enabling-integrations) off,
8080
which costs you their SQL, Redis and outgoing-HTTP breadcrumbs; set
81-
`sentry_auto_enabling_integrations=True` to keep them. The numbers on this page were measured
82-
before 2.0, against the old defaults.
81+
`sentry_auto_enabling_integrations=True` to keep them. On FastAPI, Litestar and FastMCP it also
82+
records no span for the ASGI `receive` and `send` events, which costs you those spans; set
83+
`opentelemetry_exclude_spans=[]` to get them back. The numbers on this page were measured before
84+
2.0, against the old defaults.
8385

8486
## What each saving costs you
8587

@@ -148,7 +150,8 @@ about +21 µs per tool call ([FastMCP: Sentry](../integrations/fastmcp.md#sentry
148150
--8<-- "benchmarks/README.md:otelfloor"
149151

150152
If you sample down to save CPU, this floor is what you keep paying. Excluding the `receive` and `send`
151-
spans lowers it: Litestar's 1% row with them excluded sits below its 0% row with them kept.
153+
spans lowers it: Litestar's 1% row with them excluded sits below its 0% row with them kept. Since 2.0
154+
they are excluded by default.
152155

153156
## Reproducing on macOS
154157

‎lite_bootstrap/bootstrappers/fastapi_bootstrapper.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
HealthCheckTypedDict,
1717
)
1818
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
19-
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument
19+
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryAsgiConfig, OpenTelemetryInstrument
2020
from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument
2121
from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
2222
from lite_bootstrap.instruments.sentry_instrument import (
@@ -64,16 +64,14 @@ class FastAPIConfig(
6464
CorsConfig,
6565
HealthChecksConfig,
6666
LoggingConfig,
67-
OpenTelemetryConfig,
67+
OpenTelemetryAsgiConfig,
6868
PrometheusConfig,
6969
PyroscopeConfig,
7070
SentryLeanCaptureConfig,
7171
SwaggerConfig,
7272
):
7373
application: "fastapi.FastAPI | UnsetType" = UNSET
7474
application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
75-
# Not on OpenTelemetryConfig: only the FastAPI and Litestar instrumentors take `exclude_spans`.
76-
opentelemetry_exclude_spans: list[typing.Literal["receive", "send"]] = dataclasses.field(default_factory=list)
7775
prometheus_instrumentator_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
7876
prometheus_instrument_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
7977
prometheus_expose_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)

‎lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
1212
from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument
1313
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
14-
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument
14+
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryAsgiConfig, OpenTelemetryInstrument
1515
from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument
1616
from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
1717
from lite_bootstrap.instruments.sentry_instrument import (
@@ -201,7 +201,12 @@ async def on_message(
201201

202202
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
203203
class FastMcpConfig(
204-
HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryLeanCaptureConfig
204+
HealthChecksConfig,
205+
LoggingConfig,
206+
OpenTelemetryAsgiConfig,
207+
PrometheusConfig,
208+
PyroscopeConfig,
209+
SentryLeanCaptureConfig,
205210
):
206211
application: "FastMCP[typing.Any]" = dataclasses.field(default_factory=_make_fastmcp)
207212
logging_access_log_enabled: bool = False
@@ -264,6 +269,7 @@ def _instrument_http_app(self, http_application: "StarletteWithLifespan") -> "St
264269
),
265270
# OpenTelemetryMiddleware only parses a raw string from 0.56b0; the floor is 0.49b0.
266271
excluded_urls=parse_excluded_urls(",".join(self._build_excluded_url_patterns("STARLETTE"))),
272+
exclude_spans=self.bootstrap_config.opentelemetry_exclude_spans,
267273
tracer_provider=get_tracer_provider(),
268274
meter_provider=get_meter_provider(),
269275
)

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
HealthCheckTypedDict,
1616
)
1717
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
18-
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument
18+
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryAsgiConfig, OpenTelemetryInstrument
1919
from lite_bootstrap.instruments.prometheus_instrument import (
2020
PrometheusConfig as PrometheusBootstrapperConfig,
2121
)
@@ -254,7 +254,7 @@ class LitestarConfig(
254254
CorsConfig,
255255
HealthChecksConfig,
256256
LoggingConfig,
257-
OpenTelemetryConfig,
257+
OpenTelemetryAsgiConfig,
258258
PrometheusBootstrapperConfig,
259259
PyroscopeConfig,
260260
SentryLeanCaptureConfig,
@@ -263,7 +263,6 @@ class LitestarConfig(
263263
application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108
264264
litestar_logging_middleware_config: "LoggingMiddlewareConfig | None" = None
265265
logging_access_log_enabled: bool = False
266-
opentelemetry_exclude_spans: list[typing.Literal["receive", "send"]] = dataclasses.field(default_factory=list)
267266
prometheus_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
268267
# Bounds path-label cardinality (Litestar defaults False -> raw URLs leak memory). See litestar#4891.
269268
prometheus_group_path: bool = True

0 commit comments

Comments
 (0)