Files
ModelRouter/internal/gateway/stats.go
JianFeeeee 3ddae41f0c fix(gateway): aggregate the full audit history, repair record paging
Two problems reported after the on-demand log work landed.

1. Dashboard totals were wrong. LoadAudit only replayed the last 4 MB of the
   audit file, so requests/tokens/per-key rows reflected a window instead of all
   time — a regression in reported numbers, not just in presentation.

   The aggregates are now built by streaming EVERY audit file (oldest first, so
   the hourly quota buckets keep their intended trailing window) and keeping
   nothing per record: aggregate maps are keyed by key/model/source, so their
   size is bounded by cardinality. Measured on the production host: 29 MB /
   221k lines / 37k requests in ~260 ms at startup.

   What stays bounded is the RAW-record ring: a fixed-size reqRing keeps only the
   newest maxRecs records, so the ~25 MB that used to be spent appending every
   record into a slice is still saved. auditReplayBytes is gone, and
   replayPartial now means "an audit file could not be read", which is the only
   remaining way for the totals to be incomplete.

2. Scrolling to the bottom stopped loading more records. Two independent causes:

   * paintRecords rebuilt the entire table on every 5s poll whenever the row
     count did not exceed the first screen — the "is a paged view live?" test
     compared row counts and matched exactly on the first refresh — wiping loaded
     pages and resetting scroll position.
   * IntersectionObserver only fires on TRANSITIONS. With a short list, or after a
     page whose rows all duplicated the first screen, the sentinel stayed visible
     and never fired again.

   paintRecords now builds once (recsState.built) and later polls PREPEND only
   genuinely new rows; attachRecsObserver adds a scroll-position fallback;
   fillRecordsViewport loads until the list actually overflows; and
   loadMoreRecords chains (bounded) when a page yields no new rows, since the
   first fetch necessarily overlaps the first screen.

TestUIRecordsPagingWiring pins all four mechanisms structurally, since none of
them is reachable from Go. Test names/comments referring to bounded replay are
updated to describe the bounded RING instead, and both READMEs now state that
totals come from the full history while records are paged.
2026-08-30 09:29:42 +08:00

955 lines
26 KiB
Go

package gateway
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Req is one recorded gateway request (audit trail + per-key/per-model stats).
type Req struct {
Time int64 `json:"time"` // unix milliseconds
Key string `json:"key"` // gateway key id
Type string `json:"type"` // chat | stream | image
Model string `json:"model"` // effective model used upstream
// Source provider/source name
Source string `json:"source"`
// Prompt prompt tokens
Prompt int64 `json:"prompt_tokens"`
// Compl completion tokens
Compl int64 `json:"completion_tokens"`
// LatMs total handling time ms
LatMs int64 `json:"latency_ms"`
// FirstByteMs time-to-first-byte for streaming (ms from request start
// to the first SSE chunk sent to the client); for non-streaming it
// equals LatMs. 0 when unmeasured (legacy records).
FirstByteMs int64 `json:"first_byte_ms,omitempty"`
// CacheHit / CacheMiss carry the upstream prompt-cache accounting
// (DeepSeek-style hit/miss tokens) when the upstream reports it.
// Both 0 = upstream gave no cache data.
CacheHit int64 `json:"cache_hit_tokens,omitempty"`
CacheMiss int64 `json:"cache_miss_tokens,omitempty"`
// CacheReported marks that the upstream usage reported cache
// accounting at all (even when the hit count is 0). The WebUI shows
// "0%" instead of "—" for such rows.
CacheReported bool `json:"cache_reported,omitempty"`
OK bool `json:"ok"`
// Status http status code
Status int `json:"status"`
// Err short error message
Err string `json:"error,omitempty"`
}
// Stat aggregates counters for one dimension row.
type Stat struct {
Reqs int64 `json:"reqs"`
OK int64 `json:"ok"`
Err int64 `json:"err"`
Tokens int64 `json:"tokens"`
Prompt int64 `json:"prompt_tokens"`
Compl int64 `json:"completion_tokens"`
LatSum int64 `json:"latency_sum_ms"`
LatMax int64 `json:"latency_max_ms"`
FirstByteSum int64 `json:"first_byte_sum_ms,omitempty"`
}
type agrRow struct {
Name string `json:"name"`
Stat
}
// Stats collects per-key / per-model / per-source aggregates plus a bounded
// ring of raw request records, all guarded by one mutex.
type Stats struct {
mu sync.Mutex
active int64
byKey map[string]*Stat
byModel map[string]*Stat
bySrc map[string]*Stat
byKeyModel map[string]map[string]*Stat
byKeySrc map[string]map[string]*Stat
byStatus map[int]*Stat // per http status code aggregates (incl. 402/400)
recs []Req
maxRecs int
auditPath string
replayPartial bool // aggregates built from a bounded audit tail
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
}
const hourSec = 3600
// auditRotateBytes rotates the audit file once it grows past this size (the
// file is renamed to <path>.<unix>.old and a fresh one is started); pruning
// keeps at most auditKeepOld rotated files. Both are vars so tests can shrink
// the threshold.
//
// The single-file threshold is deliberately small relative to the total budget
// (16 MB x 16 = 256 MB): records are paged by reading a file backwards, and a
// smaller file keeps the first page from seeking through a huge one.
var (
auditRotateBytes int64 = 16 << 20
auditKeepOld = 16
)
// defaultRingSize is how many recent request records stay resident. It has to
// cover two consumers: the status page's 5-minute source windows
// (SourceRecent/SourceAverages) and the first screen of the records table.
// Everything beyond that is paged from the audit file.
const defaultRingSize = 500
// firstScreenRecords is how many records a dashboard load ships by default — one
// screen's worth. Scrolling pulls the rest through /api/stats/records.
const firstScreenRecords = 100
func NewStats(maxRecords int) *Stats {
if maxRecords <= 0 {
maxRecords = defaultRingSize
}
return &Stats{
byKey: map[string]*Stat{},
byModel: map[string]*Stat{},
bySrc: map[string]*Stat{},
byKeyModel: map[string]map[string]*Stat{},
byKeySrc: map[string]map[string]*Stat{},
byStatus: map[int]*Stat{},
modelHour: map[string]map[int64]int64{},
maxRecs: maxRecords,
}
}
// Begin accounts an in-flight request; the returned func must be called once
// the request finished (defer ok).
func (s *Stats) Begin() func() {
s.mu.Lock()
s.active++
s.mu.Unlock()
return func() {
s.mu.Lock()
s.active--
s.mu.Unlock()
}
}
func inc(m map[string]*Stat, name string, r Req) {
a := m[name]
if a == nil {
a = &Stat{}
m[name] = a
}
incStatus(a, name, r)
}
func incStatus(a *Stat, name string, r Req) {
a.Reqs++
if r.OK {
a.OK++
} else {
a.Err++
}
a.Tokens += r.Prompt + r.Compl
a.Prompt += r.Prompt
a.Compl += r.Compl
a.LatSum += r.LatMs
if r.LatMs > a.LatMax {
a.LatMax = r.LatMs
}
if r.FirstByteMs > 0 {
a.FirstByteSum += r.FirstByteMs
}
}
// LoadAudit primes the in-memory state from the audit file.
//
// The aggregates (totals, per-key/model/source rows, status counts and the
// hourly quota buckets) are built from the FULL history: every audit file is
// streamed oldest-first so the dashboard shows real all-time numbers rather than
// whatever happened to fit in a replay window. This is affordable because the
// scan keeps nothing per record — aggregate maps are keyed by key/model/source,
// so their size is bounded by cardinality, not by request count. Measured on the
// production host: 29 MB / 221k lines / 37k requests in ~260 ms.
//
// The raw-record ring is what stays bounded: only the newest maxRecs records are
// retained, and everything older is paged from disk on demand by AuditPage /
// StreamAuditRecords. The old behaviour — appending EVERY record into a slice
// and truncating at the end — is what cost ~25 MB of resident memory.
func (s *Stats) LoadAudit(path string) {
s.mu.Lock()
s.auditPath = path
s.mu.Unlock()
// Oldest-first: the hourly-bucket retention prunes relative to the newest
// hour seen so far, so replaying in chronological order keeps exactly the
// intended trailing window.
chain := s.auditChain()
files := make([]string, 0, len(chain))
for i := len(chain) - 1; i >= 0; i-- {
files = append(files, chain[i])
}
ring := newReqRing(s.ringSize())
scanned := 0
s.mu.Lock()
for _, p := range files {
n, err := scanAuditFile(p, func(r Req) {
s.aggregateLocked(r)
ring.push(r)
})
scanned += n
if err != nil {
// A truncated or unreadable tail is not fatal: keep whatever was
// aggregated and mark the numbers as incomplete.
s.replayPartial = true
}
}
s.recs = ring.slice()
s.mu.Unlock()
if scanned > 0 {
log.Printf("[stats] replayed %d audit records from %d file(s) for aggregates; keeping the newest %d in memory",
scanned, len(files), len(s.recs))
}
}
func (s *Stats) ringSize() int {
s.mu.Lock()
defer s.mu.Unlock()
if s.maxRecs <= 0 {
return defaultRingSize
}
return s.maxRecs
}
// scanAuditFile streams one audit file, invoking fn for every request row, and
// returns how many request rows it saw. Access/event rows and malformed lines
// are skipped.
func scanAuditFile(path string, fn func(Req)) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
sc := bufio.NewScanner(f)
// tolerate long error summaries / oversized junk lines
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
n := 0
for sc.Scan() {
var r Req
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue
}
fn(r)
n++
}
return n, sc.Err()
}
// reqRing keeps the newest n records seen, in chronological order, without
// growing with the number of records pushed through it.
type reqRing struct {
buf []Req
next int
full bool
limit int
}
func newReqRing(n int) *reqRing {
if n <= 0 {
n = defaultRingSize
}
return &reqRing{buf: make([]Req, n), limit: n}
}
func (r *reqRing) push(rec Req) {
r.buf[r.next] = rec
r.next++
if r.next == r.limit {
r.next = 0
r.full = true
}
}
// slice returns the retained records oldest-first.
func (r *reqRing) slice() []Req {
if !r.full {
out := make([]Req, r.next)
copy(out, r.buf[:r.next])
return out
}
out := make([]Req, 0, r.limit)
out = append(out, r.buf[r.next:]...)
out = append(out, r.buf[:r.next]...)
return out
}
// ReplayPartial reports whether the resident aggregates are known to be
// incomplete (an audit file could not be read in full). Under normal operation
// the aggregates cover the entire audit history, so this is false.
func (s *Stats) ReplayPartial() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.replayPartial
}
// Record appends a finished request to the aggregates and ring buffer.
func (s *Stats) Record(r Req) {
s.mu.Lock()
defer s.mu.Unlock()
s.aggregateLocked(r)
s.recs = append(s.recs, r)
if len(s.recs) > s.maxRecs {
s.recs = s.recs[len(s.recs)-s.maxRecs:]
}
if s.auditPath != "" {
s.rotateAuditLocked()
appendAuditLine(s.auditPath, r)
}
}
// aggregateLocked folds r into every aggregate row and the quota window
// bucket. Caller must hold s.mu.
func (s *Stats) aggregateLocked(r Req) {
inc(s.byKey, r.Key, r)
if r.Model != "" {
inc(s.byModel, r.Model, r)
}
inc(s.bySrc, r.Source, r)
km := s.byKeyModel[r.Key]
if km == nil {
km = map[string]*Stat{}
s.byKeyModel[r.Key] = km
}
if r.Model != "" {
inc(km, r.Model, r)
}
ks := s.byKeySrc[r.Key]
if ks == nil {
ks = map[string]*Stat{}
s.byKeySrc[r.Key] = ks
}
inc(ks, r.Source, r)
if r.Status > 0 {
a := s.byStatus[r.Status]
if a == nil {
a = &Stat{}
s.byStatus[r.Status] = a
}
incStatus(a, strconv.Itoa(r.Status), r)
}
// window bucket for quota enforcement (per source-model pair, per unix
// hour). r.Time is unix MILLISECONDS (audit format); hourSec is seconds,
// so convert before bucketing — otherwise the bucket width would be
// 3.6s and every WindowTokens cutoff comparison would be off by ~1000x.
tok := r.Prompt + r.Compl
if tok > 0 && r.Model != "" {
key := r.Model
if r.Source != "" {
key = r.Source + "::" + r.Model
}
h := (r.Time / 1000) / hourSec
hm := s.modelHour[key]
if hm == nil {
hm = map[int64]int64{}
s.modelHour[key] = hm
}
hm[h] += tok
// retention: 24*40 = 960 hourly buckets ≈ 40 days of history (covers
// the longest "month" quota window)
if len(hm) > 24*40 {
for k := range hm {
if k < h-24*40 {
delete(hm, k)
}
}
}
}
}
// rotateAuditLocked renames the audit file to <path>.<unix>.old once it
// exceeds auditRotateBytes and prunes old files beyond auditKeepOld, keeping
// the newest ones. Caller must hold s.mu.
func (s *Stats) rotateAuditLocked() {
if s.auditPath == "" || auditRotateBytes <= 0 {
return
}
if fi, err := os.Stat(s.auditPath); err == nil && fi.Size() < auditRotateBytes {
return
}
ts := time.Now().Unix()
if os.Rename(s.auditPath, fmt.Sprintf("%s.%d.old", s.auditPath, ts)) == nil {
old, _ := filepath.Glob(s.auditPath + ".*.old")
sort.Sort(sort.Reverse(sort.StringSlice(old)))
for i := auditKeepOld; i < len(old); i++ {
_ = os.Remove(old[i])
}
}
}
func appendAuditLine(path string, row interface{}) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
if b, err := json.Marshal(row); err == nil {
_, _ = f.Write(append(b, '\n'))
}
}
// AppendAudit writes a generic event line (access log entry, login event,
// config change, …) to the same audit file without touching the aggregates.
func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()}
for k, v := range data {
row[k] = v
}
s.mu.Lock()
defer s.mu.Unlock()
if s.auditPath == "" {
return
}
s.rotateAuditLocked()
appendAuditLine(s.auditPath, row)
}
// ModelTokens returns the tokens consumed per model for one gateway key id
// (used for per-model token quota enforcement).
func (s *Stats) ModelTokens(key string) map[string]int64 {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string]int64{}
for k, v := range s.byKeyModel[key] {
out[k] = v.Tokens
}
return out
}
// KeyTokens returns the total tokens consumed by one gateway key id.
func (s *Stats) KeyTokens(key string) int64 {
s.mu.Lock()
defer s.mu.Unlock()
a := s.byKey[key]
if a == nil {
return 0
}
return a.Tokens
}
// AutoPeriodSeconds maps a quota reset period to its window length in
// seconds. "" → 0 (never resets); "hour" → 1h; "week" → 7d; "month" → 30d;
// "nhour" → Hours (>=1) hours.
func AutoPeriodSeconds(period string, hours int64) int64 {
switch period {
case "hour":
return hourSec
case "week":
return 7 * 24 * hourSec
case "month":
return 30 * 24 * hourSec
case "nhour":
if hours < 1 {
hours = 1
}
return hours * hourSec
}
return 0
}
// WindowTokens returns the tokens consumed for one model (optionally pinned
// to a single source) within the window; sec <= 0 means all time. Buckets are
// whole unix hours, so a sliding window overcounts by up to one hour — an
// accepted truncation for quota enforcement.
func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
s.mu.Lock()
defer s.mu.Unlock()
key := model
if source != "" {
key = source + "::" + model
}
now := time.Now().Unix()
hm := s.modelHour[key]
if len(hm) == 0 {
return 0
}
var total int64
if sec <= 0 {
for _, v := range hm {
total += v
}
return total
}
cut := now - sec
for h, v := range hm {
if h*hourSec >= cut {
total += v
}
}
return total
}
func rows(m map[string]*Stat) []StatsRow {
out := make([]StatsRow, 0, len(m))
for k, v := range m {
out = append(out, StatsRow{Name: k, Stat: *v})
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j].Reqs > out[j-1].Reqs; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// StatsRow is one aggregated row for the dashboard.
type StatsRow struct {
Name string `json:"name"`
Stat
}
// AuditRecords returns every request row from the audit file plus its rotated
// .old files within the [from,to] unix-millisecond window, optionally for one
// masked key id, sorted oldest-first.
//
// It materializes the whole window, so it is only for tests and small windows.
// Production paths must use StreamAuditRecords (O(1) memory, for CSV export) or
// AuditPage (bounded pages, for the records view).
func (s *Stats) AuditRecords(from, to int64, key string) []Req {
var out []Req
_ = s.StreamAuditRecords(from, to, key, func(r Req) error {
out = append(out, r)
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
return out
}
// ---- on-demand audit paging ----
//
// The records view is paged straight off disk instead of being held in memory:
// the newest page is read from the tail of the newest audit file backwards, and
// each response hands back a cursor for the next page. Nothing is cached between
// requests, so "release when the user leaves the page" is guaranteed by never
// retaining anything in the first place.
// auditCursor points at a byte offset inside one audit file. Paging walks
// backwards, so the cursor means "continue reading BEFORE this offset".
type auditCursor struct {
File string
Offset int64
}
// String encodes the cursor for the wire as "<file>:<offset>".
func (c auditCursor) String() string {
if c.File == "" {
return ""
}
return c.File + ":" + strconv.FormatInt(c.Offset, 10)
}
// parseAuditCursor decodes a wire cursor. An empty or malformed value means
// "start at the newest record".
func parseAuditCursor(s string) (auditCursor, bool) {
if s == "" {
return auditCursor{}, false
}
i := strings.LastIndex(s, ":")
if i <= 0 || i == len(s)-1 {
return auditCursor{}, false
}
off, err := strconv.ParseInt(s[i+1:], 10, 64)
if err != nil || off < 0 {
return auditCursor{}, false
}
return auditCursor{File: s[:i], Offset: off}, true
}
// auditChain lists the audit files newest-first: the live file, then the rotated
// ones in descending timestamp order.
func (s *Stats) auditChain() []string {
s.mu.Lock()
path := s.auditPath
s.mu.Unlock()
if path == "" {
return nil
}
files := []string{path}
if olds, err := filepath.Glob(path + ".*.old"); err == nil {
sort.Sort(sort.Reverse(sort.StringSlice(olds)))
files = append(files, olds...)
}
return files
}
// AuditPageResult is one page of records plus the cursor to continue from.
type AuditPageResult struct {
Records []Req `json:"records"`
Next string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
// Rotated marks that the requested cursor's file no longer exists (the
// audit log rotated under the reader), so the client should restart from
// the newest page instead of silently skipping records.
Rotated bool `json:"rotated,omitempty"`
}
// revChunk is how much is read per backwards seek. Lines are far shorter than
// this, so a page is typically satisfied by one or two chunks.
const revChunk = 64 * 1024
// AuditPage returns up to limit request records ending at cursor, newest first.
// It reads the audit files backwards and stops as soon as the page is full, so
// cost and memory are proportional to limit rather than to the file size. The
// file handle is closed before returning: no state is kept between calls.
func (s *Stats) AuditPage(cursor string, limit int, key string) AuditPageResult {
if limit <= 0 || limit > 1000 {
limit = 100
}
chain := s.auditChain()
if len(chain) == 0 {
return AuditPageResult{Records: []Req{}}
}
start := 0
var offset int64 = -1 // -1 = start at EOF
rotated := false
if c, ok := parseAuditCursor(cursor); ok {
idx := -1
for i, f := range chain {
if f == c.File {
idx = i
break
}
}
if idx < 0 {
// the cursor's file rotated away: restart from the newest page and
// tell the client so it can reset its view
rotated = true
} else {
start, offset = idx, c.Offset
}
}
out := make([]Req, 0, limit)
for i := start; i < len(chain); i++ {
next, err := s.pageFile(chain[i], offset, limit, key, &out)
offset = -1 // subsequent files always start at their EOF
if err != nil {
continue
}
if len(out) >= limit {
res := AuditPageResult{Records: out, Rotated: rotated}
if next > 0 {
res.Next = auditCursor{File: chain[i], Offset: next}.String()
res.HasMore = true
} else if i+1 < len(chain) {
res.Next = auditCursor{File: chain[i+1], Offset: fileSize(chain[i+1])}.String()
res.HasMore = true
}
return res
}
}
return AuditPageResult{Records: out, HasMore: false, Rotated: rotated}
}
func fileSize(path string) int64 {
fi, err := os.Stat(path)
if err != nil {
return 0
}
return fi.Size()
}
// pageFile reads one audit file backwards from end (or EOF when end < 0),
// appending matching records to out (newest first) until it holds limit entries.
// It returns the offset to continue from within this file, or 0 when the file is
// exhausted.
func (s *Stats) pageFile(path string, end int64, limit int, key string, out *[]Req) (int64, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close() // released before the response is written: nothing is retained
fi, err := f.Stat()
if err != nil {
return 0, err
}
if end < 0 || end > fi.Size() {
end = fi.Size()
}
buf := make([]byte, 0, revChunk)
var tail []byte // bytes of a line whose start was not in this chunk
pos := end
for pos > 0 && len(*out) < limit {
size := int64(revChunk)
if size > pos {
size = pos
}
pos -= size
buf = buf[:size]
if _, err := f.ReadAt(buf, pos); err != nil && err != io.EOF {
return 0, err
}
chunk := buf
if len(tail) > 0 {
chunk = append(append(make([]byte, 0, len(buf)+len(tail)), buf...), tail...)
tail = tail[:0]
}
// walk the chunk's complete lines from the end towards the start
lineEnd := len(chunk)
for i := len(chunk) - 1; i >= 0; i-- {
if chunk[i] != '\n' {
continue
}
if rec, ok := decodeAuditLine(chunk[i+1:lineEnd], key); ok {
*out = append(*out, rec)
if len(*out) >= limit {
// resume at the newline we just consumed
return pos + int64(i) + 1, nil
}
}
lineEnd = i
}
// chunk[:lineEnd] is an incomplete line: carry it into the next chunk
if lineEnd > 0 {
tail = append(tail[:0], chunk[:lineEnd]...)
}
if pos == 0 {
// start of file: whatever is carried is a complete first line
if rec, ok := decodeAuditLine(tail, key); ok {
*out = append(*out, rec)
}
}
}
if pos <= 0 {
return 0, nil // file exhausted
}
return pos, nil
}
// decodeAuditLine parses one audit line as a request record, filtering by key.
// Access/event rows and malformed lines are rejected.
func decodeAuditLine(line []byte, key string) (Req, bool) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
return Req{}, false
}
var r Req
if json.Unmarshal(line, &r) != nil || r.Type == "" {
return Req{}, false
}
if key != "" && r.Key != key {
return Req{}, false
}
return r, true
}
// StreamAuditRecords walks every request record in [from, to] (unix millis,
// 0 = unbounded) newest file first and hands each one to fn. Nothing is
// accumulated: an export of an arbitrarily long period costs O(1) memory, and
// the file handles are closed as the walk proceeds. fn returning an error stops
// the walk (used to abort on a broken client connection).
func (s *Stats) StreamAuditRecords(from, to int64, key string, fn func(Req) error) error {
for _, path := range s.auditChain() {
if err := streamAuditFile(path, from, to, key, fn); err != nil {
return err
}
}
return nil
}
func streamAuditFile(path string, from, to int64, key string, fn func(Req) error) error {
f, err := os.Open(path)
if err != nil {
return nil // a rotated-away file is not an export failure
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
for sc.Scan() {
var r Req
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue
}
if key != "" && r.Key != key {
continue
}
if from > 0 && r.Time < from {
continue
}
if to > 0 && r.Time > to {
continue
}
if err := fn(r); err != nil {
return err
}
}
return nil
}
// SourceRecent counts real gateway requests per source within the last
// window (unix seconds). It reads only the in-memory ring, so it is cheap and
// reflects live traffic — used by /api/status so the source status column is
// driven by what actually happens, not just a probe.
func (s *Stats) SourceRecent(windowSec int64) map[string][2]int64 {
s.mu.Lock()
defer s.mu.Unlock()
cut := time.Now().Unix() - windowSec
out := map[string][2]int64{}
for _, r := range s.recs {
if r.Time/1000 < cut {
continue
}
if r.Source == "" {
continue
}
v := out[r.Source]
if r.OK {
v[0]++
} else {
v[1]++
}
out[r.Source] = v
}
return out
}
// SourceAvg carries per-source performance averages for the status page.
type SourceAvg struct {
// AvgFirstByteMs is the mean time-to-first-byte over successful
// requests in the window (0 when no measured samples).
AvgFirstByteMs int64 `json:"avg_first_byte_ms"`
// AvgTokPerS is the aggregate completion throughput:
// sum(completion_tokens) / sum(latency_seconds) (0 when no samples).
AvgTokPerS int64 `json:"avg_tok_per_s"`
// Samples is the number of successful requests the averages cover.
Samples int64 `json:"samples"`
}
// SourceAverages computes TTFB and tokens/s averages per source from the
// in-memory ring within the window (unix seconds). Only successful chat/
// stream rows count; image and failed rows are skipped.
func (s *Stats) SourceAverages(windowSec int64) map[string]SourceAvg {
s.mu.Lock()
defer s.mu.Unlock()
cut := time.Now().Unix() - windowSec
type acc struct {
fbSum, latSum, complSum, n int64
}
accs := map[string]*acc{}
for _, r := range s.recs {
if !r.OK || r.Source == "" || r.Time/1000 < cut {
continue
}
if r.Type != "chat" && r.Type != "stream" {
continue
}
if r.LatMs <= 0 {
continue
}
a := accs[r.Source]
if a == nil {
a = &acc{}
accs[r.Source] = a
}
a.latSum += r.LatMs
a.complSum += r.Compl
a.fbSum += r.FirstByteMs
if r.FirstByteMs > 0 {
a.n++
}
}
out := make(map[string]SourceAvg, len(accs))
for src, a := range accs {
avg := SourceAvg{Samples: a.n}
if a.n > 0 {
avg.AvgFirstByteMs = a.fbSum / a.n
}
if a.latSum > 0 && a.complSum > 0 {
avg.AvgTokPerS = a.complSum * 1000 / a.latSum
}
out[src] = avg
}
return out
}
// Snapshot returns the whole dashboard payload; when key != "" the records
// and aggregate views are restricted to that gateway key.
// Snapshot returns the aggregate rows plus the FIRST SCREEN of request records
// (newest last, matching the ring order). limit bounds the record slice only;
// older records are not included here at all — the dashboard pages them from
// /api/stats/records as the user scrolls, so a dashboard load never serializes
// the whole ring. limit <= 0 means the default first screen.
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
s.mu.Lock()
defer s.mu.Unlock()
if limit <= 0 {
limit = firstScreenRecords
}
if limit > s.maxRecs {
limit = s.maxRecs
}
start := 0
if len(s.recs) > limit {
start = len(s.recs) - limit
}
recs := s.recs[start:]
if key != "" {
filt := recs[:0]
for _, r := range recs {
if r.Key == key {
filt = append(filt, r)
}
}
recs = filt
}
var total Stat
var byModel, byKey, bySrc map[string]*Stat
if key == "" {
byKey, byModel, bySrc = s.byKey, s.byModel, s.bySrc
} else {
byKey = map[string]*Stat{key: s.byKey[key]}
byModel = s.byKeyModel[key]
bySrc = s.byKeySrc[key]
}
for _, a := range byModel {
total.Reqs += a.Reqs
total.OK += a.OK
total.Err += a.Err
total.Tokens += a.Tokens
total.Prompt += a.Prompt
total.Compl += a.Compl
total.LatSum += a.LatSum
if a.LatMax > total.LatMax {
total.LatMax = a.LatMax
}
}
bs := make([]agrRow, 0, len(s.byStatus))
for code := range s.byStatus {
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *s.byStatus[code]})
}
sort.Slice(bs, func(i, j int) bool {
ci, _ := strconv.Atoi(bs[i].Name)
cj, _ := strconv.Atoi(bs[j].Name)
return ci < cj
})
return map[string]interface{}{
"active": s.active,
"total": total,
"by_key": rows(byKey),
"by_model": rows(byModel),
"by_source": rows(bySrc),
"by_status": bs,
"records": append([]Req(nil), recs...),
"replay_partial": s.replayPartial,
}
}