"""请求日志中间件:捕获每个业务请求的完整入参/出参/耗时/错误。 记录范围:/v1/* 和 /admin/api/* - 流式响应:只记录前 N 个 chunk 摘要 + 总 chunk 数 + 总字节数(避免内存爆炸) - 请求体:缓存原始 bytes,供下游读取(FastAPI 的 request.body() 会消费) - 敏感标头/key 自动脱敏 - 失败不影响主流程(记录本身失败也静默) 注意:BaseHTTPMiddleware 会消费 request body,需要用 request.state 透传缓存。 这里采用读 body → 存 request.state.cached_body → 下游通过依赖重新注入的方式。 但 FastAPI 的 Request.body() 内部有缓存,如果中间件先读了,下游再读会拿到缓存。 所以直接 await request.body() 即可,下游能拿到相同结果。 """ from __future__ import annotations import json import time from typing import Any, Awaitable, Callable from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response, StreamingResponse from .services import request_log_store # 记录范围 _RECORDED_PREFIXES = ("/v1/", "/admin/api/", "/u/api/") # 排除项(避免日志刷屏:admin HTML 轮询、health) _EXCLUDED_PATHS = {"/admin/api/request-log"} # 避免查询日志本身被记录导致递归刷屏 # 流式响应采样上限 _MAX_STREAM_CHUNKS_LOG = 20 _MAX_STREAM_BYTES_LOG = 8 * 1024 def _should_record(path: str) -> bool: if not path: return False if path in _EXCLUDED_PATHS: return False return any(path.startswith(p) for p in _RECORDED_PREFIXES) def _extract_access_key(request: Request) -> str: auth = request.headers.get("authorization") if auth: a = auth.strip() if a.lower().startswith("bearer "): return a[7:].strip() if a.lower().startswith("basic "): return a[6:].strip() return a return ( request.headers.get("x-xtc-access-key") or request.headers.get("x-xtc-access-token") or "" ) def _extract_device_id(request: Request) -> str: """从请求头 / query 参数提取设备 ID。 识别顺序: 1. ``x-xtc-device-id`` 头(推荐,前端 fetch 可直接设置) 2. ``x-device-id`` 头(兼容通用 SDK) 3. ``device_id`` query 参数(兜底) """ val = ( request.headers.get("x-xtc-device-id") or request.headers.get("x-device-id") ) if val: s = str(val).strip() if s: return s qid = request.query_params.get("device_id") if qid: s = str(qid).strip() if s: return s return "" def _safe_json_loads(s: str) -> Any: try: return json.loads(s) except Exception: return s class RequestLogMiddleware(BaseHTTPMiddleware): """记录业务请求的完整日志。""" async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: path = request.url.path if not _should_record(path): return await call_next(request) start = time.time() method = request.method query = str(request.url.query) if request.url.query else None # 读请求体(缓存到 state,下游可复用) request_body_raw: bytes = b"" try: request_body_raw = await request.body() except Exception: pass # 请求标头脱敏 req_headers = request_log_store._redact_headers(dict(request.headers)) # 请求体脱敏 req_body_redacted: Any = None if request_body_raw: ct = (request.headers.get("content-type") or "").lower() if "application/json" in ct: try: req_body_redacted = request_log_store._redact_body(json.loads(request_body_raw)) except Exception: req_body_redacted = request_body_raw.decode("utf-8", "replace") elif "multipart/form-data" in ct: req_body_redacted = "[multipart form-data]" else: req_body_redacted = request_body_raw.decode("utf-8", "replace") access_key = _extract_access_key(request) client_ip = request.client.host if request.client else None user_agent = request.headers.get("user-agent") device_id = _extract_device_id(request) # 公共日志构造 def _build_record(status_code, elapsed_ms, ok, provider, model, is_stream, error_code, error_message, resp_body, resp_headers): return { "method": method, "path": path, "query": query, "status_code": status_code, "elapsed_ms": elapsed_ms, "access_key": access_key, "client_ip": client_ip, "user_agent": user_agent, "provider": provider, "model": model, "stream": is_stream, "ok": ok, "error_code": error_code, "error_message": error_message, "request_headers": json.dumps(req_headers, ensure_ascii=False), "request_body": json.dumps(req_body_redacted, ensure_ascii=False) if req_body_redacted is not None else None, "response_body": json.dumps(resp_body, ensure_ascii=False) if resp_body is not None else None, "response_headers": json.dumps(resp_headers, ensure_ascii=False), "device_id": device_id, } # 调用下游 response: Response try: response = await call_next(request) except Exception as e: elapsed_ms = int((time.time() - start) * 1000) try: request_log_store.insert(_build_record( 500, elapsed_ms, False, None, None, False, "internal_error", str(e), None, None, )) except Exception: pass raise # 从响应头提取业务信息 provider = response.headers.get("x-xtc-provider") model = response.headers.get("x-xtc-model") is_stream = response.headers.get("content-type", "").startswith("text/event-stream") status_code = response.status_code ok = 200 <= status_code < 400 resp_headers = request_log_store._redact_headers(dict(response.headers)) if isinstance(response, StreamingResponse): # 流式响应:包装 iterator,在迭代过程中采集 chunk,迭代结束后写日志 return self._wrap_streaming( response, start, _build_record, provider, model, is_stream, status_code, ok, resp_headers, ) # 普通响应:直接读 body error_code = None error_message = None resp_body_str: Any = None try: body_bytes = b"" async for chunk in response.body_iterator: body_bytes += chunk response.body_iterator = _iter_bytes(body_bytes) ct = (response.headers.get("content-type") or "").lower() if "application/json" in ct: try: parsed = json.loads(body_bytes) resp_body_str = parsed if isinstance(parsed, dict) and parsed.get("ok") is False: err = parsed.get("error") or {} error_code = err.get("code") error_message = err.get("message") except Exception: resp_body_str = body_bytes.decode("utf-8", "replace") else: resp_body_str = body_bytes.decode("utf-8", "replace") if len(resp_body_str) > 4096: resp_body_str = resp_body_str[:4096] + "...[truncated]" except Exception as e: resp_body_str = f"[capture failed: {e}]" elapsed_ms = int((time.time() - start) * 1000) try: request_log_store.insert(_build_record( status_code, elapsed_ms, ok, provider, model, is_stream, error_code, error_message, resp_body_str, resp_headers, )) except Exception as e: print(f"[request_log] insert failed: {e}") return response def _wrap_streaming( self, response: StreamingResponse, start: float, _build_record, provider, model, is_stream, status_code, ok, resp_headers, ) -> StreamingResponse: """包装流式响应:边发送边采集,发送结束后写日志。""" original_iterator = response.body_iterator chunks_collected: list[str] = [] total_bytes = 0 total_chunks = 0 # 流式错误捕获 error_code = None error_message = None async def wrapped(): nonlocal total_bytes, total_chunks, error_code, error_message try: async for chunk in original_iterator: if isinstance(chunk, str): chunk_bytes = chunk.encode("utf-8") else: chunk_bytes = chunk total_bytes += len(chunk_bytes) total_chunks += 1 # 采集前 N 个 chunk if total_chunks <= _MAX_STREAM_CHUNKS_LOG: text = chunk_bytes.decode("utf-8", "replace") chunks_collected.append(text) # 尝试从 SSE data 中提取错误 if not ok and text.startswith("data:"): payload = text[5:].strip() if payload and payload != "[DONE]": try: obj = json.loads(payload) if isinstance(obj, dict) and obj.get("error"): err = obj["error"] error_code = err.get("code") if isinstance(err, dict) else None error_message = err.get("message") if isinstance(err, dict) else str(err) except Exception: pass yield chunk finally: # 流结束后写日志 elapsed_ms = int((time.time() - start) * 1000) summary = { "type": "streaming", "total_chunks": total_chunks, "total_bytes": total_bytes, "sampled_chunks": chunks_collected, } try: request_log_store.insert(_build_record( status_code, elapsed_ms, ok, provider, model, is_stream, error_code, error_message, summary, resp_headers, )) except Exception as e: print(f"[request_log] streaming insert failed: {e}") response.body_iterator = wrapped() return response async def _iter_bytes(data: bytes): yield data