// Package errlog is the server's error journal: a bounded, deduplicated
// ring of what has gone wrong, so 500s stop being writes to a log
// nobody reads. Every funnel that answers "internal error" to a reader
// reports the real error here; /fleet renders the ring; the notify
// plane fires on a signature's FIRST sighting only.
//
// Deliberately in-process state, not a storage tier: the ring restarts
// with the server, which is the right lifetime for "what is going wrong
// right now". Durability, when someone needs it, is fossil's answer —
// an append-only errorlog file — layered beside this, not instead.
package errlog
import (
"sync"
"time"
)
// Entry is one distinct error signature and its history.
type Entry struct {
Message string `json:"message"`
Surface string `json:"surface"` // web | api | panic
Path string `json:"path"` // where it was FIRST seen
Count int `json:"count"`
First time.Time `json:"first"`
Last time.Time `json:"last"`
}
// Ring holds the most recent distinct signatures, deduplicated by
// message: a hot loop is one row with a count, never a flood.
type Ring struct {
mu sync.Mutex
cap int
entries map[string]*Entry
// OnFirst, when set, runs on a signature's first sighting — the
// alerting hook. Called OUTSIDE the lock with a copy; an alert
// channel that repeats itself gets muted, so repeats never fire it.
OnFirst func(Entry)
}
// New builds a ring bounded to cap distinct signatures.
func New(cap int) *Ring {
if cap <= 0 {
cap = 500
}
return &Ring{cap: cap, entries: make(map[string]*Entry)}
}
// Default is the process-wide journal the funnels report into.
var Default = New(500)
// Report records one occurrence. First sighting of a signature fires
// OnFirst; when the ring is full, the signature least recently SEEN is
// evicted — an error still happening is never displaced by one that
// stopped.
func (r *Ring) Report(surface, path string, err error) {
if err == nil {
return
}
msg := err.Error()
now := time.Now()
r.mu.Lock()
e, seen := r.entries[msg]
if seen {
e.Count++
e.Last = now
r.mu.Unlock()
return
}
if len(r.entries) >= r.cap {
var oldestKey string
var oldest time.Time
for k, v := range r.entries {
if oldestKey == "" || v.Last.Before(oldest) {
oldestKey, oldest = k, v.Last
}
}
delete(r.entries, oldestKey)
}
fresh := &Entry{Message: msg, Surface: surface, Path: path,
Count: 1, First: now, Last: now}
r.entries[msg] = fresh
hook := r.OnFirst
copyE := *fresh
r.mu.Unlock()
if hook != nil {
hook(copyE)
}
}
// Recent returns entries newest-last-seen first, at most limit.
func (r *Ring) Recent(limit int) []Entry {
r.mu.Lock()
out := make([]Entry, 0, len(r.entries))
for _, e := range r.entries {
out = append(out, *e)
}
r.mu.Unlock()
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j].Last.After(out[j-1].Last); j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out
}
// Stats answers the health surface: total occurrences, distinct
// signatures, and when anything last went wrong.
func (r *Ring) Stats() (total, distinct int, lastAt time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
for _, e := range r.entries {
total += e.Count
distinct++
if e.Last.After(lastAt) {
lastAt = e.Last
}
}
return total, distinct, lastAt
}