Files
llmgw/internal/store/rollup.go
T
Ilker ManapandClaude Opus 5.5 9f479b9a70 Show each team's first-token and latency percentiles, errors and refusals
Percentiles are read from the histograms kept with the usage sums and shown
per group on the usage tab and in the people page's panels. Requests refused
by a limit are recorded as 429s against their owner, free of charge, so a team
hitting its limits shows as such. Usage can be grouped by model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:50:16 +02:00

212 lines
7.3 KiB
Go

package store
import (
"context"
"fmt"
"strings"
"time"
)
// tier is one table usage is summed into as it is written: slices of a fixed
// width, per key, person, project and model.
type tier struct {
table string
ms int64
}
var (
tier5m = tier{"usage_5m", int64(5 * time.Minute / time.Millisecond)}
tier1h = tier{"usage_1h", int64(time.Hour / time.Millisecond)}
tier1d = tier{"usage_1d", int64(24 * time.Hour / time.Millisecond)}
// tiers, finest first.
tiers = []tier{tier5m, tier1h, tier1d}
)
// floor is the start of the slice a moment falls in. A chart read from a
// tier starts its first slice there, as a chart read from the rows always did.
func (t tier) floor(at time.Time) int64 { return (millis(at) / t.ms) * t.ms }
// ceil is the start of the first slice wholly after a moment.
func (t tier) ceil(at time.Time) int64 {
return ((millis(at) + t.ms - 1) / t.ms) * t.ms
}
// tierForSlices is the coarsest tier whose slices make up a chart slice of
// width exactly, and false when none does — a slice finer than five minutes,
// or not a whole number of them, is read from the rows.
func tierForSlices(width time.Duration) (tier, bool) {
ms := width.Milliseconds()
for i := len(tiers) - 1; i >= 0; i-- {
if ms >= tiers[i].ms && ms%tiers[i].ms == 0 {
return tiers[i], true
}
}
return tier{}, false
}
// LatencyBounds are the upper edges, in milliseconds, of the histogram buckets
// latency and time to first token are counted in; one more bucket holds
// everything above the last. Fixed, because buckets are only summable across
// slices, keys and teams when every row has the same ones.
var LatencyBounds = []int64{100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000}
// latencyBucket is the histogram bucket a duration is counted in.
func latencyBucket(ms int64) int {
for i, b := range LatencyBounds {
if ms <= b {
return i
}
}
return len(LatencyBounds)
}
// rollupKey is one row of a tier: a slice, and who spent what on what.
type rollupKey struct {
bucket int64
keyID, userID, projectID, model, servedModel string
}
// rollupRow is what a batch adds to one row.
type rollupRow struct {
requests, errors, limited, prompt int64
completion, cost int64
latency, queue, ttft, ttftN, last int64
lat, ttftH []int64
}
// rollupBatch sums a batch of records into the rows of one tier they add to.
func rollupBatch(t tier, records []Usage) (map[rollupKey]*rollupRow, []rollupKey) {
rows := map[rollupKey]*rollupRow{}
var order []rollupKey
n := len(LatencyBounds) + 1
for _, r := range records {
ts := millis(r.At)
k := rollupKey{(ts / t.ms) * t.ms, r.KeyID, r.UserID, r.ProjectID, r.Model, r.ServedModel}
row, ok := rows[k]
if !ok {
row = &rollupRow{lat: make([]int64, n), ttftH: make([]int64, n)}
rows[k] = row
order = append(order, k)
}
row.requests++
if r.Status >= 400 {
row.errors++
}
if r.Status == 429 {
row.limited++
}
row.prompt += r.PromptTokens
row.completion += r.CompletionTokens
row.cost += r.CostMicros
row.latency += r.LatencyMs
row.queue += r.QueueMs
row.lat[latencyBucket(r.LatencyMs)]++
if r.TTFTMs > 0 {
row.ttft += r.TTFTMs
row.ttftN++
row.ttftH[latencyBucket(r.TTFTMs)]++
}
row.last = max(row.last, ts)
}
return rows, order
}
// rollupColumns are a tier's columns, in the order rollupUpsert takes them.
var rollupColumns = func() []string {
n := len(LatencyBounds) + 1
cols := []string{"bucket", "key_id", "user_id", "project_id", "model", "served_model",
"requests", "errors", "limited", "prompt_tokens", "completion_tokens", "cost_micros",
"latency_ms_sum", "queue_ms_sum", "ttft_ms_sum", "ttft_n", "last_ts"}
for i := range n {
cols = append(cols, fmt.Sprintf("lat_%d", i))
}
for i := range n {
cols = append(cols, fmt.Sprintf("ttft_%d", i))
}
return cols
}()
// rollupUpsert is the statement that adds a batch's sums to a tier.
func rollupUpsert(table string) string {
var sets []string
for _, c := range rollupColumns[6:] {
if c == "last_ts" {
sets = append(sets, "last_ts = CASE WHEN excluded.last_ts > "+table+".last_ts "+
"THEN excluded.last_ts ELSE "+table+".last_ts END")
continue
}
sets = append(sets, c+" = "+table+"."+c+" + excluded."+c)
}
return `INSERT INTO ` + table + ` (` + strings.Join(rollupColumns, ", ") + `)
VALUES (` + strings.TrimSuffix(strings.Repeat("?, ", len(rollupColumns)), ", ") + `)
ON CONFLICT (bucket, key_id, user_id, project_id, model, served_model)
DO UPDATE SET ` + strings.Join(sets, ", ")
}
// writeRollup adds a batch to every tier, inside the transaction that writes
// its rows, so the sums never disagree with them.
func writeRollup(ctx context.Context, tx conn, records []Usage) error {
for _, t := range tiers {
rows, order := rollupBatch(t, records)
stmt, err := tx.PrepareContext(ctx, rollupUpsert(t.table))
if err != nil {
return err
}
for _, k := range order {
r := rows[k]
args := []any{k.bucket, k.keyID, k.userID, k.projectID, k.model, k.servedModel,
r.requests, r.errors, r.limited, r.prompt, r.completion, r.cost,
r.latency, r.queue, r.ttft, r.ttftN, r.last}
for _, v := range r.lat {
args = append(args, v)
}
for _, v := range r.ttftH {
args = append(args, v)
}
if _, err := stmt.ExecContext(ctx, args...); err != nil {
_ = stmt.Close()
return fmt.Errorf("adding to %s: %w", t.table, err)
}
}
if err := stmt.Close(); err != nil {
return err
}
}
return nil
}
// usageSince is every request since a moment, exactly, as a table with the
// columns a tier has (but the histograms): the rows up to the next
// five-minute slice, those slices up to the next hour, hours up to the next
// day, and days from there. Totals are read from this — a budget against it
// must be exact, and so is everything else, at the cost of a few rows read
// from each of four tables rather than a window rounded to a slice.
//
// Its placeholders come first in any query it is put into.
func usageSince(since time.Time) (string, []any) {
from := millis(since)
to5m, to1h, to1d := tier5m.ceil(since), tier1h.ceil(since), tier1d.ceil(since)
const cols = `key_id, user_id, project_id, model, served_model, requests, errors, limited,
prompt_tokens, completion_tokens, cost_micros, latency_ms_sum, last_ts`
return `(SELECT key_id, user_id, project_id, model, served_model, 1 AS requests,
CASE WHEN status >= 400 THEN 1 ELSE 0 END AS errors,
CASE WHEN status = 429 THEN 1 ELSE 0 END AS limited,
prompt_tokens, completion_tokens, cost_micros, latency_ms AS latency_ms_sum, ts AS last_ts
FROM usage WHERE ts >= ? AND ts < ?
UNION ALL SELECT ` + cols + ` FROM usage_5m WHERE bucket >= ? AND bucket < ?
UNION ALL SELECT ` + cols + ` FROM usage_1h WHERE bucket >= ? AND bucket < ?
UNION ALL SELECT ` + cols + ` FROM usage_1d WHERE bucket >= ?)`,
[]any{from, to5m, to5m, to1h, to1h, to1d, to1d}
}
// PruneUsage deletes request rows older than a moment. The tiers keep their
// sums, so charts, totals and budgets are unchanged; what goes is the record
// of each request — `ctl request` on one that old finds nothing.
func (s *DB) PruneUsage(ctx context.Context, before time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM usage WHERE ts < ?`, millis(before))
if err != nil {
return 0, err
}
return res.RowsAffected()
}