Files
llmgw/internal/store/usageseries.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

232 lines
7.5 KiB
Go

package store
import (
"context"
"fmt"
"time"
)
// Grouping is how a usage report is broken down.
//
// Not store.Scope with one more value, although three of the four agree with
// it. Scope is where a limit hangs, and nothing hangs off a person: one person
// holds keys in several projects, so a per-user total crosses the tree rather
// than naming a level of it. Keeping the two apart means neither has to carry a
// case the other does not have.
type Grouping string
// The groupings a usage report can be asked for.
const (
GroupByOrg Grouping = "org"
GroupByTeam Grouping = "team"
GroupByProject Grouping = "project"
GroupByUser Grouping = "user"
// GroupByModel is by the model that answered, which differs from the one
// asked for when a fallback served it.
GroupByModel Grouping = "model"
)
// Valid reports whether this is a grouping usage can be read by.
func (g Grouping) Valid() bool {
_, ok := groupColumns[g]
return ok
}
// Groupings lists them in the order a page should offer them: widest first,
// then the one that is not a level at all.
func Groupings() []Grouping {
return []Grouping{GroupByOrg, GroupByTeam, GroupByProject, GroupByUser, GroupByModel}
}
// UsageSeries is one group's usage over time.
//
// Buckets are contiguous and gap-filled: a group that stopped using the cluster
// has zeroes rather than an absence, because a line drawn straight across a gap
// says the traffic continued. It also means every series in a report shares one
// set of timestamps, which is what lets a chart sum the tail of them.
type UsageSeries struct {
Grouping Grouping
ID string
Name string
Buckets []Bucket
// Totals over the whole window, so a table beside the chart does not have
// to add the buckets up and possibly disagree with them.
Requests int64
Prompt int64
Completion int64
CostMicros int64
Errors int64
}
// Tokens is what the group consumed, prompt and completion together.
func (u UsageSeries) Tokens() int64 { return u.Prompt + u.Completion }
// groupColumns is the id and display name for each grouping, and how usage
// reaches it.
//
// Written out per grouping rather than assembled from parts: these are table
// and column names going into SQL, and the one place they must not come from
// anywhere but here is the place they are interpolated. A grouping that is not
// in this map is refused before a query is built.
//
// Read from usage.project_id, for the reason UsageByScope is: joined through
// api_keys, the chat page's requests — which have no key row — were missing
// from every grouping but the per-person one.
var groupColumns = map[Grouping]struct{ id, name, joins string }{
GroupByOrg: {"o.id", "o.name", `
JOIN projects p ON p.id = NULLIF(u.project_id, '')
JOIN teams t ON t.id = p.team_id
JOIN organizations o ON o.id = t.org_id`},
GroupByTeam: {"t.id", "t.name", `
JOIN projects p ON p.id = NULLIF(u.project_id, '')
JOIN teams t ON t.id = p.team_id`},
GroupByProject: {"p.id", "p.name", `
JOIN projects p ON p.id = NULLIF(u.project_id, '')`},
// Who spent it, as recorded with the spend — which includes personal keys
// and the chat page, neither of which a key's project reaches. Traffic
// from keys with no owner groups under an empty id, which the page names
// rather than hides.
GroupByUser: {"u.user_id", "COALESCE(us.email, '')", `
LEFT JOIN users us ON us.id = NULLIF(u.user_id, '')`},
GroupByModel: {servedModel, servedModel, ""},
}
// servedModel is what answered: the served model, or the one asked for on a
// row from before the two were told apart.
const servedModel = "CASE WHEN u.served_model != '' THEN u.served_model ELSE u.model END"
// UsageBucketsBy returns each group's usage over time.
//
// One query for every group rather than one per group: the page shows them
// together, and a query per organization is a loop whose cost grows with the
// thing being reported on.
func (s *DB) UsageBucketsBy(
ctx context.Context, g Grouping, since time.Time, width time.Duration,
) ([]UsageSeries, error) {
return s.usageBuckets(ctx, g, "", since, width)
}
// UsageBucketsIn is UsageBucketsBy for one organization: only what was spent
// in its projects, grouped by its teams, projects or people. A person's use
// elsewhere — another organization, or on their own — is not this
// organization's to see.
func (s *DB) UsageBucketsIn(
ctx context.Context, g Grouping, orgID string, since time.Time, width time.Duration,
) ([]UsageSeries, error) {
if orgID == "" {
return nil, fmt.Errorf("which organization?")
}
return s.usageBuckets(ctx, g, orgID, since, width)
}
func (s *DB) usageBuckets(
ctx context.Context, g Grouping, orgID string, since time.Time, width time.Duration,
) ([]UsageSeries, error) {
c, ok := groupColumns[g]
if !ok {
return nil, fmt.Errorf(
"usage cannot be grouped by %q; try org, team, project or user", g)
}
if width <= 0 {
width = time.Hour
}
ms := width.Milliseconds()
inOrg := ""
if orgID != "" {
// By where it was spent, as recorded with the spend.
inOrg = ` AND NULLIF(u.project_id, '') IN (
SELECT p.id FROM projects p JOIN teams t ON t.id = p.team_id WHERE t.org_id = ?)`
}
// Grouped by ordinal. PostgreSQL matches a grouped expression to a selected
// one syntactically, so "(u.ts / ?) * ?" written twice is two expressions
// as far as it is concerned; the ordinal is the same one by construction,
// and both dialects accept it.
q := `
SELECT ` + c.id + `, ` + c.name + `, (u.ts / ?) * ?,
COUNT(*), SUM(u.prompt_tokens), SUM(u.completion_tokens),
SUM(u.cost_micros),
SUM(CASE WHEN u.status >= 400 THEN 1 ELSE 0 END)
FROM usage u` + c.joins + `
WHERE u.ts >= ?` + inOrg + `
GROUP BY 1, 2, 3
ORDER BY 1, 3`
from := millis(since)
// From the rollup for slices that are whole rollup slices — every
// window the pages offer — and from the rows for anything finer.
if t, ok := tierForSlices(width); ok {
q = `
SELECT ` + c.id + `, ` + c.name + `, (u.bucket / ?) * ?,
SUM(u.requests), SUM(u.prompt_tokens), SUM(u.completion_tokens),
SUM(u.cost_micros), SUM(u.errors)
FROM ` + t.table + ` u` + c.joins + `
WHERE u.bucket >= ?` + inOrg + `
GROUP BY 1, 2, 3
ORDER BY 1, 3`
from = t.floor(since)
}
args := []any{ms, ms, from}
if orgID != "" {
args = append(args, orgID)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
type cell struct {
name string
at map[int64]Bucket
}
found := map[string]*cell{}
var order []string
for rows.Next() {
var id, name string
var start int64
var b Bucket
if err := rows.Scan(&id, &name, &start,
&b.Requests, &b.Prompt, &b.Completion, &b.CostMicros, &b.Errors); err != nil {
return nil, err
}
b.Start = fromMillis(start)
e, ok := found[id]
if !ok {
e = &cell{name: name, at: map[int64]Bucket{}}
found[id] = e
order = append(order, id)
}
e.at[start] = b
}
if err := rows.Err(); err != nil {
return nil, err
}
first := (millis(since) / ms) * ms
last := (millis(time.Now()) / ms) * ms
out := make([]UsageSeries, 0, len(order))
for _, id := range order {
e := found[id]
u := UsageSeries{Grouping: g, ID: id, Name: e.name}
for t := first; t <= last; t += ms {
b, ok := e.at[t]
if !ok {
b = Bucket{Start: fromMillis(t)}
}
u.Buckets = append(u.Buckets, b)
u.Requests += b.Requests
u.Prompt += b.Prompt
u.Completion += b.Completion
u.CostMicros += b.CostMicros
u.Errors += b.Errors
}
out = append(out, u)
}
return out, nil
}