Files
ModelRouter/internal/gateway/stats_period.go
JianFeeeee c241a19b51 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 持久化生效。
2026-10-02 12:26:26 +08:00

235 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gateway
import (
"sort"
"strconv"
"strings"
"time"
)
// Period is a reporting window for the usage dashboard. The dashboard used to
// have exactly one view — everything since process start — which made a quiet
// morning indistinguishable from a quiet week. Periods give the operator a
// scale to read the numbers at: today vs this week vs this month vs all time.
//
// The set is deliberately calendar-based and UTC-anchored. A rolling 24h window
// would put "today" and "the last day" at different boundaries, so the same
// number would move between two cards depending on when you looked; calendar
// days are what people actually mean by "today". UTC also matches the billing
// plugin's peak-window arithmetic, so a peak-rate hour does not land in a
// different day in the cost view than in the usage view.
type Period string
const (
// PeriodDay is the current UTC calendar day.
PeriodDay Period = "day"
// PeriodWeek is the current ISO week (Mon 00:00 UTC to now).
PeriodWeek Period = "week"
// PeriodMonth is the current UTC calendar month.
PeriodMonth Period = "month"
// PeriodAll is since process start — the only view backed by the
// in-memory aggregates, and the only one available when no audit file is
// configured.
PeriodAll Period = "all"
)
// ValidPeriod reports whether p is a period the aggregator understands.
// An unknown period is a client error, not a silent fallback to "all": a
// dashboard that quietly shows lifetime totals when the caller asked for today
// is worse than one that refuses.
func ValidPeriod(p Period) bool {
switch p {
case PeriodDay, PeriodWeek, PeriodMonth, PeriodAll:
return true
}
return false
}
// periodStart returns the inclusive start of the window for p at time now.
// Only PeriodDay/Week/Month are meaningful here; PeriodAll returns 0, which
// every "from > 0" bounds check treats as unbounded.
func periodStart(p Period, now time.Time) int64 {
now = now.UTC()
switch p {
case PeriodDay:
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
case PeriodWeek:
// ISO week starts Monday. Go's Weekday() is Sunday=0, so the shift
// below is 1 on Sunday and 0 on Monday..Saturday.
off := (int(now.Weekday()) + 6) % 7
d := now.AddDate(0, 0, -off)
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
case PeriodMonth:
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).UnixMilli()
}
return 0
}
// PeriodBucket is one labelled point on the dashboard's timeline. Buckets are
// the aggregation grain for a period: hourly for a day (so a spike is visible
// inside "today"), daily for a week or month (so a week is not 7×24 points
// wide and a month is not 31×24), and empty for "all" — a lifetime total has
// no meaningful timeline, and pretending otherwise by drawing 500 hourly
// buckets of rolling memory would be a different lie.
type PeriodBucket struct {
Bucket string `json:"bucket"`
Stat
}
// PeriodSnapshot is the period-scoped twin of Snapshot's payload: the same
// totals and the same by_* rows, plus a timeline. It deliberately mirrors
// Snapshot's field names so the dashboard's table painters work unchanged —
// paintModelTable(st.by_model) reads rows of {name, ...Stat}, and that shape
// does not care where the numbers came from.
type PeriodSnapshot struct {
Period Period `json:"period"`
From int64 `json:"from"` // unix millis, 0 when PeriodAll
Total Stat `json:"total"`
ByKey []StatsRow `json:"by_key"`
Models []StatsRow `json:"by_model"`
Srcs []StatsRow `json:"by_source"`
Status []agrRow `json:"by_status"`
Bucket []PeriodBucket `json:"buckets"`
// Truncated marks that the window was clipped by the retained audit
// history, so the numbers are a lower bound rather than the true period
// total. The dashboard shows this next to the numbers rather than letting
// a rotated-away week read as "that week had no traffic".
Truncated bool `json:"truncated"`
}
// bucketKey maps a record's timestamp onto the timeline grain for p.
// Daily buckets are stamped at UTC midnight; hourly buckets carry the hour.
func bucketKey(p Period, ms int64) string {
t := time.UnixMilli(ms).UTC()
if p == PeriodDay {
return t.Format("2006-01-02T15")
}
return t.Format("2006-01-02")
}
// bucketOf returns the bucket label plus the truncated-flag side effects of
// walking a file: a record older than the requested window means the window
// starts before the retained history, and the file may have been cut short.
func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapshot {
out := PeriodSnapshot{Period: p}
from := periodStart(p, now)
out.From = from
// "all" is exactly what Snapshot already answers, from the in-memory
// aggregates, and it is the one view that must keep working with no audit
// file configured at all (a fresh dev setup, or an operator who turned
// auditing off). Serving it from the same code path keeps the dashboard's
// "total" card identical whether or not a period is selected.
if p == PeriodAll {
snap := s.Snapshot(firstScreenRecords, key)
if tot, ok := snap["total"].(Stat); ok {
out.Total = tot
}
out.ByKey, _ = snap["by_key"].([]StatsRow)
out.Models, _ = snap["by_model"].([]StatsRow)
out.Srcs, _ = snap["by_source"].([]StatsRow)
out.Status, _ = snap["by_status"].([]agrRow)
// replay_partial is the same "these numbers came from a bounded
// tail" caveat, carried through under this view's own name.
out.Truncated, _ = snap["replay_partial"].(bool)
return out
}
// A bounded window is aggregated from the audit files, because the
// in-memory aggregates are lifetime totals and the ring buffer holds only
// maxRecs records (500 by default). Reading the ring would silently
// under-report any period longer than the last few hundred requests.
total := Stat{}
byKey := map[string]*Stat{}
byModel := map[string]*Stat{}
bySrc := map[string]*Stat{}
byStatus := map[string]*Stat{}
buckets := map[string]*Stat{}
var seen int
err := s.StreamAuditRecords(from, 0, key, func(r Req) error {
seen++
incStatus(&total, "", r)
inc(byKey, r.Key, r)
if r.Model != "" {
inc(byModel, r.Model, r)
}
inc(bySrc, r.Source, r)
if r.Status != 0 {
inc(byStatus, strconv.Itoa(r.Status), r)
}
k := bucketKey(p, r.Time)
b := buckets[k]
if b == nil {
b = &Stat{}
buckets[k] = b
}
incStatus(b, k, r)
return nil
})
out.Total = total
out.ByKey = rows(byKey)
out.Models = rows(byModel)
out.Srcs = rows(bySrc)
bs := make([]agrRow, 0, len(byStatus))
for code, st := range byStatus {
bs = append(bs, agrRow{Name: code, Stat: *st})
}
sort.Slice(bs, func(i, j int) bool {
ci, _ := strconv.Atoi(bs[i].Name)
cj, _ := strconv.Atoi(bs[j].Name)
return ci < cj
})
out.Status = bs
ks := make([]string, 0, len(buckets))
for k := range buckets {
ks = append(ks, k)
}
// Chronological, string-sorted: "2006-01-02T15" and "2006-01-02" both
// sort lexicographically in time order, so no date parsing is needed.
sort.Strings(ks)
out.Bucket = make([]PeriodBucket, 0, len(ks))
for _, k := range ks {
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]})
}
// The window is only "complete" if the audit walk actually reached back
// far enough to cover it. Two ways it cannot:
//
// 1. The walk found nothing at all in a window that certainly had
// traffic, because the files holding it rotated away.
// 2. The walk errored part-way (I/O), leaving a partial total.
//
// Case 2 is reported from err; case 1 from seen == 0 combined with the
// caller having asked for a bounded window. It is deliberately
// conservative: a genuinely empty hour is rare enough that flagging it as
// possibly-truncated costs one tooltip, whereas silently under-reporting a
// month because rotation ate it is a wrong number with no indication.
if err != nil || (seen == 0 && p != PeriodAll) {
out.Truncated = true
}
return out
}
// periodFromQuery parses the ?period= parameter. An empty value means "all" so
// that existing callers of /api/stats keep seeing exactly what they saw.
// A malformed value is rejected by the caller (ValidPeriod) rather than
// defaulting, so a typo in a bookmarked URL surfaces as an error instead of
// silently switching the operator to lifetime totals.
func periodFromQuery(q map[string][]string) Period {
v := strings.ToLower(strings.TrimSpace(firstQuery(q, "period")))
if v == "" {
return PeriodAll
}
return Period(v)
}
func firstQuery(q map[string][]string, key string) string {
if vs, ok := q[key]; ok && len(vs) > 0 {
return vs[0]
}
return ""
}