DECIDED: unify fleet logging + metrics into one spot#
Decision record
This is the plan/decision document behind the deployed
tsunaminoai.telemetry stack, kept as an architecture-decision record.
For operating the live stack, see Alerts and
Status page.
Status: DECIDED (phase 1) — research spike completed 2026-07. The open questions from the draft have been researched and answered. The stack is VictoriaMetrics + VictoriaLogs + Grafana + vmalert/Alertmanager, with Grafana Alloy as the fleet agent — all Apache-2.0. Phase 1 (ereshkigal-only) is specified below and scaffolded as
tsunaminoai.telemetry. Rejected options are recorded in the appendix.
Why (motivation)#
The 2026-07-15 HA Green outage ran ~10 h before anyone noticed — the classic failure of a homelab with no alerting. Today the observability signal is scattered and mostly “is it up?”, never “what’s wrong / is it about to break”:
- Per-host journald, never shipped anywhere. Correlating an incident means SSHing to each box.
- Gatus (
tsunaminoai.monitoring) — a declarative endpoint-contribution module exists, but it is dormant: nothing enables it and nothing contributes endpoints, so even synthetic up/down checks aren’t live. Homer links tohttps://status.falseblue.com, which nothing serves. - Homer — a dashboard of links, no live data.
- A single orphaned metric:
servarr-backupwrites/etc/servarr-backup-metricsin node_exporter textfile format — and it is doubly broken: the content is a staticenvironment.etcstring (the$(stat …)command substitutions are never executed), and nothing scrapes it anyway. - No time-series, no log aggregation, no hardware health (SMART/IPMI/temps/fans — the very things behind this month’s fan + dead-K80 + PERC issues), no alerting.
Goal: one place to query metrics + logs + health for every host, with alerts that actually reach me when something breaks — including when home is down.
Second customer: praxis (control validation + incident determination)#
This stack has a second job beyond the homelab: it is the prototype for a
praxis (sc2in/praxis) supporting toolset for validating controls and
determining incidents. That constraint shaped the design — keep it light,
flexible, available, and secure:
- Every praxis control (and the in-tree
modules/compliancePoC it grew from) already carriesmonitors[]({name, type, interval, conditions[]}) andalerts[]({type, severity, description, remediation}) fields in its schema. Nothing consumes them today. The telemetry stack is designed as that consumer:monitors[]→ vmalert rule groups;alerts[]→ Alertmanager routing labels + annotations (remediation text arrives with the page). - praxis’s MON family already enforces the collection substrate on managed hosts (MON-02 persistent journald, MON-04 storage capacity, MON-05.1 alert-on-logging-failure, MON-07/07.1 NTS time sync, MON-10 retention). This stack is the centralized aggregation + evaluation layer on top of it.
- All 44 IRO (incident response) controls in praxis are currently disposition
organizational(policy-doc only). A pipeline that evaluatesmonitors[].conditionscontinuously and records alert history as timestamped evidence is the path to promoting IRO controls towardverified— automated incident determination instead of attestation prose. - Portability constraint: the rule-generation logic lives in a pure lib
(
modules/nixos/telemetry/lib.nix,{lib}:-only, zerotsunaminoai.*references, exposed asflake.lib.telemetry) so praxis can pin or vendor it without dragging homelab coupling along. The NixOS modules take plaindataDir/domain/*Fileoptions; kurVolumes/step-ca/sops wiring lives only in host config.
What exists today (baseline — corrected)#
| Layer | Today | Verdict |
|---|---|---|
| Synthetic uptime | Gatus collector module (tsunaminoai.monitoring.endpoints.<ns>) — dormant, zero consumers |
Revive as a separate small task. The contribution idiom is the model the telemetry collectors copy. |
| Service directory | Homer (tsunaminoai.homer.services) |
Keep — it’s a launcher, not monitoring. |
| Host logs | journald per host (praxis MON-02 makes it persistent on managed hosts) | Ship + aggregate (phase 1). |
| Metrics | none (one doubly-broken textfile) | New. Textfile collector in phase 1 gives the servarr metric a real home. |
| Hardware health | none | New (SMART-via-megaraid / IPMI). |
| Alerting / notify | none | New — the highest-value gap. |
| Deploy health | bespoke SSH-poller dashboard (tsunaminoai.deploy, port 8420) |
~~Keep for now~~ Retired (#250, 2026-08) — generation/activation panels on the Telemetry Overview carry the signal. |
| Historical | grafana / influxdb / openobserve / uptime-kuma once ran as Proxmox VMs, all decommissioned | Prior art. |
Decisions#
Each draft open-question, answered (research spike, 2026-07):
| Question | Decision | Rationale |
|---|---|---|
| Metrics backend | VictoriaMetrics single-node | Apache-2.0, single Go binary (fine on the no-AVX2 Xeon), ~10x disk efficiency vs Prometheus, one-flag retention, mature services.victoriametrics module, LTS line. MetricsQL is a PromQL superset — spot-check rate()/staleness edges on imported rules during live validation. |
| Logs backend | VictoriaLogs | Production-grade as of 2025-26; benchmarks show ~72% less CPU / ~87% less RAM than Loki; official Grafana datasource plugin; and log alerting reuses vmalert (LogsQL stats → Prometheus-compatible API), so one alert engine covers metrics + logs. |
| Agent | Grafana Alloy (one agent on every capable host) | Apache-2.0, services.alloy in nixpkgs, loki.source.journal + loki.process does the noisy-log filtering at the agent (see the chatty-log tax below), and a single Alloy writes metrics to VM and logs to VL simultaneously. Ingestion uses the Loki-push endpoint (/insert/loki/api/v1/push) rather than systemd-journal-upload, avoiding the systemd ≥ 258 requirement for auth headers. Alloy ships no armv6 builds — the Pi Zero tier is heartbeat-only by construction. |
| Where “one spot” lives | ereshkigal, data on a dedicated kur LV (/var/lib/telemetry) — never the 90%-full btrfs root |
Always-on, freed capacity, 10 G to voile. Its SPOF-ness is answered by the dead-man’s-switch, not by moving the TSDB off-site. |
| Off-site watchdog | Hosted healthchecks.io (free tier) as the phase-1 dead-man’s-switch: vmalert runs a permanent always-firing Watchdog alert → Alertmanager webhook → healthchecks.io ping URL; silence beyond grace ⇒ external email/push |
Zero new hosts, no shared failure domain with home / the tailnet / myon. (At decision time myon was an external Debian VPS, not repo-managed; it has since been onboarded as a NixOS flake host — #164, hosts/x86_64-nixos/myon — so an on-myon heartbeat is now declaratively deployable and would supplement, not replace, the external heartbeat.) |
| Notifications | Alertmanager → ntfy-alertmanager bridge → self-hosted ntfy on ereshkigal (token auth, per-topic ACLs). Grafana is strictly view-only — no Grafana unified alerting | Declarative end-to-end; rich alerts die with home, but that exact case is what healthchecks.io covers. |
| Retention | Logs: 3 months — aligned with praxis MON-10’s retention knob default (3month); metrics: 12 months (VM disk cost is trivial) |
The SCF question answered from the source: MON-10 defines the retention control; the centralized store must meet or exceed its knob. |
| Hosts to instrument | Phase 1: ereshkigal only. Fleet (mokou/shinobu/razer/octopi), voile, HAOS, macOS: later phases | Prove one dashboard + one real alert first. |
| Prometheus- vs OTel-native | Prometheus-native now | Alloy is OTel-capable, so the traces door stays open without paying for it today. |
The chatty-log tax (unchanged, now with a mechanism)#
journald here is noisy — paperless emits thousands of INFO lines, and podman
tags all container stderr as err so journal priority is unreliable. The
Alloy pipeline relabels by unit/container and applies loki.process drop
stages contributed via tsunaminoai.telemetry.logFilters — filtering happens
at the agent, keyed on unit, never on priority.
Phase-1 architecture (tsunaminoai.telemetry)#
Alloy (per host) ──HTTPS/tailnet──▶ nginx :8427 (step-ca TLS + htpasswd basic auth;
│ the ONLY ingest listener)
├─▶ victoriametrics 127.0.0.1:8428 (/api/v1/write, 12mo)
└─▶ victorialogs 127.0.0.1:9428 (/insert/loki/…, 3month)
vmalert :8880 ── queries VM (prometheus groups) + VL (type: vlogs groups)
│ rules = telemetry.alertRules ∪ monitorsToRuleGroups(complianceMonitors) ∪ Watchdog
└──▶ alertmanager 127.0.0.1:9093 ──▶ ntfy-alertmanager ──▶ ntfy (topic ACLs)
└──▶ webhook ──▶ healthchecks.io ping (dead-man's-switch)
grafana 127.0.0.1:3000 ◀── nginx vhost :3443 (step-ca pattern: useACMEHost = hostFqdn, onlySSL)
data: kurVolumes.volumes.telemetry (250G) → /var/lib/telemetry/{victoriametrics,victorialogs,grafana}
Security posture: VM/VL single-node have no multi-user auth of their own, so
the backends bind loopback-only and the nginx TLS edge is the only ingress
— step-ca cert plus an htpasswd file (multi-entry: one user per pushing host,
rotatable independently). Scaffold-time refinement over the original vmauth
idea: the repo already fronts everything with the step-ca nginx pattern, so
htpasswd-at-the-edge buys the same property with one fewer daemon; vmauth
returns only if praxis ever needs per-tenant routing. Transport is the
tailnet; all credentials via sops (/run/secrets, never /var/run; agent
creds via an EnvironmentFile, nothing secret in the store).
Module surface#
Contribution collectors emulate the Gatus idiom (namespace-keyed
attrsOf (listOf attrs), folded fleet-wide):
tsunaminoai.telemetry = {
scrapeTargets.servarr = [ { targets = ["127.0.0.1:9633"]; } ]; # job defaults to namespace
logFilters.paperless = [ {
selector = "{unit=\"podman-paperless.service\"}";
dropRegex = ".*DEBUG.*";
} ];
alertRules.systemd = [ {
alert = "UnitFailed";
expr = ''systemd_unit_state{state="failed"} == 1'';
"for" = "5m";
labels.severity = "critical";
} ];
};
Roles:
tsunaminoai.telemetry.server(ereshkigal): VM + VL + vmalert + Alertmanager + ntfy + Grafana (provisioned datasources incl. thevictoriametrics-logs-datasourceplugin, dashboards-as-code), plus thecomplianceMonitorsinput — control attrsets whosemonitors[]/alerts[]are compiled byflake.lib.telemetry.monitorsToRuleGroups/alertsToRoutes.tsunaminoai.telemetry.agent(every capable host): Alloy journald pipeline + local exporters,remote_write/Loki-push to the ingest edge.
Exporters, ranked by “would have caught this month’s incidents”:
systemd— failed units (the 3 failed services +nix-sshscope failures).smartctl(--device megaraid,Nfor the PERC H710) — disk health.ipmi— fans + temps (off by default until privileges are proven).node— base metrics; its textfile collector finally gives the servarr-backup metric a home (and the writer gets fixed to actually execute).- Later:
postgres(paperless), nginx, podman/cadvisor, blackbox, HA native/api/prometheus, borgmatic hooks, GPU on mokou.
Scope#
In the phase-1 spike (scaffold PR #243): the modules/nixos/telemetry family +
ereshkigal-only enablement; node/systemd/smartctl exporters + journald
shipping; one provisioned dashboard; one real alert (failed unit → ntfy);
Watchdog → healthchecks.io; monitorsToRuleGroups with unit tests. Eval- and
check-verified; live deploy is the acceptance step that follows.
Explicitly deferred (each a named follow-up):
- ~~Fleet agent rollout: mokou, shinobu, razer, octopi.~~ DONE (phase 2, PR #247): per-host ingest users; laptops rely on push-staleness (no absent() rules); octopi runs a memory-capped lean profile.
- voile (Synology), HAOS VM, macOS laptop instrumentation. (voile + HAOS get synthetic coverage via Gatus in the meantime.)
- ~~Gatus revival — independent small PR: enable on ereshkigal, contribute
endpoints, actually serve
status.falseblue.com.~~ DONE (phase 2, PR #245): status page; alerting rides vmalert (EndpointDown{tier!="intermittent"}), NOT Gatus-native alerting. - ~~Retiring the SSH-poller deploy dashboard — only after telemetry proves equivalent coverage.~~ DONE (#250, 2026-08): module + host blocks removed after the 2026-08-07 acceptance drill proved end-to-end coverage.
- ~~Retiring Uptime Kuma (HA add-on) — parallel-run until the new stack survives a real incident.~~ DONE (#249, 2026-08): drill-proven (deliberate unit failure → dashboard drill-down → annotated ntfy page on the phone); Homer tile + doc removed. The add-on itself is uninstalled by hand in the HAOS UI (the VM is outside the flake).
- ~~myon NixOS onboarding~~ DONE (#164, 2026-08:
hosts/x86_64-nixos/myon, see myon) — the supplemental on-myon heartbeat remains open. - ipmi exporter enablement if privileges get fiddly.
- praxis integration proper — this phase only keeps
lib.nixpure/portable. - Traces / OTel / anomaly detection.
Acceptance (live validation, post-scaffold)#
- Deploy to ereshkigal; one dashboard (node + systemd + SMART) renders in Grafana behind the step-ca vhost.
- One real alert: start a deliberately-failing unit → ntfy notification arrives with the remediation annotation.
- healthchecks.io shows the Watchdog pinging; stop vmalert briefly → the grace alarm fires (dead-man’s-switch proven end-to-end).
- Spot-check two or three PromQL-authored rules against MetricsQL evaluation.
Appendix: considered and rejected#
| Option | Why not |
|---|---|
| Prometheus (stock) | Fine, but ~10x the disk, weaker retention story, no upside over VM here. Mimir/Thanos: overkill. |
| Loki | ~72% more CPU / ~87% more RAM than VictoriaLogs in independent benchmarks, and it would drag a second alerting engine along. |
| Vector (agent) | Healthy project (MPL-2.0, Datadog OSS), great transforms — but a second config world, less batteries-included for the Grafana/VM ecosystem than Alloy. |
| exporters + promtail | Promtail is feature-complete/legacy; more moving parts than one Alloy. |
| OpenObserve | AGPL, heavier RAM than VL, no first-class journald input, immature NixOS module. Plausible all-in-one, a downgrade on “light”. |
| Netdata | Proprietary dashboard license (NCUL1; removed from Debian). Wrong shape for a centralized, declarative, API-queryable store. |
| Grafana unified alerting | Alert state in SQL, not declarative; vmalert rule files are plain YAML in the store. |
| Self-hosted healthchecks.io | Django + Postgres to babysit — for a dead-man’s-switch, operational weight is the enemy. |
systemd-journal-upload ingestion |
Wants systemd ≥ 258 for auth headers/compression; the Alloy Loki-push path needs neither. |
Related#
- home-assistant-vm.md — the outage that motivates alerting.
- ereshkigal-storage-and-health.md — the
kurcapacity this stack lives on; the fan/SMART/systemd signals it watches. - decommissioning.md — AST-02/09 evidence flow the compliance tie-in extends.