Files
ModelRouter/internal/gateway/stats.go
JianFeeeee d42c02b15d perf(gateway): load request logs on demand instead of holding them in memory
Startup RSS on this deployment was 56 MB with a 29 MB audit log and ~10 MB
without one: LoadAudit() json-unmarshalled the ENTIRE file into the aggregates
and kept a 10000-entry ring of raw records. Two more paths had the same shape —
AuditRecords() materialized a whole export window into a []Req before sorting
it, and a dashboard poll serialized the full ring so the browser could render
300 rows of it.

The audit file is now the source of truth and memory only holds the live
window:

  * LoadAudit replays only the last auditReplayBytes (4 MB) and drops the
    truncated first line; the ring default drops 10000 -> 500, which still
    covers both of its consumers (the status page's 5-minute SourceRecent /
    SourceAverages windows and the first screen of the records table).
    replayPartial is exported so the UI can say the totals cover a window
    rather than all time. Token-quota accounting is unaffected: it reads the
    modelHour buckets, not the ring (pinned by a test).
  * AuditPage(cursor, limit, key) pages records straight off disk, reading the
    newest file backwards in 64 KB chunks and returning as soon as the page is
    full. The cursor is "<file>:<offset>" and walks into rotated .old files;
    a cursor whose file rotated away reports rotated=true so the client can
    reset instead of silently skipping records. No state is cached between
    requests and the file handle is closed before responding, so "release when
    the user leaves the page" is guaranteed by never retaining anything.
  * StreamAuditRecords(from,to,key,fn) replaces the accumulate-then-sort export
    path; the CSV handler writes rows as they are read and flushes every 1000,
    and a write error (client gone) aborts the walk. Export memory is O(1)
    regardless of the window. AuditRecords is kept as a test-only wrapper.
  * Snapshot ships one screen (firstScreenRecords=100) by default; aggregates
    are untouched.
  * Audit rotation 64 MB x 10 -> 16 MB x 16: same 256 MB total budget, but a
    smaller newest file keeps the first reverse page cheap.

New route: GET /api/stats/records?before=&limit=&key= (non-admins are pinned to
their own key by exportKey). /api/status additionally reports adapter_pools for
admins.

Measured with production's 29 MB audit copied to the test instance: startup RSS
19.0 MB (was 56 MB); scrolling 10 pages (1000 records) +0.7 MB; exporting the
full history (36441 rows / 4.4 MB CSV) +0.1 MB with no residual growth.
2026-08-30 08:05:54 +08:00

898 lines
25 KiB
Go

package gateway
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"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
)
// auditReplayBytes bounds how much of the audit tail is replayed at startup.
// Replaying the WHOLE file used to cost ~25 MB of resident memory for a 29 MB
// audit log, which dominated the process RSS. Only the recent window belongs in
// memory: older records stay on disk and are paged in on demand by AuditPage /
// StreamAuditRecords, while long-term totals come from the aggregate snapshot
// instead of a full replay.
var auditReplayBytes int64 = 4 << 20
// 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. Only the last
// auditReplayBytes are replayed: the ring buffer and the recent-window
// aggregates need the tail, and paging older records is what AuditPage and
// StreamAuditRecords are for. This keeps startup memory proportional to the
// replay window instead of to the (unbounded) audit file.
func (s *Stats) LoadAudit(path string) {
s.mu.Lock()
s.auditPath = path
s.mu.Unlock()
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return
}
offset := int64(0)
partial := false
if fi.Size() > auditReplayBytes {
offset = fi.Size() - auditReplayBytes
partial = true // the first line is very likely cut in half
}
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
var recs []Req
sc := bufio.NewScanner(f)
// tolerate long error summaries / oversized junk lines
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
first := true
for sc.Scan() {
if first {
first = false
if partial {
continue // drop the truncated first record
}
}
var r Req
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
continue // access/event rows (obj) and malformed lines are no requests
}
s.aggregateLocked(r)
recs = append(recs, r)
if len(recs) > s.maxRecs {
// keep the replay bounded in memory as well as on disk: aggregates
// already absorbed the dropped record
recs = recs[len(recs)-s.maxRecs:]
}
}
s.recs = recs
s.replayPartial = partial
}
// ReplayPartial reports whether the resident aggregates were built from a
// bounded tail of the audit file rather than its full history, so the UI can say
// so instead of implying the numbers are all-time totals.
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,
}
}