metrics: alert rules — sustain windows, hysteresis, nil never fires or clears
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+403
−0
internal/metrics/alert.go+243 −0internal/metrics/alert_test.go+160 −0
diff --git a/internal/metrics/alert.go b/internal/metrics/alert.go| new file mode 100644 |
| index 000000000..8f1f4924a |
| --- /dev/null |
| +++ b/internal/metrics/alert.go |
| @@ -0,0 +1,243 @@ |
| +package metrics |
| + |
| +import ( |
| + "strconv" |
| + "time" |
| +) |
| + |
| +// Rules is the thresholds and sustain windows Evaluate checks each tick |
| +// against. Frac fields are fractions (0..1), matching HostSample; LagSecs is |
| +// the one exception — keymail relay lag arrives in seconds, not a fraction. |
| +type Rules struct { |
| + DiskFrac float64 // fire >, default 0.90, immediate |
| + MemFrac float64 // default 0.90 |
| + MemSustain time.Duration // default 10 * time.Minute |
| + CPUFrac float64 // default 0.95 |
| + CPUSustain time.Duration // default 15 * time.Minute |
| + LagSecs int64 // default 900, immediate; inert while readings are nil |
| + ClearBy float64 // hysteresis, default 0.05 (fractions); lag clears at LagSecs-300 |
| +} |
| + |
| +// DefaultRules are the thresholds shipped with the platform. |
| +func DefaultRules() Rules { |
| + return Rules{ |
| + DiskFrac: 0.90, |
| + MemFrac: 0.90, |
| + MemSustain: 10 * time.Minute, |
| + CPUFrac: 0.95, |
| + CPUSustain: 15 * time.Minute, |
| + LagSecs: 900, |
| + ClearBy: 0.05, |
| + } |
| +} |
| + |
| +// RulesFromEnv applies percent-integer overrides on top of DefaultRules: |
| +// CARLOS_ALERT_DISK_PCT, CARLOS_ALERT_MEM_PCT, CARLOS_ALERT_CPU_PCT. An |
| +// unset, unparsable, or out-of-range (0..100) value keeps the default — |
| +// deliberately pure (no logging); a caller that wants to log a rejected |
| +// override does so itself, since this package touches neither stderr nor |
| +// the OS. |
| +func RulesFromEnv(getenv func(string) string) Rules { |
| + r := DefaultRules() |
| + if v, ok := pctFrac(getenv("CARLOS_ALERT_DISK_PCT")); ok { |
| + r.DiskFrac = v |
| + } |
| + if v, ok := pctFrac(getenv("CARLOS_ALERT_MEM_PCT")); ok { |
| + r.MemFrac = v |
| + } |
| + if v, ok := pctFrac(getenv("CARLOS_ALERT_CPU_PCT")); ok { |
| + r.CPUFrac = v |
| + } |
| + return r |
| +} |
| + |
| +func pctFrac(s string) (float64, bool) { |
| + if s == "" { |
| + return 0, false |
| + } |
| + n, err := strconv.Atoi(s) |
| + if err != nil || n < 0 || n > 100 { |
| + return 0, false |
| + } |
| + return float64(n) / 100.0, true |
| +} |
| + |
| +// Observation is one tick's readings for a box. A nil field means "no |
| +// data this tick" and is handled identically everywhere in Evaluate: the |
| +// corresponding rule's state is left untouched (a firing alert stays |
| +// firing, a quiet rule stays quiet, nothing is ever emitted for it). |
| +type Observation struct { |
| + At time.Time |
| + CPUFrac *float64 |
| + MemFrac *float64 |
| + DiskFrac *float64 |
| + LagSecs *int64 |
| +} |
| + |
| +// RuleState is one rule's sustain/hysteresis bookkeeping. Since and FiredAt |
| +// use omitempty so a never-triggered rule round-trips through JSON as a |
| +// bare {"firing":false} rather than carrying zero-value time.Time noise. |
| +type RuleState struct { |
| + Firing bool `json:"firing"` |
| + Since time.Time `json:"since,omitempty"` // continuously-above-threshold since (sustain tracking) |
| + FiredAt time.Time `json:"fired_at,omitempty"` // set on fire, cleared on clear |
| + Value float64 `json:"value,omitempty"` // reading at last transition |
| +} |
| + |
| +// AlertState is the JSON persisted to disk by the agent loop and published |
| +// to alerts/boxes/<box>.json (see AlertKey), and read back by the console |
| +// watcher that diffs Rules[...].Firing across polls. |
| +type AlertState struct { |
| + V int `json:"v"` // 1 |
| + Box string `json:"box"` |
| + UpdatedAt time.Time `json:"updated_at"` |
| + Rules map[string]RuleState `json:"rules"` // "disk","mem","cpu","lag" |
| +} |
| + |
| +// Event is emitted on a firing or clearing transition — never on every |
| +// tick, and never for a nil reading. |
| +type Event struct { |
| + Rule string `json:"rule"` |
| + Firing bool `json:"firing"` // true=fired, false=cleared |
| + Value float64 `json:"value"` |
| + Box string `json:"box"` |
| +} |
| + |
| +// AlertKey is the bucket key an AlertState for box is stored/published |
| +// under. |
| +func AlertKey(box string) string { |
| + return "alerts/boxes/" + box + ".json" |
| +} |
| + |
| +// Evaluate advances AlertState by one Observation and returns the new |
| +// state plus any fire/clear transitions as Events. |
| +// |
| +// Evaluate never mutates st: it builds a fresh Rules map (copying each |
| +// RuleState by value — RuleState holds no pointers or slices, so the copy |
| +// is a true deep copy) and returns that in the new AlertState. Callers |
| +// comparing old vs new state across the call — the console watcher this |
| +// feeds diffs Rules[...].Firing on exactly that assumption — see two |
| +// independent maps, never the same one mutated in place. |
| +// |
| +// Semantics, per rule ("disk", "mem", "cpu", "lag"): |
| +// - nil reading: that rule's state is left exactly as-is. No data never |
| +// fires and never clears — clearing needs evidence. |
| +// - Not firing, reading > threshold: Since is set if it was zero; the |
| +// rule fires (emitting an Event) once obs.At.Sub(Since) >= sustain |
| +// (immediate rules use sustain 0, so they fire the same tick Since is |
| +// set). A reading back at-or-below threshold before sustain elapses |
| +// resets Since to zero — the sustain window must be continuous. |
| +// - Firing, reading < threshold-ClearBy: the rule clears (emitting an |
| +// Event) and Since/FiredAt are zeroed. |
| +// - Firing, reading in [threshold-ClearBy, threshold] (or still above |
| +// threshold): stays firing — this is the hysteresis band, and it is |
| +// deliberately wider than just "below threshold" so a reading |
| +// oscillating right at the line doesn't flap. |
| +func Evaluate(r Rules, st AlertState, obs Observation) (AlertState, []Event) { |
| + out := AlertState{ |
| + V: st.V, |
| + Box: st.Box, |
| + UpdatedAt: obs.At, |
| + Rules: make(map[string]RuleState, len(st.Rules)), |
| + } |
| + for name, rs := range st.Rules { |
| + out.Rules[name] = rs |
| + } |
| + |
| + var events []Event |
| + emit := func(name string, firing bool, value float64) { |
| + events = append(events, Event{Rule: name, Firing: firing, Value: value, Box: st.Box}) |
| + } |
| + |
| + evalFrac := func(name string, reading *float64, threshold float64, sustain time.Duration) { |
| + if reading == nil { |
| + return |
| + } |
| + val := *reading |
| + cur := out.Rules[name] |
| + clearLine := threshold - r.ClearBy |
| + |
| + if cur.Firing { |
| + if val < clearLine { |
| + cur.Firing = false |
| + cur.Since = time.Time{} |
| + cur.FiredAt = time.Time{} |
| + cur.Value = val |
| + out.Rules[name] = cur |
| + emit(name, false, val) |
| + } |
| + return |
| + } |
| + |
| + if val > threshold { |
| + if cur.Since.IsZero() { |
| + cur.Since = obs.At |
| + } |
| + if obs.At.Sub(cur.Since) >= sustain { |
| + cur.Firing = true |
| + cur.FiredAt = obs.At |
| + cur.Value = val |
| + out.Rules[name] = cur |
| + emit(name, true, val) |
| + return |
| + } |
| + out.Rules[name] = cur |
| + return |
| + } |
| + |
| + if !cur.Since.IsZero() { |
| + cur.Since = time.Time{} |
| + out.Rules[name] = cur |
| + } |
| + } |
| + |
| + evalLagSecs := func(name string, reading *int64, threshold int64, clearBy int64, sustain time.Duration) { |
| + if reading == nil { |
| + return |
| + } |
| + val := float64(*reading) |
| + cur := out.Rules[name] |
| + clearLine := float64(threshold - clearBy) |
| + thresholdF := float64(threshold) |
| + |
| + if cur.Firing { |
| + if val < clearLine { |
| + cur.Firing = false |
| + cur.Since = time.Time{} |
| + cur.FiredAt = time.Time{} |
| + cur.Value = val |
| + out.Rules[name] = cur |
| + emit(name, false, val) |
| + } |
| + return |
| + } |
| + |
| + if val > thresholdF { |
| + if cur.Since.IsZero() { |
| + cur.Since = obs.At |
| + } |
| + if obs.At.Sub(cur.Since) >= sustain { |
| + cur.Firing = true |
| + cur.FiredAt = obs.At |
| + cur.Value = val |
| + out.Rules[name] = cur |
| + emit(name, true, val) |
| + return |
| + } |
| + out.Rules[name] = cur |
| + return |
| + } |
| + |
| + if !cur.Since.IsZero() { |
| + cur.Since = time.Time{} |
| + out.Rules[name] = cur |
| + } |
| + } |
| + |
| + evalFrac("disk", obs.DiskFrac, r.DiskFrac, 0) |
| + evalFrac("mem", obs.MemFrac, r.MemFrac, r.MemSustain) |
| + evalFrac("cpu", obs.CPUFrac, r.CPUFrac, r.CPUSustain) |
| + evalLagSecs("lag", obs.LagSecs, r.LagSecs, 300, 0) |
| + |
| + return out, events |
| +} |
diff --git a/internal/metrics/alert_test.go b/internal/metrics/alert_test.go| new file mode 100644 |
| index 000000000..19c025959 |
| --- /dev/null |
| +++ b/internal/metrics/alert_test.go |
| @@ -0,0 +1,160 @@ |
| +package metrics |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestEvaluateMemSustainAndHysteresis(t *testing.T) { |
| + r := DefaultRules() |
| + st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| + t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} } |
| + |
| + st, ev := Evaluate(r, st, obs(0.95, t0)) // above, sustain starts — no event yet |
| + if len(ev) != 0 || st.Rules["mem"].Firing { |
| + t.Fatalf("must not fire before sustain: ev=%v firing=%v", ev, st.Rules["mem"].Firing) |
| + } |
| + st, ev = Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) // sustain met — fires |
| + if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "mem" { |
| + t.Fatalf("want fire event, got %v", ev) |
| + } |
| + st, ev = Evaluate(r, st, obs(0.88, t0.Add(11*time.Minute))) // hysteresis band (0.85..0.90) — stays firing |
| + if len(ev) != 0 || !st.Rules["mem"].Firing { |
| + t.Fatal("must hold inside hysteresis band") |
| + } |
| + st, ev = Evaluate(r, st, obs(0.80, t0.Add(12*time.Minute))) // below clear line — clears |
| + if len(ev) != 1 || ev[0].Firing { |
| + t.Fatalf("want clear event, got %v", ev) |
| + } |
| +} |
| + |
| +func TestEvaluateDiskFiresImmediately(t *testing.T) { |
| + r := DefaultRules() |
| + st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| + t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + d := 0.95 |
| + |
| + _, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d}) |
| + if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "disk" { |
| + t.Fatalf("disk rule is immediate (sustain 0): want fire on first tick, got %v", ev) |
| + } |
| +} |
| + |
| +func TestEvaluateSustainResetsOnDip(t *testing.T) { |
| + r := DefaultRules() |
| + st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| + t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} } |
| + |
| + st, _ = Evaluate(r, st, obs(0.95, t0)) // sustain starts |
| + st, _ = Evaluate(r, st, obs(0.80, t0.Add(5*time.Minute))) // dip below threshold — resets Since |
| + if !st.Rules["mem"].Since.IsZero() { |
| + t.Fatalf("dip below threshold must reset Since, got %v", st.Rules["mem"].Since) |
| + } |
| + // Climb back above threshold; Since restarts at t0+10m. If sustain had |
| + // wrongly resumed counting from the original t0, this would already have |
| + // fired (t0+10m is 10 minutes after t0) — it must not. |
| + st, ev := Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) |
| + if len(ev) != 0 || st.Rules["mem"].Firing { |
| + t.Fatalf("sustain must restart after a dip, not resume from the original Since: ev=%v", ev) |
| + } |
| + // A full 10 minutes after the restart (t0+20m) — now it fires. |
| + _, ev = Evaluate(r, st, obs(0.95, t0.Add(20*time.Minute))) |
| + if len(ev) != 1 || !ev[0].Firing { |
| + t.Fatalf("want fire once the restarted sustain window elapses, got %v", ev) |
| + } |
| +} |
| + |
| +func TestEvaluateNilReadingHoldsState(t *testing.T) { |
| + r := DefaultRules() |
| + st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| + t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + d := 0.95 |
| + |
| + // Fire disk (immediate). |
| + st, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d}) |
| + if len(ev) != 1 || !st.Rules["disk"].Firing { |
| + t.Fatalf("setup: disk should be firing, got %v %v", ev, st.Rules["disk"]) |
| + } |
| + quietBefore := st.Rules["mem"] |
| + |
| + // A tick with nil disk and nil mem must leave both rules exactly as they are: |
| + // a firing alert stays firing, a quiet rule stays quiet — no event either way. |
| + st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute)}) |
| + if len(ev) != 0 { |
| + t.Fatalf("nil readings must never emit events, got %v", ev) |
| + } |
| + if !st.Rules["disk"].Firing { |
| + t.Fatal("nil reading must not clear a firing rule") |
| + } |
| + if st.Rules["mem"] != quietBefore { |
| + t.Fatalf("nil reading must not change a quiet rule's state: before=%v after=%v", quietBefore, st.Rules["mem"]) |
| + } |
| +} |
| + |
| +func TestEvaluateLagNilForeverNoEvent(t *testing.T) { |
| + r := DefaultRules() |
| + st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| + t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + |
| + for i := 0; i < 5; i++ { |
| + var ev []Event |
| + st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Duration(i) * time.Minute)}) |
| + if len(ev) != 0 { |
| + t.Fatalf("lag rule must never fire while readings stay nil, got %v", ev) |
| + } |
| + if st.Rules["lag"].Firing { |
| + t.Fatal("lag rule must never fire while readings stay nil") |
| + } |
| + } |
| +} |
| + |
| +func TestEvaluateLagFiresAndClears(t *testing.T) { |
| + r := DefaultRules() |
| + st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}} |
| + t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + lag := func(s int64) *int64 { return &s } |
| + |
| + st, ev := Evaluate(r, st, Observation{At: t0, LagSecs: lag(1000)}) // > 900, immediate |
| + if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "lag" { |
| + t.Fatalf("want lag fire on first tick over threshold, got %v", ev) |
| + } |
| + // Clear line is LagSecs-300 = 600. 700 is inside the hysteresis band (600..900) — stays firing. |
| + st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute), LagSecs: lag(700)}) |
| + if len(ev) != 0 || !st.Rules["lag"].Firing { |
| + t.Fatalf("lag must hold inside its hysteresis band, got ev=%v", ev) |
| + } |
| + // 500 < 600 — clears. |
| + _, ev = Evaluate(r, st, Observation{At: t0.Add(2 * time.Minute), LagSecs: lag(500)}) |
| + if len(ev) != 1 || ev[0].Firing { |
| + t.Fatalf("want lag clear below LagSecs-300, got %v", ev) |
| + } |
| +} |
| + |
| +func TestRulesFromEnv(t *testing.T) { |
| + env := func(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| + } |
| + |
| + r := RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "95"})) |
| + if r.DiskFrac != 0.95 { |
| + t.Fatalf("want DiskFrac 0.95, got %v", r.DiskFrac) |
| + } |
| + |
| + def := DefaultRules() |
| + r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "nonsense"})) |
| + if r.DiskFrac != def.DiskFrac { |
| + t.Fatalf("unparsable value must keep default %v, got %v", def.DiskFrac, r.DiskFrac) |
| + } |
| + |
| + r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_MEM_PCT": "150"})) |
| + if r.MemFrac != def.MemFrac { |
| + t.Fatalf("out-of-range value must keep default %v, got %v", def.MemFrac, r.MemFrac) |
| + } |
| + |
| + r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_CPU_PCT": "80"})) |
| + if r.CPUFrac != 0.80 { |
| + t.Fatalf("want CPUFrac 0.80, got %v", r.CPUFrac) |
| + } |
| +} |