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
27 changes: 13 additions & 14 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions lite_bootstrap/instruments/logging_factory.py
Original file line number Diff line number Diff line change
@@ -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()
95 changes: 30 additions & 65 deletions lite_bootstrap/instruments/logging_instrument.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 = []

Expand All @@ -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
Expand Down Expand Up @@ -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[:]:
Expand Down
39 changes: 31 additions & 8 deletions tests/instruments/test_logging_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -110,17 +113,37 @@ 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"
test_logger.error(error_message)
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),
Expand Down
Loading