Skip to content

请求日志与统计

AutoRouter 的可观测性建立在两张表上:request_logs 记录每一次代理请求的完整过程,request_billing_snapshots 与它 1:1 关联存计费快照。两张表通过统一的 admin API 暴露给管理后台与外部脚本,三类聚合 API(overview / timeseries / leaderboard)在它们之上做实时计算,无任何预聚合。本页讲清字段语义、写入时机、统计口径、live SSE、以及 LOG_RETENTION_DAYS 当前实际的执行情况。

request_logs 表

src/lib/db/schema-pg.ts:279-342 定义。40 多列,按功能分组列出关键字段:

调用方与上游标识

类型说明
api_key_iduuid FK → api_keys (set null)Key 被删除后置 NULL,但 prefix/name 快照仍可用
api_key_namevarchar(255)Key 名称快照(冗余写入,避免 join 时被改名误导)
api_key_prefixvarchar(16)Key 前缀快照
upstream_iduuid FK → upstreams (set null)实际命中的上游

请求 / 响应骨架

类型说明
methodvarchar(10)HTTP 方法
pathtext完整路径
modelvarchar(128)模型名(来自请求体或响应体)
status_codeintegerHTTP 状态码;in-progress 时为 NULL,请求完成后才回填
duration_msinteger总耗时(ms),写入时 clamp 到 INT4 上限 2,147,483,647(约 24.8 天)防止溢出
routing_duration_msinteger路由选择耗时(ms),同样 clamp
ttft_msintegerTime-To-First-Byte(首字节时延 ms)
is_streamboolean是否为流式请求
error_messagetext错误描述(仅失败时填)

Token 与缓存计费维度

含义
prompt_tokens输入 token
completion_tokens输出 token
total_tokens总数(通常 = prompt + completion)
cached_tokensOpenAI 侧 usage.cached_tokens
reasoning_tokens推理 token
cache_creation_tokensAnthropic cache 写入(通用)
cache_creation_5m_tokensAnthropic 5 分钟 ephemeral cache 写入
cache_creation_1h_tokensAnthropic 1 小时 ephemeral cache 写入
cache_read_tokensAnthropic cache 命中

Token 数据由 extractNormalizedUsagesrc/lib/services/proxy-client.ts:450)从多家协议的 usage / usageMetadata 字段统一抽取,覆盖 OpenAI / Anthropic / Gemini / OpenAI Responses streaming。

路由与决策审计

说明
routing_typedirect / provider_type / tiered
priority_tier命中上游所在 tier
failover_attempts该请求做了几次 failover
failover_historytext(JSON array),每次尝试的上游 + 失败原因
routing_decisiontext(JSON object),完整决策上下文
session_idsession affinity 提取出的 ID
affinity_hit是否命中亲和缓存
affinity_migrated是否触发亲和迁移
session_id_compensated是否补偿了 session header
header_diffJSON,记录 inbound / outbound / dropped 的 header 差异
reasoning_effort请求推理强度:none / minimal / low / medium / high / xhigh / enabled
thinking_configJSON,请求的推理配置原值

索引(行 336-341)

索引用途
request_logs_api_key_id_idx按 key 检索 / 计费配额聚合
request_logs_upstream_id_idx按上游检索 / 统计
request_logs_created_at_idx时间窗扫描(list / timeseries 全走它)
request_logs_routing_type_idx按路由类型分组

关联:request_billing_snapshots

每条 request_log 有一条对应的 request_billing_snapshots 行(1:1,ON DELETE CASCADE),存 finalCost / priceSource / billingStatus / unbillableReason 等。统计接口的 cost 维度直接 LEFT JOIN 这张表。

写入路径

src/lib/services/request-logger.ts两阶段同步写,无 sampling / 无截断 / 无 batching:

client request

proxy route 决策完毕(route.ts:2959)

logRequestStart() — INSERT 一行,status_code=NULL,duration_ms=NULL

转发到上游 / 接收响应

updateRequestLog(id, {...}) — UPDATE,回填 status_code / duration_ms / tokens / errorMessage / failover_history / ttft 等

calculateAndPersistRequestBillingSnapshot() — 在 request_billing_snapshots 写计费快照

publishRequestLogLiveUpdate() — 广播 SSE 事件给 /api/admin/logs/live 订阅者

部分非流式入口直接调 logRequest()request-logger.ts:467-519)一次性 INSERT,跳过 in-progress 中间态。

duration_ms 与 routing_duration_ms 的 clamp

ts
Math.min(Math.max(0, input.durationMs), INT4_MAX); // INT4_MAX = 2_147_483_647

源码见 request-logger.ts:21,411-417,489-491。这层保护是 PR #170 / #171 的修复:早期版本 duration_ms 没有上界,长时间 stuck 的流式请求写入会超过 PostgreSQL INT4 上限直接 INSERT 失败,整条 log 丢失。clamp 之后超时请求虽然 duration_ms 失真为 24.8 天封顶,但日志能正常写入。

Stale reconcile:520 兜底

如果 in-progress 行长时间没被 update 回填(服务重启 / 进程 crash / 异常路径漏写),会留下永远 status_code IS NULL 的孤儿行。request-logger.ts:524-569reconcileStaleInProgressRequestLogs 做兜底:

常量说明
REQUEST_LOG_STALE_MINUTES15超过 15 分钟仍是 NULL 即视为 stale
REQUEST_LOG_STALE_SCAN_LIMIT200单次扫描上限,避免一次性处理过多行
stale status code520标记为 HTTP 520 + errorMessage 写明超时窗

触发时机:每次 listRequestLogs() 与各 stats 函数被调用前自动跑(非 test 环境)。失败仅 warn 不中断(:706-710)。

读到 status_code = 520 不代表上游真返了 520,而是 reconcile 兜底标记,需要人工排查上一次重启 / crash 时是否有未回填的日志。

管理 API

GET /api/admin/logs — 分页列表

src/app/api/admin/logs/route.ts。Query 参数:

参数含义
page页号,默认 1
page_size每页条数,默认 20,上限 100
id精确匹配 log ID(前端 focus=<id> 跳转场景)
api_key_id精确匹配 Key
upstream_id精确匹配上游
status_code精确匹配整数
start_timeISO datetime,created_at >=
end_timeISO datetime,created_at <=

返回 {items, total, page, pageSize, totalPages}items 中每条带完整字段 + billing snapshot。

注意没有 model 过滤参数——按模型筛要么走 stats leaderboard,要么在客户端做。

GET /api/admin/logs/live — SSE 实时

src/app/api/admin/logs/live/route.ts

  • Content-Type text/event-stream
  • 连接即推 event: connected
  • 每 15 秒一条 :keep-alive <ISO> 注释保活
  • 每次日志变更(INSERT / UPDATE)推送 event: request-log-changed,payload {type, logId, statusCode, occurredAt}

实现是进程内 in-memory pub/subsrc/lib/services/request-log-live-updates.ts:10)。多进程部署下不跨实例——instance A 写的日志,只有连到 instance A 的 SSE 订阅者会收到事件,连到 instance B 的不会。多副本场景下 live 模式实际可用性取决于负载均衡的粘性,详情见 架构 / 请求生命周期

GET /api/admin/stats — 统一聚合入口

src/app/api/admin/stats/route.ts,按 type 参数分发到三个子路由:

type 取值行为
type=overview当日 + 昨日对比
type=timeseriesrange=today|7d|30d&metric=requests|ttft|tps|tokens|duration|cost
type=leaderboardrange=today|7d|30d&limit=5(上限 50)

也可以分别直接调子路由 /api/admin/stats/overview/api/admin/stats/timeseries/api/admin/stats/leaderboard

统计聚合

src/lib/services/stats-service.ts全部实时聚合——每次 API 调用走一次 SQL,无定时预聚合任务。

Overview(当日 + 昨日对比)

getOverviewStatsstats-service.ts:265)。当日与昨日各执行一次聚合查询,LEFT JOIN request_billing_snapshots。计算:

指标口径
totalRequests当日 count(*)
avgDuration仅 2xxduration_ms 平均
avgTtft仅 2xxttft_ms 平均
totalTokens当日 sum(total_tokens)
successCount当日 count where 2xx
totalCacheReadTokens当日 sum(cache_read_tokens)
totalEffectivePromptTokens当日 sum(effective prompt,含 cache 抵扣)
totalCost当日 sum(final_cost) where billing_status='billed'
cacheHitRatecacheReadTokens / effectivePromptTokens * 100,clamp [0,100]

Timeseries(时间序列)

getTimeseriesStatsstats-service.ts:368):

range时间桶粒度
todayhour
7d / 30dday
custom差值 ≤ 2 天 → hour,否则 day

PG 用 date_trunc('hour'/'day', created_at) 分桶(stats-service.ts:150-158),并行查 per-upstream + 全量 total 两组。

metric计算口径
requestscount(*)
tokenssum(total_tokens)
durationavg(duration_ms)
ttftavg(ttft_ms)
tpstokens / second, is_stream + 2xx + completion_tokens ≥ 10 + duration_ms > 100
costsum(final_cost) where billing_status='billed'(需 LEFT JOIN billing_snapshots)

TPS 的过滤条件(stats-service.ts:109-115)是为了避免短请求 / 非流式 / 已抛错的样本污染分母。

Leaderboard(排行榜)

getLeaderboardStatsstats-service.ts:525)。三个维度并行:

维度主排序附加分布(top-5,超出归入 "Others")
API Keystop-N每个 key 的 model 分布
Upstreamstop-N直接列出
Modelstop-N每个 model 的 upstream 分布

上游 providerType 通过 getPrimaryProviderByCapabilities(routeCapabilities) 推导,因此即使上游没有显式 providerType 字段也能归类。

前端:/logs 页面与 live 模式

  • 页面:src/app/[locale]/(dashboard)/logs/page.tsx
  • Hook:src/hooks/use-request-log-live.ts

Hook 行为:

状态含义
connectingSSE 连接建立中
live已收到 connected 事件,每次 request-log-changed 触发 250ms debounced 列表刷新
fallbackSSE 断开,回落到 3000ms 轮询

断线后 10 秒重连一次(use-request-log-live.ts:23)。

实际操作建议:

  • 排查单条请求 → 列表直接按 id 过滤(管理后台支持 focus=<id> 跳转 URL)。
  • 排查某个 Key 的最近一批请求 → 列表按 api_key_id + 时间窗。
  • 排查上游故障期间的失败分布 → list 取数据 + leaderboard 取 top-N 错误来源。

保留策略:当前实际情况

LOG_RETENTION_DAYS 当前没有自动清理任务在跑

.env 里的 LOG_RETENTION_DAYS(默认 90)被 config.ts:35,82 解析存到运行时配置对象,但 整个 src 目录里没有任何代码读取这个配置值去清理 request_logs。Background sync 注册表(src/lib/services/background-sync-registry.ts)当前只有三个任务:billing price sync、upstream model catalog sync、traffic recording cleanup,不含 request_log 清理

也就是说目前 request_logs 表是无限增长的,靠 LOG_RETENTION_DAYS 不会让它停下来。需要按日期清理时,目前的手段是:

  1. 直接对 DB 执行:DELETE FROM request_logs WHERE created_at < NOW() - INTERVAL '90 days';
  2. 由于 request_billing_snapshots 通过 ON DELETE CASCADE 关联,会跟着一起被清。
  3. 生产环境建议加入 LIMIT 分批 + 索引 request_logs_created_at_idx 走顺序扫描,避免一次性锁表。

后续若添加 request_log retention 后台任务,再更新本节。

不在本页范围内

  • 录制请求 / 响应原始体(fixture):见 请求录制
  • 计费规则、价格来源、unbillable 细化原因:后续「计费」相关文档(仓库内可参考 billing-cost-service.ts:432-449UnbillableReason 列表)。
  • 上游模型与 routing decision 的字段语义:见 请求生命周期模型路由规则
  • 部署侧的日志收集 / 监控集成(Prometheus / Grafana 等):后续部署侧 troubleshooting 章节,目前未覆盖。

Released under the MIT License.