|
| 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. |
0 commit comments