Skip to content

Commit 2049357

Browse files
authored
chore(benchmarks): add the observability-overhead harness and its findings (#189)
Measures what each instrument costs per request, so the numbers behind #184, #185, #186 and #187 can be re-run rather than trusted. On a do-nothing endpoint through uvicorn the full stack costs 70% of throughput (7978 -> 2389 RPS); roughly half is recoverable, and OpenTelemetry costs twice what Sentry does. Two suites (sentry: one init() knob at a time; stack: the instruments through FastAPIBootstrapper), measured in-process, over real sockets, and per-operation. One subprocess per scenario, because sentry_sdk.init(), set_tracer_provider and the Prometheus registry all patch global state that cannot be undone. Scenarios that name a fix which does not exist yet monkeypatch the library to measure what it would be worth. benchmarks/ has no __init__.py, so coverage does not walk it and the 100% gate is unaffected, the same way scripts/ is already excluded.
1 parent 63da333 commit 2049357

14 files changed

Lines changed: 1478 additions & 0 deletions

‎benchmarks/README.md‎

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
# What the observability stack costs a lite-bootstrap FastAPI service
2+
3+
## 1. The short answer
4+
5+
On a do-nothing endpoint through uvicorn, the full stack costs **70% of throughput**
6+
(7978 → 2389 RPS). Roughly half of that is recoverable without giving up observability, and
7+
**OpenTelemetry costs twice what Sentry does** - not the ordering most people expect.
8+
9+
Two published figures about the Sentry half look contradictory and are both correct.
10+
[getsentry/sentry-python#2116](https://github.com/getsentry/sentry-python/issues/2116), open
11+
since 2023, reports a Starlette app dropping from ~2000 to ~1000 RPS after adding the SDK;
12+
Sentry's own docs claim
13+
[under 1 ms of instrumentation overhead per request](https://docs.sentry.io/product/insights/performance-overhead/).
14+
Both hold at once, because the added cost is a fixed ~80 µs: small in absolute terms, and
15+
enormous next to a handler that does nothing.
16+
17+
That is also the caveat on everything below. These ratios are an upper bound. A service that
18+
does real work per request - a database round trip, a downstream call - pays the same absolute
19+
cost against a much larger denominator, so read the µs columns rather than the percentages.
20+
21+
## 2. Method
22+
23+
Two suites - `sentry` (one `sentry_sdk.init()` knob at a time) and `stack` (the lite-bootstrap
24+
instruments, configured through `FastAPIBootstrapper`) - measured three ways:
25+
26+
- **In-process** (`run.py`) - drives the ASGI app directly (`await app(scope, receive, send)`), no
27+
sockets, no HTTP parsing. Isolates library cost; overstates the *relative* impact because the
28+
baseline is unrealistically fast.
29+
- **Real server** (`run_http.py`) - uvicorn, single worker, access log off, loaded with
30+
`ab -k -c 16 -n 20000`. Verified the load generator is not the ceiling (baseline plateaus at
31+
~8.7k RPS by c=64, vs 7.9k measured at c=16).
32+
- **Micro** (`micro.py`, `verify.py`, `profile_one.py`) - per-operation costs, what each
33+
configuration actually gives up, and cProfile.
34+
35+
Each scenario runs in its own process. `sentry_sdk.init()` monkeypatches `Starlette.__call__`,
36+
`Middleware.__init__` and `logging.Logger.callHandlers`; `set_tracer_provider` is set-once per
37+
process; the Prometheus registry is global. None of it can be undone in-process.
38+
39+
Sentry events go to a null transport that still serializes the envelope, so transport CPU is
40+
counted but network is not. OTLP spans go to a stub HTTP sink in a separate process that returns
41+
200, so the exporter succeeds instead of spinning on retry backoff.
42+
43+
Environment: Apple M2 (8 cores), macOS 26.6.2, CPython 3.14.7, sentry-sdk 2.67.1, fastapi 0.141.1,
44+
starlette 1.6.0, uvicorn 0.52.1, opentelemetry-sdk 1.44.0,
45+
opentelemetry-instrumentation-fastapi 0.65b0, prometheus-fastapi-instrumentator 8.1.0,
46+
structlog 26.1.0. Endpoint: `async def` returning `{"ok": True}`. Median of 5 rounds.
47+
48+
## 3. Headline numbers
49+
50+
Real server, uvicorn + `ab -k`, trivial async endpoint:
51+
52+
| config | RPS | µs/req | vs bare |
53+
|---|---:|---:|---|
54+
| bare FastAPI | 7978 | 125.3 | - |
55+
| full lite-bootstrap stack (otel + prometheus + structlog + sentry) | 2389 | 418.6 | **−70%** |
56+
| same stack, tuned (§6) | 4187 | 238.8 | −48% |
57+
58+
Same endpoint plus three structlog records per request:
59+
60+
| config | RPS | µs/req | vs bare |
61+
|---|---:|---:|---|
62+
| bare | 5716 | 175.0 | - |
63+
| full stack | 2013 | 496.7 | **−65%** |
64+
| tuned | 3476 | 287.7 | −39% |
65+
66+
In-process (SDK cost isolated, baseline 16.2 µs/req): full stack 61628 → 4268 RPS, **14.5x**.
67+
Tuned recovers it to 9150, **2.14x** over the untuned stack.
68+
69+
The tuning is worth **+75% RPS** on the real server, and in-process the untuned stack costs an
70+
order of magnitude of a do-nothing handler's throughput.
71+
72+
## 4. Per-instrument breakdown
73+
74+
In-process, each instrument alone, baseline 15.8 µs/req:
75+
76+
| instrument | RPS | +µs/req | share of full stack |
77+
|---|---:|---:|---|
78+
| `LoggingInstrument` (configured, no logs emitted) | 62680 | +0.1 | ~0% |
79+
| `PrometheusInstrument` | 29983 | +17.5 | 8% |
80+
| `SentryInstrument` (tracing off) | 13449 | +58.5 | 27% |
81+
| `OpenTelemetryInstrument` | 7356 | **+120.1** | 55% |
82+
| all four | 4293 | +217.1 | |
83+
84+
Costs are close to additive (0.1 + 17.5 + 58.5 + 120.1 = 196 vs 217 measured). **OpenTelemetry is
85+
twice Sentry**, which was not the expected ordering, and structlog's instrument costs nothing
86+
until you actually log.
87+
88+
### 4a. OpenTelemetry: two knobs lite-bootstrap does not expose
89+
90+
| scenario | RPS | µs/req | gain |
91+
|---|---:|---:|---|
92+
| `otel` as lite-bootstrap configures it | 7375 | 135.6 | - |
93+
| `+ exclude_spans=["receive", "send"]` | 9755 | 102.5 | −33.1 µs |
94+
| `+ ParentBased(TraceIdRatioBased(0.01))` sampler | 12484 | 80.1 | −55.5 µs |
95+
| both | 15922 | 62.8 | **2.16x** |
96+
97+
1. `FastAPIInstrumentor.instrument_app` accepts `exclude_spans: list[Literal["receive","send"]]`.
98+
lite-bootstrap passes only `app`, `tracer_provider` and `excluded_urls`, so **every request
99+
produces three spans** - the server span plus one each for the ASGI `receive` and `send`
100+
events. Two thirds of the spans, one quarter of the cost, and almost nobody looks at them.
101+
2. `OpenTelemetryInstrument.bootstrap()` constructs `TracerProvider(resource=resource)` with no
102+
sampler, which means the SDK default `parentbased_always_on`. **There is no configuration
103+
surface for a sampler anywhere in lite-bootstrap**, so a service cannot head-sample its own
104+
traces at all; every request is recorded, serialized and shipped. A 1% ratio sampler is worth
105+
55 µs/req here. (Sampling rate is a user decision, not a default to change - the gap is that
106+
it cannot be expressed.)
107+
108+
### 4b. Sentry: the cost is one thing, and it is not the one people tune
109+
110+
In-process ablation, Sentry only, baseline 15.6 µs/req:
111+
112+
| scenario | +µs | reading |
113+
|---|---:|---|
114+
| defaults (tracing off) | +61.3 | the number to beat |
115+
| `attach_stacktrace=False` | +61.5 | no effect on the happy path |
116+
| `max_breadcrumbs=0` | +62.0 | no effect - the crumb is still built |
117+
| `disabled_integrations=[Stdlib, Modules, Dedupe, Excepthook, Threading]` | +61.4 | no effect |
118+
| `default_integrations=False` (Starlette+FastAPI kept) | +61.9 | no effect |
119+
| **`integrations=[]`, no framework integration** | **+0.4** | **all of it is the ASGI integration** |
120+
| `auto_session_tracking=False` | +54.4 | sessions cost ~7 µs |
121+
| `http_methods_to_capture=()` (no Transaction) | +27.2 | the Transaction costs ~34 µs |
122+
| both of the above | +19.2 | |
123+
124+
The first block is the useful negative result: **every knob people reach for first buys nothing.**
125+
All the cost is in `SentryAsgiMiddleware._run_app`, and most of it is a `Transaction` built and
126+
thrown away because tracing is disabled.
127+
128+
Micro-benchmarks (`micro.py`):
129+
130+
| operation | µs |
131+
|---|---:|
132+
| `Random(trace_id)` - seeding Mersenne Twister | 6.42 |
133+
| `_generate_sample_rand(trace_id)` | 6.99 |
134+
| `Transaction(op, name, source)` | 9.63 |
135+
| `scope.continue_trace(headers)` | 11.02 |
136+
| `start_transaction(txn)` + exit, tracing **off** | 18.54 |
137+
| `scope.generate_propagation_context(headers)` | 0.61 |
138+
| `isolation_scope()` enter/exit | 2.25 |
139+
| `scope.fork()` | 0.62 |
140+
| `get_client()` | 0.14 (×17 per request) |
141+
142+
`Transaction.__init__` unconditionally calls `_generate_sample_rand(self.trace_id)`, which does
143+
`Random(trace_id)` - a full Mersenne Twister seed, 6.4 µs. It is 5.9 µs even for `Random(1)`, so
144+
the cost is the MT init, not the string hashing; deriving the same value arithmetically
145+
(`int(trace_id, 16) / 2**128`) takes **0.23 µs, 27x cheaper**. This runs on every request even
146+
when `traces_sample_rate is None`.
147+
148+
With `traces_sample_rate=1.0` the SDK costs +274 µs/req on the real server (2486 RPS, −68%).
149+
150+
### 4c. Logging: cost per record, not per request
151+
152+
Three records per request, in-process:
153+
154+
| scenario | +µs/req | delta |
155+
|---|---:|---:|
156+
| Sentry defaults | +99.7 | |
157+
| `LoggingIntegration(sentry_logs_level=None)` | +92.9 | −1.9 µs/record |
158+
| `LoggingIntegration(level=None, sentry_logs_level=None)` | +73.3 | −8.4 µs/record total |
159+
160+
Two handlers run per log record. `SentryLogsHandler.emit` calls `self.format(record)` *before* it
161+
checks `has_logs_enabled(client.options)`, so with Sentry Logs disabled (the default, and
162+
lite-bootstrap never sets `enable_logs`) every record is formatted an extra time for nothing.
163+
`BreadcrumbHandler` then formats it again and builds a breadcrumb dict. `max_breadcrumbs=0` does
164+
not help: the crumb is constructed before the deque drops it.
165+
166+
This hits lite-bootstrap directly because `LoggingInstrument` wires structlog through
167+
`structlog.stdlib.BoundLogger`, so every structlog call goes through the patched
168+
`logging.Logger.callHandlers` and pays both handlers.
169+
170+
## 5. What each saving actually costs you
171+
172+
Measured by capturing a real error event with an incoming `sentry-trace` header and inspecting the
173+
envelope (`verify.py`):
174+
175+
| config | txn name | continues incoming trace | breadcrumbs |
176+
|---|---|---|---|
177+
| defaults | `/ping` | yes | yes |
178+
| `http_methods_to_capture=()` | `/ping` | **no** | yes |
179+
| `LoggingIntegration(level=None)` | `/ping` | yes | **no** |
180+
| propagation kept, Transaction skipped (patched SDK) | `/ping` | yes | yes |
181+
182+
`http_methods_to_capture=()` is not free: the error event gets a fresh `trace_id` and no
183+
`parent_span_id`, which breaks cross-service correlation of errors in Sentry. Acceptable when
184+
distributed tracing is OpenTelemetry's job - as it is in any lite-bootstrap service that also runs
185+
`OpenTelemetryInstrument` - and Sentry is only an error sink. Not acceptable otherwise.
186+
187+
The last row is the interesting one: replacing `Scope.continue_trace` with a version that keeps
188+
`generate_propagation_context(headers)` and returns no Transaction loses **nothing** on the error
189+
event and still saves ~30 µs/req. That is a pure upstream bug, not a trade-off.
190+
191+
Similarly, `exclude_spans=["receive","send"]` costs you the ASGI event spans and nothing else, and
192+
`sentry_logs_level=None` costs nothing at all while Sentry Logs is disabled.
193+
194+
## 6. The tuned configuration
195+
196+
What "tuned" means in §3, all reachable through today's public API except the two OTel knobs:
197+
198+
```python
199+
FastAPIConfig(
200+
# Sentry: OTel owns distributed tracing, Sentry is an error sink
201+
sentry_integrations=[
202+
StarletteIntegration(http_methods_to_capture=()),
203+
FastApiIntegration(http_methods_to_capture=()),
204+
LoggingIntegration(level=None, sentry_logs_level=None),
205+
],
206+
sentry_additional_params={"auto_session_tracking": False},
207+
# OpenTelemetry: not expressible today, see issues
208+
# exclude_spans=["receive", "send"] on FastAPIInstrumentor.instrument_app
209+
# sampler=ParentBased(TraceIdRatioBased(0.01)) on TracerProvider
210+
)
211+
```
212+
213+
Trade-offs, in order of what you give up: log breadcrumbs on Sentry errors, Sentry release health,
214+
Sentry-side trace correlation, 99% of OTel traces, ASGI event spans.
215+
216+
## 7. Filed issues
217+
218+
lite-bootstrap (all "possible improvement", nothing implemented):
219+
220+
- [#184](https://github.com/modern-python/lite-bootstrap/issues/184) OpenTelemetry sampler is not
221+
configurable (55 µs/req)
222+
- [#185](https://github.com/modern-python/lite-bootstrap/issues/185) `exclude_spans` is never passed
223+
to `FastAPIInstrumentor` (33 µs/req)
224+
- [#186](https://github.com/modern-python/lite-bootstrap/issues/186) Sentry `sentry_logs_level`,
225+
breadcrumb level and `auto_session_tracking` are not exposed (~9 µs/req plus ~2 µs/log record)
226+
- [#187](https://github.com/modern-python/lite-bootstrap/issues/187) Document what the stack costs
227+
228+
sentry-python:
229+
230+
- [#7400](https://github.com/getsentry/sentry-python/issues/7400) A full `Transaction` is built and
231+
discarded per request when tracing is disabled (~34 µs)
232+
- [#7401](https://github.com/getsentry/sentry-python/issues/7401) `_generate_sample_rand` seeds a
233+
Mersenne Twister per `Transaction`, eagerly, even when unsampled (6.4 µs; 27x cheaper
234+
arithmetically)
235+
- [#7402](https://github.com/getsentry/sentry-python/issues/7402) `SentryLogsHandler.emit` formats
236+
the record before checking `has_logs_enabled` (~1.9 µs/record)
237+
- Measurements added as a [comment on #2116](https://github.com/getsentry/sentry-python/issues/2116#issuecomment-5565265173),
238+
the long-open "SDK causes significant performance issue" report, rather than filing a duplicate.
239+
240+
Related existing reports: [#2303](https://github.com/getsentry/sentry-python/issues/2303),
241+
[#668](https://github.com/getsentry/sentry-python/issues/668).
242+
243+
## 8. Reproducing
244+
245+
Everything runs from this directory against an interpreter that has `lite_bootstrap`, `fastapi`,
246+
`uvicorn`, `structlog`, `sentry-sdk`, the OpenTelemetry SDK and
247+
`prometheus-fastapi-instrumentator` importable - the repo's own `.venv` does. Each runner
248+
re-invokes `sys.executable` once per scenario, because none of the patching these libraries do at
249+
import or init time can be undone in-process.
250+
251+
```bash
252+
cd benchmarks
253+
254+
# per-instrument breakdown and the tuned stack (section 4)
255+
../.venv/bin/python run.py stack async bare,log,prom,sentry,otel,full,full_all_tuned
256+
257+
# the two OpenTelemetry knobs (section 4a)
258+
../.venv/bin/python run.py stack async otel,otel_exclude_spans,otel_sampler,otel_tuned
259+
260+
# the Sentry ablation, where every familiar knob turns out to be a no-op (section 4b)
261+
../.venv/bin/python run.py sentry async off,errors_only,errors_only_lean,errors_only_no_integrations,errors_only_no_txn
262+
263+
# cost per log record (section 4c)
264+
../.venv/bin/python run.py sentry logging off,errors_only,errors_only_no_sentry_logs,errors_only_logging_lean
265+
266+
# the headline numbers, over real sockets - needs `ab` on PATH
267+
../.venv/bin/python run_http.py stack async bare,full,full_all_tuned
268+
269+
# what a scenario gives up, and where the time goes inside one
270+
../.venv/bin/python verify.py errors_only_no_txn
271+
../.venv/bin/python micro.py
272+
../.venv/bin/python profile_one.py sentry errors_only
273+
../.venv/bin/python profile_one.py sentry errors_only --callers 'Random.seed'
274+
```
275+
276+
`run.py <suite> --list` prints the scenario names; they are defined in `sentry_scenarios.py`
277+
and `stack_scenarios.py`.
278+
Scenarios whose name implies a fix that does not exist yet (`errors_only_skip_txn`,
279+
`otel_sampler`, `full_all_tuned`) monkeypatch the library to simulate it, so the value of a
280+
proposed change can be measured before anyone writes it.
281+
282+
`repro_sentry_txn.py` is deliberately standalone - it is the repro pasted into
283+
[sentry-python#7400](https://github.com/getsentry/sentry-python/issues/7400) and imports nothing
284+
from this directory.
285+
286+
Numbers are machine-specific and move a few percent run to run; the ratios and the ordering are
287+
the durable part. Everything here was measured in one session on one idle machine, which is the
288+
only way the columns are comparable to each other.

‎benchmarks/driver.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Shared pieces: a null Sentry transport and an in-process ASGI request loop.
2+
3+
Driving the app directly (`await app(scope, receive, send)`) removes sockets and HTTP parsing
4+
from the measurement, so what is left is library cost. It makes the baseline unrealistically
5+
fast, which overstates the *relative* impact; `run_http.py` is the real-server counterpart.
6+
"""
7+
8+
import asyncio
9+
import gc
10+
import time
11+
import typing
12+
13+
14+
if typing.TYPE_CHECKING:
15+
from sentry_sdk.envelope import Envelope
16+
17+
from sentry_sdk.transport import Transport
18+
19+
20+
DSN: typing.Final = "https://public@o0.ingest.sentry.io/0"
21+
22+
ASGIApp = typing.Callable[..., typing.Awaitable[None]]
23+
24+
25+
class NullTransport(Transport):
26+
"""Serialize the envelope and drop it: transport CPU is counted, network is not."""
27+
28+
def __init__(self, options: dict[str, typing.Any] | None = None) -> None:
29+
super().__init__(options)
30+
self.envelopes = 0
31+
self.bytes = 0
32+
33+
def capture_envelope(self, envelope: "Envelope") -> None:
34+
self.envelopes += 1
35+
self.bytes += len(envelope.serialize())
36+
37+
38+
TRANSPORT: typing.Final = NullTransport()
39+
40+
41+
BASE_SCOPE: typing.Final[dict[str, typing.Any]] = {
42+
"type": "http",
43+
"asgi": {"version": "3.0", "spec_version": "2.3"},
44+
"http_version": "1.1",
45+
"method": "GET",
46+
"scheme": "http",
47+
"path": "/ping",
48+
"raw_path": b"/ping",
49+
"root_path": "",
50+
"query_string": b"",
51+
"headers": [
52+
(b"host", b"testserver"),
53+
(b"user-agent", b"bench/1.0"),
54+
(b"accept", b"*/*"),
55+
(b"connection", b"keep-alive"),
56+
],
57+
"client": ("127.0.0.1", 50000),
58+
"server": ("127.0.0.1", 8000),
59+
}
60+
61+
62+
async def one_request(app: ASGIApp, scope: dict[str, typing.Any] | None = None) -> int:
63+
"""Drive one request through the ASGI app and return its status code.
64+
65+
The scope is copied per call because Starlette and the instrumentations write into it
66+
(`route`, `endpoint`, `app`, ...), exactly as a real server hands over a fresh one.
67+
"""
68+
request_scope = dict(scope if scope is not None else BASE_SCOPE)
69+
status = 0
70+
body_sent = False
71+
72+
async def receive() -> dict[str, typing.Any]:
73+
nonlocal body_sent
74+
if body_sent:
75+
return {"type": "http.disconnect"}
76+
body_sent = True
77+
return {"type": "http.request", "body": b"", "more_body": False}
78+
79+
async def send(message: dict[str, typing.Any]) -> None:
80+
nonlocal status
81+
if message["type"] == "http.response.start":
82+
status = message["status"]
83+
84+
await app(request_scope, receive, send)
85+
return status
86+
87+
88+
async def run(app: ASGIApp, requests: int, rounds: int, warmup: int) -> list[float]:
89+
"""Return one requests-per-second figure per round."""
90+
expected_status = 200
91+
for _ in range(warmup):
92+
status = await one_request(app)
93+
if status != expected_status:
94+
msg = f"warmup returned {status}, expected {expected_status}"
95+
raise RuntimeError(msg)
96+
97+
results = []
98+
for _ in range(rounds):
99+
gc.collect()
100+
start = time.perf_counter()
101+
for _ in range(requests):
102+
await one_request(app)
103+
results.append(requests / (time.perf_counter() - start))
104+
return results
105+
106+
107+
def measure(app: ASGIApp, requests: int, rounds: int, warmup: int) -> list[float]:
108+
return asyncio.run(run(app, requests, rounds, warmup))

0 commit comments

Comments
 (0)