middleware.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. """请求日志中间件 — 记录最近 MAX_ENTRIES 条请求到内存
  2. 实现为纯 ASGI 中间件(而非 BaseHTTPMiddleware),原因:
  3. - BaseHTTPMiddleware 中先 `await request.body()` 再 `call_next(request)` 会消费请求体,
  4. 导致下游 handler 再次 receive 时拿不到 body,POST 请求死锁/挂起;
  5. - 单 worker 部署下,一个请求挂起会卡死整个 worker,所有请求(含 /api/v1/logs)超时。
  6. 纯 ASGI 中间件通过包装 receive/send 通道缓存请求体与响应状态,既记录日志又不破坏下游请求。
  7. """
  8. import time
  9. import uuid
  10. from collections import deque
  11. import logging
  12. logger = logging.getLogger(__name__)
  13. MAX_ENTRIES = 500
  14. _request_log: deque = deque(maxlen=MAX_ENTRIES)
  15. def get_request_log() -> list:
  16. """返回请求日志副本(最近在前)"""
  17. return list(_request_log)[::-1]
  18. def clear_request_log() -> int:
  19. """清空日志,返回清空条数"""
  20. n = len(_request_log)
  21. _request_log.clear()
  22. return n
  23. def _decode_body(raw: bytes) -> str:
  24. """将请求体字节解码为可读文本(截断超长内容)"""
  25. if not raw:
  26. return ""
  27. try:
  28. text = raw.decode("utf-8")
  29. if len(text) > 2000:
  30. text = text[:2000] + "...(truncated)"
  31. return text
  32. except Exception:
  33. return f"<binary {len(raw)} bytes>"
  34. class RequestLogMiddleware:
  35. """纯 ASGI 请求日志中间件。
  36. 通过包装 receive 通道缓存请求体(不消费、不阻塞下游),
  37. 通过包装 send 通道捕获响应状态码与响应体,避免破坏原请求。
  38. """
  39. def __init__(self, app):
  40. self.app = app
  41. async def __call__(self, scope, receive, send):
  42. if scope["type"] != "http":
  43. await self.app(scope, receive, send)
  44. return
  45. start_time = time.perf_counter()
  46. method = scope.get("method", "GET")
  47. path = scope.get("path", "")
  48. client = scope.get("client")
  49. client_ip = client[0] if client else "-"
  50. # 读取或生成 X-Request-ID(Java AiGateway 在每次调用时都会注入 UUID)
  51. request_id = None
  52. for name, value in scope.get("headers", []):
  53. if name == b"x-request-id":
  54. request_id = value.decode()
  55. break
  56. request_id = request_id or str(uuid.uuid4())
  57. # 带 trace_id 的 adapter:JsonFormatter 会把它写为顶层字段
  58. req_logger = logging.LoggerAdapter(logger, {"trace_id": request_id})
  59. # 缓存请求体(仅对带 body 的方法),不影响下游 receive
  60. body_bytes = b""
  61. body_cached = False
  62. async def receive_wrapper():
  63. nonlocal body_bytes, body_cached
  64. message = await receive()
  65. if message["type"] == "http.request":
  66. body_bytes += message.get("body", b"")
  67. if not message.get("more_body", False):
  68. body_cached = True
  69. return message
  70. # 捕获响应状态码与响应体
  71. status_code = None
  72. response_chunks = []
  73. sent_response_start = False
  74. async def send_wrapper(message):
  75. nonlocal status_code, response_chunks, sent_response_start
  76. if message["type"] == "http.response.start":
  77. status_code = message.get("status", 500)
  78. sent_response_start = True
  79. elif message["type"] == "http.response.body":
  80. body = message.get("body", b"")
  81. if body:
  82. response_chunks.append(body)
  83. total = sum(len(c) for c in response_chunks)
  84. if total > 1000:
  85. response_chunks[:] = [b"".join(response_chunks)[:1000]]
  86. return await send(message)
  87. try:
  88. await self.app(scope, receive_wrapper, send_wrapper)
  89. except Exception as exc:
  90. req_logger.error("REQUEST EXCEPTION: %s %s %.2fs error=%r",
  91. method, path, time.perf_counter() - start_time, exc)
  92. if not sent_response_start:
  93. from starlette.responses import JSONResponse
  94. resp = JSONResponse(status_code=500, content={"detail": str(exc)})
  95. await resp(scope, receive, send)
  96. status_code = status_code or 500
  97. elapsed = time.perf_counter() - start_time
  98. final_status = status_code if status_code is not None else 500
  99. entry = {
  100. "ts": time.strftime("%H:%M:%S"),
  101. "method": method,
  102. "path": path,
  103. "status": final_status,
  104. "duration_ms": round(elapsed * 1000),
  105. "client_ip": client_ip,
  106. "trace_id": request_id,
  107. "body": _decode_body(body_bytes) if body_cached else None,
  108. "response": _decode_body(b"".join(response_chunks)[:1000]) if response_chunks else "",
  109. }
  110. _request_log.appendleft(entry)
  111. if elapsed > 5:
  112. req_logger.warning("SLOW_REQUEST: %s %s %.2fs", method, path, elapsed)
  113. else:
  114. req_logger.debug("REQUEST: %s %s %.2fs", method, path, elapsed)