Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,15 +612,15 @@ leaves out by default: section 4a has the rerun.

The per-call chain is the same on every framework: structlog's stdlib `BoundLogger`, lite-bootstrap's
processors, `JSONRenderer` with orjson, stdlib `Logger.info`, `MemoryHandler(capacity=10)` and a
`StreamHandler`. What differs per framework is who logs per request: FastAPI's opt-in access log (1
`StreamHandler`. 2.0 propagates structlog records to the root logger's `StreamHandler` instead (#381). What differs per framework is who logs per request: FastAPI's opt-in access log (1
record), Litestar's opt-in `LoggingMiddleware` (2 records), FastMCP's opt-in logging middleware (1
record per MCP message), FastStream's own "Received" and "Processed" (filtered at the default
`faststream_log_level=WARNING`).

Linux, a 2-CPU container on the same Apple M2 under OrbStack, CPython 3.14.9, structlog 26.1.0,
orjson 3.11.9, sentry-sdk 2.67.1, opentelemetry-sdk 1.44.0. Output to a real `/dev/null` file,
50000 calls per round, loop overhead (0.06 µs) subtracted. The `MemoryHandler` was removed afterwards
(#377), and 2.0 writes through the `StreamHandler` directly; the rows have not been rerun.
(#377), and 2.0 writes through the root `StreamHandler`; the rows have not been rerun.

| call | µs/call |
|---|---:|
Expand Down
4 changes: 4 additions & 0 deletions docs/introduction/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,10 @@ Read more about Sentry's profiling options [here](https://docs.sentry.io/platfor

Structlog is bootstrapped by default. To opt out, set `logging_enabled=False`.

Structlog and standard-library records go through one handler on the root logger, which writes each
record to stdout as one JSON line. A logger name structlog uses stays an ordinary stdlib logger: its
stdlib records come out as JSON too, and a handler you add to root sees structlog's records.

Configuring it costs almost nothing; the cost arrives per log record, and most of it is Sentry's.
See [Performance](performance.md).

Expand Down
7 changes: 6 additions & 1 deletion lite_bootstrap/bootstrappers/faststream_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,17 @@ class FastStreamLoggingInstrument(LoggingInstrument[FastStreamConfig]):
_prior_faststream_handlers: list[logging.Handler] | None = dataclasses.field(
default=None, init=False, repr=False, compare=False
)
_prior_faststream_propagate: bool = dataclasses.field(default=False, init=False, repr=False, compare=False)

def bootstrap(self) -> None:
super().bootstrap()
brokers = self.bootstrap_config.application.brokers
if brokers and import_checker.is_structlog_installed and import_checker.is_faststream_installed:
faststream_stdlib_logger = logging.getLogger("faststream")
self._prior_faststream_handlers = faststream_stdlib_logger.handlers
self._prior_faststream_propagate = faststream_stdlib_logger.propagate
faststream_stdlib_logger.handlers = []
faststream_stdlib_logger.propagate = True
logger = structlog.get_logger("faststream")
logger.setLevel(self.bootstrap_config.faststream_log_level)
for broker in brokers:
Expand All @@ -179,7 +182,9 @@ def teardown(self) -> None:
super().teardown()
finally:
if self._prior_faststream_handlers is not None:
logging.getLogger("faststream").handlers = self._prior_faststream_handlers
faststream_stdlib_logger = logging.getLogger("faststream")
faststream_stdlib_logger.handlers = self._prior_faststream_handlers
faststream_stdlib_logger.propagate = self._prior_faststream_propagate
self._prior_faststream_handlers = None


Expand Down
6 changes: 2 additions & 4 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,7 @@ def _build_standard_lib_logging_config(self) -> "LitestarStandardLoggingConfig":
return LitestarStandardLoggingConfig(
configure_root_logger=False,
handlers={"console": {"class": "logging.NullHandler"}, "queue_listener": {"class": "logging.NullHandler"}},
loggers={
"litestar": {"level": self.bootstrap_config.logging_log_level, "handlers": [], "propagate": False}
},
loggers={"litestar": {"level": self.bootstrap_config.logging_log_level, "handlers": [], "propagate": True}},
)

def _configure_structlog_loggers(self) -> None:
Expand All @@ -334,7 +332,7 @@ def _configure_structlog_loggers(self) -> None:
config=StructlogConfig(
structlog_logging_config=StructLoggingConfig(
processors=self.structlog_processors,
logger_factory=self.memory_logger_factory,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
pretty_print_tty=False,
Expand Down
58 changes: 0 additions & 58 deletions lite_bootstrap/instruments/logging_factory.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import dataclasses
import json
import logging
import sys
import typing

from lite_bootstrap import import_checker
Expand All @@ -11,14 +9,6 @@
import orjson


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class _MemoryLoggerFactoryConfig:
logging_log_level: int
# default_factory, not a bare default: a bare one binds sys.stdout at import time, so a
# process that rebinds stdout before bootstrap would keep logging to the stale stream.
log_stream: typing.Any = dataclasses.field(default_factory=lambda: sys.stdout)


def _dumps_orjson(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401
return orjson.dumps(value, **kwargs).decode()

Expand Down Expand Up @@ -77,51 +67,3 @@ def parse(cls, formatted: str) -> "StructuredLogPayload | None":
message = loaded.get("event")
extra = {key: value for key, value in loaded.items() if key not in STRUCTLOG_META_KEYS}
return cls(message=message, extra=extra, skip_sentry=skip_sentry)


class _RenderedLineFormatter(logging.Formatter):
"""Emit the rendered line verbatim; stdlib appends nothing after it."""

def format(self, record: logging.LogRecord) -> str:
return record.getMessage()


if import_checker.is_structlog_installed:
import structlog

class MemoryLoggerFactory(structlog.stdlib.LoggerFactory):
def __init__(
self,
*args: typing.Any, # noqa: ANN401
config: "_MemoryLoggerFactoryConfig",
**kwargs: typing.Any, # noqa: ANN401
) -> None:
kwargs["ignore_frame_names"] = [*(kwargs.get("ignore_frame_names") or ()), __name__]
super().__init__(*args, **kwargs)
self.config = config
self._created_handlers: dict[logging.Logger, logging.StreamHandler[typing.Any]] = {}
self._leveled_loggers: set[logging.Logger] = set()

def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401
logger: typing.Final = super().__call__(*args)
if logger in self._created_handlers:
return logger
handler: typing.Final = logging.StreamHandler(stream=self.config.log_stream)
handler.setFormatter(_RenderedLineFormatter())
logger.addHandler(handler)
if logger.level == logging.NOTSET:
logger.setLevel(self.config.logging_log_level)
self._leveled_loggers.add(logger)
logger.propagate = False
self._created_handlers[logger] = handler
return logger

def close_handlers(self) -> None:
for created_logger, handler in self._created_handlers.items():
created_logger.removeHandler(handler)
created_logger.propagate = True
if created_logger in self._leveled_loggers:
created_logger.setLevel(logging.NOTSET)
handler.close()
self._created_handlers.clear()
self._leveled_loggers.clear()
59 changes: 23 additions & 36 deletions lite_bootstrap/instruments/logging_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,33 @@
from lite_bootstrap import import_checker
from lite_bootstrap.exceptions import collect_teardown_errors
from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument
from lite_bootstrap.instruments.logging_factory import (
_MemoryLoggerFactoryConfig,
_serialize_log_to_string,
)
from lite_bootstrap.instruments.logging_factory import _serialize_log_to_string


if typing.TYPE_CHECKING:
from structlog.typing import EventDict, WrappedLogger

from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory

class _RenderedLine(str):
"""A line structlog's chain already rendered, which the root handler's formatter emits as is."""

__slots__ = ()


def _mark_rendered(_: "WrappedLogger", __: str, rendered_line: str) -> _RenderedLine:
return _RenderedLine(rendered_line)


if import_checker.is_structlog_installed:
import structlog

from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory
class _RenderedLinePassthroughFormatter(structlog.stdlib.ProcessorFormatter):
"""Render stdlib records through the foreign chain, and emit structlog's rendered lines verbatim."""

def format(self, record: logging.LogRecord) -> str:
if type(record.msg) is _RenderedLine:
return record.msg
return super().format(record)


if import_checker.is_opentelemetry_installed:
Expand Down Expand Up @@ -53,7 +64,6 @@ def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "E
__all__ = [
"LoggingConfig",
"LoggingInstrument",
"MemoryLoggerFactory",
"tracer_injection",
]

Expand All @@ -77,9 +87,6 @@ class LoggingConfig(BaseConfig):
class LoggingInstrument(BaseInstrument[LoggingConfigT]):
not_configured_reason = "logging_enabled is False"
missing_dependency_message = "structlog is not installed"
_logger_factory: "MemoryLoggerFactory | None" = dataclasses.field(
default_factory=lambda: None, init=False, repr=False, compare=False
)
_attached_filters: list[tuple[logging.Logger, logging.Filter]] = dataclasses.field(
default_factory=list, init=False, repr=False, compare=False
)
Expand Down Expand Up @@ -139,26 +146,14 @@ def structlog_processors(self) -> list[typing.Any]:
*self.structlog_pre_chain_processors,
*self.bootstrap_config.logging_extra_processors,
structlog.processors.JSONRenderer(serializer=_serialize_log_to_string),
_mark_rendered,
]

@property
def memory_logger_factory(self) -> "MemoryLoggerFactory":
cached: MemoryLoggerFactory | None = self._logger_factory
if cached is None:
config = self.bootstrap_config
cached = MemoryLoggerFactory(
config=_MemoryLoggerFactoryConfig(
logging_log_level=config.logging_log_level,
),
)
self._logger_factory = cached
return cached

def _configure_structlog_loggers(self) -> None:
structlog.configure(
processors=self.structlog_processors,
context_class=dict,
logger_factory=self.memory_logger_factory,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
Expand All @@ -167,7 +162,7 @@ def _configure_foreign_loggers(self) -> None:
root_logger: typing.Final = logging.getLogger()
stream_handler: typing.Final = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(
structlog.stdlib.ProcessorFormatter(
_RenderedLinePassthroughFormatter(
foreign_pre_chain=self.structlog_pre_chain_processors,
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
Expand Down Expand Up @@ -207,11 +202,9 @@ def teardown(self) -> None:
Filters from ``logging_record_filters`` are removed one pair at a time, so a filter the
application or another library put on the same logger survives.

Best-effort cleanup: errors from individual ``handler.close()`` calls and from the memory
logger factory's ``close_handlers()`` are collected and re-raised together via
:class:`~lite_bootstrap.exceptions.TeardownError` after the rest of teardown completes,
so no single misbehaving cleanup step prevents the instrument from releasing the
rest of its resources.
Best-effort cleanup: errors from individual ``handler.close()`` calls are collected and
re-raised together via :class:`~lite_bootstrap.exceptions.TeardownError` after the rest of
teardown completes, so no single misbehaving handler keeps the others open.
"""
structlog.reset_defaults()
root_logger = logging.getLogger()
Expand All @@ -224,9 +217,3 @@ def teardown(self) -> None:
root_logger.setLevel(logging.WARNING)
finally:
self._detach_record_filters()
if self._logger_factory is not None:
try:
with teardown_errors.capture("MemoryLoggerFactory"):
self._logger_factory.close_handlers()
finally:
self._logger_factory = None
Loading
Loading