feat(api,ui): 新增按Key调用详情与错误日志查找并联动前端

引入按密钥维度的请求详情及错误日志关联,新增错误日志精确
查找接口,并扩展统计时间维度,提升故障定位与可观测性。

- 新增 /api/logs/errors/lookup 接口:支持按 gemini_key / timestamp /
  status_code 与时间窗口查找最接近的错误日志;ErrorLogDetailResponse
  增加 error_code 字段
- Stats 接口增强:get_api_call_details 返回 status_code、latency_ms,
  并在失败时尝试匹配 error_log_id;新增 /api/stats/key-details 获取指
  定密钥调用详情;新增 8h 时间段
- DB 层:add_error_log 支持传入 request_datetime(默认使用 UTC);新增
  find_error_log_by_info 封装按 key/时间窗口/状态码的查询
- 前端 keys_status:趋势图支持 8 小时区间;调用详情表新增状态码/耗时与
  失败详情按钮;可按 key 查看期内调用详情并查看匹配错误日志;优化统计
  摘要展示与模态层级(z-index)
- OpenAIChatService:错误记录携带请求时间;改进日志与健壮性处理
This commit is contained in:
snaily
2025-08-18 05:19:29 +08:00
parent 01312317a1
commit e9601ca76c
8 changed files with 607 additions and 133 deletions
+128 -18
View File
@@ -146,7 +146,7 @@ class StatsService:
period: 时间段标识 ('1m', '1h', '24h')
Returns:
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status, status_code, latency_ms, error_log_id(可选)
Raises:
ValueError: 如果 period 无效
@@ -156,6 +156,8 @@ class StatsService:
start_time = now - datetime.timedelta(minutes=1)
elif period == "1h":
start_time = now - datetime.timedelta(hours=1)
elif period == "8h":
start_time = now - datetime.timedelta(hours=8)
elif period == "24h":
start_time = now - datetime.timedelta(hours=24)
else:
@@ -167,7 +169,8 @@ class StatsService:
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code,
RequestLog.status_code.label("status_code"),
RequestLog.latency_ms.label("latency_ms"),
)
.where(RequestLog.request_time >= start_time)
.order_by(RequestLog.request_time.desc())
@@ -175,29 +178,138 @@ class StatsService:
results = await database.fetch_all(query)
details = []
# 为失败调用尝试查找匹配的错误日志ID(时间窗口 +/- 5 分钟)
from app.database.models import ErrorLog # 延迟导入避免循环依赖
details: list[dict] = []
for row in results:
status = "failure"
if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure"
details.append(
{
"timestamp": row[
"timestamp"
].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
}
)
record = {
"timestamp": row["timestamp"].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
"status_code": row["status_code"],
"latency_ms": row["latency_ms"],
}
# 如果失败,尝试附带一个相关的错误日志ID,便于前端拉取详情
if status == "failure" and row["key"]:
try:
ts = row["timestamp"]
start_win = ts - datetime.timedelta(minutes=5)
end_win = ts + datetime.timedelta(minutes=5)
err_query = (
select(ErrorLog.id)
.where(
ErrorLog.gemini_key == row["key"],
ErrorLog.request_time >= start_win,
ErrorLog.request_time <= end_win,
)
.order_by(ErrorLog.request_time.desc())
.limit(1)
)
err = await database.fetch_one(err_query)
if err:
record["error_log_id"] = err["id"]
except Exception as _e:
logger.debug(
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
)
details.append(record)
logger.info(
f"Retrieved {len(details)} API call details for period '{period}'"
)
return details
except Exception as e:
logger.error(f"Failed to get API call details for period '{period}': {e}")
raise
async def get_key_call_details(self, key: str, period: str) -> list[dict]:
"""获取指定密钥在指定时间段内的调用详情 (与 get_api_call_details 结构一致)"""
now = datetime.datetime.now()
if period == "1m":
start_time = now - datetime.timedelta(minutes=1)
elif period == "1h":
start_time = now - datetime.timedelta(hours=1)
elif period == "8h":
start_time = now - datetime.timedelta(hours=8)
elif period == "24h":
start_time = now - datetime.timedelta(hours=24)
else:
raise ValueError(f"无效的时间段标识: {period}")
try:
query = (
select(
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code.label("status_code"),
RequestLog.latency_ms.label("latency_ms"),
)
.where(RequestLog.request_time >= start_time, RequestLog.api_key == key)
.order_by(RequestLog.request_time.desc())
)
results = await database.fetch_all(query)
from app.database.models import ErrorLog
details: list[dict] = []
for row in results:
status = "failure"
if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure"
record = {
"timestamp": row["timestamp"].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
"status_code": row["status_code"],
"latency_ms": row["latency_ms"],
}
if status == "failure" and row["key"]:
try:
ts = row["timestamp"]
start_win = ts - datetime.timedelta(minutes=5)
end_win = ts + datetime.timedelta(minutes=5)
err_query = (
select(ErrorLog.id)
.where(
ErrorLog.gemini_key == row["key"],
ErrorLog.request_time >= start_win,
ErrorLog.request_time < end_win,
)
.order_by(ErrorLog.request_time.desc())
.limit(1)
)
err = await database.fetch_one(err_query)
if err:
record["error_log_id"] = err["id"]
except Exception as _e:
logger.debug(
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
)
details.append(record)
logger.info(
f"Retrieved {len(details)} key call details for key=...{key[-4:] if key else ''} period '{period}'"
)
return details
except Exception as e:
logger.error(
f"Failed to get API call details for period '{period}': {e}")
f"Failed to get key call details for key=...{key[-4:] if key else ''} period '{period}': {e}"
)
raise
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
@@ -220,8 +332,7 @@ class StatsService:
try:
query = (
select(
RequestLog.model_name, func.count(
RequestLog.id).label("call_count")
RequestLog.model_name, func.count(RequestLog.id).label("call_count")
)
.where(
RequestLog.api_key == key,
@@ -240,8 +351,7 @@ class StatsService:
)
return {}
usage_details = {row["model_name"]: row["call_count"]
for row in results}
usage_details = {row["model_name"]: row["call_count"] for row in results}
logger.info(
f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}"
)