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.
Grafana admin via Cloudflare Access#
Grafana at grafana.inaba.network signs users in through Cloudflare Access
(OIDC, Entra behind it). Admin vs Viewer is decided by group membership, and
three things must all be true or everyone lands as Viewer (this cost an
afternoon on 2026-08-03):
- The Cloudflare SaaS-OIDC app must offer the
groupsscope and Grafana must request it (auth.generic_oauth.scopes = "openid email profile groups"inmodules/nixos/telemetry/server.nix;oidc.scopeson the ereshkigal origin). Without the scope Cloudflare omits the claim entirely —group_filter_regexalone does nothing. oidc.groupFilterRegexon the origin (→saas_app.group_filter_regexinmodules/flake/terraform/cloudflare.nix) selects which IdP group names appear; ereshkigal scopes it to^sg-voile-personal-admins$.- Cloudflare emits the claim as plain name strings
(
groups:["sg-voile-personal-admins"]), not objects — sografana.oauthRoleAttributePathmust match the name, never an Entra GUID:contains(groups[*], 'sg-voile-personal-admins') && 'Admin' || 'Viewer'.
Changing the scope or regex is a Cloudflare change: nix run
.#terraform-cloudflare -- plan, then the owner runs apply. To see what
Grafana actually received, set
services.grafana.settings.log.filters = "oauth.generic_oauth:debug"
temporarily — it logs the raw token, granted scopes, and the resolved role.
auto_login fires immediately on restart, so a login that predates a
just-applied terraform change can look like a failure when it is only timing.
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.