From f6b98f7fbdb90149144a7a0ca2427f8103266313 Mon Sep 17 00:00:00 2001 From: andy-takker Date: Fri, 27 Jun 2025 23:19:01 +0300 Subject: [PATCH] [ISSUE-29] Add struct log --- library/application/logging.py | 88 +++++++++++++++++++++++++++++++ library/presentors/rest/config.py | 4 +- pyproject.toml | 1 + uv.lock | 11 ++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 library/application/logging.py diff --git a/library/application/logging.py b/library/application/logging.py new file mode 100644 index 0000000..faeaee8 --- /dev/null +++ b/library/application/logging.py @@ -0,0 +1,88 @@ +import logging +import sys +from dataclasses import dataclass, field +from enum import StrEnum, unique +from os import environ +from types import TracebackType + +import structlog + + +@unique +class LogLevel(StrEnum): + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +def setup_logging(log_level: LogLevel = LogLevel.INFO, use_json: bool = False) -> None: + shared_processors: list[structlog.typing.Processor] = [ + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.TimeStamper(fmt="iso"), + structlog.contextvars.merge_contextvars, + ] + structlog.configure( + processors=shared_processors + + [ + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + log_renderer: structlog.types.Processor + if use_json: + log_renderer = structlog.processors.JSONRenderer() + else: + log_renderer = structlog.dev.ConsoleRenderer() + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + log_renderer, + ], + ) + handler = logging.StreamHandler() + handler.setFormatter(formatter) + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(log_level.upper()) + + for _log in [ + "_granian", + "granian.access", + "granian.error", + "faststream", + "pytest", + ]: + logging.getLogger(_log).handlers.clear() + logging.getLogger(_log).propagate = True + + def handle_exception( + exc_type: type[BaseException], + exc_value: BaseException, + exc_traceback: TracebackType | None, + ) -> None: + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + logging.error( + "Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback) + ) + + sys.excepthook = handle_exception + + +@dataclass(frozen=True, kw_only=True, slots=True) +class LoggingConfig: + log_level: LogLevel = field( + default_factory=lambda: LogLevel(environ.get("APP_LOG_LEVEL", "DEBUG").upper()) + ) + use_json: bool = field( + default_factory=lambda: environ.get("APP_LOG_JSON", "false").lower() == "true" + ) diff --git a/library/presentors/rest/config.py b/library/presentors/rest/config.py index b1e4052..cc99fe8 100644 --- a/library/presentors/rest/config.py +++ b/library/presentors/rest/config.py @@ -2,10 +2,12 @@ from library.adapters.database.config import DatabaseConfig from library.application.config import AppConfig, SecretConfig +from library.application.logging import LoggingConfig -@dataclass +@dataclass(frozen=True, kw_only=True, slots=True) class RestConfig: + log: LoggingConfig = field(default_factory=lambda: LoggingConfig()) app: AppConfig = field(default_factory=lambda: AppConfig()) database: DatabaseConfig = field(default_factory=lambda: DatabaseConfig()) secret: SecretConfig = field(default_factory=lambda: SecretConfig()) diff --git a/pyproject.toml b/pyproject.toml index 5c4a1d7..58f1c37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "granian>=1.6.4", "pydantic[email]>=2.10.3", "greenlet>=3.2.3", + "structlog>=25.4.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 8086cb7..480ba00 100644 --- a/uv.lock +++ b/uv.lock @@ -403,6 +403,7 @@ dependencies = [ { name = "greenlet" }, { name = "pydantic", extra = ["email"] }, { name = "sqlalchemy" }, + { name = "structlog" }, { name = "uvloop" }, ] @@ -431,6 +432,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.2.3" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.3" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, + { name = "structlog", specifier = ">=25.4.0" }, { name = "uvloop", specifier = ">=0.21.0" }, ] @@ -830,6 +832,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] +[[package]] +name = "structlog" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b9/6e672db4fec07349e7a8a8172c1a6ae235c58679ca29c3f86a61b5e59ff3/structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4", size = 1369138, upload-time = "2025-06-02T08:21:12.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4a/97ee6973e3a73c74c8120d59829c3861ea52210667ec3e7a16045c62b64d/structlog-25.4.0-py3-none-any.whl", hash = "sha256:fe809ff5c27e557d14e613f45ca441aabda051d119ee5a0102aaba6ce40eed2c", size = 68720, upload-time = "2025-06-02T08:21:11.43Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.0"