"""结构化日志 — 单行 JSON 输出 + 请求上下文注入 设计要点: 1. 零第三方依赖,只用 stdlib(logging + json + contextvars)。 2. 业务代码通过 `extra={"extra_fields": {...}}` 附加结构化字段, 不要把可检索的字段拼进 msg 字符串 —— 拼进去就只能靠正则捞了。 3. request_id / user 走 contextvar。contextvar 在 asyncio 下按任务隔离, 并发请求之间不会串号;由 RequestContextMiddleware 与 get_current_user 写入。 """ from __future__ import annotations import json import logging import sys from contextvars import ContextVar from datetime import datetime, timezone # 请求级上下文 request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None) user_var: ContextVar[str | None] = ContextVar("user", default=None) class _ContextFilter(logging.Filter): """把 contextvar 注入每条 record,使 JSON 自带 request_id / user""" def filter(self, record: logging.LogRecord) -> bool: record.request_id = request_id_var.get() record.user = user_var.get() return True class JsonFormatter(logging.Formatter): """单行 JSON — 便于 Loki / ELK / CloudWatch 直接解析,无需正则""" def format(self, record: logging.LogRecord) -> str: payload: dict = { "ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(), "level": record.levelname, "logger": record.name, "msg": record.getMessage(), } if getattr(record, "request_id", None): payload["request_id"] = record.request_id if getattr(record, "user", None): payload["user"] = record.user payload.update(getattr(record, "extra_fields", None) or {}) if record.exc_info: payload["exc"] = self.formatException(record.exc_info) return json.dumps(payload, ensure_ascii=False, default=str) class TextFormatter(logging.Formatter): """本地开发可读格式(LOG_JSON=false 时启用)""" def format(self, record: logging.LogRecord) -> str: line = ( f"{self.formatTime(record, '%H:%M:%S')} " f"{record.levelname:<5} {record.name} - {record.getMessage()}" ) extras = getattr(record, "extra_fields", None) if extras: line += " | " + " ".join(f"{k}={v}" for k, v in extras.items()) if record.exc_info: line += "\n" + self.formatException(record.exc_info) return line def setup_logging(level: str = "INFO", json_output: bool = True) -> None: """配置根 logger。必须在应用启动前调用一次。""" handler = logging.StreamHandler(sys.stdout) handler.setFormatter(JsonFormatter() if json_output else TextFormatter()) handler.addFilter(_ContextFilter()) root = logging.getLogger() # 清空既有 handler:uvicorn --reload / 多 worker 下模块可能被重复导入, # 不清会看到每条日志打印 N 遍 root.handlers.clear() root.addHandler(handler) root.setLevel(level.upper()) # uvicorn 自带 handler 会绕过上面的 formatter,必须清掉并让它向根传播 for name in ("uvicorn", "uvicorn.error", "uvicorn.access"): lg = logging.getLogger(name) lg.handlers.clear() lg.propagate = True # 访问日志统一由 RequestContextMiddleware 输出(含耗时 / 用户 / request_id), # 故关闭 uvicorn 自带的访问日志,避免重复 logging.getLogger("uvicorn.access").disabled = True