Skip to content

Commit 927dc99

Browse files
authored
docs: document what the observability stack costs in RPS (#234)
* refactor(benchmarks): reach the tuned stack through config, not monkeypatches * docs(benchmarks): re-measure the whole write-up in one session * docs: add a performance page sourced from the benchmark write-up * review: name the baseline, complete the tuning table, reconcile figures
1 parent effba9d commit 927dc99

5 files changed

Lines changed: 187 additions & 110 deletions

File tree

‎benchmarks/README.md‎

Lines changed: 71 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
## 1. The short answer
44

55
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
6+
(7698 → 2313 RPS). Roughly a third of that is recoverable without giving up observability, and
77
**OpenTelemetry costs twice what Sentry does** - not the ordering most people expect.
88

99
Two published figures about the Sentry half look contradictory and are both correct.
1010
[getsentry/sentry-python#2116](https://github.com/getsentry/sentry-python/issues/2116), open
1111
since 2023, reports a Starlette app dropping from ~2000 to ~1000 RPS after adding the SDK;
1212
Sentry's own docs claim
1313
[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
14+
Both hold at once, because the added cost is a fixed ~70 µs: small in absolute terms, and
1515
enormous next to a handler that does nothing.
1616

1717
That is also the caveat on everything below. These ratios are an upper bound. A service that
@@ -28,7 +28,7 @@ instruments, configured through `FastAPIBootstrapper`) - measured three ways:
2828
baseline is unrealistically fast.
2929
- **Real server** (`run_http.py`) - uvicorn, single worker, access log off, loaded with
3030
`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).
31+
~8.4k RPS by c=64, vs 7.7k measured at c=16).
3232
- **Micro** (`micro.py`, `verify.py`, `profile_one.py`) - per-operation costs, what each
3333
configuration actually gives up, and cProfile.
3434

@@ -47,52 +47,58 @@ structlog 26.1.0. Endpoint: `async def` returning `{"ok": True}`. Median of 5 ro
4747

4848
## 3. Headline numbers
4949

50+
<!-- --8<-- [start:headline] -->
5051
Real server, uvicorn + `ab -k`, trivial async endpoint:
5152

5253
| config | RPS | µs/req | vs bare |
5354
|---|---:|---:|---|
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% |
55+
| bare FastAPI | 7698 | 129.9 | - |
56+
| full lite-bootstrap stack (otel + prometheus + structlog + sentry) | 2313 | 432.3 | **−70%** |
57+
| same stack, tuned (§6) | 4164 | 240.2 | −46% |
5758

5859
Same endpoint plus three structlog records per request:
5960

6061
| config | RPS | µs/req | vs bare |
6162
|---|---:|---:|---|
62-
| bare | 5716 | 175.0 | - |
63-
| full stack | 2013 | 496.7 | **−65%** |
64-
| tuned | 3476 | 287.7 | −39% |
63+
| bare | 5548 | 180.3 | - |
64+
| full stack | 1944 | 514.5 | **−65%** |
65+
| tuned | 3418 | 292.5 | −38% |
6566

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.
67+
<!-- --8<-- [end:headline] -->
6868

69-
The tuning is worth **+75% RPS** on the real server, and in-process the untuned stack costs an
69+
In-process (SDK cost isolated, baseline 16.2 µs/req): full stack 61667 → 4056 RPS, **15.2x**.
70+
Tuned recovers it to 9061, **2.23x** over the untuned stack.
71+
72+
The tuning is worth **+80% RPS** on the real server, and in-process the untuned stack costs an
7073
order of magnitude of a do-nothing handler's throughput.
7174

7275
## 4. Per-instrument breakdown
7376

74-
In-process, each instrument alone, baseline 15.8 µs/req:
77+
In-process, each instrument alone, baseline 16.2 µs/req:
7578

79+
<!-- --8<-- [start:perinstrument] -->
7680
| instrument | RPS | +µs/req | share of full stack |
7781
|---|---:|---:|---|
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
82+
| `LoggingInstrument` (configured, no logs emitted) | 61133 | +0.1 | ~0% |
83+
| `PrometheusInstrument` | 29470 | +17.7 | 8% |
84+
| `SentryInstrument` (tracing off) | 13541 | +57.6 | 25% |
85+
| `OpenTelemetryInstrument` | 7280 | **+121.1** | 53% |
86+
| all four | 4056 | +230.3 | |
87+
<!-- --8<-- [end:perinstrument] -->
88+
89+
Costs are roughly additive, with the combination 17% dearer than the parts
90+
(0.1 + 17.7 + 57.6 + 121.1 = 196 vs 230 measured). **OpenTelemetry is
8591
twice Sentry**, which was not the expected ordering, and structlog's instrument costs nothing
8692
until you actually log.
8793

8894
### 4a. OpenTelemetry: the two knobs that pay
8995

9096
| scenario | RPS | µs/req | gain |
9197
|---|---:|---:|---|
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** |
98+
| `otel` as lite-bootstrap configures it | 7267 | 137.6 | - |
99+
| `+ opentelemetry_exclude_spans=["receive", "send"]` | 9651 | 103.6 | −34.0 µs |
100+
| `+ opentelemetry_sampler=ParentBased(TraceIdRatioBased(0.01))` | 12154 | 82.3 | −55.3 µs |
101+
| both | 15518 | 64.4 | **2.14x** |
96102

97103
1. `FastAPIInstrumentor.instrument_app` accepts `exclude_spans: list[Literal["receive","send"]]`,
98104
which `FastAPIConfig.opentelemetry_exclude_spans` passes through. It is empty by default, so
@@ -106,19 +112,21 @@ until you actually log.
106112

107113
### 4b. Sentry: the cost is one thing, and it is not the one people tune
108114

109-
In-process ablation, Sentry only, baseline 15.6 µs/req:
115+
In-process ablation, Sentry only, baseline 16.5 µs/req:
110116

117+
<!-- --8<-- [start:sentryablation] -->
111118
| scenario | +µs | reading |
112119
|---|---:|---|
113-
| defaults (tracing off) | +61.3 | the number to beat |
114-
| `attach_stacktrace=False` | +61.5 | no effect on the happy path |
115-
| `max_breadcrumbs=0` | +62.0 | no effect - the crumb is still built |
116-
| `disabled_integrations=[Stdlib, Modules, Dedupe, Excepthook, Threading]` | +61.4 | no effect |
117-
| `default_integrations=False` (Starlette+FastAPI kept) | +61.9 | no effect |
118-
| **`integrations=[]`, no framework integration** | **+0.4** | **all of it is the ASGI integration** |
119-
| `auto_session_tracking=False` | +54.4 | sessions cost ~7 µs |
120-
| `http_methods_to_capture=()` (no Transaction) | +27.2 | the Transaction costs ~34 µs |
121-
| both of the above | +19.2 | |
120+
| defaults (tracing off) | +64.2 | the number to beat |
121+
| `attach_stacktrace=False` | +63.2 | no effect on the happy path |
122+
| `max_breadcrumbs=0` | +63.4 | no effect - the crumb is still built |
123+
| `disabled_integrations=[Stdlib, Modules, Dedupe, Excepthook, Threading]` | +63.8 | no effect |
124+
| `default_integrations=False` (Starlette+FastAPI kept) | +63.8 | no effect |
125+
| **`integrations=[]`, no framework integration** | **−0.1** | **all of it is the ASGI integration** |
126+
| `auto_session_tracking=False` | +56.5 | sessions cost ~7.7 µs |
127+
| `http_methods_to_capture=()` (no Transaction) | +29.2 | the Transaction costs ~35 µs |
128+
| both of the above | +19.9 | |
129+
<!-- --8<-- [end:sentryablation] -->
122130

123131
The first block is the useful negative result: **every knob people reach for first buys nothing.**
124132
All the cost is in `SentryAsgiMiddleware._run_app`, and most of it is a `Transaction` built and
@@ -128,37 +136,41 @@ Micro-benchmarks (`micro.py`):
128136

129137
| operation | µs |
130138
|---|---:|
131-
| `Random(trace_id)` - seeding Mersenne Twister | 6.42 |
132-
| `_generate_sample_rand(trace_id)` | 6.99 |
133-
| `Transaction(op, name, source)` | 9.63 |
134-
| `scope.continue_trace(headers)` | 11.02 |
135-
| `start_transaction(txn)` + exit, tracing **off** | 18.54 |
136-
| `scope.generate_propagation_context(headers)` | 0.61 |
137-
| `isolation_scope()` enter/exit | 2.25 |
139+
| `Random(trace_id)` - seeding Mersenne Twister | 6.60 |
140+
| `_generate_sample_rand(trace_id)` | 7.18 |
141+
| `Transaction(op, name, source)` | 10.10 |
142+
| `scope.continue_trace(headers)` | 11.28 |
143+
| `start_transaction(txn)` + exit, tracing **off** | 19.25 |
144+
| `scope.generate_propagation_context(headers)` | 0.64 |
145+
| `isolation_scope()` enter/exit | 2.31 |
138146
| `scope.fork()` | 0.62 |
139147
| `get_client()` | 0.14 (×17 per request) |
140148

141149
`Transaction.__init__` unconditionally calls `_generate_sample_rand(self.trace_id)`, which does
142-
`Random(trace_id)` - a full Mersenne Twister seed, 6.4 µs. It is 5.9 µs even for `Random(1)`, so
150+
`Random(trace_id)` - a full Mersenne Twister seed, 6.6 µs. It is 6.1 µs even for `Random(1)`, so
143151
the cost is the MT init, not the string hashing; deriving the same value arithmetically
144-
(`int(trace_id, 16) / 2**128`) takes **0.23 µs, 27x cheaper**. This runs on every request even
152+
(`int(trace_id, 16) / 2**128`) takes **0.25 µs, 27x cheaper**. This runs on every request even
145153
when `traces_sample_rate is None`.
146154

147-
With `traces_sample_rate=1.0` the SDK costs +274 µs/req on the real server (2486 RPS, −68%).
155+
With `traces_sample_rate=1.0` the SDK costs +353 µs/req on the real server: 1948 RPS against
156+
this suite's own baseline of 6241, −69%. That baseline is Sentry-off on the same app, not §3's
157+
bare FastAPI, so the two tables are not directly comparable.
148158

149159
### 4c. Logging: cost per record, not per request
150160

151161
Three records per request, in-process:
152162

153163
| scenario | +µs/req | delta |
154164
|---|---:|---:|
155-
| Sentry defaults | +99.7 | |
156-
| `LoggingIntegration(sentry_logs_level=None)` | +92.9 | −1.9 µs/record |
157-
| `LoggingIntegration(level=None, sentry_logs_level=None)` | +73.3 | −8.4 µs/record total |
165+
| sentry-sdk defaults | +101.1 | |
166+
| `sentry_logs_level=None` (lite-bootstrap's default since #186) | +93.8 | −2.4 µs/record |
167+
| also `sentry_logging_breadcrumb_level=None` | +72.4 | −9.6 µs/record total |
158168

159169
Two handlers run per log record. `SentryLogsHandler.emit` calls `self.format(record)` *before* it
160170
checks `has_logs_enabled(client.options)`, so with Sentry Logs disabled (the default, and
161171
lite-bootstrap never sets `enable_logs`) every record is formatted an extra time for nothing.
172+
lite-bootstrap passes `sentry_logs_level=None` by default, so a service gets the second row
173+
without configuring anything.
162174
`BreadcrumbHandler` then formats it again and builds a breadcrumb dict. `max_breadcrumbs=0` does
163175
not help: the crumb is constructed before the deque drops it.
164176

@@ -171,12 +183,14 @@ This hits lite-bootstrap directly because `LoggingInstrument` wires structlog th
171183
Measured by capturing a real error event with an incoming `sentry-trace` header and inspecting the
172184
envelope (`verify.py`):
173185

186+
<!-- --8<-- [start:tradeoffs] -->
174187
| config | txn name | continues incoming trace | breadcrumbs |
175188
|---|---|---|---|
176189
| defaults | `/ping` | yes | yes |
177190
| `http_methods_to_capture=()` | `/ping` | **no** | yes |
178-
| `LoggingIntegration(level=None)` | `/ping` | yes | **no** |
191+
| `sentry_logging_breadcrumb_level=None` | `/ping` | yes | **no** |
179192
| propagation kept, Transaction skipped (patched SDK) | `/ping` | yes | yes |
193+
<!-- --8<-- [end:tradeoffs] -->
180194

181195
`http_methods_to_capture=()` is not free: the error event gets a fresh `trace_id` and no
182196
`parent_span_id`, which breaks cross-service correlation of errors in Sentry. Acceptable when
@@ -185,7 +199,7 @@ distributed tracing is OpenTelemetry's job - as it is in any lite-bootstrap serv
185199

186200
The last row is the interesting one: replacing `Scope.continue_trace` with a version that keeps
187201
`generate_propagation_context(headers)` and returns no Transaction loses **nothing** on the error
188-
event and still saves ~30 µs/req. That is a pure upstream bug, not a trade-off.
202+
event and still saves ~32 µs/req. That is a pure upstream bug, not a trade-off.
189203

190204
Similarly, `opentelemetry_exclude_spans=["receive","send"]` costs you the ASGI event spans and
191205
nothing else, and `sentry_logs_level=None` costs nothing at all while Sentry Logs is disabled -
@@ -195,6 +209,7 @@ which is why lite-bootstrap now applies it by default.
195209

196210
What "tuned" means in §3, all reachable through today's public API:
197211

212+
<!-- --8<-- [start:tuned] -->
198213
```python
199214
FastAPIConfig(
200215
# Sentry: OTel owns distributed tracing, Sentry is an error sink
@@ -209,6 +224,7 @@ FastAPIConfig(
209224
opentelemetry_sampler=ParentBased(TraceIdRatioBased(0.01)),
210225
)
211226
```
227+
<!-- --8<-- [end:tuned] -->
212228

213229
Trade-offs, in order of what you give up: log breadcrumbs on Sentry errors, Sentry release health,
214230
Sentry-side trace correlation, 99% of OTel traces, ASGI event spans.
@@ -230,12 +246,12 @@ lite-bootstrap (all "possible improvement"):
230246
sentry-python:
231247

232248
- [#7400](https://github.com/getsentry/sentry-python/issues/7400) A full `Transaction` is built and
233-
discarded per request when tracing is disabled (~34 µs)
249+
discarded per request when tracing is disabled (~35 µs)
234250
- [#7401](https://github.com/getsentry/sentry-python/issues/7401) `_generate_sample_rand` seeds a
235-
Mersenne Twister per `Transaction`, eagerly, even when unsampled (6.4 µs; 27x cheaper
251+
Mersenne Twister per `Transaction`, eagerly, even when unsampled (6.6 µs; 27x cheaper
236252
arithmetically)
237253
- [#7402](https://github.com/getsentry/sentry-python/issues/7402) `SentryLogsHandler.emit` formats
238-
the record before checking `has_logs_enabled` (~1.9 µs/record)
254+
the record before checking `has_logs_enabled` (~2.4 µs/record)
239255
- Measurements added as a [comment on #2116](https://github.com/getsentry/sentry-python/issues/2116#issuecomment-5565265173),
240256
the long-open "SDK causes significant performance issue" report, rather than filing a duplicate.
241257

@@ -278,8 +294,9 @@ cd benchmarks
278294
`run.py <suite> --list` prints the scenario names; they are defined in `sentry_scenarios.py`
279295
and `stack_scenarios.py`.
280296
Scenarios whose name implies a fix that does not exist yet (`errors_only_skip_txn`,
281-
`otel_sampler`, `full_all_tuned`) monkeypatch the library to simulate it, so the value of a
282-
proposed change can be measured before anyone writes it.
297+
`errors_only_lazy_sample_rand`) monkeypatch sentry-sdk to simulate it, so the value of a proposed
298+
change can be measured before anyone writes it. The stack suite no longer patches anything: every
299+
scenario there, `full_all_tuned` included, is reachable through `FastAPIConfig`.
283300

284301
`repro_sentry_txn.py` is deliberately standalone - it is the repro pasted into
285302
[sentry-python#7400](https://github.com/getsentry/sentry-python/issues/7400) and imports nothing

‎benchmarks/stack_scenarios.py‎

Lines changed: 12 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
OTLP spans go to `stub_otlp.py` in a separate process so the exporter succeeds instead of
55
spinning on retry backoff; Sentry events go to `driver.TRANSPORT`.
66
7-
The `_patch_*` helpers simulate configuration lite-bootstrap does not expose yet
8-
(issues #184 and #185), so the value of exposing it can be measured.
7+
Every scenario here is reachable through `FastAPIConfig`, so a reader can copy the tuned
8+
configuration into their own service and get the measured behaviour.
99
"""
1010

1111
import os
@@ -14,14 +14,10 @@
1414
import structlog
1515
from driver import DSN, TRANSPORT
1616
from fastapi import FastAPI
17-
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
18-
from opentelemetry.sdk.trace import TracerProvider
1917
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
2018
from sentry_sdk.integrations.fastapi import FastApiIntegration
21-
from sentry_sdk.integrations.logging import LoggingIntegration
2219
from sentry_sdk.integrations.starlette import StarletteIntegration
2320

24-
import lite_bootstrap.instruments.opentelemetry_instrument as otel_instrument
2521
from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig
2622

2723

@@ -43,6 +39,8 @@
4339
"opentelemetry_endpoint": OTLP_ENDPOINT,
4440
"opentelemetry_exporter_protocol": "http",
4541
}
42+
_OTEL_EXCLUDE_SPANS: typing.Final = {"opentelemetry_exclude_spans": ["receive", "send"]}
43+
_OTEL_SAMPLER: typing.Final = {"opentelemetry_sampler": ParentBased(TraceIdRatioBased(SAMPLE_RATIO))}
4644
_PROMETHEUS: typing.Final = {"prometheus_metrics_path": "/metrics"}
4745
_LOGGING: typing.Final = {"logging_enabled": True}
4846
_SENTRY: typing.Final = {
@@ -58,9 +56,10 @@ def _sentry_tuned() -> dict[str, typing.Any]:
5856
"sentry_integrations": [
5957
StarletteIntegration(http_methods_to_capture=()),
6058
FastApiIntegration(http_methods_to_capture=()),
61-
LoggingIntegration(level=None, sentry_logs_level=None),
6259
],
63-
"sentry_additional_params": {"transport": TRANSPORT, "auto_session_tracking": False},
60+
"sentry_logging_breadcrumb_level": None,
61+
"sentry_auto_session_tracking": False,
62+
"sentry_additional_params": {"transport": TRANSPORT},
6463
}
6564

6665

@@ -86,44 +85,13 @@ def config(*parts: dict[str, typing.Any]) -> dict[str, typing.Any]:
8685
"full_no_otel": lambda: config(_PROMETHEUS, _LOGGING, _SENTRY),
8786
"full_no_sentry": lambda: config(_OTEL, _PROMETHEUS, _LOGGING),
8887
"full_sentry_tuned": lambda: config(_OTEL, _PROMETHEUS, _LOGGING, _sentry_tuned()),
88+
"otel_exclude_spans": lambda: config(_OTEL, _OTEL_EXCLUDE_SPANS),
89+
"otel_sampler": lambda: config(_OTEL, _OTEL_SAMPLER),
90+
"otel_tuned": lambda: config(_OTEL, _OTEL_EXCLUDE_SPANS, _OTEL_SAMPLER),
91+
"full_all_tuned": lambda: config(_OTEL, _OTEL_EXCLUDE_SPANS, _OTEL_SAMPLER, _PROMETHEUS, _LOGGING, _sentry_tuned()),
8992
}
9093

9194

92-
def _patch_exclude_send_receive_spans() -> None:
93-
"""`instrument_app` accepts `exclude_spans`; lite-bootstrap never passes it (issue #185)."""
94-
original = FastAPIInstrumentor.instrument_app
95-
96-
def patched(**kwargs: object) -> None:
97-
kwargs.setdefault("exclude_spans", ["receive", "send"])
98-
original(**typing.cast("dict[str, typing.Any]", kwargs))
99-
100-
FastAPIInstrumentor.instrument_app = staticmethod(patched) # ty: ignore[invalid-assignment]
101-
102-
103-
def _patch_ratio_sampler() -> None:
104-
"""`TracerProvider()` defaults to always-on and no sampler is configurable (issue #184)."""
105-
original = otel_instrument.TracerProvider
106-
107-
def patched(**kwargs: object) -> TracerProvider:
108-
typed = typing.cast("dict[str, typing.Any]", kwargs)
109-
return original(sampler=ParentBased(TraceIdRatioBased(SAMPLE_RATIO)), **typed)
110-
111-
otel_instrument.TracerProvider = patched # ty: ignore[invalid-assignment]
112-
113-
114-
PATCHES: dict[str, list[typing.Callable[[], None]]] = {
115-
"otel_exclude_spans": [_patch_exclude_send_receive_spans],
116-
"otel_sampler": [_patch_ratio_sampler],
117-
"otel_tuned": [_patch_exclude_send_receive_spans, _patch_ratio_sampler],
118-
"full_all_tuned": [_patch_exclude_send_receive_spans, _patch_ratio_sampler],
119-
}
120-
121-
SCENARIOS["otel_exclude_spans"] = SCENARIOS["otel"]
122-
SCENARIOS["otel_sampler"] = SCENARIOS["otel"]
123-
SCENARIOS["otel_tuned"] = SCENARIOS["otel"]
124-
SCENARIOS["full_all_tuned"] = SCENARIOS["full_sentry_tuned"]
125-
126-
12795
def make_app(kind: str) -> FastAPI:
12896
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
12997

@@ -149,21 +117,14 @@ async def ping_log() -> dict[str, bool]:
149117
return app
150118

151119

152-
def setup(scenario: str) -> None:
153-
"""Apply the patches, then bootstrap. Patches must land before `instrument_app` runs."""
154-
for patch in PATCHES.get(scenario, ()):
155-
patch()
156-
157-
158120
def bootstrap(scenario: str, app: FastAPI) -> None:
159121
FastAPIBootstrapper(FastAPIConfig(application=app, **SCENARIOS[scenario]())).bootstrap()
160122

161123

162124
def build(scenario: str, app_kind: str) -> FastAPI:
163-
setup(scenario)
164125
app = make_app(app_kind)
165126
bootstrap(scenario, app)
166127
return app
167128

168129

169-
__all__ = ["APPS", "PATCHES", "SCENARIOS", "bootstrap", "build", "config", "make_app", "setup"]
130+
__all__ = ["APPS", "SCENARIOS", "bootstrap", "build", "config", "make_app"]

0 commit comments

Comments
 (0)