lamhieu/docsifer
13
1"""Logging setup with optional structured JSON output and request-id binding.2 3Call :func:`configure_logging` exactly once at application start (inside the4FastAPI lifespan).5"""6 7from __future__ import annotations8 9import json10import logging11import logging.config12import sys13from contextvars import ContextVar14from typing import Any15 16#: Context variable carrying the current request id for the active task.17request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)18 19 20class RequestIdFilter(logging.Filter):21 """Inject the current request id (if any) into every log record."""22 23 def filter(self, record: logging.LogRecord) -> bool:24 record.request_id = request_id_var.get() or "-"25 return True26 27 28class JsonFormatter(logging.Formatter):29 """Minimal JSON formatter producing a single line per record."""30 31 _RESERVED = {32 "args",33 "asctime",34 "created",35 "exc_info",36 "exc_text",37 "filename",38 "funcName",39 "levelname",40 "levelno",41 "lineno",42 "module",43 "msecs",44 "message",45 "msg",46 "name",47 "pathname",48 "process",49 "processName",50 "relativeCreated",51 "stack_info",52 "thread",53 "threadName",54 }55 56 def format(self, record: logging.LogRecord) -> str:57 payload: dict[str, Any] = {58 "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),59 "level": record.levelname,60 "logger": record.name,61 "msg": record.getMessage(),62 "request_id": getattr(record, "request_id", "-"),63 }64 if record.exc_info:65 payload["exc"] = self.formatException(record.exc_info)66 # Allow callers to attach extra fields via ``logger.info("...", extra={...})``.67 for key, value in record.__dict__.items():68 if key in self._RESERVED or key.startswith("_") or key == "request_id":69 continue70 try:71 json.dumps(value)72 payload[key] = value73 except (TypeError, ValueError):74 payload[key] = repr(value)75 return json.dumps(payload, ensure_ascii=False, default=str)76 77 78def configure_logging(level: str = "INFO", json_output: bool = True) -> None:79 """Configure the root logger and library loggers in one place.80 81 Args:82 level: Log level name (``DEBUG``/``INFO``/...).83 json_output: When ``True`` emit one JSON object per line; otherwise use84 a human-readable format (useful in local development).85 """86 formatter: dict[str, Any]87 if json_output:88 formatter = {"()": "docsifer.logging_config.JsonFormatter"}89 else:90 formatter = {91 "format": "%(asctime)s %(levelname)-8s %(name)s [%(request_id)s] %(message)s",92 "datefmt": "%H:%M:%S",93 }94 95 config: dict[str, Any] = {96 "version": 1,97 "disable_existing_loggers": False,98 "filters": {99 "request_id": {"()": "docsifer.logging_config.RequestIdFilter"},100 },101 "formatters": {"default": formatter},102 "handlers": {103 "default": {104 "class": "logging.StreamHandler",105 "stream": sys.stdout,106 "formatter": "default",107 "filters": ["request_id"],108 }109 },110 "loggers": {111 "": {"handlers": ["default"], "level": level, "propagate": False},112 "uvicorn": {"handlers": ["default"], "level": level, "propagate": False},113 "uvicorn.error": {"handlers": ["default"], "level": level, "propagate": False},114 "uvicorn.access": {"handlers": ["default"], "level": level, "propagate": False},115 "gunicorn.error": {"handlers": ["default"], "level": level, "propagate": False},116 "gunicorn.access": {"handlers": ["default"], "level": level, "propagate": False},117 },118 }119 120 logging.config.dictConfig(config)121 