Files
Neo-ZQYY/apps/backend/app/trace/middleware.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

212 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
TraceMiddleware — ASGI 中间件
拦截 xcx_* 路由前缀(/api/xcx/)的请求,创建 TraceContext 并记录全链路 span。
非 xcx 路由直接跳过,不创建 TraceContext。
DEV_TRACE_ENABLED 关闭时跳过所有采集。
记录的 span 类型:
- HTTP_IN: 请求进入method, path, query_params, body_preview
- HTTP_OUT: 请求结束status_code, duration, body_size
- MIDDLEWARE: ResponseWrapperMiddleware 执行耗时
- MIDDLEWARE_ERROR: 响应包装失败时记录
"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from urllib.parse import unquote
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.trace.config import get_trace_config
from app.trace.context import (
SpanType,
TraceContext,
TraceSpan,
create_http_trace,
get_current_trace,
set_current_trace,
trace_context_var,
)
from app.trace.writer import get_trace_writer
logger = logging.getLogger(__name__)
# xcx 路由前缀——仅匹配此前缀的请求才采集 trace
XCX_PATH_PREFIX = "/api/xcx/"
def _should_trace(path: str) -> bool:
"""判断请求路径是否属于 xcx_* 路由前缀,需要采集 trace。"""
return path.startswith(XCX_PATH_PREFIX)
class TraceMiddleware:
"""ASGI 中间件:全链路请求追踪。
执行顺序(最外层,最先执行):
TraceMiddleware → CORSMiddleware → ResponseWrapperMiddleware → 路由处理
职责:
1. 检查 DEV_TRACE_ENABLED 开关(运行时检查,支持动态切换)
2. 仅拦截 /api/xcx/ 前缀的请求
3. 创建 TraceContext 存入 contextvars
4. 记录 HTTP_IN / HTTP_OUT / MIDDLEWARE / MIDDLEWARE_ERROR span
5. 响应头写入 X-Request-ID, X-Process-Time, X-DB-Queries, X-DB-Time
6. 调用 TraceWriter 写入完整 trace
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
# 仅处理 HTTP 请求
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
config = get_trace_config()
# 开关关闭 或 非 xcx 路由 → 直接透传
if not config.enabled or not _should_trace(path):
await self.app(scope, receive, send)
return
# ── 创建 TraceContext ──
method = scope.get("method", "UNKNOWN")
query_string = scope.get("query_string", b"").decode("latin-1", errors="replace")
ctx = create_http_trace(method, path)
token = set_current_trace(ctx)
# 记录 HTTP_IN span
query_params = {}
if query_string:
# 简单解析 query string 为 dict
for pair in query_string.split("&"):
if "=" in pair:
k, v = pair.split("=", 1)
query_params[unquote(k)] = unquote(v)
ctx.add_span(TraceSpan(
span_type=SpanType.HTTP_IN,
module="trace.middleware",
function="TraceMiddleware.__call__",
description_zh=f"接收请求 {method} {path}",
description_en=f"Received request {method} {path}",
params={"query": query_params},
result_summary="",
duration_ms=0.0,
timestamp=datetime.now().isoformat(),
))
start_time = time.perf_counter()
# ── 拦截响应,注入 trace 头和采集 HTTP_OUT ──
status_code = 200
response_body_parts: list[bytes] = []
middleware_start = time.perf_counter()
middleware_error: str | None = None
async def send_wrapper(message: Message) -> None:
nonlocal status_code, middleware_error
if message["type"] == "http.response.start":
status_code = message.get("status", 200)
# 计算 trace 统计数据
elapsed_ms = (time.perf_counter() - start_time) * 1000
current_ctx = get_current_trace()
db_queries = 0
db_time_ms = 0.0
if current_ctx:
for s in current_ctx.spans:
if s.span_type == SpanType.DB_QUERY:
db_queries += 1
db_time_ms += s.duration_ms
# 注入响应头
headers = list(message.get("headers", []))
headers.append([b"x-request-id", ctx.request_id.encode()])
headers.append([b"x-process-time", f"{elapsed_ms:.1f}ms".encode()])
headers.append([b"x-db-queries", str(db_queries).encode()])
headers.append([b"x-db-time", f"{db_time_ms:.1f}ms".encode()])
message = {**message, "headers": headers}
await send(message)
return
if message["type"] == "http.response.body":
body = message.get("body", b"")
response_body_parts.append(body)
await send(message)
return
await send(message)
try:
await self.app(scope, receive, send_wrapper)
except Exception as exc:
# 即使内层异常,也要记录 trace
middleware_error = f"{type(exc).__name__}: {exc}"
raise
finally:
elapsed_ms = (time.perf_counter() - start_time) * 1000
middleware_elapsed_ms = (time.perf_counter() - middleware_start) * 1000
body_size = sum(len(p) for p in response_body_parts)
# 记录 MIDDLEWARE spanResponseWrapperMiddleware 执行耗时)
if middleware_error:
ctx.add_span(TraceSpan(
span_type=SpanType.MIDDLEWARE_ERROR,
module="middleware.response_wrapper",
function="ResponseWrapperMiddleware.__call__",
description_zh=f"响应包装失败: {middleware_error}",
description_en=f"Response wrapping failed: {middleware_error}",
params={},
result_summary=middleware_error,
duration_ms=middleware_elapsed_ms,
timestamp=datetime.now().isoformat(),
))
else:
ctx.add_span(TraceSpan(
span_type=SpanType.MIDDLEWARE,
module="middleware.response_wrapper",
function="ResponseWrapperMiddleware.__call__",
description_zh="响应包装中间件执行完成",
description_en="Response wrapper middleware completed",
params={},
result_summary=f"body_size={body_size}",
duration_ms=middleware_elapsed_ms,
timestamp=datetime.now().isoformat(),
extra={"body_size": body_size},
))
# 记录 HTTP_OUT span
ctx.add_span(TraceSpan(
span_type=SpanType.HTTP_OUT,
module="trace.middleware",
function="TraceMiddleware.__call__",
description_zh=f"响应返回 {status_code},耗时 {elapsed_ms:.0f}ms",
description_en=f"Response sent {status_code}, took {elapsed_ms:.0f}ms",
params={},
result_summary=f"{status_code}, {body_size}B body",
duration_ms=elapsed_ms,
timestamp=datetime.now().isoformat(),
extra={"status_code": status_code, "body_size": body_size},
))
# 写入 trace 日志
try:
writer = get_trace_writer()
await writer.write_trace(ctx)
except Exception:
logger.warning("Trace 日志写入失败", exc_info=True)
# 恢复 contextvars
trace_context_var.reset(token)