Telemetry (metrics, logs & alerting)#

tsunaminoai.telemetry is the fleet’s single observability pane: one place metrics and logs land, one alert engine, one notification pipeline (decided in the observability unification plan). Everything backend runs loopback-only on ereshkigal (tsunaminoai.telemetry.server); every capable host runs a Grafana Alloy agent (tsunaminoai.telemetry.agent) that pushes metrics to VictoriaMetrics and journald to VictoriaLogs over one authenticated HTTPS edge. This page is the operations map — what listens where, which rules can page, and how to onboard a host. Delivery (ntfy/Discord, subscribing) is alerts.md; synthetic checks are status-page.md.

Data flow#

every agent host                          ereshkigal (server role)
┌──────────────────────────┐   HTTPS      ┌────────────────────────────────────────┐
│ Alloy: journald ─────────┼─ basic auth ─▶ nginx ingest edge :8427                │
│        node_exporter     │  (step-ca)   │   /api/v1/write        → VM     :8428  │
│        systemd exporter  │              │   /insert/loki/api/v1/ → VLogs  :9428  │
│        smartctl/ipmi     │              │            │                           │
└──────────────────────────┘              │  vmalert :8880 (metrics) :8881 (logs)  │
                                          │            ▼                           │
                                          │  Alertmanager :9093                    │
                                          │   ├─ Watchdog → healthchecks.io (DMS)  │
                                          │   ├─ critical, no control → Discord    │
                                          │   └─ everything → bridge :8111         │
                                          │        → nginx hop :2585 → ntfy :2586  │
                                          │  Grafana :3080 reads VM + VLogs        │
                                          └────────────────────────────────────────┘

Port map (ereshkigal)#

Only three surfaces are meant for humans; everything else is a loopback debug query surface you hit from a shell on ereshkigal (there is no tcpdump on hosts, but curl against these is the fastest triage there is).

Address Service Who reaches it
:8427 (HTTPS) nginx ingest edge (tsunaminoai.telemetry.ingestPort, basic auth from server.ingestBasicAuthFile) fleet Alloy agents
127.0.0.1:8428 VictoriaMetrics — curl ':8428/api/v1/query?query=ALERTS{alertstate="firing"}' loopback debug
127.0.0.1:9428 VictoriaLogs loopback debug
127.0.0.1:3080 Grafana backend (NOT :3000 — Open-WebUI owns that; the collision was a real outage) humans, via grafana.inaba.network (CF Access + OIDC; server.grafana.externalDomain drops the tailnet :3443 vhost)
127.0.0.1:9093 Alertmanager (silences, active alerts) loopback debug
127.0.0.1:8880 vmalert — metrics rule groups loopback debug
127.0.0.1:8881 vmalert — VictoriaLogs (type: vlogs) rule groups loopback debug
127.0.0.1:8111 alertmanager-ntfy publish bridge internal only
127.0.0.1:2585 nginx publish hop — stamps synthetic visitor IP 10.66.66.1 so the bridge gets its own ntfy rate-limit bucket internal only
127.0.0.1:2586 ntfy backend — never curl this unauthenticated (see Gotchas) humans, via the :2587 tailnet vhost (phones) and alerts.inaba.network
:2587 (HTTPS) ntfy tailnet vhost (server.ntfy.httpsPort) phone subscriptions
127.0.0.1:8425 Gatus primary (tsunaminoai.monitoring.web.port) — owns probing + /metrics humans, via status.falseblue.com / status.inaba.network
127.0.0.1:8430+ tenant-scoped tsunaminoai.monitoring.statusPages.<name> Gatus instances (metrics off — the primary owns alerting) each behind its own CF Access origin

Alert rule catalogue#

Alerting is ONE engine: vmalert evaluates every rule, Alertmanager fans out. Rules come from three sources — the server module itself, the Gatus module (modules/nixos/monitoring/default.nix), and any module contributing to tsunaminoai.telemetry.alertRules.<namespace> (one vmalert group per namespace; set datasource = "logs" on a rule for a LogsQL group).

Rule Watches for Severity
SystemdUnitFailed systemd_unit_state{state="failed"} == 1 — any unit not tiered non-critical 5m critical
SystemdUnitFailedNonCritical same expr, only units listed in tsunaminoai.telemetry.server.nonCriticalUnits (housekeeping tier — ntfy, no Discord) 5m warning
FilesystemAlmostFull any non-tmpfs filesystem over 90% 15m warning
SmartDeviceUnhealthy smartctl_device_smart_status != 1 10m critical
AutoUpgradeCompareFailed auto_upgrade_guard{result=~"compare_failed\|unresolved"} == 1 — a host’s anti-downgrade guard can’t compare against the release (fail-closed ⇒ it has silently stopped converging) 48h warning
AutoUpgradeStuckBehindRelease auto_upgrade_commits_behind_release > 0 — the guard wants to upgrade but the host never converges (broken release tip, or runs keep skipping) 72h warning
EndpointDown gatus_results_endpoint_success == 0, excluding tenant="personal", tier="intermittent", tier="informational" (paging scope lives in this expr) 5m critical
GatusScrapeDown up{job="gatus"} == 0 — Gatus config errors are startup-fatal 10m warning
ProbedCertificateExpiringSoon any probed TLS cert < 14 days out, except cert="external" opt-outs 1h warning
Watchdog vector(1) — always firing; its silence at healthchecks.io is the alarm (dead-man’s-switch) none
compliance-<ID> groups compiled from each SCF control’s monitors[] via flake.lib.telemetry.monitorsToRuleGroups (server.complianceMonitors) per-monitor per-control (carry a control label)

Both AutoUpgrade* rules point at the fleet-convergence runbook; the guard metrics they watch are written by every nixos-upgrade guard run to the node-exporter textfile collector.

Grafana dashboards are Nix attrsets#

There are no dashboard JSON files. Dashboards are built in modules/nixos/telemetry/server.nix from the mkPanel / mkTable / mkLogs / mkDashboard helpers and serialized to JSON at build time — so nix build .#nixosConfigurations.ereshkigal.config.system.build.toplevel catches a malformed dashboard before it ever reaches Grafana. Two helper facts that bite:

  • Data links need the literal-${ form. Grafana interpolates ${__data.fields.<column>} itself; inside a Nix indented string that must be written ''${__data.fields.host} (or \${…} in a double-quoted string) or Nix eats it as interpolation.
  • The failed-units table deep-links each row to the Logs & Errors dashboard by passing var-host / var-unit — which only resolves because the template variables are sourced from the systemd exporter’s label set (every unit on every host, no allowlist).

Onboarding a new host (agent)#

  1. Enable the agent and point it at a per-host credentials file:
tsunaminoai.telemetry.agent = {
  enable = true;
  credentialsEnvFile = config.sops.secrets."telemetry/ingest-env-<host>".path;
};
sops.secrets."telemetry/ingest-env-<host>".mode = "0400";
  1. Add the secret to secrets.yaml before building (sops-nix validates at eval time). It must define TELEMETRY_INGEST_USERNAME and TELEMETRY_INGEST_PASSWORD; keep it under /run/secrets, never /var/run.
  2. Give the host its own user in the server’s htpasswd (tsunaminoai.telemetry.server.ingestBasicAuthFile) — one entry per host so credentials rotate per-host: echo "user:$(openssl passwd -6 'pass')".
  3. Exporters default on: node_exporter (:9100, with the textfile collector) and the standalone systemd exporter (:9558). smartctl and ipmi are opt-in (agent.exporters.smartctl.enable, …). Journald shipping defaults on (agent.journal.enable).

Label plumbing worth knowing:

  • Failed units are systemd_unit_state{name=…,state="failed"} from the standalone systemd exporter — NOT node_systemd_* (that node_exporter collector is not enabled).
  • Journald log fields host/unit join exactly against metric labels host/name — the agent stamps host as an external label on both streams and relabels __journal__systemd_unitunit. This is what makes the dashboard’s per-unit log drill-down line up.
  • Extra scrape targets and journald drop filters are contributed per-module via tsunaminoai.telemetry.scrapeTargets.<ns> and tsunaminoai.telemetry.logFilters.<ns> (filter on unit/container, never on level — podman corrupts journald priority).
  • armv6 hosts (Pi Zero tier) get no agent — Grafana dropped armv6 builds; they stay heartbeat-only.

Retention#

Store Option Default
VictoriaMetrics tsunaminoai.telemetry.server.retention.metrics "12" (bare number = months)
VictoriaLogs tsunaminoai.telemetry.server.retention.logs "3M" — 3 months, tracking praxis MON-10’s per-host retention floor

The two grammars differ: VictoriaLogs wants a capital-M month suffix — 3month makes victoria-logs exit with cannot parse duration and crash-loop. Data lives under server.dataDir (/var/lib/telemetry), which on ereshkigal must be the telemetry kur LV, never the 90%-full btrfs root — the units carry RequiresMountsFor so a missing mount stops them instead of silently filling the root disk.

Compliance exceptions (risk acceptances)#

A control whose live verification can’t pass on a host gets a documented risk acceptance instead of a permanently-red critical: declare tsunaminoai.compliance.exceptions.<ID> and wire it through with

tsunaminoai.telemetry.server.complianceExceptions =
  config.tsunaminoai.compliance.exceptions;

The excepted monitors are dropped before vmalert rule compilation (so the alert genuinely stops), while the control’s config still applies and the attestation records risk_acceptance instead of a pass. The two options are deliberately separate so telemetry has no dependency on the compliance module. Live example: ereshkigal waives LGO-02’s auditd monitor (the host boots audit=0). Details in compliance.

Gotchas#

  • Alertmanager route order is load-bearing. First matching child route wins; continue resumes at the next sibling, never falling back to the parent receiver. The order is: watchdog → Discord (continue = true) → explicit catch-all ntfy sibling. Delete the catch-all and criticals go to Discord only.
  • Alertmanager takes exactly ONE EnvironmentFile and envsubst’s its config at preStart — so the healthchecks ping URL and DISCORD_WEBHOOK_URL are merged into one file by the ereshkigal alertmanager-env sops template. webhook_url_file cannot work: the unit is DynamicUser, the sops secrets are root-owned 0400.
  • Never fire unauthenticated curls at ntfy loopback (:2586). ntfy checks its auth-failure limiter (HTTP 429, code 42909, no exemption knob) before credentials, and no-XFF clients all share the 0.0.0.0 visitor bucket — one “test” login failure blocks the publish bridge. Full story in alerts.md.
  • Alert acceptance drill (run it after touching the pipeline): systemd-run --unit drill-test sh -c 'exit 1' → Failed-units row on the overview dashboard within ~1 min → SystemdUnitFailed fires after its 5m for → ntfy page. systemctl reset-failed drill-test resolves it.
  • Grafana’s loopback port is 3080, not 3000 — Open-WebUI defaults to 3000 and whoever loses the bind race proxies the wrong app.
  • Alerts & Notifications — how pages are delivered (ntfy topic, phone setup, the Discord mirror, the ntfy access model).
  • Status page — the Gatus synthetic checks feeding EndpointDown and the tenant-scoped status pages.
  • Fleet convergence runbook — what to do when AutoUpgradeCompareFailed / AutoUpgradeStuckBehindRelease fires.
  • Observability unification plan — the decision record behind this architecture.
  • Compliance — the SCF control monitors compiled into vmalert and the risk-acceptance mechanism.