- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update) - 组织隔离目标: LICA - 端口规划: 前端 8030 / 后端 8031 / 数据库 8032 - 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本) - 已排除工作区未提交改动,取干净的 192c8ee 状态
90 lines
3.5 KiB
Python
90 lines
3.5 KiB
Python
"""结构化日志 — 单行 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
|