Files
yetone d1d4b7ed2a usage: a heatmap of the last 53 weeks on the Requests tab, by tokens, requests or cost (#1369, nianlee-official)
What changed: the Requests tab of the Usage page shows a heatmap card like
CC Switch's: a cell per day for the last 53 weeks, Monday on top, month
names over the weeks and weekday names at the edge, a Tokens / Requests /
Cost switch (kept in localStorage), a five-shade Less…More legend, and a
tip on each day with its date, tokens, requests and cost.

How: GET /api/usage/heatmap (usage.HeatmapOf) reads QueryPage's cached
request index over a hidden period, "heatmap", which starts on the Monday 52
weeks before this week's. Its rows are summed by local calendar day in both
the compact and the ledger path. So no raw log is scanned per render, and the
days count what the chart counts:
- the page's agent/provider/model/key filters, but not the period or day
  picked;
- the gateway's own rejections left out;
- cost of priced calls only, with an unpriced count.
The days are in the local time zone and the shades use theme tokens
(.l0–.l4, as in the sessions calendar). There are no left-border accents and
no native <select>. A click never scrolls the page. A narrow window scrolls
the card sideways inside itself, with the newest week in sight (kept there
as it's laid out or resized, until the reader scrolls back), and the weekday
names stay at the card's edge. The card is hidden when no day has calls. New
strings are in zh, zh-TW, ja and de.

Run:
- go test -tags nogui ./internal/usage ./internal/gui (new TestHeatmapDays,
  TestHeatmapOf, TestUsageHeatmapRoute); gofmt -l clean; go vet darwin,
  GOOS=linux and GOOS=windows go vet -tags nogui on both packages; go build
  for darwin, linux and windows.
- New internal/gui/tests/usage-heatmap.test.cjs in Chromium and WebKit, at
  420 and 1100, in en, zh, zh-TW, ja and de, plus dark and the empty case.
  It checks:
  - the cells, labels, legend and the busiest and quiet days;
  - the newest week in sight and only the card scrolling sideways;
  - the tip's text and that it stays inside the card;
  - that a metric click moves nothing and is kept;
  - that the filters, but no period or day, are sent.
- Breaking checks: with the keep-at-end guard reverted, 5 of its 11 subtests
  fail in WebKit; with the server's heatmap dropped, TestUsageHeatmapRoute
  fails.
- i18n-keys, click-scroll, gui-de, gui-ja, gui-zh-tw, sessions-usage-open
  and all usage-*.test.cjs GUI tests pass in both engines (161 each).
- A sandboxed `magpie web` (temp HOME, 2605 fake calls over 400 days, ports
  3671/3672): the card fills 369 days at 1280 and 420 wide, and the tip
  reads "2026年10月1日周四 / 2.6M token · 23 个请求 · ≈$1.05+".
2026-10-09 11:33:47 +08:00

1188 lines
34 KiB
Go

package usage
// The request page keeps compact per-source records, not a second full ledger.
// Unchanged sources are never decoded again. Aggregation streams those records;
// only the requested page is materialized/sorted. Export can still ask for the
// complete ledger explicitly. This cache is disposable and bounded.
import (
"container/heap"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"time"
"unsafe"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/sessions"
"github.com/yetone/magpie/internal/settings"
)
type RequestPage struct {
Rows []Row
Sum Totals
Total int
Agents, Providers []string
Purposes []string // all purposes in the period, independent of the selected filter
CallerKeys []Group
// Through and Direct are the rows the gateway served and the rows read
// from the agents' own session files, which the gateway never saw. Sum
// is the two together; the page shows the three beside each other so a
// reader can tell what magpie carried from what it only read about.
Through, Direct Totals
// Accounts are the subscription accounts that answered calls in the
// period, by provider and account, for the Account filter (#557)
Accounts []Group
Bucket string
Series []SeriesPoint
By map[string][]Share
ChartBy map[string][]Share // the whole period's legend while a day is selected
// Computers are the rows told apart by the computer they were made on,
// ThisComputer's and each other's by id, of the rows without the
// filter's computer, and Names what the others are called: none when no
// other computer's calls were brought here by sync (#542)
Computers []Share
Names map[string]string
// Heat is the rows by day, as the chart's filter keeps them: only for
// the heatmap's own period (HeatmapOf)
Heat *Heatmap
}
type packedRow struct {
Time time.Time
Text [28]uint32
Tokens [6]int64
Millis, TTFT, FirstText, Order int64
Sent, Flow int64
RouteID int64
Cost float64
Status int32
Flags uint8
}
type rowChunk struct {
Archive []byte
Locations []*time.Location
Rows []packedRow
Strings []string
dict map[string]uint32
Source sessions.CallSource
Bytes int64
Used uint64
Count int
Latest time.Time
// Computer is the other computer whose day this is (#542), by id
Computer string
}
// rowMsg is the Text of a row's Claude message id, after rowText's
const rowMsg = 27
func rowText(r *Row) [27]*string {
return [27]*string{&r.Agent, &r.Provider, &r.Host, &r.SessionProvider, &r.SessionAccount, &r.Model, &r.Requested, &r.Served, &r.Effort, &r.Error, &r.ErrType, &r.RequestID, &r.Endpoint, &r.Session, &r.NativeSession, &r.Kind, &r.Source, &r.Via, &r.ProviderKeyID, &r.ProviderKeyName, &r.CallerKeyID, &r.CallerKeyName, &r.Archive, &r.Operation, &r.ProviderAccount, &r.ResponseID, &r.Upstream}
}
func (c *rowChunk) add(r Row, msg string, order int64, failed bool) {
if c.dict == nil {
c.dict = map[string]uint32{"": 0}
c.Strings = []string{""}
}
intern := func(s string) uint32 {
if id, ok := c.dict[s]; ok {
return id
}
id := uint32(len(c.Strings))
c.dict[s] = id
c.Strings = append(c.Strings, s)
c.Bytes += int64(len(s) + 48)
return id
}
p := packedRow{Time: r.Time, Tokens: [6]int64{int64(r.Input), int64(r.Output), int64(r.CacheRead), int64(r.CacheWrite), int64(r.Reasoning), int64(r.CacheWrite1h)}, Millis: r.Millis, TTFT: r.TTFT, FirstText: r.FirstText, Sent: r.Sent, Flow: r.Flow, Order: order, RouteID: r.RouteID, Cost: r.Cost, Status: int32(r.Status)}
for i, s := range rowText(&r) {
p.Text[i] = intern(*s)
}
p.Text[rowMsg] = intern(msg)
if r.Priced {
p.Flags |= 1
}
if r.Swapped {
p.Flags |= 2
}
if r.Rejected {
p.Flags |= 4
}
if r.SessionOfficialLogin {
p.Flags |= 8
}
if r.Routed {
p.Flags |= 32
}
if failed {
p.Flags |= 16
}
c.Rows = append(c.Rows, p)
c.Count = len(c.Rows)
if r.Time.After(c.Latest) {
c.Latest = r.Time
}
c.Bytes += int64(unsafe.Sizeof(packedRow{}))
}
func (c *rowChunk) row(i int) Row {
p := &c.Rows[i]
r := Row{Record: Record{RouteID: p.RouteID, Time: p.Time, Input: int(p.Tokens[0]), Output: int(p.Tokens[1]), CacheRead: int(p.Tokens[2]), CacheWrite: int(p.Tokens[3]), CacheWrite1h: int(p.Tokens[5]), Reasoning: int(p.Tokens[4]), Millis: p.Millis, TTFT: p.TTFT, FirstText: p.FirstText, Sent: p.Sent, Flow: p.Flow, Status: int(p.Status), Rejected: p.Flags&4 != 0, SessionOfficialLogin: p.Flags&8 != 0}, Cost: p.Cost, Priced: p.Flags&1 != 0, Swapped: p.Flags&2 != 0, Routed: p.Flags&32 != 0}
for i, s := range rowText(&r) {
*s = c.Strings[p.Text[i]]
}
if r.Swapped && (SameSpelled(r.Model, r.Served) || GeminiServing(r.Model, r.Served)) {
r.Swapped = false // kept before a name spelled otherwise, or Google's serving name, was the same
}
r.Computer = c.Computer
return r
}
type rowRef struct {
Chunk *rowChunk
Index int
}
func (r rowRef) row() Row { return r.Chunk.row(r.Index) }
func (r rowRef) at() time.Time { return r.Chunk.Rows[r.Index].Time }
func refNewer(a, b rowRef) bool {
if !a.at().Equal(b.at()) {
return a.at().After(b.at())
}
if (a.Chunk.Source.Path == "") != (b.Chunk.Source.Path == "") {
return a.Chunk.Source.Path == ""
}
if a.Chunk.Source.Path != b.Chunk.Source.Path {
return a.Chunk.Source.Path > b.Chunk.Source.Path
}
return a.Chunk.Rows[a.Index].Order > b.Chunk.Rows[b.Index].Order
}
type newestHeap []rowRef
func (h newestHeap) Len() int { return len(h) }
func (h newestHeap) Less(i, j int) bool { return refNewer(h[j], h[i]) }
func (h newestHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *newestHeap) Push(v any) { *h = append(*h, v.(rowRef)) }
func (h *newestHeap) Pop() any { a := *h; v := a[len(a)-1]; *h = a[:len(a)-1]; return v }
type pageKey struct {
Period Period
Filter Filter
Offset, Limit int
}
type requestIndex struct {
sync.Mutex
root, meta, key string
chunks map[string]*rowChunk
gateways map[*rowChunk]*rowChunk // immutable raw block -> priced block
pages map[pageKey]RequestPage
tick uint64
}
var requestCache requestIndex
var requestCacheBytes int64 = 24 << 20
func statKey(path string) string {
s, e := os.Stat(path)
if e != nil {
return path + ":missing"
}
return fmt.Sprintf("%s:%d:%d", path, s.Size(), s.ModTime().UnixNano())
}
func recordHash(path string, n int64) string {
f, e := os.Open(path)
if e != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, e = io.CopyN(h, f, n); e != nil {
return ""
}
return fmt.Sprintf("%x", h.Sum(nil))
}
func QueryPage(p Period, f Filter, offset, limit int) RequestPage {
return queryPage(p, f, offset, limit, sessions.ReadCallSource)
}
func queryPage(p Period, f Filter, offset, limit int, readSource func(sessions.CallSource) []sessions.Call) RequestPage {
if limit <= 0 {
limit = 100
}
limit = min(limit, 500)
offset = max(0, offset)
// The time is read once, so that the rows, the chart and the day the page
// is kept for are of one day when midnight falls while it is worked out.
now := Clock()
// A replacement reader remains useful to callers supplying synthetic logs.
if LogCalls != nil {
return pageFromLedgerAt(p, f, offset, limit, LedgerOfAt(p, Filter{}, now), now)
}
sources := sessions.CallSources()
slices.SortFunc(sources, func(a, b sessions.CallSource) int {
if n := a.Modified.Compare(b.Modified); n != 0 {
return n
}
return strings.Compare(a.Path, b.Path)
})
ids, _ := json.Marshal(provider.SessionIdentities(sessions.CodexDir()))
renamed, _ := json.Marshal(provider.Renamed())
cfg := settings.Load()
prices, _ := json.Marshal(cfg.ModelPrices)
wires, _ := json.Marshal(cfg.ModelWires)
meta := fmt.Sprintf("%s|%s|%s|%s|%s|%s", ids, renamed, prices, wires, statKey(provider.Path()), statKey(catalog.CachePath()))
h := sha256.New()
for _, current := range provider.All() {
fmt.Fprintf(h, "%q:%q:%v:%t:%t:%t;", current.ID, current.Where(), current.Catalogs(), current.IsPlugin(), current.Account == nil, current.Key != "")
fmt.Fprint(h, statKey(catalog.LivePath(current.ID)))
}
for _, root := range sessions.DesktopDataDirs() {
for _, kind := range []string{"local-agent-mode-sessions", "claude-code-sessions"} {
files, _ := sessions.SessionGlob(filepath.Join(root, kind, "*", "*", "local_*.json"))
for _, path := range files {
fmt.Fprint(h, statKey(path))
}
}
}
meta += fmt.Sprintf("|%x|%s", h.Sum(nil), statKey(catalog.LivePath("antigravity")))
h.Reset()
snapshot := logSnapshotFor(true)
version := snapshot.version
fmt.Fprint(h, meta, version, now.Format("2006-01-02 MST"))
for _, s := range sources {
fmt.Fprintf(h, "%s:%d:%d;", s.Path, s.Size, s.Modified.UnixNano())
}
days, someShared := sharedSources(time.Time{})
for _, s := range days {
fmt.Fprintf(h, "%s:%d:%d;", s.path, s.size, s.mod.UnixNano())
}
key := fmt.Sprintf("%x", h.Sum(nil))
root := Path() + "|" + catalog.CachePath()
idx := &requestCache
idx.Lock()
if idx.root != root || idx.meta != meta {
idx.root, idx.meta = root, meta
idx.chunks = map[string]*rowChunk{}
idx.gateways = map[*rowChunk]*rowChunk{}
idx.pages = nil
idx.key = ""
}
if idx.key != key {
idx.pages = map[pageKey]RequestPage{}
idx.key = key
}
q := pageKey{p, f, offset, limit}
if page, ok := idx.pages[q]; ok {
idx.Unlock()
return page
}
idx.tick++
// Copy only metadata while holding the lock. Published chunks are immutable;
// reading, decoding, pricing and aggregation use a private snapshot.
shared := idx
idx = &requestIndex{root: idx.root, meta: idx.meta, key: idx.key, tick: idx.tick,
chunks: maps.Clone(idx.chunks), gateways: maps.Clone(idx.gateways)}
shared.Unlock()
if snapshot.uncached && len(snapshot.blocks) == 0 {
snapshot = readLogSnapshot()
}
price := pricer()
priceRow := func(r Record, source string) Row {
sent := r.Model
if source == "" {
sent = provider.SentNameIn(cfg.ModelWires, r.Provider, r.Model, r.Effort)
}
row := Row{Record: r, Source: source, Swapped: r.Served != "" && Swapped(sent, r.Served), Routed: GroupRouted(sent, r.Served)}
if pr := price(r); pr != nil && r.Input+r.Output > 0 {
row.Priced = true
row.Cost = r.CostAt(*pr)
}
row.Agent = AgentOf(r.Agent)
return row
}
since := p.Since(now)
gatewaySince := since
if !since.IsZero() {
gatewaySince = since.Add(-24 * time.Hour)
}
var gateways []*rowChunk
aliases := provider.Renamed()
live := make(map[*rowChunk]bool, len(snapshot.blocks))
for _, packed := range snapshot.blocks {
live[packed] = true
if !gatewaySince.IsZero() && packed.Latest.Before(gatewaySince) {
continue
}
cached := idx.gateways[packed]
c := cached.unpack()
if c == nil {
raw := packed.unpack()
if raw == nil {
continue
}
c = &rowChunk{}
// Cache the entire block: another period can reuse the same prices.
// Only the append tail changes; a tariff change clears all blocks.
for i, pr := range raw.Rows {
r := raw.row(i).Record
if id, ok := aliases[r.Provider]; ok {
r.Provider = id
}
c.add(priceRow(r, ""), "", pr.Order, false)
}
cached = c.freeze()
}
kept := *cached
kept.Used = idx.tick
idx.gateways[packed] = &kept
gateways = append(gateways, c)
}
for raw := range idx.gateways {
if !live[raw] {
delete(idx.gateways, raw)
}
}
on := map[string]bool{}
var chunks []*rowChunk
var resolver *sessionResolver
read := prefetchSources(sources, func(s sessions.CallSource) bool {
if !since.IsZero() && s.Modified.Before(since) {
return false
}
c := idx.chunks[s.Path]
return c == nil || c.Source.Size != s.Size || !c.Source.Modified.Equal(s.Modified)
}, readSource)
for i, s := range sources {
on[s.Path] = true
if !since.IsZero() && s.Modified.Before(since) {
continue
}
cached := idx.chunks[s.Path]
c := cached.unpack()
reused := c != nil && c.Source.Size == s.Size && c.Source.Modified.Equal(s.Modified)
if !reused {
if resolver == nil {
resolver = newSessionResolver([]sessions.Call{{}})
}
cs := read(i)
c = &rowChunk{Source: s, Rows: make([]packedRow, 0, len(cs))}
for _, call := range cs {
r := logRecord(call)
r.SessionAccount = resolver.resolve(call)
r.SessionOfficialLogin = resolver.officialLogin(call, r.SessionAccount)
r.SessionProvider = call.Upstream
c.add(priceRow(r, "log"), call.Msg, call.To, call.Error != "")
}
c.Bytes -= int64(32 * len(c.Strings))
c.dict = nil // local chunks are immutable until the source changes
idx.chunks[s.Path] = c
}
next := *c
c = &next
c.Used = idx.tick
if reused {
kept := *cached
kept.Used = idx.tick
idx.chunks[s.Path] = &kept
} else {
idx.chunks[s.Path] = c.pack()
}
chunks = append(chunks, c)
}
// the other computers' days (#542), a chunk each, read again only when
// sync brought a new copy
var others []*rowChunk
var renames map[string]string
from := ""
if !since.IsZero() {
from = since.AddDate(0, 0, -1).Format(time.DateOnly)
}
for _, s := range days {
on[s.path] = true
if s.day < from {
continue
}
cached := idx.chunks[s.path]
c := cached.unpack()
reused := c != nil && c.Source.Size == s.size && c.Source.Modified.Equal(s.mod)
if !reused {
d, _ := readShared(s.path)
if renames == nil {
renames = provider.Renamed()
}
c = &rowChunk{Source: sessions.CallSource{Path: s.path, Size: s.size, Modified: s.mod}, Computer: s.computer, Rows: make([]packedRow, 0, len(d.Calls))}
for i, call := range d.Calls {
r := call.Record
r.Computer = ""
if id, ok := renames[r.Provider]; ok {
r.Provider = id
}
c.add(priceRow(r, call.Source), "", int64(i), false)
}
c.Bytes -= int64(32 * len(c.Strings))
c.dict = nil
}
next := *c
c = &next
c.Used = idx.tick
if reused {
kept := *cached
kept.Used = idx.tick
idx.chunks[s.path] = &kept
} else {
idx.chunks[s.path] = c.pack()
}
others = append(others, c)
}
for path := range idx.chunks {
if !on[path] {
delete(idx.chunks, path)
}
}
var names map[string]string
if someShared {
names = sharedNames()
}
page := buildRequestBlocks(p, f, offset, limit, now, gateways, chunks, others, names)
shared.Lock()
defer shared.Unlock()
// A query for an older filesystem snapshot may finish after a newer one.
if shared.root != idx.root || shared.meta != idx.meta || shared.key != idx.key || snapshot.version != version {
return page
}
for path, c := range idx.chunks {
if prev := shared.chunks[path]; prev == nil || prev.Used <= c.Used {
shared.chunks[path] = c
}
}
for path := range shared.chunks {
if !on[path] {
delete(shared.chunks, path)
}
}
for raw, c := range idx.gateways {
if prev := shared.gateways[raw]; prev == nil || prev.Used <= c.Used {
shared.gateways[raw] = c
}
}
for raw := range shared.gateways {
if !live[raw] {
delete(shared.gateways, raw)
}
}
if len(shared.pages) >= 16 {
shared.pages = map[pageKey]RequestPage{}
}
if !snapshot.uncached {
shared.pages[q] = page
}
// Raw blocks are also retained by gateway-map keys: charge their bytes once.
bytes := snapshot.bytes
budget := int64(requestCacheBytes)
if snapshot.uncached {
budget = 0
shared.pages = map[pageKey]RequestPage{}
}
type cachedChunk struct {
path string
raw *rowChunk
row *rowChunk
}
var kept []cachedChunk
for path, c := range shared.chunks {
bytes += c.Bytes
kept = append(kept, cachedChunk{path: path, row: c})
}
for raw, c := range shared.gateways {
bytes += c.Bytes
kept = append(kept, cachedChunk{raw: raw, row: c})
}
slices.SortFunc(kept, func(a, b cachedChunk) int {
if a.row.Used < b.row.Used {
return -1
}
if a.row.Used > b.row.Used {
return 1
}
return strings.Compare(a.path, b.path)
})
for _, c := range kept {
if bytes <= budget {
break
}
if c.raw != nil {
delete(shared.gateways, c.raw)
} else {
delete(shared.chunks, c.path)
}
bytes -= c.row.Bytes
}
return page
}
// localRefs visits first occurrences of Claude message IDs. It stores only
// references for matches, never a full hydrated row for every call.
func visibleLocal(chunks []*rowChunk) map[rowRef]bool {
duplicates := map[rowRef]bool{}
seen := map[string]bool{}
for _, c := range chunks {
for i, p := range c.Rows {
msg := c.Strings[p.Text[rowMsg]]
if msg == "" {
continue
}
if seen[msg] {
duplicates[rowRef{c, i}] = true
} else {
seen[msg] = true
}
}
}
return duplicates
}
// matchKey narrows candidates before comparing their end times. Request IDs
// are consumed first; fallback matching still requires uniqueness both ways.
// The agent isn't in it: a session's id names the one conversation, and a
// client built on Codex (miyi-helper, say) is logged under its own name by
// the gateway while its Codex session file names Codex, so its calls were
// counted twice, once through magpie and once as not.
type matchKey struct {
session string
tokens [3]int64
failed, hasID bool
}
// matchTokens is what a call's tokens are matched by: its input with what it
// wrote to the cache in it, its output and what it read from the cache. The
// two logs needn't split a cache write out of the input alike (#589): a
// Codex that names cache_write_input_tokens beside a gateway record that
// didn't, an older Codex that doesn't beside one that does; the sum is the
// same either way.
func matchTokens(t [6]int64) [3]int64 { return [3]int64{t[0] + t[3], t[1], t[2]} }
type matchEnd struct {
at time.Time
index rowRef
}
type matchGroup struct {
ends []matchEnd
counts []int
}
func matchedLocal(gateway *rowChunk, chunks []*rowChunk, skip map[rowRef]bool, since time.Time) map[rowRef]bool {
return matchedBlocks([]*rowChunk{gateway}, chunks, skip, since, true)
}
func matchedBlocks(gateways []*rowChunk, chunks []*rowChunk, skip map[rowRef]bool, since time.Time, newestIDs bool) map[rowRef]bool {
gatewaySince := since
if !since.IsZero() {
gatewaySince = since.Add(-24 * time.Hour)
}
byID := map[string][]rowRef{}
for _, gateway := range gateways {
for i, p := range gateway.Rows {
if (p.Flags&4 != 0 || p.Text[1] == 0 && p.Status >= 400) || p.Time.Before(gatewaySince) {
continue
}
if id := gateway.Strings[p.Text[11]]; id != "" {
byID[id] = append(byID[id], rowRef{gateway, i})
}
}
}
byRequest := map[string][]rowRef{}
for _, c := range chunks {
for i, p := range c.Rows {
ref := rowRef{c, i}
if skip[ref] || p.Time.Before(since) {
continue
}
if id := c.Strings[p.Text[11]]; len(byID[id]) > 0 {
byRequest[id] = append(byRequest[id], ref)
}
}
}
matched, used := map[rowRef]bool{}, map[rowRef]bool{}
for id, refs := range byRequest {
if newestIDs {
slices.SortFunc(refs, func(a, b rowRef) int {
if refNewer(a, b) {
return -1
}
if refNewer(b, a) {
return 1
}
return 0
})
}
for j, ref := range refs {
if j >= len(byID[id]) {
break
}
matched[ref], used[byID[id][j]] = true, true
}
}
groups := map[matchKey]*matchGroup{}
for _, gateway := range gateways {
for i, p := range gateway.Rows {
if used[rowRef{gateway, i}] || (p.Flags&4 != 0 || p.Text[1] == 0 && p.Status >= 400) || p.Time.Before(gatewaySince) {
continue
}
session := gateway.Strings[p.Text[14]]
if session == "" {
session = gateway.Strings[p.Text[13]]
}
if session == "" {
continue
}
key := matchKey{session, matchTokens(p.Tokens), p.Status >= 400 || p.Text[9] != 0, p.Text[11] != 0}
g := groups[key]
if g == nil {
g = &matchGroup{}
groups[key] = g
}
g.ends = append(g.ends, matchEnd{p.Time.Add(time.Duration(p.Millis) * time.Millisecond), rowRef{gateway, i}})
}
}
for _, g := range groups {
slices.SortFunc(g.ends, func(a, b matchEnd) int { return a.at.Compare(b.at) })
g.counts = make([]int, len(g.ends)+1)
}
candidates := map[rowRef]rowRef{}
for _, c := range chunks {
for j, p := range c.Rows {
ref := rowRef{c, j}
if skip[ref] || matched[ref] || p.Time.Before(since) {
continue
}
failed := p.Flags&16 != 0
if p.Tokens[0]+p.Tokens[1]+p.Tokens[2]+p.Tokens[3] == 0 && !failed {
continue
}
key := matchKey{session: c.Strings[p.Text[13]], tokens: matchTokens(p.Tokens), failed: failed}
count, candidate := 0, (rowRef{})
// Without a local ID either gateway partition can match. With an ID,
// only an unnamed gateway call can match (different IDs stay distinct).
for _, hasID := range []bool{false, true} {
if hasID && p.Text[11] != 0 {
continue
}
key.hasID = hasID
g := groups[key]
if g == nil {
continue
}
lo := sort.Search(len(g.ends), func(i int) bool { return !g.ends[i].at.Before(p.Time.Add(-2 * time.Second)) })
hi := sort.Search(len(g.ends), func(i int) bool { return g.ends[i].at.After(p.Time.Add(2 * time.Second)) })
if hi > lo {
count += hi - lo
candidate = g.ends[lo].index
// Range counts preserve ambiguity even for thousands of identical
// retries, without enumerating their Cartesian product.
g.counts[lo]++
g.counts[hi]--
}
}
if count == 1 {
candidates[ref] = candidate
}
}
}
unique := map[rowRef]bool{}
for _, g := range groups {
count := 0
for i, end := range g.ends {
count += g.counts[i]
if count == 1 {
unique[end.index] = true
}
}
}
for ref, i := range candidates {
if unique[i] {
matched[ref] = true
}
}
return matched
}
func sharesOf(m map[string]*Share) []Share {
out := make([]Share, 0, len(m))
for _, s := range m {
out = append(out, *s)
}
slices.SortFunc(out, func(a, b Share) int {
if a.AllTokens() != b.AllTokens() {
return b.AllTokens() - a.AllTokens()
}
if a.Calls != b.Calls {
return b.Calls - a.Calls
}
return strings.Compare(a.ID, b.ID)
})
return out
}
func buildRequestPage(p Period, f Filter, offset, limit int, gateway *rowChunk, chunks []*rowChunk) RequestPage {
return buildRequestBlocks(p, f, offset, limit, Clock(), []*rowChunk{gateway}, chunks, nil, nil)
}
// Shared days are never matched to this computer's gateway or session calls.
// now is the moment the page is asked at: its period and its chart's hours or
// days are read from it.
func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, gateways, chunks, others []*rowChunk, names map[string]string) RequestPage {
skip := visibleLocal(chunks)
since := p.Since(now)
matched := matchedBlocks(gateways, chunks, skip, since, true)
all := append(append(slices.Clone(gateways), chunks...), others...)
visit := func(fn func(rowRef, Row)) {
for _, c := range all {
for i, pr := range c.Rows {
ref := rowRef{c, i}
if pr.Time.Before(since) || skip[ref] || matched[ref] {
continue
}
fn(ref, c.row(i))
}
}
}
out := RequestPage{Rows: []Row{}, Agents: []string{}, Providers: []string{}, Purposes: []string{}, By: map[string][]Share{}}
agents, providers := map[string]bool{}, map[string]bool{}
purposes := map[string]bool{}
callers := map[string]*Group{}
accounts := map[string]*Group{}
computers := map[string]*Share{}
groups := map[string]map[string]*Share{}
chartGroups := map[string]map[string]*Share{}
seriesGroups := map[string]map[string]*Share{}
keys := keyer{}
for _, d := range Dimensions {
groups[d] = map[string]*Share{}
chartGroups[d] = map[string]*Share{}
seriesGroups[d] = map[string]*Share{}
}
// Bound the requested prefix before adding limit: external offsets can
// reach MaxInt, and a page beyond all sources needs no heap at all.
maxRows := 0
for _, c := range all {
maxRows += len(c.Rows)
}
take := 0
if offset < maxRows {
take = offset + min(limit, maxRows-offset)
}
selected := newestHeap{}
chartFilter := f
chartFilter.Day = ""
// the two source cells' own numbers, counted without the source pick
viaFilter := f
viaFilter.Through = ""
var first time.Time
visit(func(ref rowRef, r Row) {
agents[r.Agent] = true
purposes[PurposeOf(r.Kind)] = true
if r.Provider != "" {
providers[r.Provider] = true
}
addCallerRow(callers, r)
addAccountRow(accounts, r)
keep := f.keepsRow(r)
if keep {
out.Total++
if take > 0 && len(selected) < take {
heap.Push(&selected, ref)
} else if take > 0 && refNewer(ref, selected[0]) {
selected[0] = ref
heap.Fix(&selected, 0)
}
if !r.IsRejected() {
out.Sum.addRow(r)
}
}
// The two sources are counted with the Via pick cleared, as the
// dimension groups above are: the cells are what the reader
// switches between, so each must go on saying its own number
// while another is picked, or one of them would read 0 and
// there would be no way back.
if !r.IsRejected() && viaFilter.keepsRow(r) {
if r.Source == "log" {
out.Direct.addRow(r)
} else {
out.Through.addRow(r)
}
}
if r.IsRejected() {
return
}
if keep || f.Day != "" && chartFilter.keepsRow(r) {
for _, d := range Dimensions {
k := keys.key(r, d)
s := seriesGroups[d][k]
if s == nil {
s = &Share{ID: k}
seriesGroups[d][k] = s
}
s.addRow(r)
}
if first.IsZero() || r.Time.Before(first) {
first = r.Time
}
}
if g := f; names != nil {
g.Computer = ""
g.Day = ""
if g.keepsRow(r) {
k := r.key("computer")
if computers[k] == nil {
computers[k] = &Share{ID: k}
}
computers[k].addRow(r)
}
}
for _, d := range Dimensions {
g := f
if d == "provider" {
g.Provider = ""
}
if d == "agent" {
g.Agent = ""
}
if d == "model" || d == "modelAt" {
g.Model = ""
}
if d == "modelAt" {
g.Provider = ""
}
if f.Day != "" {
g.Day = ""
if g.keepsRow(r) {
k := keys.key(r, d)
if chartGroups[d][k] == nil {
chartGroups[d][k] = &Share{ID: k}
}
chartGroups[d][k].addRow(r)
}
g.Day = f.Day
}
if !g.keepsRow(r) {
continue
}
k := keys.key(r, d)
s := groups[d][k]
if s == nil {
s = &Share{ID: k}
groups[d][k] = s
}
s.addRow(r)
}
})
slices.SortFunc(selected, func(a, b rowRef) int {
if refNewer(a, b) {
return -1
}
if refNewer(b, a) {
return 1
}
return 0
})
for _, ref := range selected[min(offset, len(selected)):] {
out.Rows = append(out.Rows, ref.row())
}
for a := range agents {
out.Agents = append(out.Agents, a)
}
slices.Sort(out.Agents)
for a := range providers {
out.Providers = append(out.Providers, a)
}
slices.Sort(out.Providers)
for purpose := range purposes {
out.Purposes = append(out.Purposes, purpose)
}
slices.Sort(out.Purposes)
out.CallerKeys = callerGroups(callers)
out.Accounts = callerGroups(accounts)
for _, d := range Dimensions {
out.By[d] = sharesOf(groups[d])
}
if f.Day != "" {
out.ChartBy = map[string][]Share{}
for _, d := range Dimensions {
out.ChartBy[d] = sharesOf(chartGroups[d])
}
}
out.Computers, out.Names = computerShares(computers, names)
var base []Point
chartSince := since
chartSince, out.Bucket, base = timeline(p, now, first)
out.Series = make([]SeriesPoint, len(base))
for i := range base {
out.Series[i] = SeriesPoint{Point: base[i], By: map[string]map[string]Part{}}
for _, d := range Dimensions {
out.Series[i].By[d] = map[string]Part{}
}
}
visit(func(_ rowRef, r Row) {
if !chartFilter.keepsRow(r) || r.IsRejected() {
return
}
t := r.Time.In(time.Local)
if t.Before(chartSince) {
return
}
i := bucketIndex(out.Bucket, chartSince, t)
if i < 0 || i >= len(out.Series) {
return
}
pt := &out.Series[i]
pt.addRow(r)
for _, d := range Dimensions {
k := keys.key(r, d)
part := pt.By[d][k]
part.add(r)
pt.By[d][k] = part
}
})
if p == heatmapPeriod {
out.Heat = heatmapOf(since, now, func(add func(Row)) {
visit(func(_ rowRef, r Row) {
if chartFilter.keepsRow(r) {
add(r)
}
})
})
}
// Match LedgerSeries's top-24 selection on the filtered data, not facets.
for _, d := range Dimensions {
kept := map[string]bool{}
for i, s := range sharesOf(seriesGroups[d]) {
if i < seriesKeep {
kept[s.ID] = true
}
}
for i := range out.Series {
for k := range out.Series[i].By[d] {
if !kept[k] {
delete(out.Series[i].By[d], k)
}
}
}
}
return out
}
func pageFromLedger(p Period, f Filter, offset, limit int, all Ledgered) RequestPage {
return pageFromLedgerAt(p, f, offset, limit, all, Clock())
}
// pageFromLedgerAt is the page of a ledger read at now, its chart over the
// period read at the same moment.
func pageFromLedgerAt(p Period, f Filter, offset, limit int, all Ledgered, now time.Time) RequestPage {
l := all.Filtered(f)
out := RequestPage{Sum: l.Sum, Total: len(l.Rows), Agents: l.Agents, Providers: l.Providers, Purposes: []string{}, By: map[string][]Share{}}
// the same split the streaming path makes, so both pages of the ledger
// agree: "log" is a call read from an agent's own session file, which
// the gateway never saw; the rest went through it. Counted with the source
// pick cleared, as the streaming path does, so the three cells stay
// switchable whichever one is picked.
viaFilter := f
viaFilter.Through = ""
for _, r := range all.Rows {
if r.IsRejected() || !viaFilter.keepsRow(r) {
continue
}
if r.Source == "log" {
out.Direct.addRow(r)
} else {
out.Through.addRow(r)
}
}
callers, accounts := map[string]*Group{}, map[string]*Group{}
purposes := map[string]bool{}
for i := len(all.Rows) - 1; i >= 0; i-- {
purposes[PurposeOf(all.Rows[i].Kind)] = true
addCallerRow(callers, all.Rows[i])
addAccountRow(accounts, all.Rows[i])
}
for purpose := range purposes {
out.Purposes = append(out.Purposes, purpose)
}
slices.Sort(out.Purposes)
out.CallerKeys = callerGroups(callers)
out.Accounts = callerGroups(accounts)
offset = min(offset, len(l.Rows))
out.Rows = l.Rows[offset:min(len(l.Rows), offset+limit)]
chartRows := l.Rows
if f.Day != "" {
chartFilter := f
chartFilter.Day = ""
chartRows = all.Filtered(chartFilter).Rows
out.ChartBy = map[string][]Share{}
}
out.Bucket, out.Series = ledgerSeriesAt(p, chartRows, now)
if p == heatmapPeriod {
out.Heat = heatmapOf(p.Since(now), now, func(add func(Row)) {
for _, r := range chartRows {
add(r)
}
})
}
for _, d := range Dimensions {
g := f
if d == "provider" {
g.Provider = ""
}
if d == "agent" {
g.Agent = ""
}
if d == "model" || d == "modelAt" {
g.Model = ""
}
if d == "modelAt" {
g.Provider = ""
}
out.By[d] = Breakdown(all.Filtered(g).Rows, d)
if f.Day != "" {
g.Day = ""
out.ChartBy[d] = Breakdown(all.Filtered(g).Rows, d)
}
}
if names := sharedNames(); len(names) > 0 {
g := f
g.Computer = ""
g.Day = ""
computers := map[string]*Share{}
for _, s := range Breakdown(all.Filtered(g).Rows, "computer") {
computers[s.ID] = &s
}
out.Computers, out.Names = computerShares(computers, names)
}
return out
}
// computerShares are the computers' shares, this one and every other kept
// here among them though it made no call in the period; none without names.
func computerShares(m map[string]*Share, names map[string]string) ([]Share, map[string]string) {
if names == nil {
return nil, nil
}
for _, id := range append([]string{ThisComputer}, slices.Collect(maps.Keys(names))...) {
if m[id] == nil {
m[id] = &Share{ID: id}
}
}
return sharesOf(m), names
}
// Caller choices cover the period, not just the current page or selected key.
func addCallerRow(groups map[string]*Group, r Row) {
if r.CallerKeyID == "" || r.IsRejected() {
return
}
g := groups[r.CallerKeyID]
if g == nil {
g = &Group{ID: r.CallerKeyID, CallerKeyID: r.CallerKeyID}
groups[r.CallerKeyID] = g
}
g.CallerKeyName = r.CallerKeyName
g.addRow(r)
}
// Account choices cover the period too: each account that answered a call,
// by provider, for the Account filter, whatever else is picked.
func addAccountRow(groups map[string]*Group, r Row) {
who := r.Account()
if who == "" || r.IsRejected() {
return
}
id := r.Provider + "@" + who
g := groups[id]
if g == nil {
g = &Group{ID: id, Provider: r.Provider, Account: who}
groups[id] = g
}
g.addRow(r)
}
func callerGroups(groups map[string]*Group) []Group {
out := make([]Group, 0, len(groups))
for _, g := range groups {
out = append(out, *g)
}
slices.SortFunc(out, func(a, b Group) int {
if a.Tokens() != b.Tokens() {
return b.Tokens() - a.Tokens()
}
if a.Calls != b.Calls {
return b.Calls - a.Calls
}
return strings.Compare(a.ID, b.ID)
})
return out
}
// prefetchSources reads the session files a query has to parse a few at a
// time, ahead of the loop that takes them in order. The first All after
// magpie starts parses every one of them, and one by one that was a wait on
// the disk for each: Requests stayed a skeleton for 40 s over 12k Codex
// sessions. read(i) gives source i's calls; at most a window of them waits
// to be taken, so the history is never all held at once. A source that
// wasn't wanted is read when it is asked for.
func prefetchSources(sources []sessions.CallSource, want func(sessions.CallSource) bool, readSource func(sessions.CallSource) []sessions.Call) func(int) []sessions.Call {
type slot struct {
done chan struct{}
calls []sessions.Call
}
slots := map[int]*slot{}
var order []int
for i, s := range sources {
if want(s) {
slots[i] = &slot{done: make(chan struct{})}
order = append(order, i)
}
}
if len(order) < 2 {
return func(i int) []sessions.Call { return readSource(sources[i]) }
}
workers := min(8, len(order))
window := make(chan struct{}, 4*workers)
next := make(chan int)
go func() {
for _, i := range order {
window <- struct{}{}
next <- i
}
close(next)
}()
for range workers {
go func() {
for i := range next {
s := slots[i]
s.calls = readSource(sources[i])
close(s.done)
}
}()
}
return func(i int) []sessions.Call {
s := slots[i]
if s == nil {
return readSource(sources[i])
}
<-s.done
<-window
cs := s.calls
s.calls = nil
return cs
}
}