Files
public/docs/observability.md
T
thesabear d9e8b9edf9 observability.md: full 7-step audit, first pass this session
1. Live-verify: journald persistent-storage config confirmed on two
   hosts (1G vs 500M cap, matches the doc's claim exactly), Alloy
   active confirmed across all 11 fleet hosts.
2. Fix KB: Alloy Agent Model was stale - version had moved from
   v1.18.0 to v1.18.1 on all 8 'newer' hosts since last checked,
   role's own default still unreconciled at 1.17.1. Fixed and
   confirmed via re-fetch.
3. Pulled the full Observability Pipeline tree: Alloy Agent Model,
   Log Flow, Metrics Flow.
4. Replaced both mermaid diagrams (broke this repo's Nextcloud-SVG
   convention) with real diagrams built through the proper round-trip.
5. Evidence: real journald config dumps, plus 3 real Grafana
   screenshots (host metrics dashboard, detailed Loki log stream
   showing job=technitium-dns/docker/system, and the fleet-wide log
   volume dashboard) - one screenshot round caught and fixed an
   unredacted internal IP before being used.
6/7. Compelling-proof test passes; sanitize-check clean.
2026-08-28 23:44:06 +03:00

6.9 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 in docs/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

Four per-host sources (journald, Docker containers, node metrics, native app metrics) all feed into Grafana Alloy, which ships logs to Loki and metrics to Prometheus via remote_write every 30s

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 upgrade handling it), accepted because it keeps the exact version pinned and reproducible across the fleet through the same tag-driven Ansible model described in docs/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. This is the real config on two different hosts, not a described default:

$ cat /etc/systemd/journald.conf.d/*.conf   # the higher-volume host
[Journal]
Storage=persistent
SystemMaxUse=1G
SystemKeepFree=1G
MaxRetentionSec=14day

$ cat /etc/systemd/journald.conf.d/*.conf   # every other host
[Journal]
Storage=persistent
SystemMaxUse=500M
SystemKeepFree=1G
MaxRetentionSec=14day

Three independent log fan-outs, each landing in Loki under its own job label: journald as job=system, Docker container logs (via the container API, not path-tailing) as job=docker, and the DNS query log as job=dns with real event time instead of ingestion time

This is real log data flowing through that pipeline, not a mockup — job=technitium-dns entries showing real DNS query/answer content, job=docker entries from real containers, and job=system from journald itself, all landing in the same Loki instance:

Real Grafana logs view: job=technitium-dns DNS query/answer lines, job=docker container log lines, and a job=system journald line, all in the same log stream

And the aggregate view across the whole fleet — real log volume, not an estimate:

A real Grafana dashboard: total log line count, log volume broken down by host, and log rate over time — actual ingested data, not a described capacity

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. This is that same push path landing in Grafana, for a real host's own metrics — not one of the 5 app endpoints specifically, but proof the pipeline actually delivers real numbers, not placeholder gauges:

A real Grafana dashboard built entirely from remote_write metrics: live CPU/RAM/swap/disk gauges and real 2-day CPU, memory, network, and disk-usage graphs for an actual fleet host


Related: docs/firewall-and-network.md, docs/dns-naming.md, docs/security-monitoring.md, docs/dashy-soc-board.md · Stacks: stacks/platform/grafana-stack/