103 lines
3.4 KiB
Python
103 lines
3.4 KiB
Python
"""
|
|
Centralized logging configuration using Loguru.
|
|
|
|
Outputs structured JSON to stdout (INFO/WARNING) and stderr (ERROR/CRITICAL).
|
|
Designed for Docker + Grafana Loki / Promtail.
|
|
|
|
Usage:
|
|
from logger import logger, setup_logging
|
|
|
|
setup_logging() # call once at application entry point
|
|
logger.info("message")
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import logging
|
|
from dotenv import load_dotenv
|
|
from loguru import logger
|
|
|
|
|
|
def get_log_level() -> str:
|
|
"""Reads LOG_LEVEL or log_level from .env or environment, defaults to 'INFO'."""
|
|
load_dotenv()
|
|
level = os.getenv("LOG_LEVEL") or os.getenv("log_level") or "INFO"
|
|
return level.strip().upper()
|
|
|
|
|
|
# ── Stdout / stderr filters ───────────────────────────────────────────────────
|
|
|
|
def _stdout_filter(record: dict) -> bool:
|
|
"""Pass DEBUG / INFO / WARNING to stdout."""
|
|
return record["level"].no < logging.ERROR
|
|
|
|
|
|
def _stderr_filter(record: dict) -> bool:
|
|
"""Pass ERROR / CRITICAL to stderr."""
|
|
return record["level"].no >= logging.ERROR
|
|
|
|
|
|
# ── Stdlib → Loguru bridge ────────────────────────────────────────────────────
|
|
|
|
class InterceptHandler(logging.Handler):
|
|
"""Redirect all stdlib logging calls into Loguru."""
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
try:
|
|
level = logger.level(record.levelname).name
|
|
except ValueError:
|
|
level = record.levelno
|
|
|
|
frame, depth = sys._getframe(6), 6
|
|
while frame and frame.f_code.co_filename == logging.__file__:
|
|
frame = frame.f_back
|
|
depth += 1
|
|
|
|
logger.opt(depth=depth, exception=record.exc_info).log(
|
|
level, record.getMessage()
|
|
)
|
|
|
|
|
|
# ── Public setup function ─────────────────────────────────────────────────────
|
|
|
|
def setup_logging(level: str | None = None) -> None:
|
|
"""
|
|
Configure Loguru sinks and intercept all stdlib loggers.
|
|
Call once at the very start of the application entry point.
|
|
"""
|
|
if not level:
|
|
level = get_log_level()
|
|
else:
|
|
level = level.strip().upper()
|
|
|
|
logger.remove() # remove default sink
|
|
|
|
common: dict = {
|
|
"level": level,
|
|
"serialize": True, # JSON output
|
|
"backtrace": False,
|
|
"diagnose": False,
|
|
}
|
|
|
|
# stdout — DEBUG / INFO / WARNING
|
|
logger.add(sys.stdout, filter=_stdout_filter, **common)
|
|
|
|
# stderr — ERROR / CRITICAL
|
|
logger.add(sys.stderr, filter=_stderr_filter, **{**common, "level": "ERROR"})
|
|
|
|
# Redirect all stdlib loggers (uvicorn, motor, aiogram, aio_pika …)
|
|
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
|
|
|
# Suppress noisy third-party loggers — we handle HTTP access via middleware
|
|
_quiet = {
|
|
"uvicorn.access": logging.WARNING, # replaced by our middleware
|
|
"motor": logging.WARNING,
|
|
"aio_pika": logging.WARNING,
|
|
"aiormq": logging.WARNING,
|
|
}
|
|
for name, lvl in _quiet.items():
|
|
_lib_logger = logging.getLogger(name)
|
|
_lib_logger.handlers = [InterceptHandler()]
|
|
_lib_logger.setLevel(lvl)
|
|
_lib_logger.propagate = False
|