|
|
@@ -0,0 +1,99 @@
|
|
|
+"""请求日志中间件 — 记录最近 MAX_ENTRIES 条请求到内存"""
|
|
|
+import time
|
|
|
+import asyncio
|
|
|
+from collections import deque
|
|
|
+from typing import Optional
|
|
|
+from fastapi import Request, Response
|
|
|
+import logging
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+MAX_ENTRIES = 500
|
|
|
+_request_log: deque = deque(maxlen=MAX_ENTRIES)
|
|
|
+
|
|
|
+
|
|
|
+def get_request_log() -> list:
|
|
|
+ """返回请求日志副本(最近在前)"""
|
|
|
+ return list(_request_log)[::-1]
|
|
|
+
|
|
|
+
|
|
|
+def clear_request_log() -> int:
|
|
|
+ """清空日志,返回清空条数"""
|
|
|
+ n = len(_request_log)
|
|
|
+ _request_log.clear()
|
|
|
+ return n
|
|
|
+
|
|
|
+
|
|
|
+async def request_log_middleware(request: Request, call_next) -> Response:
|
|
|
+ start_time = time.perf_counter()
|
|
|
+ client_ip = request.client.host if request.client else "-"
|
|
|
+
|
|
|
+ # 读取请求体(不重复消费)
|
|
|
+ body = None
|
|
|
+ if request.method in ("POST", "PUT", "PATCH"):
|
|
|
+ try:
|
|
|
+ raw = await request.body()
|
|
|
+ if raw:
|
|
|
+ try:
|
|
|
+ body = raw.decode("utf-8")
|
|
|
+ if len(body) > 2000:
|
|
|
+ body = body[:2000] + "...(truncated)"
|
|
|
+ except Exception:
|
|
|
+ body = f"<binary {len(raw)} bytes>"
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ try:
|
|
|
+ response = await call_next(request)
|
|
|
+ except Exception as exc:
|
|
|
+ elapsed = time.perf_counter() - start_time
|
|
|
+ status_code = 500
|
|
|
+ response_body = ""
|
|
|
+ logger.error("REQUEST EXCEPTION: %s %s %.2fs error=%r",
|
|
|
+ request.method, request.url.path, elapsed, exc)
|
|
|
+ _request_log.appendleft({
|
|
|
+ "ts": time.strftime("%H:%M:%S"),
|
|
|
+ "method": request.method,
|
|
|
+ "path": request.url.path,
|
|
|
+ "status": 500,
|
|
|
+ "duration_ms": round(elapsed * 1000),
|
|
|
+ "client_ip": client_ip,
|
|
|
+ "body": body,
|
|
|
+ "error": str(exc)[:500],
|
|
|
+ })
|
|
|
+ # 构造错误响应
|
|
|
+ from fastapi.responses import JSONResponse
|
|
|
+ return JSONResponse(status_code=500, content={"detail": str(exc)})
|
|
|
+
|
|
|
+ elapsed = time.perf_counter() - start_time
|
|
|
+ status_code = response.status_code
|
|
|
+
|
|
|
+ # 读取响应体(不可逆,只取内容长度)
|
|
|
+ response_body = ""
|
|
|
+ if hasattr(response, "body") and response.body:
|
|
|
+ try:
|
|
|
+ response_body = response.body.decode("utf-8")[:1000]
|
|
|
+ except Exception:
|
|
|
+ response_body = f"<binary {len(response.body)} bytes>"
|
|
|
+
|
|
|
+ # 写入日志
|
|
|
+ entry = {
|
|
|
+ "ts": time.strftime("%H:%M:%S"),
|
|
|
+ "method": request.method,
|
|
|
+ "path": request.url.path,
|
|
|
+ "status": status_code,
|
|
|
+ "duration_ms": round(elapsed * 1000),
|
|
|
+ "client_ip": client_ip,
|
|
|
+ "body": body,
|
|
|
+ "response": response_body,
|
|
|
+ }
|
|
|
+ _request_log.appendleft(entry)
|
|
|
+
|
|
|
+ # 超5秒标记 warning
|
|
|
+ if elapsed > 5:
|
|
|
+ logger.warning("SLOW_REQUEST: %s %s %.2fs", request.method, request.url.path, elapsed)
|
|
|
+ else:
|
|
|
+ logger.debug("REQUEST: %s %s %.2fs", request.method, request.url.path, elapsed)
|
|
|
+
|
|
|
+ response.headers["X-Response-Time"] = f"{elapsed:.3f}s"
|
|
|
+ return response
|