Repository navigation
Description
Every structlog call in lite-bootstrap goes structlog.stdlib.BoundLogger → lite-bootstrap's processors → JSONRenderer(orjson) → stdlib Logger.info(rendered) → MemoryHandler → StreamHandler (lite_bootstrap/instruments/logging_instrument.py, lite_bootstrap/instruments/logging_factory.py:109-133). The stdlib half is the main structural cost.
Measured on 2026-10-24 at 705d8a3 on Linux with 2 CPUs (CPython 3.14.9, output to /dev/null, 50000 calls per round), against a structlog-native configuration that is not lite-bootstrap code (make_filtering_bound_logger, BytesLoggerFactory, orjson bytes):
| call | lite-bootstrap chain | structlog-native |
|---|---|---|
log.info("did a thing", a=1) |
9.5 µs | 2.6 µs |
log.debug(...) filtered at INFO |
1.1 µs | 0.2 µs |
Roughly a third of a lite-bootstrap call is its processors, half is the stdlib bridge (Logger.makeRecord alone is 1.4 µs, Logger.info into a handler 3.8-4.1 µs), and the rest is structlog's dispatch. The MemoryHandler buys nothing per call: MemoryHandler.flush hands each buffered record to StreamHandler.emit, which writes and flushes each record on its own. The tables and scripts are to be published with #335.
The bridge is not dead weight. A native path loses what it provides today:
- Sentry error events and breadcrumbs from structlog calls.
LoggingIntegrationonly sees stdlib records, andenrich_sentry_event_from_structlog_logdepends on that (ADR-0006). logging_record_filters, which attach stdliblogging.Filters per logger.- Per-logger stdlib levels, and anything else a user hangs on stdlib handlers.
This is a question, not a plan: is a native path worth offering, for whom, and how does Sentry capture work on it?
Work
- Decide whether to pursue it. If yes, write the design first, in this issue or an ADR:
- who opts in (a config flag, or only services without Sentry), and what happens when Sentry is configured too;
- how Sentry capture would work without the stdlib record, for example a structlog processor that calls
sentry_sdk.capture_eventor adds breadcrumbs, and what that costs per call; - what
logging_record_filtersand per-logger levels become.
- Measure the chosen design inside lite-bootstrap, with Sentry on, before any PR.
Verify: a decision recorded here, and if the answer is yes, a follow-up issue with the design and its measured cost.
Would be breaking only if it became the default.
Activity
lesnik512 commentedon Oct 28, 2026
Findings: structlog-native output path, measured inside lite-bootstrap
I prototyped a native path that runs lite-bootstrap's own processors and captures to Sentry from a structlog processor, and measured it against the current chain with and without Sentry. I also measured the intermediate step from the issue: keep the stdlib bridge and drop
MemoryHandler.Setup
uv sync --all-extras.sentry_sdk.init()patchesLogger.callHandlersfor good.FreeConfigdefaults, built by the realLoggingInstrumentandSentryInstrument. With Sentry: a dummy DSN and a transport that serializes each envelope and drops it, so serialization CPU is counted and there is no network./dev/null:sys.stdoutis rebound before bootstrap for the bridge, and the native path writes to a/dev/nullbinary file.timeit.Timerwith a warmup of a tenth of the round.info: 15 rounds of 20,000 calls. Filtereddebug: 15 rounds of 200,000.errorandexception: 9 rounds of 2,000. The table reports the median per call. Two full runs agreed within 4%; the numbers below are the second run.Results (µs per call, median)
info("did a thing", a=1)debugfiltered at INFOerror(Sentry event)exception(Sentry event)MemoryHandlerMemoryHandler+ SentryWithout Sentry, the native path cuts an info line from 8.6 to 4.0 µs and a filtered debug call from 1.0 to 0.16 µs. With Sentry, an info line goes from 14.3 to 7.7 µs.
The 4.0 µs is higher than the 2.6 µs in the issue because this run keeps lite-bootstrap's processors (ISO timestamper,
tracer_injection,StackInfoRenderer,format_exc_info,UnicodeDecoder). Different machines, too.On the native path, Sentry costs 3.8 µs per info line. The processor with breadcrumbs off costs 0.24 µs, so breadcrumbs are about 3.5 µs.
sentry_sdk.add_breadcrumbalone measures 0.9 µs. Of the rest,fnmatchagainst sentry-sdk's three ignored logger names is 0.9 µs and can be cached per logger name. The remainder is building thedatadict and the timestamp. On the bridge, Sentry costs 5.7 µs per info line, through the patchedcallHandlersandBreadcrumbHandler.The event columns measure Sentry building and serializing an event. The logging path is a rounding error there. The plain
errorrow is large becauseattach_stacktrace=Truewalks the current stack with local variables, and the benchmark stack has big locals. The absolute number depends on the caller's stack. The native event is cheaper mostly because the attached stack has fewer frames (6 against 9: the stdlibloggingframes are gone).Sentry capture on the native path
The prototype is two processors.
stash_exc_inforuns beforeformat_exc_infoand keeps the exception tuple.SentryCaptureruns afterlogging_extra_processorsand before the renderer. Both are built only when Sentry is configured._SENTRY_META_KEYSisSTRUCTLOG_META_KEYS | {"_sentry_exc_info"}. The sentry-sdk APIs used aresentry_sdk.capture_event,sentry_sdk.add_breadcrumb,sentry_sdk.get_clientandsentry_sdk.utils.event_from_exception, checked against 2.73.0._IGNORED_LOGGERSis private to sentry-sdk; it is read soignore_logger()keeps working.I compared captured events from the same four calls on both paths (
info,error,exceptionin anexcept,errorwithskip_sentry=True):level,loggererror,app.modulelogentry.formattedenrich_sentry_event_from_structlog_logrewrites itlogentry.messageformatted)contexts.structloglog.exception()ValueError: bad value, mechanismlogging, handlederrorattach_stacktrace, 9 frames, the last four stdlibloggingskip_sentry=Truemessagedata{}hintlog_record(aLogRecord)structlog_event_dictenrich_sentry_event_from_structlog_logstays inbefore_sendand does nothing for native events, because theirformatteddoes not start with{. The native path does not need the ADR-0006 seam for structlog calls; foreign stdlib loggers still go through it.Differences a user could notice:
before_sendthat readshint["log_record"]gets nothing for structlog events.loggerisNoneforstructlog.get_logger()called without a name.BytesLoggerFactorypasses the first positional argument as the name and does not guess the caller's module. Thenameattribute onBytesLoggeris new in structlog 26.1; the declared floor is 21.3, so the real change needs its own small logger class or a higher floor for this path.Foreign stdlib loggers are unchanged on the native path: they still go through the root
StreamHandlerandLoggingIntegration. Structlog calls never reach stdlib, so nothing is captured twice.logging_record_filtersand per-logger levelslogging_record_filterskeeps working for what the docs describe, filtering a third-party library's stdlib records, because foreign loggers stay on stdlib. A filter keyed on the name of a structlog logger would stop seeing structlog calls. On the native path,SentryCaptureruns afterlogging_extra_processors, so an extra processor can rewritelevelor raiseDropEventbefore Sentry sees the event. That is the native equivalent. The "too late to change what Sentry captured" rule in CONTEXT.md does not hold on this path, and the docs would need to say so. I would document this rather than translate filters into processors.Per-logger levels are not available on the native path.
make_filtering_bound_loggertakes one global level, which is where the 0.16 µs filtered debug comes from. A per-name level map would be a processor raisingDropEvent, and it would bring back most of the cost of building a filtered event.This rules out two bootstrappers as they stand:
FastStreamBootstrappercallsstructlog.get_logger("faststream").setLevel(faststream_log_level).FilteringBoundLoggerhas nosetLevel.LitestarLoggingInstrumenthandswrapper_class=structlog.stdlib.BoundLoggerand the stdlib factory to Litestar'sStructlogPlugin.Per-logger levels on the bridge are weaker than they look today, too:
MemoryLoggerFactory.__call__runslogger.setLevel(logging_log_level)every time a structlog proxy binds, so a level set on that stdlib logger beforehand is overwritten. I checked:logging.getLogger("y").setLevel(DEBUG)before the firststructlog.get_logger("y").debug(...)leaves the level at 20 and drops the line.Dropping
MemoryHandler(the intermediate step)Writing through a
StreamHandlerdirectly measures the same as today: 8.92 against 8.64 µs per info line, and 14.80 against 14.29 with Sentry, both inside run-to-run noise. The issue is right thatMemoryHandlerbuys nothing per call.It is a behaviour change, though, and the change is a fix:
logging_buffer_capacity=10,logging_flush_level=ERROR), structlog INFO lines wait in the buffer until there are 10 or an ERROR arrives, while foreign stdlib lines are written at once.structlog.info("1"); logging.getLogger("lib").warning("2"); structlog.info("3")prints 2, 1, 3.os._exit, SIGKILL, OOM kill) the buffered lines are lost. The same three calls followed byos._exit(0)print only line 2. A normal exit flushes them throughlogging.shutdown().logging_buffer_capacityandlogging_flush_levelwould do nothing and need a deprecation cycle. Many bootstrap tests already setlogging_buffer_capacity=0.This is worth a small separate change. The case for it is ordering and crash safety; it saves no time.
Two bugs found while measuring
Both are in
MemoryLoggerFactoryon the current path and worth their own issues:structlog.get_logger(name)proxy adds anotherMemoryHandlerto the same stdlib logger on first use. Two proxies for"x"print the second one's lines twice. Callingstructlog.get_logger()inside a function that runs three times prints the third call three times, and the handlers pile up for the life of the process.structlog.get_logger()is namedlite_bootstrap.instruments.logging_factory.structlog.stdlib.LoggerFactoryguesses the name from the first frame outside structlog, and that frame isMemoryLoggerFactory.__call__.Recommendation
Pursue it as an opt-in flag,
Falseby default, with Sentry supported.ConfigurationErrorwhen the flag is set on Litestar or FastStream until per-logger levels have an answer there.LoggingInstrumentbuilds the processor whensentry_dsnis set, read withgetattrper ADR-0002, and passessentry_logging_breadcrumb_levelto it.SentryInstrumentkeepsLoggingIntegrationfor foreign loggers.LoggingIntegration's event and breadcrumb shape that has to track sentry-sdk, and the docs changes above. The gain is small in absolute terms next to per-request costs elsewhere in the stack (OpenTelemetry alone is tens of µs per request), so this is worth it for log-heavy services and invisible for the rest.Do the
MemoryHandlerremoval separately and first, as a correctness change. It is not breaking for output, and it shrinks the bridge that the native path would sit next to. File the two factory bugs before either.Benchmark and prototype
The native instrument subclasses
LoggingInstrumentand replaces_configure_structlog_loggers:structlog.stdlib.filter_by_levelandPositionalArgumentsFormatterare dropped: the filtering bound logger does both. Foreign loggers keep_configure_foreign_loggersas is.The "without
MemoryHandler" variant is aMemoryLoggerFactorysubclass whose__call__attaches theStreamHandlerwith_RenderedLineFormatterdirectly.Harness, one process per scenario:
lesnik512 commentedon Oct 29, 2026
Decision: not pursuing the native path. Closing.
The saving is about 4.7 µs per info line, or 6.6 µs with Sentry. That isn't worth keeping a second logging pipeline next to the stdlib one, along with a copy of sentry-sdk's event and breadcrumb format that would have to track sentry-sdk across majors (#380). Per-logger levels would also rule it out for Litestar and FastStream.
The intermediate step and the bugs found here are done: #358 fixed the two
MemoryLoggerFactorybugs, and #377 replaced theMemoryHandlerwith a plainStreamHandler.