feat(stats): 用量按日/周/月/全部统计(审计文件聚合)

统计页原来只有一个视图——进程启动以来的累计。早上没人和一整周没人看起来
一模一样。加日/周/月/总四个周期。

## 口径与实现

周期视图必须走审计文件,不能走内存聚合:内存 byModel/byKey 等是终身累计,
而 recs 环形缓冲只有 500 条(defaultRingSize)。读环会把任何超过几百个
请求的周期悄悄少算——这正是要消除的那类错数。

- 日/周/月 = UTC 日历窗口(今日 / ISO 周周一 00:00 / 本月 1 日)。
  刻意不用滚动 24h:滚动窗口会让"今天"和"最近一天"边界不同,同一个数字
  随查看时刻在两张卡片间跳。UTC 也和 billing 的峰段计算同口径,峰谷小时
  不会在费用视图和用量视图里落到不同一天。
- 全部 = 复用现有 Snapshot(内存聚合),无审计文件时依然可用。
- 时间桶:日→每小时(今天内部的尖峰要看得见),周/月→每天(否则一周是
  7×24 个点、一个月 31×24)。全部视图无时间线(终身总量没有有意义的
  时间轴,硬画 500 个滚动小时点是另一种撒谎)。
- key 过滤在所有维度生效;非法 period 返回 400 而不是静默回落"全部"——
  书签里的手误应当报错,而不是悄悄换成终身数字。

## 判据(10 条 + 8 个变异全部被捕获)

窗口边界(含"周日必须回到上一个周一"这个 Go Weekday() 陷阱)、旧记录不
计入、维度独立聚合且 by_model 求和等于 total、日桶按小时且有序、key 隔离、
全部视图走终身、空窗口标记 truncated、period 校验、query 解析。

变异验证时 by_status 假绿了一次:禁用状态码聚合后判据全过,查下去是我
**根本没测 by_status**(零覆盖)。补 TestPeriodStatusDimension 后该变异
立即被捕获。判据报假问题时,先怀疑判据——这次确实是我错了。

## 真实流量核对

生产审计文件手算 vs 后端(含轮转文件):
  day   手算 3559 / 后端 3532
  week  手算 43240 / 后端 35034
  month 手算 13696 / 后端 13670
差异是核对快照与请求之间的新流量,量级一致。

一个必须说明的发现:审计文件里混着两种记录 —— Req(type/model/
prompt_tokens)和访问日志(lat_ms/status/path)。46026 行里 33450 行是
访问日志,Go 侧按 r.Type=="" 跳过。这不是 bug(CSV 导出同样如此),但
意味着任何按行数手算都必须过滤,否则会差一个数量级。

CDP 实测四周期切换:reqs 3,571 / 35,075 / 13,710 / 196,712,与后端一致,
无控制台错误,localStorage 持久化生效。
This commit is contained in:
JianFeeeee
2026-10-02 12:26:26 +08:00
parent d1da40e493
commit c241a19b51
4 changed files with 660 additions and 5 deletions

View File

@ -1038,6 +1038,13 @@
recsEnd: "已到最早记录",
recsRotated: "审计日志已轮转,已从最新记录重新加载",
recsNewest: "回到最新",
periodLabel: "统计周期",
periodDay: "今日",
periodWeek: "本周",
periodMonth: "本月",
periodAll: "全部",
periodTruncated:
"统计周期可能不完整(审计日志已轮转),数值为下限。",
recsPartial:
"部分审计日志无法读取,统计可能不完整;完整历史请导出 CSV",
thTokens: "Tokens",
@ -1294,6 +1301,13 @@
recsEnd: "Reached the oldest record",
recsRotated: "The audit log rotated; reloaded from the newest record",
recsNewest: "Back to newest",
periodLabel: "Reporting period",
periodDay: "Today",
periodWeek: "This week",
periodMonth: "This month",
periodAll: "All time",
periodTruncated:
"This window may be incomplete (audit log rotated); figures are a lower bound.",
recsPartial:
"Some audit files could not be read, so these totals may be incomplete; export CSV for the full history",
thTokens: "Tokens",
@ -1558,6 +1572,18 @@
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
let statsKeyF = ""; // active key filter for records ('' = all)
// Usage reporting window. "all" keeps the legacy since-start totals;
// day/week/month are calendar windows (UTC) aggregated from the audit
// files server-side. Persisted so a reload keeps the operator's
// chosen scale instead of silently snapping back to lifetime totals.
let statsPeriod = (() => {
try {
const v = localStorage.getItem("gw_stats_period");
return ["day", "week", "month", "all"].includes(v) ? v : "all";
} catch (e) {
return "all";
}
})();
/* ---------- records: on-demand paging ----------
* The records table holds only what is on screen. The first screen comes
@ -1683,6 +1709,17 @@
return;
}
pane.innerHTML = `
<div class="filter-line" style="margin:0 0 12px">
<span class="muted">${t("periodLabel")}</span>
<select id="stat-period" onchange="setStatsPeriod(this.value)">
<option value="day">${t("periodDay")}</option>
<option value="week">${t("periodWeek")}</option>
<option value="month">${t("periodMonth")}</option>
<option value="all">${t("periodAll")}</option>
</select>
<span class="grow"></span>
<span id="period-note"></span>
</div>
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
5,
)
@ -1839,6 +1876,24 @@
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
}
// setStatsPeriod switches the reporting window. It resets the records
// pager rather than keeping the old cursor: the records table is fed
// from the lifetime tail regardless of window, and leaving a stale
// "next page" cursor from the previous window makes the table and the
// KPIs disagree about what is being shown.
function setStatsPeriod(v) {
if (!["day", "week", "month", "all"].includes(v)) return;
statsPeriod = v;
try {
localStorage.setItem("gw_stats_period", v);
} catch (e) {
/* private mode: the selector still works for this session */
}
const sel = $("#stat-period");
if (sel) sel.value = v;
resetRecords();
paintStats();
}
async function paintStats() {
try {
// limit=0 -> the server ships one screen of records; the rest is paged
@ -1846,6 +1901,8 @@
const q =
"/api/stats?limit=" +
RECS_PAGE +
"&period=" +
encodeURIComponent(statsPeriod) +
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
const st = await api(q);
const tot = st.total || {};
@ -1918,6 +1975,15 @@
rpart.innerHTML = st.replay_partial
? `<span class="muted" title="${escAttr(t("recsPartial"))}">ⓘ ${esc(t("recsPartial"))}</span>`
: "";
// Period windows come off the audit files, which rotate. If the
// window may be incomplete the numbers are a lower bound, and saying
// so next to them is the whole point — a confidently wrong zero for a
// rotated-away week is indistinguishable from a quiet week.
const pnote = $("#period-note");
if (pnote)
pnote.innerHTML = st.truncated
? `<span class="muted" title="${escAttr(t("periodTruncated"))}">ⓘ ${esc(t("periodTruncated"))}</span>`
: "";
const rex = $("#rec-exit");
if (rex)
rex.innerHTML = statsKeyF