Files
ModelRouter/internal/gateway/stats_period.go
JianFeeeee 7836b95a84 fix(gateway): ?key= 传完整 key 会让 /api/stats panic
给 usage 插件探路时撞出来的,与插件无关。

Snapshot 的统计 map 以**掩码** key id 为键(keyID(req.Key))——by_key 返回什么、
面板就传什么。而 exportKey 对 admin 允许 ?key= 任意值,于是:

    byKey = map[string]*Stat{key: s.byKey[key]}   // 传完整 key → 零值 nil *Stat
    ...
    rows() 里 Stat: *v                          // 解引用 nil → panic

实测(改动前,线上):

    /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
    → curl: (52) Empty reply from server
    → 日志: http: panic serving …: invalid memory address or nil pointer dereference
        llmsproxy/internal/gateway.rows  stats.go:771

net/http 会 recover 单个连接所以进程不死,但客户端拿到空响应,日志里每次一条
panic 栈。任何「这个 key 用了多少」的问法(key 已配但当天无流量、period 把它
的历史滤掉了)都会命中 —— 而零正是那个问题的答案,不是错误。

三处防御,因为同一个错误形状有四个入口:
- rows() 跳过 nil 而不是解引用(调用方不止 Snapshot)
- byStatus 的两处遍历同理
- PeriodSnapshot 的 buckets[k] 同理

by_key 仍保留该 key 的一行零值:面板按行存在与否渲染,缺行会让 key 整个消失。

判据:internal/gateway/stats_unknown_key_test.go(5 个测试)。反向验证过——
把 Snapshot 与 rows 的修复撤掉,TestSnapshotUnknownKeyDoesNotPanic 立刻 panic。

(cherry picked from commit af977efbf7)
2026-10-07 17:37:22 +08:00

242 lines
8.7 KiB
Go
Raw Permalink 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 {
if st == nil {
continue
}
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 {
// Defensive for the same reason rows() is: ks comes from the bucket map's
// keys, and a nil value would panic on the deref below.
if st := buckets[k]; st != nil {
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *st})
}
}
// 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 ""
}