mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
给 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)
242 lines
8.7 KiB
Go
242 lines
8.7 KiB
Go
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 ""
|
||
}
|