ai-model-studio / src /utils /error_logger.py
slk1st's picture
Deploy AI Model Studio
8a03d2c verified
Raw
History Blame Contribute Delete
12.3 kB
"""
错误快照模块
保存请求错误的完整上下文,便于调试和问题排查。
特性:
- 分层目录结构
- 完整的请求/响应链路记录
- 自动清理旧快照
- 与主日志系统集成
"""
import json
import shutil
from datetime import datetime, timedelta
from typing import Any, cast
from pathlib import Path
class ErrorSnapshotManager:
"""错误快照管理器"""
def __init__(
self,
base_dir: str = "errors",
max_snapshots: int = 100,
max_age_days: int = 7,
compress_old: bool = True
):
"""
初始化错误快照管理器
Args:
base_dir: 快照存储基础目录
max_snapshots: 最大保留快照数量
max_age_days: 快照最大保留天数
compress_old: 是否压缩旧快照
"""
self.base_dir = Path(base_dir)
self.max_snapshots = max_snapshots
self.max_age_days = max_age_days
self.compress_old = compress_old
# 确保目录存在
self.base_dir.mkdir(parents=True, exist_ok=True)
def save_snapshot(
self,
downstream_payload: dict[str, Any],
upstream_payload: dict[str, Any],
upstream_response: str,
error_type: str = "request_error",
metadata: dict[str, Any] | None = None
) -> str | None:
"""
保存错误快照
Args:
downstream_payload: 接收到的原始请求
upstream_payload: 发送给上游的请求
upstream_response: 上游返回的响应
error_type: 错误类型
metadata: 额外元数据
Returns:
快照目录路径,失败返回 None
"""
try:
# 创建时间戳目录
timestamp = datetime.now()
date_dir = timestamp.strftime("%Y-%m-%d")
time_str = timestamp.strftime("%H%M%S_%f")
# 目录结构: errors/2024-01-21/http_400_143052_123456/
snapshot_name = f"{error_type}_{time_str}"
snapshot_dir = self.base_dir / date_dir / snapshot_name
snapshot_dir.mkdir(parents=True, exist_ok=True)
# 1. 保存下游请求
self._save_json(
snapshot_dir / "1_downstream_request.json",
downstream_payload
)
# 2. 保存上游请求
self._save_json(
snapshot_dir / "2_upstream_request.json",
upstream_payload
)
# 3. 保存上游响应
resp_ext = self._save_response(
snapshot_dir / "3_upstream_response",
upstream_response
)
# 4. 保存摘要
summary: dict[str, Any] = {
"timestamp": timestamp.isoformat(),
"error_type": error_type,
"snapshot_id": snapshot_name,
"files": [
"1_downstream_request.json",
"2_upstream_request.json",
f"3_upstream_response{resp_ext}"
],
"metadata": metadata or {}
}
self._save_json(snapshot_dir / "summary.json", summary)
# 清理旧快照
self._cleanup_old_snapshots()
return str(snapshot_dir)
except Exception:
return None
def _save_json(self, path: Path, data: Any):
"""保存 JSON 文件"""
with open(path, 'w', encoding='utf-8') as f:
if isinstance(data, (dict, list)):
json.dump(data, f, ensure_ascii=False, indent=2)
else:
f.write(str(data))
def _save_response(self, path_base: Path, response: str) -> str:
"""保存响应内容,自动检测格式,返回文件后缀"""
if self._is_json(response):
try:
data = json.loads(response)
self._save_json(path_base.with_suffix('.json'), data)
return ".json"
except json.JSONDecodeError:
pass
# 保存为文本
with open(path_base.with_suffix('.txt'), 'w', encoding='utf-8') as f:
f.write(str(response))
return ".txt"
def _is_json(self, text: str) -> bool:
"""检查是否为 JSON 格式"""
# Pylance: text 已经是 str,不需要 isinstance 检查
# 但为了运行时健壮性,如果 text 可能为 None 或其他类型,可以保留,
# 但既然类型提示是 str,我们假设它就是 str。
# 如果调用者可能传 None,类型提示应该是 Optional[str]
# 兼容性处理:如果 text 为空
if not text:
return False
# 安全转换,防止传入了非字符串
text_str = str(text).strip()
return text_str.startswith('{') or text_str.startswith('[')
def _cleanup_old_snapshots(self):
"""清理旧快照"""
try:
# 获取所有日期目录
date_dirs = sorted(
[d for d in self.base_dir.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True
)
# 按日期清理
cutoff_date = datetime.now() - timedelta(days=self.max_age_days)
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
for date_dir in date_dirs:
if date_dir.name < cutoff_str:
# 删除过期目录
shutil.rmtree(date_dir)
# 按数量清理
all_snapshots: list[dict[str, Any]] = []
for date_dir in self.base_dir.iterdir():
if date_dir.is_dir():
for snapshot_dir in date_dir.iterdir():
if snapshot_dir.is_dir():
summary_file = snapshot_dir / "summary.json"
if summary_file.exists():
try:
with open(summary_file, 'r') as f:
summary = json.load(f)
all_snapshots.append({
'path': snapshot_dir,
'timestamp': str(summary.get('timestamp', ''))
})
except Exception:
pass
# 按时间排序,删除超出数量限制的
# 显式指定 key 函数的返回类型
def sort_key(x: dict[str, Any]) -> str:
return str(x['timestamp'])
all_snapshots.sort(key=sort_key, reverse=True)
for snapshot in all_snapshots[self.max_snapshots:]:
snapshot_path = cast(Path, snapshot['path'])
shutil.rmtree(snapshot_path)
# 清理空的日期目录
for date_dir in self.base_dir.iterdir():
if date_dir.is_dir() and not any(date_dir.iterdir()):
date_dir.rmdir()
except Exception:
pass
def list_snapshots(
self,
error_type: str | None = None,
limit: int = 20
) -> list[dict[str, Any]]:
"""
列出快照
Args:
error_type: 过滤错误类型
limit: 返回数量限制
Returns:
快照摘要列表
"""
snapshots: list[dict[str, Any]] = []
try:
for date_dir in sorted(self.base_dir.iterdir(), reverse=True):
if not date_dir.is_dir():
continue
for snapshot_dir in sorted(date_dir.iterdir(), reverse=True):
if not snapshot_dir.is_dir():
continue
summary_file = snapshot_dir / "summary.json"
if not summary_file.exists():
continue
try:
with open(summary_file, 'r') as f:
summary = json.load(f)
if error_type and summary.get('error_type') != error_type:
continue
summary['path'] = str(snapshot_dir)
snapshots.append(summary)
if len(snapshots) >= limit:
return snapshots
except Exception:
continue
except Exception:
pass
return snapshots
def get_snapshot(self, snapshot_path: str) -> dict[str, Any] | None:
"""
获取完整快照内容
Args:
snapshot_path: 快照目录路径
Returns:
快照完整内容
"""
try:
snapshot_dir = Path(snapshot_path)
if not snapshot_dir.exists():
return None
result: dict[str, Any] = {}
for file in snapshot_dir.iterdir():
if file.suffix == '.json':
with open(file, 'r', encoding='utf-8') as f:
result[file.stem] = json.load(f)
elif file.suffix == '.txt':
with open(file, 'r', encoding='utf-8') as f:
result[file.stem] = f.read()
return result
except Exception:
return None
# ==================== 全局实例 ====================
_snapshot_manager: ErrorSnapshotManager | None = None
def _get_manager() -> ErrorSnapshotManager:
"""获取或创建快照管理器"""
global _snapshot_manager
if _snapshot_manager is None:
try:
from src.core.config import load_config
config = load_config()
base_dir = config.get("error_dir", "errors")
except Exception:
base_dir = "errors"
_snapshot_manager = ErrorSnapshotManager(base_dir=base_dir)
return _snapshot_manager
# ==================== 便捷函数 ====================
def save_error_snapshot(
downstream_payload: dict[str, Any],
upstream_payload: dict[str, Any],
upstream_response: str,
error_type: str = "request_error"
) -> str | None:
"""
保存请求错误快照
这是主要的对外接口,保持与原有 API 兼容。
Args:
downstream_payload: 接收到的原始请求 (Gemini 格式)
upstream_payload: 最终发送给 Google 的 Payload
upstream_response: 上游返回的原始错误信息
error_type: 错误类别 (如 http_400, transform_error 等)
Returns:
快照目录路径,失败返回 None
"""
manager = _get_manager()
return manager.save_snapshot(
downstream_payload=downstream_payload,
upstream_payload=upstream_payload,
upstream_response=upstream_response,
error_type=error_type
)
def list_error_snapshots(
error_type: str | None = None,
limit: int = 20
) -> list[dict[str, Any]]:
"""
列出错误快照
Args:
error_type: 过滤错误类型
limit: 返回数量限制
Returns:
快照摘要列表
"""
manager = _get_manager()
return manager.list_snapshots(error_type=error_type, limit=limit)
def get_error_snapshot(snapshot_path: str) -> dict[str, Any] | None:
"""
获取完整错误快照
Args:
snapshot_path: 快照目录路径
Returns:
快照完整内容
"""
manager = _get_manager()
return manager.get_snapshot(snapshot_path)