Files
ModelRouter/internal/gateway/stats_period_test.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

322 lines
12 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// auditPeriod writes records straight into an audit file, which is what the
// period aggregator reads. Going through Record() would also rotate/prune the
// file; here the file shape is the thing under test.
func auditPeriod(t *testing.T, path string, recs ...Req) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create audit file: %v", err)
}
defer f.Close()
for _, r := range recs {
if r.Time == 0 {
t.Fatal("test record needs an explicit timestamp")
}
if r.Type == "" {
r.Type = "chat"
}
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal record: %v", err)
}
if _, err := f.Write(append(b, '\n')); err != nil {
t.Fatalf("write record: %v", err)
}
}
}
func periodStats(t *testing.T, auditPath string) *Stats {
t.Helper()
s := NewStats(10)
s.mu.Lock()
s.auditPath = auditPath
s.mu.Unlock()
return s
}
// TestPeriodWindowBoundaries is the boundary contract: a day window covers
// exactly today-from-midnight, a week window starts Monday (not Sunday — the
// Go Weekday() trap), and a month window starts on the 1st. All UTC.
func TestPeriodWindowBoundaries(t *testing.T) {
// Wednesday 2026-03-11 15:30 UTC.
now := time.Date(2026, 3, 11, 15, 30, 0, 0, time.UTC)
day := time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC)
if got := time.UnixMilli(periodStart(PeriodDay, now)).UTC(); !got.Equal(day) {
t.Errorf("day window starts %s, want %s", got, day)
}
// Wednesday minus 2 days = Monday the 9th.
monday := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC)
if got := time.UnixMilli(periodStart(PeriodWeek, now)).UTC(); !got.Equal(monday) {
t.Errorf("week window starts %s, want Monday %s", got, monday)
}
// Same instant on a Sunday must still start on the PRECEDING Monday,
// never on the Sunday itself. This is the off-by-one that
// int(now.Weekday()) would introduce (Sunday=0 → no shift).
sun := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) // Sunday
prevMon := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC)
if got := time.UnixMilli(periodStart(PeriodWeek, sun)).UTC(); !got.Equal(prevMon) {
t.Errorf("Sunday week window starts %s, want the Monday before it %s", got, prevMon)
}
first := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
if got := time.UnixMilli(periodStart(PeriodMonth, now)).UTC(); !got.Equal(first) {
t.Errorf("month window starts %s, want %s", got, first)
}
if got := periodStart(PeriodAll, now); got != 0 {
t.Errorf("all window from = %d, want 0 (unbounded)", got)
}
}
// TestPeriodAggregationExcludesOlderRecords is the reason the aggregator reads
// the audit file: a record outside the window must not be counted, even though
// it sits in the very same file, right next to records that are.
func TestPeriodAggregationExcludesOlderRecords(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC)
h := func(n int) int64 { return now.Add(-time.Duration(n) * time.Hour).UnixMilli() }
auditPeriod(t, path,
// inside today
Req{Time: h(1), Model: "m", Source: "s", Prompt: 100, Compl: 10, LatMs: 5, OK: true},
Req{Time: h(2), Model: "m", Source: "s", Prompt: 200, Compl: 20, LatMs: 7, OK: true},
// outside today (yesterday) but inside the week
Req{Time: now.AddDate(0, 0, -1).UnixMilli(), Model: "old", Source: "s", Prompt: 999, Compl: 99, OK: true},
)
s := periodStats(t, path)
day := s.PeriodSnapshot(PeriodDay, "", now)
if day.Total.Reqs != 2 {
t.Errorf("day window counted %d requests, want 2 (yesterday's record must be excluded)", day.Total.Reqs)
}
if day.Total.Prompt != 300 {
t.Errorf("day prompt tokens = %d, want 300", day.Total.Prompt)
}
week := s.PeriodSnapshot(PeriodWeek, "", now)
if week.Total.Reqs != 3 {
t.Errorf("week window counted %d requests, want 3", week.Total.Reqs)
}
if week.Total.Prompt != 1299 {
t.Errorf("week prompt tokens = %d, want 1299", week.Total.Prompt)
}
}
// TestPeriodDimensionsAggregateIndependently checks the by_* rows actually
// split by their own key rather than all collapsing into one row.
func TestPeriodDimensionsAggregateIndependently(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
auditPeriod(t, path,
Req{Time: now.UnixMilli(), Key: "k1", Model: "alpha", Source: "s1", Prompt: 10, OK: true},
Req{Time: now.UnixMilli(), Key: "k1", Model: "beta", Source: "s2", Prompt: 20, OK: true},
Req{Time: now.UnixMilli(), Key: "k2", Model: "alpha", Source: "s1", Prompt: 30, OK: true},
)
s := periodStats(t, path)
got := s.PeriodSnapshot(PeriodDay, "", now)
if len(got.Models) != 2 {
t.Fatalf("by_model has %d rows, want 2: %+v", len(got.Models), got.Models)
}
// alpha = 10 + 30
for _, r := range got.Models {
if r.Name == "alpha" && r.Prompt != 40 {
t.Errorf("alpha prompt = %d, want 40", r.Prompt)
}
}
if len(got.Srcs) != 2 {
t.Errorf("by_source has %d rows, want 2: %+v", len(got.Srcs), got.Srcs)
}
if len(got.ByKey) != 2 {
t.Errorf("by_key has %d rows, want 2: %+v", len(got.ByKey), got.ByKey)
}
// The rows must add up to the total, or the dashboard shows a total that
// disagrees with its own table.
var sum int64
for _, r := range got.Models {
sum += r.Prompt
}
if sum != got.Total.Prompt {
t.Errorf("by_model prompts sum to %d but total is %d — the table would contradict the KPI", sum, got.Total.Prompt)
}
}
// TestPeriodDayBucketsAreHourlyAndOrdered pins the timeline grain and its
// order. Day = hourly (a spike must be visible inside "today"); week/month =
// daily. Sorted chronologically, because a chart fed unsorted buckets draws
// nonsense.
func TestPeriodDayBucketsAreHourlyAndOrdered(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC)
// Deliberately out of order in the file.
auditPeriod(t, path,
Req{Time: now.Add(-2 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true},
Req{Time: now.Add(-4 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true},
Req{Time: now.UnixMilli(), Model: "m", Prompt: 1, OK: true},
)
s := periodStats(t, path)
got := s.PeriodSnapshot(PeriodDay, "", now)
if len(got.Bucket) != 3 {
t.Fatalf("day buckets = %d, want 3 (hourly): %+v", len(got.Bucket), got.Bucket)
}
for i := 1; i < len(got.Bucket); i++ {
if got.Bucket[i-1].Bucket >= got.Bucket[i].Bucket {
t.Errorf("day buckets not chronological at %d: %q >= %q",
i, got.Bucket[i-1].Bucket, got.Bucket[i].Bucket)
}
}
// Weekly grain is daily, so the same three records collapse to one day.
week := s.PeriodSnapshot(PeriodWeek, "", now)
if len(week.Bucket) != 1 {
t.Errorf("week buckets = %d, want 1 (daily grain): %+v", len(week.Bucket), week.Bucket)
}
}
// TestPeriodKeyFilterIsolatesKeys guards the multi-tenant boundary: asking for
// one key must never return another key's rows in any dimension.
func TestPeriodKeyFilterIsolatesKeys(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
auditPeriod(t, path,
Req{Time: now.UnixMilli(), Key: "alice", Model: "m", Source: "s", Prompt: 10, OK: true},
Req{Time: now.UnixMilli(), Key: "bob", Model: "m", Source: "s", Prompt: 999, OK: true},
)
s := periodStats(t, path)
got := s.PeriodSnapshot(PeriodDay, "alice", now)
if got.Total.Reqs != 1 || got.Total.Prompt != 10 {
t.Errorf("alice's window = %d reqs / %d prompt, want 1 / 10 — another key leaked in",
got.Total.Reqs, got.Total.Prompt)
}
if len(got.Srcs) != 1 || got.Srcs[0].Prompt != 10 {
t.Errorf("by_source leaked: %+v", got.Srcs)
}
}
// TestPeriodAllUsesLifetimeAggregates checks "all" still answers from the
// in-memory aggregates (no audit file needed) and carries the same rows the
// dashboard already renders.
func TestPeriodAllUsesLifetimeAggregates(t *testing.T) {
s := NewStats(100)
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
s.Record(Req{Time: now.AddDate(0, 0, -40).UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 7, OK: true})
s.Record(Req{Time: now.UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 3, OK: true})
got := s.PeriodSnapshot(PeriodAll, "", now)
if got.Total.Reqs != 2 || got.Total.Prompt != 10 {
t.Errorf("all view = %d reqs / %d prompt, want 2 / 10 (lifetime)", got.Total.Reqs, got.Total.Prompt)
}
if len(got.Models) != 1 || got.Models[0].Prompt != 10 {
t.Errorf("all view by_model = %+v, want one row with 10 prompt", got.Models)
}
// A lifetime total has no timeline to draw; buckets must stay empty
// rather than inventing 500 hourly points.
if len(got.Bucket) != 0 {
t.Errorf("all view produced %d buckets, want 0", len(got.Bucket))
}
}
// TestPeriodEmptyWindowFlagsTruncated covers the "rotated away" case: a bounded
// window with nothing to show may mean "quiet day" or "the files are gone".
// The aggregator must not report a confidently wrong zero.
func TestPeriodEmptyWindowFlagsTruncated(t *testing.T) {
dir := t.TempDir()
s := periodStats(t, filepath.Join(dir, "audit.jsonl")) // file does not exist
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
got := s.PeriodSnapshot(PeriodDay, "", now)
if got.Total.Reqs != 0 {
t.Errorf("empty window reqs = %d, want 0", got.Total.Reqs)
}
if !got.Truncated {
t.Error("★ an empty bounded window must be flagged truncated — otherwise a " +
"rotated-away week reads as 'no traffic that week'")
}
}
// TestValidPeriodRejectsUnknown guards the deliberate strictness: a typo must
// be an error, not a silent fallback to lifetime totals.
func TestValidPeriodRejectsUnknown(t *testing.T) {
for _, ok := range []Period{PeriodDay, PeriodWeek, PeriodMonth, PeriodAll} {
if !ValidPeriod(ok) {
t.Errorf("ValidPeriod(%q) = false, want true", ok)
}
}
for _, bad := range []Period{"", "year", "hour", "today", "DAY "} {
if ValidPeriod(bad) {
t.Errorf("ValidPeriod(%q) = true, want false", bad)
}
}
}
// TestPeriodStatusDimensionCountsFailures covers by_status, which nothing else
// exercised — an aggregation branch with no test is exactly how a period view
// silently loses the failure pie. M8 (disabling the status dimension) is
// caught only because this test exists.
func TestPeriodStatusDimensionCountsFailures(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
auditPeriod(t, path,
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 200, OK: true, Prompt: 5},
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1},
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1},
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 429, OK: false, Prompt: 1},
)
s := periodStats(t, path)
got := s.PeriodSnapshot(PeriodDay, "", now)
byCode := map[string]int64{}
for _, r := range got.Status {
byCode[r.Name] = r.Reqs
}
if len(got.Status) != 3 {
t.Fatalf("by_status has %d rows, want 3 (200/502/429): %+v", len(got.Status), got.Status)
}
if byCode["200"] != 1 || byCode["502"] != 2 || byCode["429"] != 1 {
t.Errorf("by_status counts = %v, want 200:1 502:2 429:1", byCode)
}
// Status codes must sort numerically: "429" before "502" lexically would
// render the failure legend in the wrong order.
if got.Status[0].Name != "200" || got.Status[1].Name != "429" || got.Status[2].Name != "502" {
t.Errorf("by_status not numerically sorted: %s, %s, %s",
got.Status[0].Name, got.Status[1].Name, got.Status[2].Name)
}
// The failure rows must agree with the OK/Err split on the total, or the
// pie and the KPI row contradict each other.
if got.Total.OK != 1 || got.Total.Err != 3 {
t.Errorf("total ok/err = %d/%d, want 1/3", got.Total.OK, got.Total.Err)
}
}
func TestPeriodFromQuery(t *testing.T) {
cases := []struct {
query string
want Period
}{
{"", PeriodAll}, // no selector → unchanged legacy behaviour
{"?period=day", PeriodDay},
{"?period=WEEK", PeriodWeek},
{"?period=month", PeriodMonth},
{"?period=all", PeriodAll},
}
for _, c := range cases {
r := httptest.NewRequest(http.MethodGet, "/api/stats"+c.query, nil)
if got := periodFromQuery(r.URL.Query()); got != c.want {
t.Errorf("periodFromQuery(%q) = %q, want %q", c.query, got, c.want)
}
}
}