分页总数(#5): - get_all_tasks 的 total 原为 len(flat_tasks)(当前页条数),移动端 「我的任务」用 tasks.length < total 判断 hasMore,首页满员时恒为 false,列表永远停在第一页 20 条。改为独立 COUNT 查询(与 notifications.py 已有写法保持一致)。 可观测性(#9): - 新增 core/logging.py:单行 JSON 结构化日志 + request_id/user 上下文 注入;零第三方依赖;接管 uvicorn 自带 handler 避免格式绕过。 - 新增 core/middleware.py:RequestContextMiddleware 生成/透传 X-Request-ID 并回写响应头,输出含耗时/用户的结构化访问日志。 - 新增 core/health.py:拆分存活/就绪探针。/health/live 不触依赖; /health/ready 探主库,不可用返回 503;MOM 挂掉仅降级不摘流量。 - main.py:全局异常处理器只把堆栈写日志,响应体仅回 request_id; 接入可选 Sentry(未装 SDK 时静默跳过)。 - auth_service:解析 Token 后写入 user 上下文,日志自动带操作人。 注:异常处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置, 故 request_id 同时写入 request.state(由 ASGI scope 承载)再读取。 已用 TestClient 验证:探针状态码/检查项、X-Request-ID 透传与生成、 500 响应携带可对账的 request_id 且不泄露堆栈。
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
|