Performance¶
Turning on the observability stack is not free, and the ordering of what costs what is not the one most people expect. This page is the short version of the benchmark write-up, which has the method, the full ablations and the commands to reproduce everything here.
The short answer¶
Measured on a trivial async def endpoint returning {"ok": True}, uvicorn with a single worker,
loaded with ab -k -c 16 -n 20000:
Real server, uvicorn + ab -k, trivial async endpoint:
| config | RPS | µs/req | vs bare |
|---|---|---|---|
| bare FastAPI | 7698 | 129.9 | - |
| full lite-bootstrap stack (otel + prometheus + structlog + sentry) | 2313 | 432.3 | −70% |
| same stack, tuned (§6) | 4164 | 240.2 | −46% |
Same endpoint plus three structlog records per request:
| config | RPS | µs/req | vs bare |
|---|---|---|---|
| bare | 5548 | 180.3 | - |
| full stack | 1944 | 514.5 | −65% |
| tuned | 3418 | 292.5 | −38% |
Tuning is worth +80% RPS on the real server, and every setting behind it is a documented configuration field.
Where the time goes¶
Each instrument alone, driving the ASGI app in-process so only library cost shows:
| instrument | RPS | +µs/req | share of full stack |
|---|---|---|---|
LoggingInstrument (configured, no logs emitted) |
61133 | +0.1 | ~0% |
PrometheusInstrument |
29470 | +17.7 | 8% |
SentryInstrument (tracing off) |
13541 | +57.6 | 25% |
OpenTelemetryInstrument |
7280 | +121.1 | 53% |
| all four | 4056 | +230.3 |
Four things here are worth knowing before you tune anything:
- OpenTelemetry costs about twice what Sentry does, and is the dominant cost of the stack.
- Structlog costs nothing until you actually log.
LoggingInstrumentadds ~0.1 µs per request when configured. The cost arrives per record, not per request, and most of it is Sentry's two log handlers: on a three-record endpoint Sentry costs 12.3 µs per record, of which those handlers are 9.6. sentry_traces_sample_rate=1.0costs a further +353 µs per request. If you set it, set it next to a sample rate you actually want.- The Sentry knobs people reach for do nothing.
| scenario | +µs | reading |
|---|---|---|
| defaults (tracing off) | +64.2 | the number to beat |
attach_stacktrace=False |
+63.2 | no effect on the happy path |
max_breadcrumbs=0 |
+63.4 | no effect - the crumb is still built |
disabled_integrations=[Stdlib, Modules, Dedupe, Excepthook, Threading] |
+63.8 | no effect |
default_integrations=False (Starlette+FastAPI kept) |
+63.8 | no effect |
integrations=[], no framework integration |
−0.1 | all of it is the ASGI integration |
auto_session_tracking=False |
+56.5 | sessions cost ~7.7 µs |
http_methods_to_capture=() (no Transaction) |
+29.2 | the Transaction costs ~35 µs |
| both of the above | +19.9 |
attach_stacktrace, max_breadcrumbs and dropping the default integrations all measure inside
noise. Essentially the entire cost is the ASGI integration, and over half of that is a Transaction
built and discarded because sentry_traces_sample_rate is unset.
If you are RPS-constrained, start here¶
In rough order of what they return:
| setting | saves | what you give up |
|---|---|---|
opentelemetry_sampler, 1% ratio |
~55 µs/req | 99% of your traces |
http_methods_to_capture=() on the Sentry ASGI integrations |
~35 µs/req | Sentry-side trace correlation |
opentelemetry_exclude_spans, FastAPI and Litestar |
~34 µs/req | the two ASGI event spans |
sentry_auto_session_tracking=False |
~7.7 µs/req | Sentry release health |
sentry_logging_breadcrumb_level=None |
~7 µs per log record | log breadcrumbs on errors |
Together those are worth ~132 µs per request against the ~136 µs the tuned row actually recovers, so there is nothing else material hiding in it. That configuration is:
FastAPIConfig(
# Sentry: OTel owns distributed tracing, Sentry is an error sink
sentry_integrations=[
StarletteIntegration(http_methods_to_capture=()),
FastApiIntegration(http_methods_to_capture=()),
],
sentry_logging_breadcrumb_level=None,
sentry_auto_session_tracking=False,
# OpenTelemetry: sampling is the single biggest saving here
opentelemetry_exclude_spans=["receive", "send"],
opentelemetry_sampler=ParentBased(TraceIdRatioBased(0.01)),
)
One thing to leave off rather than turn on: the FastAPI access log
(fastapi_logging_middleware_enabled) is off by default, and
turning it on makes every request emit a log record. On a service that otherwise logs nothing per
request, every request then pays the per-record cost measured here:
Three records per request, in-process:
| scenario | +µs/req | delta |
|---|---|---|
| sentry-sdk defaults | +101.1 | |
sentry_logs_level=None (lite-bootstrap's default since #186) |
+93.8 | −2.4 µs/record |
also sentry_logging_breadcrumb_level=None |
+72.4 | −9.6 µs/record total |
lite-bootstrap already applies one saving for you: it passes sentry_logs_level=None by default,
because it never enables Sentry Logs and the handler formats every record before checking whether
they are enabled. That one costs nothing, which is why it is a default rather than a knob.
What each saving costs you¶
Speed alone does not settle whether a setting is worth changing. Measured by capturing a real error
event with an incoming sentry-trace header and inspecting the envelope:
| config | txn name | continues incoming trace | breadcrumbs |
|---|---|---|---|
| defaults | /ping |
yes | yes |
http_methods_to_capture=() |
/ping |
no | yes |
sentry_logging_breadcrumb_level=None |
/ping |
yes | no |
| propagation kept, Transaction skipped (patched SDK) | /ping |
yes | yes |
The row that needs a decision is http_methods_to_capture=(): the error event gets a fresh
trace_id and no parent_span_id, so errors stop correlating across services in Sentry. Take it
when OpenTelemetry owns distributed tracing and Sentry is only an error sink, which is the case in
any service running OpenTelemetryInstrument. Do not take it otherwise.
The caveat that matters most¶
These ratios are an upper bound. The endpoint measured here does nothing, so the fixed per-request cost of the stack is compared against an unrealistically small denominator. A service that does real work per request, a database round trip or a downstream call, pays the same absolute cost against a much larger one.
Read the µs columns, not the percentages. A stack that costs 70% of a do-nothing handler's throughput costs a few percent of a handler that waits 5 ms on a database.
Numbers are machine-specific and move a few percent between runs. The ordering and ratios are stable across runs.