Artifact 6b536c10e2fc57a94e7c8b2db05b32fe8e75eb2eb95ee4e27b9eedcce150cbb4:
- File
internal/obs/obs.go
— part of check-in
[e2b74749b4]
at
2026-08-25 22:19:15
on branch fleet-census
— Analytics: say whether history is being kept, and stop keeping it for ever
The live panels read in-memory rings, so a forge whose store failed to open looked exactly like a healthy one. Sink.Err() existed and nothing asked it. The page now distinguishes three states: off, broken, and recording to a named path.
Retention was 'append for ever', which is a default nobody was asked about. --analytics-keep sets the window, 14d by default, 0 keeps everything. Pruned at startup and daily. (user: dmestas, size: 8361) [annotate] [blame] [check-ins using] [more...]
// Package obs is the forge's view of its own behaviour: how long pages // take, and what the store subprocesses cost. // // It exists because quarry could not answer either question. A render // time was printed in the page footer and thrown away, so a 4.24-second // files page was visible only to whoever loaded it and happened to read // the footer. Subprocess load was visible nowhere at all: a warm loop // once spawned 134 concurrent `zit ls --long` against the same hash, // load average 546 on 14 cores, and the forge's own admin page showed // "all healthy" throughout. // // Two deliberate limits. // // The rings are SMALL and in memory. This package answers "what is // happening now, and in the recent past" — the snapshot every comparable // admin surface settles on, and the one GitLab's own docs are candid // about: a point-in-time view cannot tell you what happened an hour ago. // History belongs in the analytics store behind Sink, not here. // // Sink is an interface with a no-op default because the persistent // implementation is DuckDB, which is cgo. quarry is CGO-free on purpose // — the SQLite driver is a wasm build for exactly this reason — so that // the forge can theoretically compile to wasm for an embedded UI. That // embedded build ships no analytics, so it wires no sink and never // links DuckDB. Nothing on the request path may import the persistent // implementation directly. package obs import ( "sync" "time" ) // Render is one page served. type Render struct { // ID ties a render to the store calls made while serving it. The // panel could say a route was slow and nothing more: the subprocess // list and the render list were two independent tables, so // "GET /search took 1132ms" and "zit artifact ran 499 times" could // not be connected even when one caused the other. ID string At time.Time Path string // the ROUTE, not the URL: /r/{repo}/checkin/{hash} Repo string Elapsed time.Duration // Ops is time inside op handlers and CONTAINS Zit: the registry is // the seam every page's data crosses, and store calls happen inside // it. They are reported as container and content, never summed. // // Neither accounts for the whole of Elapsed, and the REMAINDER is // the point: the files page spent 675ms with no zit call at all and // nothing could say where it went. What these two do not cover is // template execution and the server's own overhead, and seeing that // gap shrink is how the instrumentation earns its keep. Ops, Zit time.Duration Status int } // Subprocess is one zit invocation. type Subprocess struct { // RenderID names the request being served, empty for work with no // request behind it — ingest rounds, warm passes, the doc indexer. // That emptiness is informative: store load with no render to blame // is background work, and telling the two apart is most of what the // subprocess panel is for. RenderID string At time.Time Verb string // ls, diff, cat, tarball… Repo string Duration time.Duration Err bool } // Sink receives every observation for durable storage. The default is a // no-op: a forge with no analytics store still shows the live panels, // because those read the rings rather than the sink. type Sink interface { Render(Render) Subprocess(Subprocess) } type nopSink struct{} func (nopSink) Render(Render) {} func (nopSink) Subprocess(Subprocess) {} // Recorder holds the live rings and forwards to a sink. type Recorder struct { mu sync.Mutex sink Sink health Health rend ring[Render] subs ring[Subprocess] paths map[string]*pathStat // live is the CURRENT subprocess count, which is the number the // stampede made matter: a listing that is slow is a performance // note, and thirteen of the same listing at once is an incident. live int liveMax int // slow remembers which routes have already been reported, so a // route that is slow a thousand times notifies once. errlog's rule: // an alert channel that repeats itself gets muted, and a muted // channel is worse than none because it reads as healthy. slow map[string]bool // OnSlow runs the FIRST time a route exceeds Budget. Set by the // composition root when a notification plane exists; nil is the // ordinary case and costs nothing. OnSlow func(Render) } // Budget is the render time above which a page needs attention. // // ONE definition. It was a literal repeated in a template — the panel // coloured a p95 over 500ms and nothing else knew the number — so a // forge could show a route in red and tell nobody. The colouring and // the notification read this. // // 500ms is a product decision, not a measurement: it is the point at // which a page stops feeling immediate. Everything the forge serves is // comfortably inside it today; the value exists so that stops being // true loudly rather than quietly. const Budget = 500 * time.Millisecond // Default is the process-wide recorder, the shape errlog.Default // already uses for the same reason: the funnels that observe (one HTTP // middleware, one subprocess wrapper) are deep in call chains that // should not have to be handed one. var Default = NewRecorder(500, nil) // NewRecorder builds a recorder keeping the last n of each kind. func NewRecorder(n int, sink Sink) *Recorder { if n <= 0 { n = 500 } if sink == nil { sink = nopSink{} } return &Recorder{sink: sink, rend: newRing[Render](n), subs: newRing[Subprocess](n), paths: map[string]*pathStat{}, slow: map[string]bool{}} } // SetSink swaps the durable destination. Safe at any time; the rings are // unaffected, so the live panels never depend on it. func (r *Recorder) SetSink(s Sink) { if s == nil { s = nopSink{} } r.mu.Lock() r.sink = s r.mu.Unlock() } // Health is what an admin surface can say about the durable store. // // A forge that failed to open it records nothing and, until this // existed, said nothing either: the live panels are fed by the rings // and look identical either way. That is how a schema failure would // have reached production unnoticed — it was caught in a test run by // luck, not by the interface. type Health struct { // Configured is false when analytics is switched off, which is a // choice rather than a fault and must not read as one. Configured bool `json:"configured"` Path string `json:"path"` // Err is the reason the store is not recording: a failure to open // it, or the last write failure reported by the sink. Err string `json:"err"` // Kept is the retention window, empty when rows are kept forever. Kept string `json:"kept"` } // SetHealth records what happened when the durable store was opened. func (r *Recorder) SetHealth(h Health) { r.mu.Lock() r.health = h r.mu.Unlock() } // Health reports the durable store's state, folding in the sink's own // last write failure so a store that opened and then broke is not // reported as healthy. func (r *Recorder) Health() Health { r.mu.Lock() h, sink := r.health, r.sink r.mu.Unlock() if h.Err == "" { if e, ok := sink.(interface{ Err() error }); ok { if err := e.Err(); err != nil { h.Err = err.Error() } } } return h } // RecordRender files one page. func (r *Recorder) RecordRender(v Render) { if r == nil { return } r.mu.Lock() r.rend.push(v) p := r.paths[v.Path] if p == nil { p = &pathStat{} r.paths[v.Path] = p } p.add(v.Elapsed) sink := r.sink // First crossing only, and decided under the lock so two slow // requests racing cannot both report the same route. var tell func(Render) if v.Elapsed > Budget && !r.slow[v.Path] { r.slow[v.Path] = true tell = r.OnSlow } r.mu.Unlock() sink.Render(v) if tell != nil { tell(v) } } // RecordSubprocess files one zit invocation and closes its live slot. func (r *Recorder) RecordSubprocess(v Subprocess) { if r == nil { return } r.mu.Lock() r.subs.push(v) if r.live > 0 { r.live-- } sink := r.sink r.mu.Unlock() sink.Subprocess(v) } // Spawned opens a live slot, before the work begins. Paired with // RecordSubprocess so the count reflects what is RUNNING rather than // what has finished — the finished ones were never the problem. func (r *Recorder) Spawned() { if r == nil { return } r.mu.Lock() r.live++ if r.live > r.liveMax { r.liveMax = r.live } r.mu.Unlock() }