diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index dc381e1..bad4066 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -158,22 +158,21 @@ class LitestarLoggingInstrument(LoggingInstrument): def bootstrap(self) -> None: self._unset_handlers() - if import_checker.is_structlog_installed and import_checker.is_litestar_installed: - self.bootstrap_config.application_config.plugins.append( - StructlogPlugin( - config=StructlogConfig( - structlog_logging_config=StructLoggingConfig( - processors=self.structlog_processors, - logger_factory=self.memory_logger_factory, - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, - pretty_print_tty=False, - standard_lib_logging_config=None, - ), + self.bootstrap_config.application_config.plugins.append( + StructlogPlugin( + config=StructlogConfig( + structlog_logging_config=StructLoggingConfig( + processors=self.structlog_processors, + logger_factory=self.memory_logger_factory, + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + pretty_print_tty=False, + standard_lib_logging_config=None, ), - ) + ), ) - self._configure_foreign_loggers() + ) + self._configure_foreign_loggers() @dataclasses.dataclass(kw_only=True, frozen=True) diff --git a/lite_bootstrap/instruments/logging_factory.py b/lite_bootstrap/instruments/logging_factory.py new file mode 100644 index 0000000..6e1d7fb --- /dev/null +++ b/lite_bootstrap/instruments/logging_factory.py @@ -0,0 +1,74 @@ +import dataclasses +import logging +import logging.handlers +import sys +import typing + +import orjson + +from lite_bootstrap import import_checker + + +ScopeType = typing.MutableMapping[str, typing.Any] + + +class AddressProtocol(typing.Protocol): + host: str + port: int + + +class RequestProtocol(typing.Protocol): + client: AddressProtocol + scope: ScopeType + method: str + + +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class _MemoryLoggerFactoryConfig: + logging_buffer_capacity: int + logging_flush_level: int + logging_log_level: int + log_stream: typing.Any = sys.stdout + + +def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 + return orjson.dumps(value, **kwargs).decode() + + +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: + super().__init__(*args, **kwargs) + self.config = config + self._created_handlers: list[tuple[logging.Logger, logging.handlers.MemoryHandler]] = [] + + def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 + logger: typing.Final = super().__call__(*args) + stream_handler: typing.Final = logging.StreamHandler(stream=self.config.log_stream) + handler: typing.Final = logging.handlers.MemoryHandler( + capacity=self.config.logging_buffer_capacity, + flushLevel=self.config.logging_flush_level, + target=stream_handler, + ) + logger.addHandler(handler) + logger.setLevel(self.config.logging_log_level) + logger.propagate = False + self._created_handlers.append((logger, handler)) + return logger + + def close_handlers(self) -> None: + for created_logger, handler in self._created_handlers: + created_logger.removeHandler(handler) + created_logger.propagate = True + target = handler.target + handler.close() + if target is not None: + target.close() + self._created_handlers.clear() diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index 05304ed..2c4ec79 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -1,22 +1,30 @@ import dataclasses import logging -import logging.handlers import sys import typing -import orjson - from lite_bootstrap import import_checker from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument +from lite_bootstrap.instruments.logging_factory import ( + AddressProtocol, + RequestProtocol, + ScopeType, + _MemoryLoggerFactoryConfig, + _serialize_log_with_orjson_to_string, +) if typing.TYPE_CHECKING: from structlog.typing import EventDict, WrappedLogger + from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory + if import_checker.is_structlog_installed: import structlog + from lite_bootstrap.instruments.logging_factory import MemoryLoggerFactory + if import_checker.is_opentelemetry_installed: from opentelemetry import trace @@ -40,65 +48,15 @@ def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "E return event_dict -ScopeType = typing.MutableMapping[str, typing.Any] - - -class AddressProtocol(typing.Protocol): - host: str - port: int - - -class RequestProtocol(typing.Protocol): - client: AddressProtocol - scope: ScopeType - method: str - - -if import_checker.is_structlog_installed: - - class MemoryLoggerFactory(structlog.stdlib.LoggerFactory): - def __init__( - self, - *args: typing.Any, # noqa: ANN401 - logging_buffer_capacity: int, - logging_flush_level: int, - logging_log_level: int, - log_stream: typing.Any = sys.stdout, # noqa: ANN401 - **kwargs: typing.Any, # noqa: ANN401 - ) -> None: - super().__init__(*args, **kwargs) - self.logging_buffer_capacity = logging_buffer_capacity - self.logging_flush_level = logging_flush_level - self.logging_log_level = logging_log_level - self.log_stream = log_stream - self._created_handlers: list[tuple[logging.Logger, logging.handlers.MemoryHandler]] = [] - - def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401 - logger: typing.Final = super().__call__(*args) - stream_handler: typing.Final = logging.StreamHandler(stream=self.log_stream) - handler: typing.Final = logging.handlers.MemoryHandler( - capacity=self.logging_buffer_capacity, - flushLevel=self.logging_flush_level, - target=stream_handler, - ) - logger.addHandler(handler) - logger.setLevel(self.logging_log_level) - logger.propagate = False - self._created_handlers.append((logger, handler)) - return logger - - def close_handlers(self) -> None: - for created_logger, handler in self._created_handlers: - created_logger.removeHandler(handler) - created_logger.propagate = True - target = handler.target - handler.close() - if target is not None: - target.close() - self._created_handlers.clear() - - def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 - return orjson.dumps(value, **kwargs).decode() +__all__ = [ + "AddressProtocol", + "LoggingConfig", + "LoggingInstrument", + "MemoryLoggerFactory", + "RequestProtocol", + "ScopeType", + "tracer_injection", +] @dataclasses.dataclass(kw_only=True, frozen=True) @@ -143,6 +101,7 @@ def check_dependencies() -> bool: return import_checker.is_structlog_installed def _unset_handlers(self) -> None: + """Clear handlers on the named loggers. Mutation is permanent; teardown() does not restore.""" for unset_handlers_logger in self.bootstrap_config.logging_unset_handlers: logging.getLogger(unset_handlers_logger).handlers = [] @@ -160,9 +119,11 @@ def memory_logger_factory(self) -> "MemoryLoggerFactory": cached: MemoryLoggerFactory | None = self._logger_factory if cached is None: cached = MemoryLoggerFactory( - logging_buffer_capacity=self.bootstrap_config.logging_buffer_capacity, - logging_flush_level=self.bootstrap_config.logging_flush_level, - logging_log_level=self.bootstrap_config.logging_log_level, + config=_MemoryLoggerFactoryConfig( + logging_buffer_capacity=self.bootstrap_config.logging_buffer_capacity, + logging_flush_level=self.bootstrap_config.logging_flush_level, + logging_log_level=self.bootstrap_config.logging_log_level, + ), ) object.__setattr__(self, "_logger_factory", cached) return cached @@ -199,6 +160,10 @@ def bootstrap(self) -> None: self._configure_foreign_loggers() def teardown(self) -> None: + """Reset structlog and root logger. + + Root logger level is unconditionally set to WARNING; pre-existing user configuration is overwritten. + """ structlog.reset_defaults() root_logger = logging.getLogger() for h in root_logger.handlers[:]: diff --git a/tests/instruments/test_logging_instrument.py b/tests/instruments/test_logging_instrument.py index 2d8d26e..d859415 100644 --- a/tests/instruments/test_logging_instrument.py +++ b/tests/instruments/test_logging_instrument.py @@ -6,6 +6,7 @@ import structlog from opentelemetry.trace import get_tracer +from lite_bootstrap.instruments.logging_factory import _MemoryLoggerFactoryConfig from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument, MemoryLoggerFactory from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument from tests.conftest import LoggingMock @@ -87,10 +88,12 @@ def test_memory_logger_factory_info() -> None: test_stream = StringIO() logger_factory = MemoryLoggerFactory( - logging_buffer_capacity=test_capacity, - logging_flush_level=test_flush_level, - logging_log_level=logging.INFO, - log_stream=test_stream, + config=_MemoryLoggerFactoryConfig( + logging_buffer_capacity=test_capacity, + logging_flush_level=test_flush_level, + logging_log_level=logging.INFO, + log_stream=test_stream, + ), ) test_logger = logger_factory() test_message = "test message" @@ -110,10 +113,12 @@ def test_memory_logger_factory_error() -> None: test_stream = StringIO() logger_factory = MemoryLoggerFactory( - logging_buffer_capacity=test_capacity, - logging_flush_level=test_flush_level, - logging_log_level=logging.INFO, - log_stream=test_stream, + config=_MemoryLoggerFactoryConfig( + logging_buffer_capacity=test_capacity, + logging_flush_level=test_flush_level, + logging_log_level=logging.INFO, + log_stream=test_stream, + ), ) test_logger = logger_factory() error_message = "error message" @@ -121,6 +126,24 @@ def test_memory_logger_factory_error() -> None: assert error_message in test_stream.getvalue() +def test_logging_instrument_lifecycle_replay(logging_mock: LoggingMock) -> None: + instrument = LoggingInstrument( + bootstrap_config=LoggingConfig( + logging_buffer_capacity=0, + logging_extra_processors=[logging_mock], + ), + ) + try: + instrument.bootstrap() + instrument.teardown() + instrument.bootstrap() + logger = structlog.getLogger(__name__) + logger.info("after replay") + assert any(e.get("event") == "after replay" for e in logging_mock.entries) + finally: + instrument.teardown() + + def test_logging_instrument_teardown_resets_factory_when_close_handlers_raises() -> None: instrument = LoggingInstrument( bootstrap_config=LoggingConfig(logging_buffer_capacity=0),