mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user