carlos / platform Public

spec: host & process monitoring — needles for operators, on-box alerting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 42e1a70f66f7761909531e57147de552839dfaa5 parent c0823de
1 file changed, +139 −0
  • docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md +139 −0
diff --git a/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md
new file mode 100644
index 000000000..0c074bbde
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md
@@ -0,0 +1,139 @@
+# Host & process monitoring — design
+
+Operator-grade monitoring for CARLOS boxes: real CPU, memory, and disk
+numbers per host and per process, with history, alerting, and a console
+dashboard. This is a new capability, distinct from the fleet status page
+(`2026-08-01-fleet-status-page-design.md`): that page publishes band words
+and aggregates for a public audience; this publishes needles for operators.
+The two share probes but never a pipeline — the status page's "no needles
+ever" contract stays structurally intact because its writer never reads
+anything this spec introduces.
+
+Approach: CARLOS-native. Extend the existing box agent, store in the
+bucket, render in the console. No Prometheus/Grafana fleet to host, no
+CloudWatch coupling — the fleet is a handful of boxes and the platform's
+control plane is already the bucket.
+
+## 1. Collection — a metrics sampler in the box agent
+
+A new sampler loop in `internal/host`, ticking every **60 seconds**,
+independent of the 5-minute status snapshot tick (which is untouched).
+
+Host-level, each tick:
+
+- **CPU busy fraction** — delta between successive `/proc/stat` readings
+ (busy = non-idle, non-iowait jiffies over total). The delta is why this
+ is a stateful sampler rather than a stateless probe like the existing
+ ones; the first tick after start has no delta and reports nil.
+- **Load averages** — 1/5/15 from `/proc/loadavg`.
+- **Memory fraction** — reuse `readMemFrac` (MemAvailable-based).
+- **Disk fraction** — reuse `readDiskFrac` (Statfs on the data dir).
+
+Per-process, each tick — where "process" means **systemd unit**, read from
+cgroup v2 files, never `ps` scraping:
+
+- Covered units: every `carlos-app@*.service` (tenant apps — this is the
+ noisy-neighbour attribution) plus the platform's own units: the console,
+ the agent itself, litestream, and the keymail relay.
+- Per unit: CPU fraction (`cpu.stat` usage_usec delta over wall clock),
+ `memory.current`, `memory.peak`, and cumulative read/write bytes from
+ `io.stat`.
+- A hibernating app has no live cgroup and is simply **absent** from that
+ tick's sample — absence is the data; nothing is invented for it.
+
+Every reading keeps the probe convention already documented in
+`internal/host/probes.go`: **fail toward nil, never a guessed zero.** A
+box that can't answer is not a box that is fine.
+
+Samples accumulate in memory and flush to the bucket every 5 minutes.
+
+## 2. Storage — bucket, additive only
+
+All-new objects under a new prefix; no existing artifact changes shape
+(wire-shape rule: everything already in production buckets still parses).
+
+- **Raw**: `metrics/boxes/<box>/raw/<YYYY-MM-DD-HH>.json` — the current
+ hour's minute-samples (host + per-unit), rewritten in full at each
+ 5-minute flush (12 PUTs/hour/box). `<box>` is the same label the status
+ snapshot uses. Retained **48 hours**; the agent prunes expired hours on
+ its hourly boundary.
+- **Rollups**: `metrics/boxes/<box>/days/<YYYY-MM-DD>.json` — hourly
+ min/avg/max per metric, host and per-unit, appended once an hour by
+ rewriting the current day object. Retained **indefinitely** (they are
+ tiny); this is the capacity/trend history.
+- Object bodies carry a `"v": 1` field so later shape changes can be
+ additive and detectable.
+
+Privacy boundary: these are operator-plane needles, private by
+construction — readable only with bucket access or through the console's
+operator role. The public status page writer takes no input from the
+`metrics/` or `alerts/` prefixes.
+
+## 3. Alerting — evaluated on-box
+
+The box judges itself, so alerting works even when the console is down.
+
+**v1 rules**, defaults baked in, overridable through the existing
+app-config mechanism:
+
+| Rule | Threshold | Sustain |
+|---|---|---|
+| Disk fraction | > 90% | immediate — disk doesn't spike back down |
+| Memory fraction | > 90% | 10 minutes |
+| Host CPU | > 95% | 15 minutes |
+| Litestream lag | > 15 minutes | immediate |
+
+Per-unit alerts are deliberately out of v1: the host-level alert points at
+the box, the dashboard's per-unit table answers "which app did it."
+
+**Hysteresis and state**: fire at threshold, clear at threshold minus 5
+percentage points (lag: minus 5 minutes). Alert state persists on-box
+(under the data dir) so an agent restart neither re-fires nor forgets an
+active alert, and is published to `alerts/boxes/<box>.json`.
+
+**Delivery**, on fire and on clear:
+
+- **Keymail** to configured operator addresses.
+- **Webhook** — POST of the alert JSON to a configured URL, if set.
+- **Console** — reads `alerts/boxes/*.json` and shows red state on the
+ operator dashboard; this channel needs no configuration.
+
+Delivery failure never blocks sampling or state transitions; it is logged
+and retried on the next tick while the alert remains active.
+
+## 4. Console — operator dashboard
+
+A new console page behind the **existing operator role**, with the
+existing security gate ordering (session → CSRF → role; byte-identical
+404 for non-operators, `requireFreshAuth` last if used).
+
+- Per-box: current CPU / memory / disk as real numbers, 48-hour
+ sparklines from the raw chunks, longer trends from the daily rollups.
+- Per-box, per-unit table sortable by CPU and memory — the
+ noisy-neighbour view. Hibernating (absent) units render as absent, not
+ as zero.
+- Active alerts render red at the top; a cleared alert disappears rather
+ than lingering.
+- House rendering rules: vendored assets only (Lucide-style in-code SVG,
+ no CDN); sparklines drawn from inline data.
+
+## 5. Testing
+
+- Samplers and cgroup readers are injectable fixtures at the same seam
+ the existing `statusTick` tests use — tests never touch `/proc`,
+ cgroups, or a live syscall.
+- CPU-delta computation, banding, rule evaluation, and hysteresis are
+ pure functions with direct unit tests (including: first-tick nil,
+ sustain windows, fire/clear boundaries, restart-with-persisted-state).
+- A contract test asserts the public status page output contains no
+ numeric needles — guarding the privacy boundary against regression.
+- Bucket object shapes get round-trip tests, and a
+ parses-yesterday's-shape test once `v` ever bumps.
+
+## Out of scope (v1)
+
+- Per-unit alert rules.
+- Network and per-request metrics (edge/router latency, request rates).
+- Public exposure of any of this — the public page keeps band words only.
+- Cross-box aggregate alerting ("the fleet is degraded") — each box
+ alerts for itself.