carlos / platform Public

host: metrics sampler loop — 60s ticks, 5min flush, hourly rollup+prune, on-box alerts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e8f21100e899eefaf60a7ae7d281471bc0e32c4c parent 6a99775
3 files changed, +636 −0
  • internal/host/agent.go +1 −0
  • internal/host/metrics_loop.go +428 −0
  • internal/host/metrics_loop_test.go +207 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 07a1d6f55..33423c717 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -146,6 +146,7 @@ func RunAgent(args []string) {
go canaryAdoptLoop(reg)
go statusLoop(reg)
go domainsLoop(reg, peekAcmeCache(args))
+ go metricsLoop()
// The same serving loop as `carlos edge`, with the activator injected.
// Caching applies here too: cached hits still avoid the activator, which
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go
new file mode 100644
index 000000000..6b99e7164
--- /dev/null
+++ b/internal/host/metrics_loop.go
@@ -0,0 +1,428 @@
+// The metrics sampler loop — the box-side half of host-process monitoring.
+// Every 60s it samples the whole host plus every monitored systemd unit
+// (sampler.tick), evaluates on-box alert rules against the freshest sample
+// (alertTick), and every fifth tick flushes the buffered samples out to the
+// deployment bucket as raw chunks, rolling up and pruning across hour
+// boundaries (metricsFlush). The sampler/probes split mirrors statusLoop's
+// own status.Probes seam: sampleProbes is the injectable fixture bundle so
+// host_test.go never touches /proc, a cgroup file, or a real clock.
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/metrics"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// sampleProbes is the injectable fixture bundle sampler.tick reads from each
+// tick, like status.Probes for statusTick. now is its own seam (rather than
+// time.Now called directly) so tests control the elapsed time between ticks
+// that unit CPU deltas depend on.
+type sampleProbes struct {
+ procStat func() *metrics.CPUCounters
+ loadavg func() (l1, l5, l15 *float64)
+ memFrac func() *float64
+ diskFrac func() *float64
+ units func() map[string]unitRaw
+ now func() time.Time
+}
+
+// sampler carries the delta state between ticks: the previous /proc/stat
+// reading (for host CPU), the previous per-unit cgroup usage_usec reading
+// (for unit CPU), the wall-clock time of the previous tick (unit CPU needs
+// elapsed wall time, not just the counter delta), and the buffered samples
+// awaiting their next flush.
+type sampler struct {
+ prevCPU *metrics.CPUCounters
+ prevUnit map[string]*int64 // unit -> cpu usec at previous tick
+ prevAt time.Time
+ buf []metrics.Sample
+}
+
+// tick reads p once and appends one metrics.Sample to sm.buf. The very
+// first tick on a fresh sampler has no previous reading to diff against, so
+// its host and unit CPU fractions are nil — not a guessed zero — exactly
+// like metrics.CPUFrac/UnitCPUFrac's own nil-on-no-prior-reading contract.
+func (sm *sampler) tick(p sampleProbes) {
+ now := p.now()
+ curCPU := p.procStat()
+ l1, l5, l15 := p.loadavg()
+ mem := p.memFrac()
+ disk := p.diskFrac()
+ units := p.units()
+
+ var elapsed time.Duration
+ if !sm.prevAt.IsZero() {
+ elapsed = now.Sub(sm.prevAt)
+ }
+
+ hostCPU := metrics.CPUFrac(sm.prevCPU, curCPU)
+
+ var unitSamples map[string]metrics.UnitSample
+ var newPrevUnit map[string]*int64
+ if units != nil {
+ unitSamples = make(map[string]metrics.UnitSample, len(units))
+ newPrevUnit = make(map[string]*int64, len(units))
+ for name, u := range units {
+ var prevUsec *int64
+ if sm.prevUnit != nil {
+ prevUsec = sm.prevUnit[name]
+ }
+ unitSamples[name] = metrics.UnitSample{
+ CPUFrac: metrics.UnitCPUFrac(prevUsec, u.cpuUsec, elapsed),
+ MemBytes: u.memBytes,
+ MemPeakBytes: u.memPeakBytes,
+ ReadBytes: u.readBytes,
+ WriteBytes: u.writeBytes,
+ }
+ newPrevUnit[name] = u.cpuUsec
+ }
+ }
+
+ sm.buf = append(sm.buf, metrics.Sample{
+ At: now,
+ Host: metrics.HostSample{
+ CPUFrac: hostCPU,
+ Load1: l1,
+ Load5: l5,
+ Load15: l15,
+ MemFrac: mem,
+ DiskFrac: disk,
+ },
+ Units: unitSamples,
+ })
+
+ sm.prevCPU = curCPU
+ sm.prevUnit = newPrevUnit
+ sm.prevAt = now
+}
+
+// metricsRawPrefix is the bucket prefix under which a box's raw chunks live
+// — metrics.RawKey's own prefix, duplicated here only for List's sake (List
+// takes a bare prefix, not a templated key).
+func metricsRawPrefix(box string) string {
+ return "metrics/boxes/" + box + "/raw/"
+}
+
+// metricsFlush writes sm.buf out as raw chunks grouped by each sample's own
+// hour — never mixing two hours into one chunk, even when a single flush
+// call spans a boundary. Samples in the current hour (relative to now) are
+// written as that hour's chunk and kept in buf (the current hour's chunk
+// gets rewritten wholesale on every flush as more samples accumulate,
+// mirroring WriteRaw's own "replace whatever is there" contract). Samples
+// from any older hour are written to their own chunk, rolled up via
+// RollupHour+UpsertRollup, and then dropped from buf — that hour is done,
+// buf must not grow unbounded by carrying it forever. Pruning of raw chunks
+// older than 48h runs only when an hour actually rolled over (there is
+// nothing new to prune otherwise).
+func metricsFlush(ctx context.Context, s release.Store, box string, sm *sampler, now time.Time) error {
+ if len(sm.buf) == 0 {
+ return nil
+ }
+ curHour := metrics.HourOf(now)
+
+ curSamples := make([]metrics.Sample, 0, len(sm.buf))
+ oldByHour := map[string][]metrics.Sample{}
+ var oldHours []string
+ for _, samp := range sm.buf {
+ h := metrics.HourOf(samp.At)
+ if h == curHour {
+ curSamples = append(curSamples, samp)
+ continue
+ }
+ if _, ok := oldByHour[h]; !ok {
+ oldHours = append(oldHours, h)
+ }
+ oldByHour[h] = append(oldByHour[h], samp)
+ }
+
+ for _, hour := range oldHours {
+ samples := oldByHour[hour]
+ if err := metrics.WriteRaw(ctx, s, metrics.RawChunk{V: 1, Box: box, Hour: hour, Samples: samples}); err != nil {
+ return err
+ }
+ agg := metrics.RollupHour(samples)
+ if err := metrics.UpsertRollup(ctx, s, box, samples[0].At, agg); err != nil {
+ return err
+ }
+ }
+
+ if len(oldHours) > 0 {
+ if err := pruneStaleRaw(ctx, s, box, now); err != nil {
+ return err
+ }
+ }
+
+ if len(curSamples) > 0 {
+ if err := metrics.WriteRaw(ctx, s, metrics.RawChunk{V: 1, Box: box, Hour: curHour, Samples: curSamples}); err != nil {
+ return err
+ }
+ }
+
+ sm.buf = curSamples
+ return nil
+}
+
+// pruneStaleRaw deletes this box's raw chunks older than 48h — List +
+// metrics.StaleRawKeys (which never returns a key it cannot parse — see its
+// own doc comment) + Delete.
+func pruneStaleRaw(ctx context.Context, s release.Store, box string, now time.Time) error {
+ keys, err := s.List(ctx, metricsRawPrefix(box))
+ if err != nil {
+ return err
+ }
+ for _, key := range metrics.StaleRawKeys(keys, now, 48*time.Hour) {
+ if err := s.Delete(ctx, key); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// alertTick evaluates rules against latest, persists the resulting
+// AlertState to statePath (create parent dir; write via temp+rename — never
+// leaving a half-written state file a restart could load), publishes the
+// new state to metrics.AlertKey(box) only when a rule's firing state
+// actually changed (Evaluate emits Events exactly on those transitions —
+// see its own doc comment — so "any events" is exactly the "state changed"
+// test), and posts each event to webhookURL ("" skips the webhook
+// entirely). A webhook post failure is logged and otherwise ignored:
+// alertTick's job is to persist and publish reliably, and it must not let an
+// unreachable third party stop that — the console watcher polling
+// AlertKey(box) is the reliable channel, the webhook is best-effort.
+func alertTick(ctx context.Context, s release.Store, box string, rules metrics.Rules,
+ st *metrics.AlertState, latest metrics.Sample, statePath, webhookURL string,
+ post func(url string, body []byte) error) error {
+
+ obs := metrics.Observation{
+ At: latest.At,
+ CPUFrac: latest.Host.CPUFrac,
+ MemFrac: latest.Host.MemFrac,
+ DiskFrac: latest.Host.DiskFrac,
+ LagSecs: nil, // v1: no lag reading feeds the agent loop yet
+ }
+ newState, events := metrics.Evaluate(rules, *st, obs)
+
+ if err := persistAlertState(statePath, newState); err != nil {
+ return err
+ }
+
+ if len(events) > 0 {
+ body, err := json.MarshalIndent(newState, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := putAlertState(ctx, s, metrics.AlertKey(box), body); err != nil {
+ return err
+ }
+ }
+
+ for _, ev := range events {
+ if webhookURL == "" {
+ continue
+ }
+ evBody, err := json.Marshal(ev)
+ if err != nil {
+ log.Printf("host: alert event %s: marshal: %v", ev.Rule, err)
+ continue
+ }
+ if err := post(webhookURL, evBody); err != nil {
+ log.Printf("host: alert webhook post (rule %s): %v", ev.Rule, err)
+ }
+ }
+
+ *st = newState
+ return nil
+}
+
+// persistAlertState writes st to path as indented JSON via the repo's
+// standard temp+rename idiom (internal/appconfig/sync.go's renderEnv,
+// internal/release/store.go's DirStore.Put): a crashed write must never
+// leave a half-written state file behind for the next boot to load and
+// choke on. The parent directory (<dataDir>/monitor/) is created if
+// missing — this is the first thing that ever writes into it.
+func persistAlertState(path string, st metrics.AlertState) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ body, err := json.MarshalIndent(st, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, body, 0o644); err != nil {
+ return err
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ return nil
+}
+
+// putAlertState upserts key with a replace-or-create Put, retrying once on
+// an ErrCASConflict — the same read-etag/Put/retry-once dance WriteRaw and
+// UpsertRollup use, since this box is the only writer that should ever be
+// targeting its own alert key.
+func putAlertState(ctx context.Context, s release.Store, key string, body []byte) error {
+ etag, err := currentAlertEtag(ctx, s, key)
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ etag, err = currentAlertEtag(ctx, s, key)
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// currentAlertEtag reads key's current etag for a replace-only Put, folding
+// "doesn't exist yet" into "" (create-only) — the same convention
+// metrics.WriteRaw's own currentRawEtag uses.
+func currentAlertEtag(ctx context.Context, s release.Store, key string) (string, error) {
+ _, etag, err := s.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ return "", err
+ }
+ return etag, nil
+}
+
+// httpPostWebhook is the production webhook post func: a 5s-timeout POST
+// with a JSON content type. Any non-2xx status is an error — the caller
+// (alertTick) only logs it, but it still needs to know delivery failed.
+func httpPostWebhook(url string, body []byte) error {
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Post(url, "application/json", bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("host: webhook: non-2xx status %s", resp.Status)
+ }
+ return nil
+}
+
+// realSampleProbes wires sampleProbes to the actual OS/cgroup readers in
+// metrics_probes.go and probes.go — the production seam metricsLoop uses,
+// never called from a test (host_test.go's convention: tests inject
+// fixtures, never touch /proc or a syscall).
+func realSampleProbes() sampleProbes {
+ return sampleProbes{
+ procStat: readProcStat,
+ loadavg: readLoadAvg,
+ memFrac: readMemFrac,
+ diskFrac: readDiskFrac,
+ units: realUnitReadings,
+ now: time.Now,
+ }
+}
+
+// realUnitReadings lists the box's currently active CARLOS units and reads
+// each one's cgroup usage — the production units func sampleProbes.units
+// wraps.
+func realUnitReadings() map[string]unitRaw {
+ names := listCarlosUnits()
+ if names == nil {
+ return nil
+ }
+ out := make(map[string]unitRaw, len(names))
+ for _, name := range names {
+ out[name] = readUnitRaw(name)
+ }
+ return out
+}
+
+// loadAlertState reads a persisted AlertState from path, falling back to a
+// fresh state for box on anything that isn't a clean read — a missing file
+// (first boot) or a corrupt one (a previous crash mid-write, or a hand
+// edit) must never crash the agent loop; monitoring starting from a blank
+// slate is always a safe, honest state to resume from.
+func loadAlertState(path, box string) metrics.AlertState {
+ fresh := metrics.AlertState{V: 1, Box: box, Rules: map[string]metrics.RuleState{}}
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return fresh
+ }
+ var st metrics.AlertState
+ if err := json.Unmarshal(body, &st); err != nil {
+ return fresh
+ }
+ if st.Rules == nil {
+ st.Rules = map[string]metrics.RuleState{}
+ }
+ return st
+}
+
+// metricsLoop runs the metrics sampler's 60s tick forever, once armed — thin
+// and untested by the same convention as statusLoop/adoptLoop: everything
+// with behavior lives in sampler.tick, metricsFlush, and alertTick, all
+// exercised above with fixtures. Missing status/deployment bucket config
+// disables the loop with one log line, the same "a box with no store is
+// still a valid deployment" shape every other optional loop in this package
+// uses. Flush runs every 5th tick (5 minutes at the 60s tick cadence);
+// alertTick runs every tick, since a threshold breach deserves faster
+// detection than a metrics flush does.
+func metricsLoop() {
+ ctx := context.Background()
+ bucket := resolveStatusBucket(os.Getenv)
+ if bucket == "" {
+ log.Printf("agent: metrics loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set")
+ return
+ }
+ s, err := release.NewS3Store(ctx, bucket)
+ if err != nil {
+ log.Printf("agent: metrics loop disabled: %v", err)
+ return
+ }
+
+ box := status.Label(readMachineID())
+ statePath := filepath.Join(dataDir(), "monitor", "alerts.json")
+ st := loadAlertState(statePath, box)
+ rules := metrics.RulesFromEnv(os.Getenv)
+ webhookURL := os.Getenv("CARLOS_ALERT_WEBHOOK")
+
+ log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to bucket %s", bucket)
+
+ sm := &sampler{}
+ probes := realSampleProbes()
+ tick := 0
+ for {
+ time.Sleep(60 * time.Second)
+ tick++
+
+ sm.tick(probes)
+ latest := sm.buf[len(sm.buf)-1]
+ if err := alertTick(ctx, s, box, rules, &st, latest, statePath, webhookURL, httpPostWebhook); err != nil {
+ log.Printf("agent: alert tick: %v", err)
+ }
+
+ if tick%5 == 0 {
+ if err := metricsFlush(ctx, s, box, sm, time.Now()); err != nil {
+ log.Printf("agent: metrics flush: %v", err)
+ }
+ }
+ }
+}
diff --git a/internal/host/metrics_loop_test.go b/internal/host/metrics_loop_test.go
new file mode 100644
index 000000000..741c6f146
--- /dev/null
+++ b/internal/host/metrics_loop_test.go
@@ -0,0 +1,207 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/metrics"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestSamplerTickAndFlush is the brief's Step-1 test, verbatim: two ticks
+// across a delta (first has no CPU reading, second derives a fraction from
+// both /proc/stat and a unit's cgroup usage_usec counters), then a flush
+// within the hour followed by a flush across an hour boundary, checking the
+// raw chunk and the day rollup it produces.
+func TestSamplerTickAndFlush(t *testing.T) {
+ ctx := context.Background()
+ store := release.NewDirStore(t.TempDir())
+ at := time.Date(2026, 8, 2, 10, 59, 30, 0, time.UTC)
+ c0, c1 := &metrics.CPUCounters{Busy: 100, Total: 1000}, &metrics.CPUCounters{Busy: 160, Total: 1100}
+ mem, disk := 0.5, 0.3
+ usec0, usec1 := int64(0), int64(3_000_000)
+ sm := &sampler{}
+ probes := func(c *metrics.CPUCounters, usec int64, now time.Time) sampleProbes {
+ return sampleProbes{
+ procStat: func() *metrics.CPUCounters { return c },
+ loadavg: func() (*float64, *float64, *float64) { return nil, nil, nil },
+ memFrac: func() *float64 { return &mem },
+ diskFrac: func() *float64 { return &disk },
+ units: func() map[string]unitRaw {
+ return map[string]unitRaw{"carlos-app@a.example.com.service": {cpuUsec: &usec}}
+ },
+ now: func() time.Time { return now },
+ }
+ }
+ sm.tick(probes(c0, usec0, at))
+ sm.tick(probes(c1, usec1, at.Add(time.Minute)))
+ if len(sm.buf) != 2 {
+ t.Fatalf("buf=%d, want 2", len(sm.buf))
+ }
+ if sm.buf[0].Host.CPUFrac != nil {
+ t.Fatal("first tick has no delta — CPU must be nil, not 0")
+ }
+ if f := sm.buf[1].Host.CPUFrac; f == nil || math.Abs(*f-0.6) > 1e-9 {
+ t.Fatalf("second tick CPU=%v, want 0.6", f)
+ }
+ if f := sm.buf[1].Units["carlos-app@a.example.com.service"].CPUFrac; f == nil || math.Abs(*f-0.05) > 1e-9 {
+ t.Fatalf("unit CPU=%v, want 0.05 (3s of CPU over 60s)", f)
+ }
+
+ // Flush within the hour: one raw chunk, no rollup yet.
+ if err := metricsFlush(ctx, store, "box-a", sm, at.Add(time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := store.Get(ctx, "metrics/boxes/box-a/raw/2026-08-02-10.json"); err != nil {
+ t.Fatalf("raw chunk missing: %v", err)
+ }
+ // Tick past the hour boundary, flush again: previous hour rolled up.
+ sm.tick(probes(c1, usec1, at.Add(2*time.Minute))) // 11:01:30
+ if err := metricsFlush(ctx, store, "box-a", sm, at.Add(2*time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := store.Get(ctx, "metrics/boxes/box-a/days/2026-08-02.json")
+ if err != nil {
+ t.Fatalf("day rollup missing: %v", err)
+ }
+ var day metrics.DayRollup
+ if err := json.Unmarshal(body, &day); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := day.Hours["10"]; !ok {
+ t.Fatalf("hour 10 not rolled up: %v", day.Hours)
+ }
+}
+
+// TestMetricsFlushPrunesOldChunks pins the 48h prune: a raw chunk seeded
+// well past the retention window must be gone after a flush that crosses an
+// hour boundary (the only time pruning runs), while a fresh chunk survives.
+func TestMetricsFlushPrunesOldChunks(t *testing.T) {
+ ctx := context.Background()
+ store := release.NewDirStore(t.TempDir())
+ box := "box-a"
+
+ oldHour := time.Date(2026, 7, 30, 9, 0, 0, 0, time.UTC) // 3 days before "now" below
+ if err := metrics.WriteRaw(ctx, store, metrics.RawChunk{
+ V: 1, Box: box, Hour: metrics.HourOf(oldHour), Samples: []metrics.Sample{{At: oldHour}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ freshHour := time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)
+ if err := metrics.WriteRaw(ctx, store, metrics.RawChunk{
+ V: 1, Box: box, Hour: metrics.HourOf(freshHour), Samples: []metrics.Sample{{At: freshHour}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ // A sampler buffer holding one sample from the hour just before "now" —
+ // this is what triggers the hour-rollover branch (rollup + prune).
+ sm := &sampler{buf: []metrics.Sample{
+ {At: time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)},
+ }}
+ now := time.Date(2026, 8, 2, 10, 0, 5, 0, time.UTC)
+ if err := metricsFlush(ctx, store, box, sm, now); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, _, err := store.Get(ctx, metrics.RawKey(box, metrics.HourOf(oldHour))); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("old raw chunk should be pruned, got err=%v", err)
+ }
+ if _, _, err := store.Get(ctx, metrics.RawKey(box, metrics.HourOf(freshHour))); err != nil {
+ t.Errorf("fresh raw chunk should survive pruning: %v", err)
+ }
+}
+
+// TestAlertTickPersistsPublishesAndPosts pins the fire path: a disk reading
+// over threshold persists local state, publishes the new AlertState to the
+// store, and posts exactly one webhook event for the transition — a second,
+// identical tick (still firing, no transition) must not post again.
+func TestAlertTickPersistsPublishesAndPosts(t *testing.T) {
+ ctx := context.Background()
+ store := release.NewDirStore(t.TempDir())
+ statePath := filepath.Join(t.TempDir(), "monitor", "alerts.json")
+ box := "box-a"
+ rules := metrics.DefaultRules()
+ st := &metrics.AlertState{V: 1, Box: box, Rules: map[string]metrics.RuleState{}}
+
+ disk := 0.95
+ latest := metrics.Sample{
+ At: time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC),
+ Host: metrics.HostSample{DiskFrac: &disk},
+ }
+
+ var posts [][]byte
+ post := func(url string, body []byte) error {
+ posts = append(posts, body)
+ return nil
+ }
+
+ if err := alertTick(ctx, store, box, rules, st, latest, statePath, "http://example.test/webhook", post); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := os.Stat(statePath); err != nil {
+ t.Fatalf("state file not written: %v", err)
+ }
+ body, _, err := store.Get(ctx, metrics.AlertKey(box))
+ if err != nil {
+ t.Fatalf("published alert state missing: %v", err)
+ }
+ var published metrics.AlertState
+ if err := json.Unmarshal(body, &published); err != nil {
+ t.Fatal(err)
+ }
+ if !published.Rules["disk"].Firing {
+ t.Errorf("disk rule not firing in published state: %+v", published.Rules["disk"])
+ }
+ if len(posts) != 1 {
+ t.Fatalf("posts = %d, want 1", len(posts))
+ }
+ if !strings.Contains(string(posts[0]), `"rule":"disk"`) {
+ t.Errorf("post body = %s, want it to contain the disk rule", posts[0])
+ }
+
+ // Second, identical tick: already firing, no new transition -> no post.
+ if err := alertTick(ctx, store, box, rules, st, latest, statePath, "http://example.test/webhook", post); err != nil {
+ t.Fatal(err)
+ }
+ if len(posts) != 1 {
+ t.Errorf("posts after second tick = %d, want still 1 (no new transition)", len(posts))
+ }
+}
+
+// TestAlertTickWebhookFailureDoesNotBlock pins the fire-and-forget webhook
+// contract: a post failure is logged, never returned — state must still be
+// persisted locally and published to the store.
+func TestAlertTickWebhookFailureDoesNotBlock(t *testing.T) {
+ ctx := context.Background()
+ store := release.NewDirStore(t.TempDir())
+ statePath := filepath.Join(t.TempDir(), "monitor", "alerts.json")
+ box := "box-a"
+ rules := metrics.DefaultRules()
+ st := &metrics.AlertState{V: 1, Box: box, Rules: map[string]metrics.RuleState{}}
+
+ disk := 0.95
+ latest := metrics.Sample{
+ At: time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC),
+ Host: metrics.HostSample{DiskFrac: &disk},
+ }
+ post := func(url string, body []byte) error { return errors.New("webhook unreachable") }
+
+ if err := alertTick(ctx, store, box, rules, st, latest, statePath, "http://example.test/webhook", post); err != nil {
+ t.Fatalf("alertTick must not fail on a webhook error: %v", err)
+ }
+ if _, err := os.Stat(statePath); err != nil {
+ t.Errorf("state file must still be written: %v", err)
+ }
+ if _, _, err := store.Get(ctx, metrics.AlertKey(box)); err != nil {
+ t.Errorf("state must still be published: %v", err)
+ }
+}