microbootstrap 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ from microbootstrap.instruments.logging_instrument import LoggingConfig
2
+ from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig
3
+ from microbootstrap.instruments.prometheus_instrument import PrometheusConfig
4
+ from microbootstrap.instruments.sentry_instrument import SentryConfig
5
+ from microbootstrap.settings import LitestarSettings
6
+
7
+
8
+ __all__ = (
9
+ "SentryConfig",
10
+ "OpentelemetryConfig",
11
+ "PrometheusConfig",
12
+ "LoggingConfig",
13
+ "LitestarBootstrapper",
14
+ "LitestarSettings",
15
+ )
File without changes
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+ import abc
3
+ import typing
4
+
5
+ import typing_extensions
6
+
7
+ from microbootstrap.console_writer import ConsoleWriter
8
+ from microbootstrap.helpers import dataclass_to_dict_no_defaults, merge_dataclasses_configs, merge_dict_configs
9
+ from microbootstrap.instruments.instrument_box import InstrumentBox
10
+ from microbootstrap.settings import SettingsT
11
+
12
+
13
+ if typing.TYPE_CHECKING:
14
+ from _typeshed import DataclassInstance
15
+
16
+ from microbootstrap.instruments.base import Instrument, InstrumentConfigT
17
+
18
+
19
+ ApplicationT = typing.TypeVar("ApplicationT")
20
+ DataclassT = typing.TypeVar("DataclassT", bound="DataclassInstance")
21
+
22
+
23
+ class ApplicationBootstrapper(abc.ABC, typing.Generic[SettingsT, ApplicationT, DataclassT]):
24
+ application_type: type[ApplicationT]
25
+ application_config: DataclassT
26
+ console_writer: ConsoleWriter
27
+ __instrument_box: InstrumentBox
28
+
29
+ def __init__(self, settings: SettingsT) -> None:
30
+ self.settings = settings
31
+ self.console_writer = ConsoleWriter(writer_enabled=settings.service_debug)
32
+
33
+ if not hasattr(self, "__instrument_box"):
34
+ self.__instrument_box = InstrumentBox()
35
+ self.__instrument_box.initialize(self.settings)
36
+
37
+ def configure_application(
38
+ self: typing_extensions.Self,
39
+ application_config: DataclassT,
40
+ ) -> typing_extensions.Self:
41
+ self.application_config = merge_dataclasses_configs(self.application_config, application_config)
42
+ return self
43
+
44
+ def configure_instrument(
45
+ self: typing_extensions.Self,
46
+ instrument_config: InstrumentConfigT,
47
+ ) -> typing_extensions.Self:
48
+ self.__instrument_box.configure_instrument(instrument_config)
49
+ return self
50
+
51
+ def configure_instruments(
52
+ self: typing_extensions.Self,
53
+ *instrument_configs: InstrumentConfigT,
54
+ ) -> typing_extensions.Self:
55
+ for instrument_config in instrument_configs:
56
+ self.configure_instrument(instrument_config)
57
+ return self
58
+
59
+ @classmethod
60
+ def use_instrument(
61
+ cls,
62
+ ) -> typing.Callable[
63
+ [type[Instrument[InstrumentConfigT]]],
64
+ type[Instrument[InstrumentConfigT]],
65
+ ]:
66
+ if not hasattr(cls, "__instrument_box"):
67
+ cls.__instrument_box = InstrumentBox()
68
+ return cls.__instrument_box.extend_instruments
69
+
70
+ def bootstrap(self: typing_extensions.Self) -> ApplicationT:
71
+ resulting_application_config = dataclass_to_dict_no_defaults(self.application_config)
72
+ for instrument in self.__instrument_box.instruments:
73
+ if instrument.is_ready():
74
+ instrument.bootstrap()
75
+
76
+ resulting_application_config = merge_dict_configs(
77
+ resulting_application_config,
78
+ instrument.bootstrap_before(),
79
+ )
80
+ instrument.write_status(self.console_writer)
81
+
82
+ application = self.application_type(
83
+ **merge_dict_configs(resulting_application_config, self.bootstrap_before()),
84
+ )
85
+
86
+ for instrument in self.__instrument_box.instruments:
87
+ if instrument.is_ready():
88
+ application = instrument.bootstrap_after(application)
89
+
90
+ return self.bootstrap_after(application)
91
+
92
+ def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:
93
+ """Add some framework-related parameters to final bootstrap result before application creation."""
94
+ return {}
95
+
96
+ def bootstrap_after(self: typing_extensions.Self, application: ApplicationT) -> ApplicationT:
97
+ """Add some framework-related parameters to final bootstrap result after application creation."""
98
+ return application
99
+
100
+ def teardown(self: typing_extensions.Self) -> None:
101
+ for instrument in self.__instrument_box.instruments:
102
+ instrument.teardown()
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ import typing
3
+
4
+ import litestar
5
+ import litestar.types
6
+ import sentry_sdk
7
+ import typing_extensions
8
+ from litestar import status_codes
9
+ from litestar.config.app import AppConfig as LitestarConfig
10
+ from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig
11
+ from litestar.contrib.prometheus import PrometheusConfig as LitestarPrometheusConfig
12
+ from litestar.contrib.prometheus import PrometheusController
13
+ from litestar.exceptions.http_exceptions import HTTPException
14
+
15
+ from microbootstrap.bootstrappers.base import ApplicationBootstrapper
16
+ from microbootstrap.instruments.logging_instrument import LoggingInstrument
17
+ from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
18
+ from microbootstrap.instruments.prometheus_instrument import PrometheusInstrument
19
+ from microbootstrap.instruments.sentry_instrument import SentryInstrument
20
+ from microbootstrap.middlewares.litestar import build_litestar_logging_middleware
21
+ from microbootstrap.settings import LitestarSettings
22
+
23
+
24
+ class LitestarBootstrapper(
25
+ ApplicationBootstrapper[LitestarSettings, litestar.Litestar, LitestarConfig],
26
+ ):
27
+ application_config = LitestarConfig()
28
+ application_type = litestar.Litestar
29
+
30
+ def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:
31
+ return {
32
+ "debug": self.settings.service_debug,
33
+ "on_shutdown": [self.teardown],
34
+ "on_startup": [self.console_writer.print_bootstrap_table],
35
+ }
36
+
37
+
38
+ @LitestarBootstrapper.use_instrument()
39
+ class LitestarSentryInstrument(SentryInstrument):
40
+ @staticmethod
41
+ async def sentry_exception_catcher_hook(
42
+ exception: Exception,
43
+ _request_scope: litestar.types.Scope,
44
+ ) -> None:
45
+ if (
46
+ not isinstance(exception, HTTPException)
47
+ or exception.status_code >= status_codes.HTTP_500_INTERNAL_SERVER_ERROR
48
+ ):
49
+ sentry_sdk.capture_exception(exception)
50
+
51
+ def bootstrap_before(self) -> dict[str, typing.Any]:
52
+ return {"after_exception": [self.sentry_exception_catcher_hook]}
53
+
54
+
55
+ @LitestarBootstrapper.use_instrument()
56
+ class LitetstarOpentelemetryInstrument(OpentelemetryInstrument):
57
+ def bootstrap_before(self) -> dict[str, typing.Any]:
58
+ return {
59
+ "middleware": [
60
+ LitestarOpentelemetryConfig(
61
+ tracer_provider=self.tracer_provider,
62
+ exclude=self.instrument_config.opentelemetry_exclude_urls,
63
+ ).middleware,
64
+ ],
65
+ }
66
+
67
+
68
+ @LitestarBootstrapper.use_instrument()
69
+ class LitestarLoggingInstrument(LoggingInstrument):
70
+ def bootstrap_before(self) -> dict[str, typing.Any]:
71
+ return {"middleware": [build_litestar_logging_middleware(self.instrument_config.logging_exclude_endpoints)]}
72
+
73
+
74
+ @LitestarBootstrapper.use_instrument()
75
+ class LitestarPrometheusInstrument(PrometheusInstrument):
76
+ def bootstrap_before(self) -> dict[str, typing.Any]:
77
+ class LitestarPrometheusController(PrometheusController):
78
+ path = self.instrument_config.prometheus_metrics_path
79
+ openmetrics_format = True
80
+
81
+ litestar_prometheus_config: typing.Final = LitestarPrometheusConfig(
82
+ app_name=self.instrument_config.service_name,
83
+ **self.instrument_config.prometheus_additional_params,
84
+ )
85
+
86
+ return {"route_handlers": [LitestarPrometheusController], "middleware": [litestar_prometheus_config.middleware]}
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+ import dataclasses
3
+ import typing
4
+
5
+ from rich.console import Console
6
+ from rich.rule import Rule
7
+ from rich.table import Table
8
+
9
+
10
+ @dataclasses.dataclass
11
+ class ConsoleWriter:
12
+ writer_enabled: bool = True
13
+ rich_console: Console = dataclasses.field(init=False, default_factory=Console)
14
+ rich_table: Table = dataclasses.field(init=False)
15
+
16
+ def __post_init__(self) -> None:
17
+ self.rich_table = Table(show_header=False, header_style="cyan")
18
+ self.rich_table.add_column("Item", style="cyan")
19
+ self.rich_table.add_column("Status")
20
+ self.rich_table.add_column("Reason", style="yellow")
21
+
22
+ def write_instrument_status(
23
+ self,
24
+ instrument_name: str,
25
+ is_enabled: bool,
26
+ disable_reason: str | None = None,
27
+ ) -> None:
28
+ is_enabled_value: typing.Final = "[green]Enabled[/green]" if is_enabled else "[red]Disabled[/red]"
29
+ self.rich_table.add_row(rf"{instrument_name}", is_enabled_value, disable_reason or "")
30
+
31
+ def print_bootstrap_table(self) -> None:
32
+ if self.writer_enabled:
33
+ self.rich_console.print(Rule("[yellow]Bootstrapping application[/yellow]", align="left"))
34
+ self.rich_console.print(self.rich_table)
@@ -0,0 +1,10 @@
1
+ class MicroBootstrapBaseError(Exception):
2
+ """Base for all exceptions."""
3
+
4
+
5
+ class ConfigMergeError(MicroBootstrapBaseError):
6
+ """Raises when it's impossible to merge configs due to type mismatch."""
7
+
8
+
9
+ class MissingInstrumentError(MicroBootstrapBaseError):
10
+ """Raises when attempting to configure instrument, that is not supported yet."""
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ import typing
4
+
5
+ import granian
6
+ from granian.constants import Interfaces, Loops
7
+ from granian.log import LogLevels
8
+
9
+
10
+ if typing.TYPE_CHECKING:
11
+ from microbootstrap.settings import BaseBootstrapSettings
12
+
13
+
14
+ GRANIAN_LOG_LEVELS_MAP = {
15
+ logging.CRITICAL: LogLevels.critical,
16
+ logging.ERROR: LogLevels.error,
17
+ logging.WARNING: LogLevels.warning,
18
+ logging.WARNING: LogLevels.warn,
19
+ logging.INFO: LogLevels.info,
20
+ logging.DEBUG: LogLevels.debug,
21
+ }
22
+
23
+
24
+ # TODO: create bootstrappers for application servers. granian/uvicorn # noqa: TD002
25
+ def create_granian_server(
26
+ target: str,
27
+ settings: BaseBootstrapSettings,
28
+ **granian_options: typing.Any, # noqa: ANN401
29
+ ) -> granian.Granian:
30
+ return granian.Granian(
31
+ target=target,
32
+ address=settings.server_host,
33
+ port=settings.server_port,
34
+ interface=Interfaces.ASGI,
35
+ loop=Loops.uvloop,
36
+ workers=settings.server_workers_count,
37
+ log_level=GRANIAN_LOG_LEVELS_MAP[settings.logging_log_level],
38
+ reload=settings.server_reload,
39
+ **granian_options,
40
+ )
@@ -0,0 +1,96 @@
1
+ import dataclasses
2
+ import re
3
+ import typing
4
+ from dataclasses import _MISSING_TYPE
5
+
6
+ from microbootstrap import exceptions
7
+
8
+
9
+ if typing.TYPE_CHECKING:
10
+ from dataclasses import _DataclassT
11
+
12
+ from pydantic import BaseModel
13
+
14
+
15
+ PydanticConfigT = typing.TypeVar("PydanticConfigT", bound="BaseModel")
16
+ VALID_PATH_PATTERN: typing.Final = r"^(/[a-zA-Z0-9_-]+)+/?$"
17
+
18
+
19
+ def dataclass_to_dict_no_defaults(dataclass_to_convert: "_DataclassT") -> dict[str, typing.Any]:
20
+ conversion_result: typing.Final = {}
21
+ for dataclass_field in dataclasses.fields(dataclass_to_convert):
22
+ value = getattr(dataclass_to_convert, dataclass_field.name)
23
+ if isinstance(dataclass_field.default, _MISSING_TYPE):
24
+ conversion_result[dataclass_field.name] = value
25
+ continue
26
+ if dataclass_field.default != value and isinstance(dataclass_field.default_factory, _MISSING_TYPE):
27
+ conversion_result[dataclass_field.name] = value
28
+ continue
29
+ if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[misc]
30
+ conversion_result[dataclass_field.name] = value
31
+
32
+ return conversion_result
33
+
34
+
35
+ def merge_pydantic_configs(
36
+ config_to_merge: PydanticConfigT,
37
+ config_with_changes: PydanticConfigT,
38
+ ) -> PydanticConfigT:
39
+ config_class: typing.Final = config_to_merge.__class__
40
+ resulting_dict_config: typing.Final = merge_dict_configs(
41
+ config_to_merge.model_dump(exclude_defaults=True, exclude_unset=True),
42
+ config_with_changes.model_dump(exclude_defaults=True, exclude_unset=True),
43
+ )
44
+ return config_class(**resulting_dict_config)
45
+
46
+
47
+ def merge_dataclasses_configs(
48
+ config_to_merge: "_DataclassT",
49
+ config_with_changes: "_DataclassT",
50
+ ) -> "_DataclassT":
51
+ config_class: typing.Final = config_to_merge.__class__
52
+ resulting_dict_config: typing.Final = merge_dict_configs(
53
+ dataclass_to_dict_no_defaults(config_to_merge),
54
+ dataclass_to_dict_no_defaults(config_with_changes),
55
+ )
56
+ return config_class(**resulting_dict_config)
57
+
58
+
59
+ def merge_dict_configs(
60
+ config_dict: dict[str, typing.Any],
61
+ changes_dict: dict[str, typing.Any],
62
+ ) -> dict[str, typing.Any]:
63
+ for change_key, change_value in changes_dict.items():
64
+ config_value = config_dict.get(change_key)
65
+
66
+ if isinstance(config_value, set):
67
+ if not isinstance(change_value, set):
68
+ raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
69
+ config_dict[change_key] = {*config_value, *change_value}
70
+ continue
71
+
72
+ if isinstance(config_value, tuple):
73
+ if not isinstance(change_value, tuple):
74
+ raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
75
+ config_dict[change_key] = (*config_value, *change_value)
76
+ continue
77
+
78
+ if isinstance(config_value, list):
79
+ if not isinstance(change_value, list):
80
+ raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
81
+ config_dict[change_key] = [*config_value, *change_value]
82
+ continue
83
+
84
+ if isinstance(config_value, dict):
85
+ if not isinstance(change_value, dict):
86
+ raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
87
+ config_dict[change_key] = {**config_value, **change_value}
88
+ continue
89
+
90
+ config_dict[change_key] = change_value
91
+
92
+ return config_dict
93
+
94
+
95
+ def is_valid_path(maybe_path: str) -> bool:
96
+ return bool(re.fullmatch(VALID_PATH_PATTERN, maybe_path))
@@ -0,0 +1,7 @@
1
+ from microbootstrap.instruments.logging_instrument import LoggingConfig
2
+ from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig
3
+ from microbootstrap.instruments.prometheus_instrument import PrometheusConfig
4
+ from microbootstrap.instruments.sentry_instrument import SentryConfig
5
+
6
+
7
+ __all__ = ("SentryConfig", "OpentelemetryConfig", "PrometheusConfig", "LoggingConfig")
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+ import abc
3
+ import dataclasses
4
+ import typing
5
+
6
+ import pydantic
7
+
8
+ from microbootstrap.helpers import merge_pydantic_configs
9
+
10
+
11
+ if typing.TYPE_CHECKING:
12
+ from microbootstrap.console_writer import ConsoleWriter
13
+
14
+
15
+ InstrumentConfigT = typing.TypeVar("InstrumentConfigT", bound="BaseInstrumentConfig")
16
+ ApplicationT = typing.TypeVar("ApplicationT")
17
+
18
+
19
+ class BaseInstrumentConfig(pydantic.BaseModel):
20
+ model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class Instrument(abc.ABC, typing.Generic[InstrumentConfigT]):
25
+ instrument_config: InstrumentConfigT
26
+
27
+ def configure_instrument(
28
+ self,
29
+ incoming_config: InstrumentConfigT,
30
+ ) -> None:
31
+ self.instrument_config = merge_pydantic_configs(self.instrument_config, incoming_config)
32
+
33
+ @abc.abstractmethod
34
+ def write_status(self, console_writer: ConsoleWriter) -> None:
35
+ raise NotImplementedError
36
+
37
+ @abc.abstractmethod
38
+ def is_ready(self) -> bool:
39
+ raise NotImplementedError
40
+
41
+ @abc.abstractmethod
42
+ def bootstrap(self) -> None:
43
+ raise NotImplementedError
44
+
45
+ @abc.abstractmethod
46
+ def teardown(self) -> None:
47
+ raise NotImplementedError
48
+
49
+ @classmethod
50
+ @abc.abstractmethod
51
+ def get_config_type(cls) -> type[InstrumentConfigT]:
52
+ raise NotImplementedError
53
+
54
+ def bootstrap_before(self) -> dict[str, typing.Any]:
55
+ """Add some framework-related parameters to final bootstrap result before application creation."""
56
+ return {}
57
+
58
+ def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
59
+ """Add some framework-related parameters to final bootstrap result after application creation."""
60
+ return application
@@ -0,0 +1,51 @@
1
+ import typing
2
+
3
+ import typing_extensions
4
+
5
+ from microbootstrap import exceptions
6
+ from microbootstrap.instruments.base import Instrument, InstrumentConfigT
7
+ from microbootstrap.settings import SettingsT
8
+
9
+
10
+ class InstrumentBox:
11
+ __instruments__: typing.ClassVar[list[type[Instrument[typing.Any]]]] = []
12
+ __initialized_instruments__: list[Instrument[typing.Any]]
13
+
14
+ def initialize(self, settings: SettingsT) -> None:
15
+ settings_dump = settings.model_dump()
16
+ self.__initialized_instruments__ = [
17
+ instrument_type(instrument_type.get_config_type()(**settings_dump))
18
+ for instrument_type in self.__instruments__
19
+ ]
20
+
21
+ def configure_instrument(
22
+ self: typing_extensions.Self,
23
+ instrument_config: InstrumentConfigT,
24
+ ) -> None:
25
+ for instrument in self.__initialized_instruments__:
26
+ if isinstance(instrument_config, instrument.get_config_type()):
27
+ instrument.configure_instrument(instrument_config)
28
+ return
29
+
30
+ raise exceptions.MissingInstrumentError(
31
+ f"Instrument for config {instrument_config.__class__.__name__} is not supported yet.",
32
+ )
33
+
34
+ @classmethod
35
+ def extend_instruments(
36
+ cls,
37
+ instrument_class: type[Instrument[InstrumentConfigT]],
38
+ ) -> type[Instrument[InstrumentConfigT]]:
39
+ """Extend list of instruments, excluding one whose config is already in use."""
40
+ cls.__instruments__ = list(
41
+ filter(
42
+ lambda instrument: instrument.get_config_type() is not instrument_class.get_config_type(),
43
+ cls.__instruments__,
44
+ ),
45
+ )
46
+ cls.__instruments__.append(instrument_class)
47
+ return instrument_class
48
+
49
+ @property
50
+ def instruments(self) -> list[Instrument[typing.Any]]:
51
+ return self.__initialized_instruments__
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ import logging.handlers
4
+ import time
5
+ import typing
6
+ import urllib.parse
7
+
8
+ import pydantic
9
+ import structlog
10
+ from opentelemetry import trace
11
+
12
+ from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
13
+
14
+
15
+ if typing.TYPE_CHECKING:
16
+ import fastapi
17
+ import litestar
18
+ from structlog.typing import EventDict, WrappedLogger
19
+
20
+ from microbootstrap.console_writer import ConsoleWriter
21
+
22
+
23
+ ScopeType = typing.MutableMapping[str, typing.Any]
24
+
25
+ access_logger: typing.Final = structlog.get_logger("api.access")
26
+
27
+
28
+ def make_path_with_query_string(scope: ScopeType) -> str:
29
+ path_with_query_string: typing.Final = urllib.parse.quote(scope["path"])
30
+ if scope["query_string"]:
31
+ return f'{path_with_query_string}?{scope["query_string"].decode("ascii")}'
32
+ return path_with_query_string
33
+
34
+
35
+ def fill_log_message(
36
+ log_level: str,
37
+ request: litestar.Request | fastapi.Request,
38
+ status_code: int,
39
+ start_time: int,
40
+ ) -> None:
41
+ process_time: typing.Final = time.perf_counter_ns() - start_time
42
+ url_with_query: typing.Final = make_path_with_query_string(typing.cast(ScopeType, request.scope))
43
+ client_host: typing.Final = request.client.host if request.client is not None else None
44
+ client_port: typing.Final = request.client.port if request.client is not None else None
45
+ http_method: typing.Final = request.method
46
+ http_version: typing.Final = request.scope["http_version"]
47
+ log_on_correct_level: typing.Final = getattr(access_logger, log_level)
48
+ log_on_correct_level(
49
+ http={
50
+ "url": url_with_query,
51
+ "status_code": status_code,
52
+ "method": http_method,
53
+ "version": http_version,
54
+ },
55
+ network={"client": {"ip": client_host, "port": client_port}},
56
+ duration=process_time,
57
+ )
58
+
59
+
60
+ def tracer_injection(_: WrappedLogger, __: str, event_dict: EventDict) -> EventDict:
61
+ event_dict["tracing"] = {}
62
+ current_span: typing.Final[trace.Span] = trace.get_current_span()
63
+ if current_span == trace.INVALID_SPAN:
64
+ return event_dict
65
+
66
+ span_context: typing.Final[trace.SpanContext] = current_span.get_span_context()
67
+ if span_context == trace.INVALID_SPAN_CONTEXT:
68
+ return event_dict
69
+
70
+ event_dict["tracing"]["trace_id"] = format(span_context.span_id, "016x")
71
+ event_dict["tracing"]["span_id"] = format(span_context.trace_id, "032x")
72
+
73
+ return event_dict
74
+
75
+
76
+ DEFAULT_STRUCTLOG_PROCESSORS: typing.Final[list[typing.Any]] = [
77
+ structlog.stdlib.filter_by_level,
78
+ structlog.stdlib.add_log_level,
79
+ structlog.stdlib.add_logger_name,
80
+ tracer_injection,
81
+ structlog.stdlib.PositionalArgumentsFormatter(),
82
+ structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
83
+ structlog.processors.StackInfoRenderer(),
84
+ structlog.processors.format_exc_info,
85
+ structlog.processors.UnicodeDecoder(),
86
+ ]
87
+ DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR: typing.Final = structlog.processors.JSONRenderer()
88
+
89
+
90
+ class MemoryLoggerFactory(structlog.stdlib.LoggerFactory):
91
+ def __init__(
92
+ self,
93
+ *args: typing.Any, # noqa: ANN401
94
+ logging_buffer_capacity: int,
95
+ logging_flush_level: int,
96
+ logging_log_level: int,
97
+ log_stream: typing.Any = None, # noqa: ANN401
98
+ **kwargs: typing.Any, # noqa: ANN401
99
+ ) -> None:
100
+ super().__init__(*args, **kwargs)
101
+ self.logging_buffer_capacity = logging_buffer_capacity
102
+ self.logging_flush_level = logging_flush_level
103
+ self.logging_log_level = logging_log_level
104
+ self.log_stream = log_stream
105
+
106
+ def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401
107
+ logger: typing.Final = super().__call__(*args)
108
+ stream_handler: typing.Final = logging.StreamHandler(stream=self.log_stream)
109
+ handler: typing.Final = logging.handlers.MemoryHandler(
110
+ capacity=self.logging_buffer_capacity,
111
+ flushLevel=self.logging_flush_level,
112
+ target=stream_handler,
113
+ )
114
+ logger.addHandler(handler)
115
+ logger.setLevel(self.logging_log_level)
116
+ logger.propagate = False
117
+ return logger
118
+
119
+
120
+ class LoggingConfig(BaseInstrumentConfig):
121
+ service_debug: bool = True
122
+
123
+ logging_log_level: int = pydantic.Field(default=logging.INFO)
124
+ logging_flush_level: int = pydantic.Field(default=logging.ERROR)
125
+ logging_buffer_capacity: int = pydantic.Field(default=10)
126
+ logging_extra_processors: list[typing.Any] = pydantic.Field(default_factory=list)
127
+ logging_unset_handlers: list[str] = pydantic.Field(
128
+ default_factory=lambda: ["uvicorn", "uvicorn.access"],
129
+ )
130
+ logging_exclude_endpoints: list[str] = pydantic.Field(default_factory=lambda: ["/health"])
131
+
132
+
133
+ class LoggingInstrument(Instrument[LoggingConfig]):
134
+ def write_status(self, console_writer: ConsoleWriter) -> None:
135
+ if self.is_ready():
136
+ console_writer.write_instrument_status("Logging", is_enabled=True)
137
+ else:
138
+ console_writer.write_instrument_status(
139
+ "Logging",
140
+ is_enabled=False,
141
+ disable_reason="Works only in non-debug mode",
142
+ )
143
+
144
+ def is_ready(self) -> bool:
145
+ return not self.instrument_config.service_debug
146
+
147
+ def teardown(self) -> None:
148
+ structlog.reset_defaults()
149
+
150
+ def bootstrap(self) -> None:
151
+ for unset_handlers_logger in self.instrument_config.logging_unset_handlers:
152
+ logging.getLogger(unset_handlers_logger).handlers = []
153
+
154
+ structlog.configure(
155
+ processors=[
156
+ *DEFAULT_STRUCTLOG_PROCESSORS,
157
+ *self.instrument_config.logging_extra_processors,
158
+ DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR,
159
+ ],
160
+ context_class=dict,
161
+ logger_factory=MemoryLoggerFactory(
162
+ logging_buffer_capacity=self.instrument_config.logging_buffer_capacity,
163
+ logging_flush_level=self.instrument_config.logging_flush_level,
164
+ logging_log_level=self.instrument_config.logging_log_level,
165
+ ),
166
+ wrapper_class=structlog.stdlib.BoundLogger,
167
+ cache_logger_on_first_use=True,
168
+ )
169
+
170
+ @classmethod
171
+ def get_config_type(cls) -> type[LoggingConfig]:
172
+ return LoggingConfig