Same treatment as the security batch - real tech names, 'why this vs the alternative' reasoning, heavy cross-linking, ranked/specific tradeoffs instead of generic hedges.
5.6 KiB
📈 Observability
Note
Grafana Alloy runs natively on every fleet host, shipping logs to Loki and metrics to Prometheus via
remote_write— pushed outward rather than scraped, a choice made specifically because of the firewall model indocs/firewall-and-network.md, not despite it.
| Agent | Grafana Alloy, native systemd service on every host, not Docker |
| Logs | journald → Loki |
| Metrics | Push-based remote_write, not pull-based scraping |
| Coverage | All fleet hosts, confirmed live |
Why push instead of pull — the actual reasoning
A pull-based model (Prometheus scraping every host directly) is the more common default, and it was rejected for a specific reason tied directly to docs/firewall-and-network.md: scraping means the monitoring host needs inbound network access to every single host it monitors, which means punching a hole through the zero-blanket-allow firewall model for every host, every metrics port. A push model inverts that — only the monitoring host needs to accept inbound writes, and every other host only ever initiates outbound connections, which fits the existing "explicit outbound rule, default-deny inbound" posture instead of fighting it. The tradeoff is real: push-based metrics are slightly harder to reason about for ad-hoc debugging (no simple curl against a target to see what it'd report), but it was the right call given the network model already in place.
Pipeline
flowchart LR
subgraph Sources["Per-host sources"]
J["journald<br/>persistent, 14-day retention"]
D["Docker container<br/>stdout/stderr"]
N["node metrics<br/>CPU/mem/disk/net"]
App["Native app metrics<br/>5 apps expose endpoints"]
end
Agent["Grafana Alloy<br/>native systemd service"]
J --> Agent
D -->|docker_hosts only| Agent
N --> Agent
App --> Agent
Agent -->|logs| Loki["Loki"]
Agent -->|remote_write, 30s| Prom["Prometheus"]
Agent install: deliberate, not default
Tip
The agent binary is downloaded directly from upstream releases and run under a hand-written systemd unit — not installed from a package repo or the vendor's own packaged service file. That's a real maintenance cost (manual version bumps instead of
apt upgradehandling it), accepted because it keeps the exact version pinned and reproducible across the fleet through the same tag-driven Ansible model described indocs/iac-pipeline.md, rather than whatever version a package repo happens to serve on a given day. Before install, a preflight check fails the run if the host doesn't run systemd, or if the root filesystem has too little free space for journald's own headroom, and configures/waits for time-sync confirmation (skipped on container-based hosts, where the clock can't be locally adjusted anyway).
Logs: journald only, no double-handling
The system's built-in syslog daemon is explicitly disabled once journald takes over — avoids the same log stream getting handled twice, which would double both storage cost and noise in every downstream query. journald itself is switched from the default in-memory ring buffer to persistent on-disk storage, sized larger on the one host with much higher container-log volume than the rest of the fleet.
flowchart LR
A["journald<br/>system logs"] --> L1["Loki<br/>job=system"]
B["Docker API<br/>container logs<br/>survives recreation"] --> L2["Loki<br/>job=docker"]
C["DNS query log<br/>daily rotation"] --> L3["Loki<br/>job=dns · real event time"]
Every host's logs land in the same Loki instance under its own host label, filterable in Grafana by host or job — the same "one source of truth, not N inconsistent ones" instinct behind the DNS model in docs/dns-naming.md. Docker logs are scraped via the container runtime's own API (not by tailing log files on disk) specifically so log collection survives a container being recreated during the update flows in docs/registry-and-build.md — tailing a file path would silently stop working the moment that container got rebuilt. The DNS query log pipeline (feeding into docs/dns-naming.md's enforcement story) is kept deliberately separate from general system/container error tracking, with its timestamps extracted from the log line itself rather than ingestion time — the same UTC-vs-wall-clock discipline that matters when querying Wazuh's alert data in docs/security-monitoring.md.
Metrics: pushed, not pulled
| App | What's exposed |
|---|---|
| Vault | Dedicated read-only metrics token, separate from its normal agent token — see docs/secrets-and-pki.md |
| Kestra | Built-in metrics endpoint |
| Gitea | Built-in metrics endpoint |
| n8n | Built-in metrics endpoint |
| Authentik | Built-in metrics endpoint, localhost-only |
Five apps expose their own Prometheus-format metrics endpoints directly, scraped by that host's own Alloy instance and pushed through the same path as host-level metrics — no separate pipeline needed for app-level visibility, and no separate firewall exception needed either, since it rides the same outbound remote_write connection every host already has open.
Related: docs/firewall-and-network.md, docs/dns-naming.md, docs/security-monitoring.md, docs/dashy-soc-board.md · Stacks: stacks/platform/grafana-stack/