浏览代码

feat(langgraph): 新增请求日志中间件 + /api/v1/logs 查询接口

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 2 月之前
父节点
当前提交
cbab51382f
共有 3 个文件被更改,包括 144 次插入 和 18 次删除
  1. 38 0
      cfc-langgraph/app/api/logs.py
  2. 7 18
      cfc-langgraph/app/main.py
  3. 99 0
      cfc-langgraph/app/middleware.py

+ 38 - 0
cfc-langgraph/app/api/logs.py

@@ -0,0 +1,38 @@
+"""请求日志查询接口"""
+from fastapi import APIRouter
+from app.middleware import get_request_log, clear_request_log
+
+router = APIRouter(tags=["logs"])
+
+
+@router.get("/api/v1/logs")
+async def list_logs(
+    limit: int = 100,
+    path: str = None,
+    method: str = None,
+    status: int = None,
+):
+    """
+    获取请求日志(最近 N 条)
+    - limit: 返回条数(默认100,最大500)
+    - path: 路径过滤(支持模糊)
+    - method: 方法过滤(GET/POST)
+    - status: 状态码过滤
+    """
+    logs = get_request_log()
+    if limit and 0 < limit <= 500:
+        logs = logs[:limit]
+    if path:
+        logs = [l for l in logs if path.lower() in l.get("path", "").lower()]
+    if method:
+        logs = [l for l in logs if l.get("method", "").upper() == method.upper()]
+    if status:
+        logs = [l for l in logs if l.get("status") == status]
+    return {"total": len(get_request_log()), "count": len(logs), "logs": logs}
+
+
+@router.post("/api/v1/logs/clear")
+async def clear_logs():
+    """清空请求日志"""
+    n = clear_request_log()
+    return {"cleared": n}

+ 7 - 18
cfc-langgraph/app/main.py

@@ -3,14 +3,19 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal
+from fastapi.middleware.base import BaseHTTPMiddleware
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs
 from app import monitoring
 from src.app import router as questionnaire_router
+from app.middleware import request_log_middleware
 
 logger = logging.getLogger(__name__)
 
 app = FastAPI(title="cfc-langgraph", version="0.3.0")
 
+# 注册请求日志中间件(必须在其他路由之前)
+app.add_middleware(BaseHTTPMiddleware, dispatch=request_log_middleware)
+
 app.include_router(health.router)
 app.include_router(recommend.router)
 app.include_router(chat.router)
@@ -21,23 +26,7 @@ app.include_router(monitoring.router)
 app.include_router(questionnaire_router)
 app.include_router(report_parse.router)
 app.include_router(meal.router)
-
-
-@app.middleware("http")
-async def timing_middleware(request: Request, call_next):
-    start = time.perf_counter()
-    response = await call_next(request)
-    elapsed = time.perf_counter() - start
-
-    if elapsed > 5:
-        logger.warning("SLOW_REQUEST: %s %s took %.2fs",
-                       request.method, request.url.path, elapsed)
-    else:
-        logger.debug("REQUEST: %s %s took %.2fs",
-                     request.method, request.url.path, elapsed)
-
-    response.headers["X-Response-Time"] = f"{elapsed:.3f}s"
-    return response
+app.include_router(logs.router)
 
 
 @app.on_event("startup")

+ 99 - 0
cfc-langgraph/app/middleware.py

@@ -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