33## 1. The short answer
44
55On 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
99Two 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
1111since 2023, reports a Starlette app dropping from ~ 2000 to ~ 1000 RPS after adding the SDK;
1212Sentry'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
1515enormous next to a handler that does nothing.
1616
1717That 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] -->
5051Real 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
5859Same 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
7073order 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
8591twice Sentry** , which was not the expected ordering, and structlog's instrument costs nothing
8692until 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
971031 . ` 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
123131The first block is the useful negative result: ** every knob people reach for first buys nothing.**
124132All 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
143151the 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
145153when ` 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
151161Three 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
159169Two handlers run per log record. ` SentryLogsHandler.emit ` calls ` self.format(record) ` * before* it
160170checks ` has_logs_enabled(client.options) ` , so with Sentry Logs disabled (the default, and
161171lite-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
163175not 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
171183Measured by capturing a real error event with an incoming ` sentry-trace ` header and inspecting the
172184envelope (` 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
186200The 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
190204Similarly, ` opentelemetry_exclude_spans=["receive","send"] ` costs you the ASGI event spans and
191205nothing 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
196210What "tuned" means in §3, all reachable through today's public API:
197211
212+ <!-- --8<-- [start:tuned] -->
198213``` python
199214FastAPIConfig(
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
213229Trade-offs, in order of what you give up: log breadcrumbs on Sentry errors, Sentry release health,
214230Sentry-side trace correlation, 99% of OTel traces, ASGI event spans.
@@ -230,12 +246,12 @@ lite-bootstrap (all "possible improvement"):
230246sentry-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 `
279295and ` stack_scenarios.py ` .
280296Scenarios 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
0 commit comments