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)#
- 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";
- Add the secret to
secrets.yamlbefore building (sops-nix validates at eval time). It must defineTELEMETRY_INGEST_USERNAMEandTELEMETRY_INGEST_PASSWORD; keep it under/run/secrets, never/var/run. - 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')". - Exporters default on: node_exporter (
:9100, with the textfile collector) and the standalone systemd exporter (:9558).smartctlandipmiare 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 — NOTnode_systemd_*(that node_exporter collector is not enabled). - Journald log fields
host/unitjoin exactly against metric labelshost/name— the agent stampshostas an external label on both streams and relabels__journal__systemd_unit→unit. 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>andtsunaminoai.telemetry.logFilters.<ns>(filter onunit/container, never onlevel— 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;
continueresumes 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
EnvironmentFileand envsubst’s its config atpreStart— so the healthchecks ping URL andDISCORD_WEBHOOK_URLare merged into one file by the ereshkigalalertmanager-envsops template.webhook_url_filecannot work: the unit isDynamicUser, the sops secrets are root-owned0400. - 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 the0.0.0.0visitor 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 →SystemdUnitFailedfires after its 5mfor→ ntfy page.systemctl reset-failed drill-testresolves it. - Grafana’s loopback port is 3080, not 3000 — Open-WebUI defaults to 3000 and whoever loses the bind race proxies the wrong app.
Related#
- Alerts & Notifications — how pages are delivered (ntfy topic, phone setup, the Discord mirror, the ntfy access model).
- Status page — the Gatus synthetic checks feeding
EndpointDownand the tenant-scoped status pages. - Fleet convergence runbook — what to do
when
AutoUpgradeCompareFailed/AutoUpgradeStuckBehindReleasefires. - Observability unification plan — the decision record behind this architecture.
- Compliance — the SCF control monitors compiled into vmalert and the risk-acceptance mechanism.