refactor(observability): Phase2.1 统一可观测基座 trace_id+结构化日志+span计时 [v2026.07.19.2]
CI / gate (push) Has been cancelled

This commit is contained in:
QihuAI Design committed 2026-07-19 07:47:25 +08:00
1 parent 1b6a184d27
commit 6aefdfa5c2
6 files changed
+202 -7

No files matched your search

+10 -2
View File
@@ -3,11 +3,11 @@
# 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始) # 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始)
# 环境标签: prod = 正式版 | test = 测试版 # 环境标签: prod = 正式版 | test = 测试版
VERSION=v2026.07.19.1 VERSION=v2026.07.19.2
ENV=prod ENV=prod
DATE=2026-07-19 DATE=2026-07-19
BASE_IMAGE=qihu-aiflow:with-deps BASE_IMAGE=qihu-aiflow:with-deps
BACKUP_IMAGE=qihu-aiflow:backup-20260719-001 BACKUP_IMAGE=qihu-aiflow:backup-20260719-002
## 本版本变更 (v2026.07.19.1 / prod) ## 本版本变更 (v2026.07.19.1 / prod)
- Phase1 chat.py 巨石拆分收官:10070 行单体拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py 共 8 个子模块,controller 层 chat.py 降至 1352 行(<3000 目标达成) - Phase1 chat.py 巨石拆分收官:10070 行单体拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py 共 8 个子模块,controller 层 chat.py 降至 1352 行(<3000 目标达成)
@@ -16,6 +16,14 @@ BACKUP_IMAGE=qihu-aiflow:backup-20260719-001
- 测试安全网守住 R1/R2/R3 不回退:后端 9 passed(smoke/market/sms + R1/R2/R3),前端 4 passed(datetime 时区格式化);ci_gate 全绿 - 测试安全网守住 R1/R2/R3 不回退:后端 9 passed(smoke/market/sms + R1/R2/R3),前端 4 passed(datetime 时区格式化);ci_gate 全绿
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-001(旧 chat.py 10070 行) - 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-001(旧 chat.py 10070 行)
## 本版本变更 (v2026.07.19.2 / prod)
- Phase2.1 统一可观测基座:新增 app/core/observability.py(trace_id contextvar + TraceIdFilter + span 计时器 + 可选 JSON formatter),零侵入为全平台日志注入链路 ID
- main.py APITrackingMiddleware 复用既有 request_id 作为 trace_id,并为每个响应注入 X-Trace-Id 响应头(前端可关联排查)
- config.py setup_file_logging 接入 TraceIdFilter 并将日志格式升级为 [tid:%(trace_id)s],所有 getLogger(__name__) 日志自动带链路 ID
- chat.py send_message 路由用 span 包裹 _send_message_core(耗时+trace_id+conv_id/user_id),覆盖 LLM/MCP/媒体生成整体耗时,为后续成本归因建立回归基线
- 纯增量埋点,不改任何接口返回结构与业务行为;向后兼容既有 logging 用法
- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-002
## 历史 ## 历史
- v2026.07.18.5 (prod):Phase0 收官 — 最小 CI 门禁(ci_gate.py + pre-push 钩子 + 休眠 Gitea workflow)。后端 9 passed,前端 4 passed。 - v2026.07.18.5 (prod):Phase0 收官 — 最小 CI 门禁(ci_gate.py + pre-push 钩子 + 休眠 Gitea workflow)。后端 9 passed,前端 4 passed。
- v2026.07.18.4 (prod):前端 vitest 基建落地(4 passed,datetime 时区格式化)。 - v2026.07.18.4 (prod):前端 vitest 基建落地(4 passed,datetime 时区格式化)。
+3 -1
View File
@@ -777,7 +777,9 @@ async def send_message(
): ):
return await _send_message_core(conv_id, req, db, current_user) from app.core.observability import span
async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)):
return await _send_message_core(conv_id, req, db, current_user)
@router.post("/conversations/{conv_id}/clear") @router.post("/conversations/{conv_id}/clear")
+11 -4
View File
@@ -5,6 +5,7 @@ import os
import logging import logging
from pathlib import Path from pathlib import Path
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from app.core.observability import TraceIdFilter, JsonFormatter
from dotenv import load_dotenv from dotenv import load_dotenv
# 加载 .env 文件(Docker 环境跳过,直接使用容器环境变量) # 加载 .env 文件(Docker 环境跳过,直接使用容器环境变量)
@@ -80,10 +81,14 @@ SLOW_REQUEST_THRESHOLD = float(os.getenv("SLOW_REQUEST_THRESHOLD", "3.0"))
def setup_file_logging(): def setup_file_logging():
"""配置应用日志按天轮转输出到文件""" """配置应用日志按天轮转输出到文件"""
log_format = logging.Formatter( use_json = os.getenv("AIFLOW_JSON_LOGS", "0") == "1"
"%(asctime)s [%(levelname)s] %(name)s - %(message)s", if use_json:
datefmt="%Y-%m-%d %H:%M:%S", log_format = JsonFormatter()
) else:
log_format = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s [tid:%(trace_id)s] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 应用日志 - 按天轮转,保留30天 # 应用日志 - 按天轮转,保留30天
app_handler = TimedRotatingFileHandler( app_handler = TimedRotatingFileHandler(
@@ -123,10 +128,12 @@ def setup_file_logging():
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
root_logger.addHandler(app_handler) root_logger.addHandler(app_handler)
root_logger.addHandler(error_handler) root_logger.addHandler(error_handler)
root_logger.addFilter(TraceIdFilter())
# 配置访问日志专用logger # 配置访问日志专用logger
access_logger = logging.getLogger("aiflow.access") access_logger = logging.getLogger("aiflow.access")
access_logger.addHandler(access_handler) access_logger.addHandler(access_handler)
access_logger.propagate = False access_logger.propagate = False
access_logger.addFilter(TraceIdFilter())
return access_logger return access_logger
+1
View File
@@ -0,0 +1 @@
# app/core package - 统一可观测基座 (Phase 2.1)
+174
View File
@@ -0,0 +1,174 @@
"""
Qihu-AIFlow 统一可观测基座 (Phase 2.1)
=====================================
提供贯穿一次请求的全局链路 ID(trace_id)与结构化日志能力,
为后续重构(上下文引擎、媒体/LLM 成本归因)建立回归基线。
设计原则(Strangler Fig 增量):
- 纯增量埋点,不改任何既有接口的返回结构。
- 向后兼容现有 `logging.getLogger(__name__)` 用法:trace_id 由 root logger 的
TraceIdFilter 自动注入,调用方无需改动。
- 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。
使用方式:
from app.core.observability import set_trace_id, get_trace_id, span
# 在中间件里为每个请求注入:
set_trace_id(request_id)
# 在关键调用处打计时 span(自动带 trace_id):
async with span("chat.send_message", conv_id=conv_id):
return await _send_message_core(...)
"""
import logging
import time
import uuid
import json
from contextvars import ContextVar
# ---------------------------------------------------------------------------
# trace_id:贯穿一次请求(含其内所有子调用/协程)的全局链路 ID
# ---------------------------------------------------------------------------
_trace_id_var: ContextVar[str] = ContextVar("aiflow_trace_id", default="-")
def new_trace_id() -> str:
"""生成一个崭新的链路 ID(32 位 hex)。"""
return uuid.uuid4().hex
def set_trace_id(tid: str | None = None) -> str:
"""设置当前上下文的 trace_id;不传则自动生成。返回最终使用的 ID。"""
tid = tid or new_trace_id()
_trace_id_var.set(tid)
return tid
def get_trace_id() -> str:
"""读取当前上下文的 trace_id;未设置时返回占位 '-'。"""
return _trace_id_var.get()
class TraceIdFilter(logging.Filter):
"""为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。
挂载到 root logger 后,所有 `getLogger(__name__)` 的子 logger 都会继承,
从而让全平台既有日志自动带上链路 ID,零侵入。
"""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "trace_id"):
try:
record.trace_id = get_trace_id()
except Exception:
record.trace_id = "-"
return True
# 标准 logging 字段,JSON 序列化时需排除,避免与自定义 extra 冲突
_STD_ATTRS = set(logging.makeLogRecord({}).__dict__.keys()) | {
"message", "asctime", "msg", "trace_id", "exc_info", "stack_info", "args",
}
class JsonFormatter(logging.Formatter):
"""结构化 JSON 日志格式(通过环境变量 AIFLOW_JSON_LOGS=1 启用)。
默认不启用,避免破坏现有日志采集/人工查阅习惯。
"""
def format(self, record: logging.LogRecord) -> str:
data = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"trace_id": getattr(record, "trace_id", "-"),
"msg": record.getMessage(),
}
for k, v in record.__dict__.items():
if k not in _STD_ATTRS:
try:
json.dumps(v, ensure_ascii=False)
data[k] = v
except (TypeError, ValueError):
data[k] = str(v)
if record.exc_info:
data["exc"] = self.formatException(record.exc_info)
return json.dumps(data, ensure_ascii=False)
def get_logger(name: str | None = None) -> logging.Logger:
"""获取 logger(向后兼容现有用法)。trace_id 由 root filter 自动注入。"""
return logging.getLogger(name)
# ---------------------------------------------------------------------------
# span:结构化计时上下文管理器(耗时 + trace_id + 自定义字段)
# ---------------------------------------------------------------------------
class _Span:
"""同步/异步通用的计时 span。退出时记录 elapsed_ms 与状态。"""
def __init__(self, name: str, **fields):
self.name = name
self.fields = fields
self._logger = logging.getLogger("aiflow.span")
self._start = None
self.elapsed_ms = 0.0
def _enter(self):
self._start = time.perf_counter()
self._logger.info(
f"SPAN_START {self.name}",
extra={"span": self.name, "event": "start", "trace_id": get_trace_id(), **self.fields},
)
return self
def _exit(self, exc_type, exc, tb):
self.elapsed_ms = (time.perf_counter() - self._start) * 1000
status = "error" if exc_type else "ok"
self._logger.info(
f"SPAN_END {self.name} {self.elapsed_ms:.1f}ms",
extra={
"span": self.name,
"event": "end",
"elapsed_ms": round(self.elapsed_ms, 1),
"status": status,
"trace_id": get_trace_id(),
**self.fields,
},
)
return False # 不吞掉异常
# 同步协议
def __enter__(self):
return self._enter()
def __exit__(self, exc_type, exc, tb):
return self._exit(exc_type, exc, tb)
# 异步协议
async def __aenter__(self):
return self._enter()
async def __aexit__(self, exc_type, exc, tb):
return self._exit(exc_type, exc, tb)
def span(name: str, **fields):
"""创建一个计时 span。可同步或异步 with。
async with span("chat.send_message", conv_id=cid):
...
"""
return _Span(name, **fields)
__all__ = [
"new_trace_id",
"set_trace_id",
"get_trace_id",
"TraceIdFilter",
"JsonFormatter",
"get_logger",
"span",
]
+3
View File
@@ -25,6 +25,7 @@ from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router from app.api.admin.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin, achievement_admin from app.api.admin import asset_admin, achievement_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.core.observability import set_trace_id
from app.database import SessionLocal from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
@@ -41,6 +42,7 @@ class APITrackingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
set_trace_id(request_id)
start_time = time.time() start_time = time.time()
path = request.url.path path = request.url.path
method = request.method method = request.method
@@ -53,6 +55,7 @@ class APITrackingMiddleware(BaseHTTPMiddleware):
response = await call_next(request) response = await call_next(request)
elapsed = time.time() - start_time elapsed = time.time() - start_time
status_code = response.status_code status_code = response.status_code
response.headers["X-Trace-Id"] = request_id
if not should_skip: if not should_skip:
access_logger.info( access_logger.info(