# ๐Ÿ“ˆ 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`](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`](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 ```mermaid flowchart LR subgraph Sources["Per-host sources"] J["journald
persistent, 14-day retention"] D["Docker container
stdout/stderr"] N["node metrics
CPU/mem/disk/net"] App["Native app metrics
5 apps expose endpoints"] end Agent["Grafana Alloy
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 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`](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. ```mermaid flowchart LR A["journald
system logs"] --> L1["Loki
job=system"] B["Docker API
container logs
survives recreation"] --> L2["Loki
job=docker"] C["DNS query log
daily rotation"] --> L3["Loki
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`](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`](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`](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`](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`](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`](firewall-and-network.md), [`docs/dns-naming.md`](dns-naming.md), [`docs/security-monitoring.md`](security-monitoring.md), [`docs/dashy-soc-board.md`](dashy-soc-board.md) ยท Stacks: [`stacks/platform/grafana-stack/`](../stacks/platform/grafana-stack/)