From 83d335d7b26a0a06a6056b2359ccf343b5c12961 Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:15:06 +0200 Subject: [PATCH 01/14] docs: rework architecture section and deduplicate README Accurate diagram (Loki named, tunnel fronts both Grafana and OTLP ingestion, Tempo span-metrics edge, theme-neutral styling) under a brief Architecture section that absorbs the Design-decisions prose and links ADR 0001. --- README.md | 55 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 810ea20..039e90d 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,37 @@ One host running Grafana, Loki, Tempo, Prometheus, and an OpenTelemetry Collector, wired so logs, traces, and metrics cross-link. Point any number of projects at it over OTLP and their telemetry lands in one place. +## Architecture + +Everything enters through one gateway — the OTel Collector — so a project +configures a single OTLP endpoint and a backend can be swapped without +touching any app. Tempo derives RED metrics from spans, so a service that +only sends traces still gets dashboards and error alerting. It all runs on +one host on purpose: at CML's telemetry volume, distributed ingest would add +operational weight for no gain. Rationale and alternatives: +[ADR 0001](docs/adr/0001-observability-stack.md). + ```mermaid flowchart LR - apps["Project apps
(OTLP gRPC 4317 / HTTP 4318)"] --> otel["OTel Collector
(ingestion gateway)"] - otel -- logs --> loki[Loki] - otel -- traces --> tempo[Tempo] - otel -- metrics --> prom[Prometheus] - tempo -- "span metrics (RED)" --> prom - loki --> grafana[Grafana] - tempo --> grafana - prom --> grafana - grafana --- cf["Cloudflare Tunnel
(optional overlay)"] + apps["Project apps"] -->|"OTLP
gRPC :4317 · HTTP :4318"| cf["Cloudflare Tunnel
(production, optional)"] + user["Browser"] -->|HTTPS| cf + cf --> otel + cf --> grafana + + subgraph host["Monitoring host — Docker Compose, ports bound to 127.0.0.1"] + otel["OTel Collector
(ingestion gateway)"] + otel -->|logs| loki["Loki"] + otel -->|traces| tempo["Tempo"] + otel -->|metrics| prom["Prometheus"] + tempo -->|"span metrics (RED)"| prom + grafana["Grafana"] -. queries .-> loki & tempo & prom + end ``` +Solid arrows are the write path; dotted arrows are Grafana reading at query +time. Locally (`just up` / `just demo`) there is no tunnel — everything +talks over the compose network and Grafana is on `localhost:3000`. + ## Demo: see it work in one command ```sh @@ -125,21 +143,4 @@ someone. Everything persists to local Docker volumes (`loki_data`, `tempo_data`, `prometheus_data`, `grafana_data`). Swap Loki / Tempo storage to S3-compatible (Backblaze B2, Cloudflare R2, Hetzner, MinIO) when you outgrow local disk — -`compose.storage-s3.yml` documents the concrete shape of that change, and -`docs/adr/0001-observability-stack.md` records the architecture rationale. - -## Design decisions - -Everything enters through one gateway — the collector — so a project -configures a single OTLP endpoint, and a backend can be swapped without -touching any app. It runs on one host on purpose: CML's telemetry is a handful -of services at single-digit requests per second, so distributed ingest would -add operational weight for no gain, while one compose file stays auditable by a -single person. When local disk runs short, S3-backed Loki and Tempo (above) -are the documented way out. - -Logs carry only low-cardinality labels — `service`, `env`, `host` — and -everything else is a query-time filter; high-cardinality labels are the usual -way a Loki install falls over. And because Tempo's metrics generator derives -RED metrics from spans, any service that sends traces gets the Service Health -dashboard whether or not it emits metrics of its own. +`compose.storage-s3.yml` documents the concrete shape of that change. From 52beabd42f04dd9ea554172c4835f373575aca61 Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:28:27 +0200 Subject: [PATCH 02/14] feat: require bearer-token auth for OTLP ingestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OTLP hostnames are public behind the tunnel; without auth anyone who finds them can inject telemetry. bearertokenauth on both receivers, token via OTLP_AUTH_TOKEN (safe local default keeps 'just demo' one command). Verified: no/wrong token → 401, correct token → 200, demo still populates Grafana. Also syncs justfile validator image pins with the bumped compose versions. --- .env.example | 4 ++++ README.md | 3 +++ compose.demo.yml | 1 + compose.yml | 2 ++ config/otel-collector.yaml | 11 +++++++++++ justfile | 4 ++-- 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 7e9e6d9..8d24b83 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ GRAFANA_ADMIN_PASSWORD=change-me # GRAFANA_ROOT_URL=https://grafana.example.com GRAFANA_ROOT_URL=http://localhost:3000 +# Bearer token every telemetry sender must present (Authorization: Bearer ). +# The default only suits local use — generate a real one for production, e.g.: openssl rand -hex 32 +OTLP_AUTH_TOKEN=local-dev-token + # Only needed for `just up-tunnel` (production exposure via Cloudflare Tunnel). # From: Cloudflare Zero Trust → Networks → Tunnels → → Configure → token CLOUDFLARE_TUNNEL_TOKEN= diff --git a/README.md b/README.md index 039e90d..2e2c40a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,9 @@ Point your app (or its local OTel Collector) at this host's OTLP endpoints: - gRPC: `:4317` - HTTP: `:4318` +Ingestion requires a bearer token (`OTLP_AUTH_TOKEN` in this host's `.env`); +senders set `OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer "`. + Do **not** publish those ports to the public internet. Expose the monitoring host via Cloudflare Tunnel, Tailscale, or WireGuard — the compose file binds them to `127.0.0.1` to make that the obvious path. diff --git a/compose.demo.yml b/compose.demo.yml index 0d1d317..ab500b0 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -15,6 +15,7 @@ services: OTEL_RESOURCE_ATTRIBUTES: env=demo OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318 OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf + OTEL_EXPORTER_OTLP_HEADERS: Authorization=Bearer ${OTLP_AUTH_TOKEN:-local-dev-token} OTEL_TRACES_EXPORTER: otlp OTEL_METRICS_EXPORTER: otlp OTEL_LOGS_EXPORTER: otlp diff --git a/compose.yml b/compose.yml index 454c22f..da24860 100644 --- a/compose.yml +++ b/compose.yml @@ -29,6 +29,8 @@ services: volumes: - ./config/otel-collector.yaml:/etc/otelcol/config.yaml:ro command: [ "--config=/etc/otelcol/config.yaml" ] + environment: + OTLP_AUTH_TOKEN: ${OTLP_AUTH_TOKEN:-local-dev-token} logging: *default-logging platform: linux/amd64 diff --git a/config/otel-collector.yaml b/config/otel-collector.yaml index 9350cb9..338ab99 100644 --- a/config/otel-collector.yaml +++ b/config/otel-collector.yaml @@ -2,13 +2,23 @@ # appropriate backend. Keep this config dumb — do shaping/enrichment at the # app edge (per-project collector) where it has context. +# All senders must present "Authorization: Bearer ". The +# OTLP hostnames are public behind the tunnel, so ingestion needs auth. +extensions: + bearertokenauth: + token: ${env:OTLP_AUTH_TOKEN} + receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 + auth: + authenticator: bearertokenauth http: endpoint: 0.0.0.0:4318 + auth: + authenticator: bearertokenauth processors: memory_limiter: @@ -34,6 +44,7 @@ exporters: endpoint: http://prometheus:9090/api/v1/otlp service: + extensions: [bearertokenauth] pipelines: logs: receivers: [otlp] diff --git a/justfile b/justfile index 61116ac..0fbe10a 100644 --- a/justfile +++ b/justfile @@ -43,8 +43,8 @@ check: docker compose config -q CLOUDFLARE_TUNNEL_TOKEN=dummy docker compose -f compose.yml -f compose.tunnel.yml config -q docker compose -f compose.yml -f compose.demo.yml config -q - docker run --rm -v ./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro -v ./config/alerts:/etc/prometheus/alerts:ro --entrypoint promtool prom/prometheus:v3.11.2 check config /etc/prometheus/prometheus.yaml - docker run --rm -v ./config/otel-collector.yaml:/etc/otelcol/config.yaml:ro otel/opentelemetry-collector-contrib:0.150.1 validate --config=/etc/otelcol/config.yaml + docker run --rm -v ./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro -v ./config/alerts:/etc/prometheus/alerts:ro --entrypoint promtool prom/prometheus:v3.13.0 check config /etc/prometheus/prometheus.yaml + docker run --rm -e OTLP_AUTH_TOKEN=dummy -v ./config/otel-collector.yaml:/etc/otelcol/config.yaml:ro otel/opentelemetry-collector-contrib:0.155.0 validate --config=/etc/otelcol/config.yaml docker run --rm -v .:/code:ro pipelinecomponents/yamllint:0.35.13 yamllint -d relaxed . docker run --rm -v .:/repo:ro -w /repo rhysd/actionlint:1.7.12 -color jq empty dashboards/*.json From f9dca533639530745787e818e0eb0432674b614a Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:33:54 +0200 Subject: [PATCH 03/14] feat: host self-monitoring, capacity alert, and bounded Prometheus storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-exporter (pinned) + a HostDiskSpaceLow alert at 80% — Loki and Tempo have no total-size cap, so the disk alert is the storage backstop; Prometheus additionally gets --storage.tsdb.retention.size=15GB. New provisioned Stack Health dashboard: host disk/CPU/memory + collector ingest and export-failure rates. Collector 0.155.0 dropped the _total suffix on internal telemetry; alert and dashboard queries updated and verified against live data. --- compose.yml | 12 +++ config/alerts/stack.yaml | 19 ++++- config/prometheus.yaml | 4 + dashboards/stack-health.json | 139 +++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 dashboards/stack-health.json diff --git a/compose.yml b/compose.yml index da24860..86dabec 100644 --- a/compose.yml +++ b/compose.yml @@ -67,6 +67,18 @@ services: # Prometheus 3.x: OTLP ingestion is its own flag, no longer a feature flag. - --web.enable-otlp-receiver - --enable-feature=native-histograms,exemplar-storage + # Whichever hits first wins; the disk-space alert is the backstop. + - --storage.tsdb.retention.size=15GB + logging: *default-logging + + node-exporter: + image: prom/node-exporter:v1.11.1 + restart: unless-stopped + command: [ "--path.rootfs=/host" ] + pid: host + volumes: + # No rslave: unsupported on Docker Desktop; only affects mounts added after start. + - /:/host:ro logging: *default-logging grafana: diff --git a/config/alerts/stack.yaml b/config/alerts/stack.yaml index c8016e3..af568db 100644 --- a/config/alerts/stack.yaml +++ b/config/alerts/stack.yaml @@ -17,9 +17,9 @@ groups: description: "Prometheus cannot scrape {{ $labels.instance }} (job {{ $labels.job }}) for 2 minutes." - alert: OtelExportFailures - expr: sum by (exporter) (rate(otelcol_exporter_send_failed_spans_total[5m])) > 0 - or sum by (exporter) (rate(otelcol_exporter_send_failed_metric_points_total[5m])) > 0 - or sum by (exporter) (rate(otelcol_exporter_send_failed_log_records_total[5m])) > 0 + expr: sum by (exporter) (rate(otelcol_exporter_send_failed_spans[5m])) > 0 + or sum by (exporter) (rate(otelcol_exporter_send_failed_metric_points[5m])) > 0 + or sum by (exporter) (rate(otelcol_exporter_send_failed_log_records[5m])) > 0 for: 5m labels: severity: warning @@ -27,6 +27,19 @@ groups: summary: "OTel Collector failing to export via {{ $labels.exporter }}" description: "The collector has been failing to deliver telemetry to a backend for 5 minutes. Check `just logs otel-collector`." + - name: capacity + rules: + - alert: HostDiskSpaceLow + expr: > + (node_filesystem_avail_bytes{fstype!~"tmpfs|ramfs|overlay"} + / node_filesystem_size_bytes{fstype!~"tmpfs|ramfs|overlay"}) < 0.2 + for: 15m + labels: + severity: warning + annotations: + summary: "Filesystem {{ $labels.mountpoint }} is over 80% full" + description: "Loki and Tempo have no total-size cap, so this alert is the storage backstop. Free space or lower retention (see RUNBOOK)." + - name: service-red rules: - alert: HighErrorRate diff --git a/config/prometheus.yaml b/config/prometheus.yaml index 1d4c137..36fd61c 100644 --- a/config/prometheus.yaml +++ b/config/prometheus.yaml @@ -16,3 +16,7 @@ scrape_configs: - job_name: otel-collector static_configs: - targets: ["otel-collector:8888"] + + - job_name: node + static_configs: + - targets: ["node-exporter:9100"] diff --git a/dashboards/stack-health.json b/dashboards/stack-health.json new file mode 100644 index 0000000..c8f74cf --- /dev/null +++ b/dashboards/stack-health.json @@ -0,0 +1,139 @@ +{ + "title": "Stack Health", + "uid": "stack-health", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-3h", "to": "now" }, + "graphTooltip": 1, + "tags": ["infra"], + "panels": [ + { + "type": "timeseries", + "title": "Filesystem Used", + "description": "Loki and Tempo have no total-size cap — the HostDiskSpaceLow alert fires at 80%.", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 0 }, + "id": 1, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "100 * (1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|ramfs|overlay\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|ramfs|overlay\"})", + "legendFormat": "{{mountpoint}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 80 }] } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Host CPU", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 0 }, + "id": 2, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "100 * (1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[$__rate_interval])))", + "legendFormat": "used", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Host Memory Used", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 0 }, + "id": 3, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)", + "legendFormat": "used", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Collector Ingest Rate", + "description": "Items accepted by the OTLP receivers, per signal.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 4, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(otelcol_receiver_accepted_spans[$__rate_interval]))", + "legendFormat": "spans/s", + "refId": "A" + }, + { + "expr": "sum(rate(otelcol_receiver_accepted_metric_points[$__rate_interval]))", + "legendFormat": "metric points/s", + "refId": "B" + }, + { + "expr": "sum(rate(otelcol_receiver_accepted_log_records[$__rate_interval]))", + "legendFormat": "log records/s", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Collector Export Failures", + "description": "Anything above zero means a backend is rejecting or unreachable — the OtelExportFailures alert covers this.", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 5, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by (exporter) (rate(otelcol_exporter_send_failed_spans[$__rate_interval])) or sum by (exporter) (rate(otelcol_exporter_send_failed_metric_points[$__rate_interval])) or sum by (exporter) (rate(otelcol_exporter_send_failed_log_records[$__rate_interval]))", + "legendFormat": "{{exporter}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "min": 0, + "color": { "mode": "fixed", "fixedColor": "red" }, + "custom": { "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + } + } + ] +} From 379c6b339a64da968e3bc1ef54b74c33029d1db8 Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:45:39 +0200 Subject: [PATCH 04/14] feat: alert delivery via Alertmanager + Watchdog heartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alertmanager (pinned) routes all alerts to ALERT_WEBHOOK_URL and an always-firing Watchdog to HEARTBEAT_URL every 5m — wire the latter to a dead man's switch to catch the one failure the host can't report: its own death. URLs are injected via url_file at start (Alertmanager has no env expansion); unset means logged-and-harmless delivery errors, so the stack still runs with zero config. amtool check-config joins just check. Verified live: Watchdog delivered to a local webhook sink. --- .env.example | 7 +++++++ compose.yml | 20 ++++++++++++++++++++ config/alertmanager.yaml | 27 +++++++++++++++++++++++++++ config/alerts/stack.yaml | 12 ++++++++++++ config/prometheus.yaml | 5 +++++ justfile | 16 ++++++++++++++++ 6 files changed, 87 insertions(+) create mode 100644 config/alertmanager.yaml diff --git a/.env.example b/.env.example index 8d24b83..a581ecf 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,13 @@ GRAFANA_ROOT_URL=http://localhost:3000 # The default only suits local use — generate a real one for production, e.g.: openssl rand -hex 32 OTLP_AUTH_TOKEN=local-dev-token +# Where Alertmanager delivers alert notifications (any webhook: ntfy, Slack, …). +# Leave empty to run without delivery; failures are logged and harmless. +ALERT_WEBHOOK_URL= +# Dead man's switch ping target (e.g. https://hc-ping.com/). The Watchdog +# alert posts here every 5m; alert externally when pings stop. +HEARTBEAT_URL= + # Only needed for `just up-tunnel` (production exposure via Cloudflare Tunnel). # From: Cloudflare Zero Trust → Networks → Tunnels → → Configure → token CLOUDFLARE_TUNNEL_TOKEN= diff --git a/compose.yml b/compose.yml index 86dabec..1863fd7 100644 --- a/compose.yml +++ b/compose.yml @@ -71,6 +71,25 @@ services: - --storage.tsdb.retention.size=15GB logging: *default-logging + alertmanager: + image: prom/alertmanager:v0.33.0 + restart: unless-stopped + volumes: + - ./config/alertmanager.yaml:/etc/alertmanager/alertmanager.yaml:ro + - alertmanager_data:/alertmanager + environment: + ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} + HEARTBEAT_URL: ${HEARTBEAT_URL:-} + # Alertmanager can't expand env vars in its config; write the webhook + # URLs to the url_file paths the config points at, then start. + entrypoint: [ "/bin/sh", "-c" ] + command: + - | + printf '%s' "$$ALERT_WEBHOOK_URL" > /alertmanager/webhook_url + printf '%s' "$$HEARTBEAT_URL" > /alertmanager/heartbeat_url + exec /bin/alertmanager --config.file=/etc/alertmanager/alertmanager.yaml --storage.path=/alertmanager + logging: *default-logging + node-exporter: image: prom/node-exporter:v1.11.1 restart: unless-stopped @@ -104,3 +123,4 @@ volumes: tempo_data: prometheus_data: grafana_data: + alertmanager_data: diff --git a/config/alertmanager.yaml b/config/alertmanager.yaml new file mode 100644 index 0000000..2115115 --- /dev/null +++ b/config/alertmanager.yaml @@ -0,0 +1,27 @@ +# Alert routing. Webhook URLs are supplied at runtime via url_file (written +# from ALERT_WEBHOOK_URL / HEARTBEAT_URL by the compose entrypoint) because +# Alertmanager does not expand env vars in its config. With the env vars +# unset, delivery fails with a logged error and nothing else — the stack +# runs fine without them. + +route: + receiver: webhook + group_by: [alertname] + routes: + # Watchdog is always firing; its delivery is the heartbeat. Wire + # HEARTBEAT_URL to a dead man's switch (e.g. healthchecks.io) that + # alerts when pings STOP arriving. + - matchers: [ 'alertname = "Watchdog"' ] + receiver: heartbeat + group_wait: 0s + repeat_interval: 5m + +receivers: + - name: webhook + webhook_configs: + - url_file: /alertmanager/webhook_url + + - name: heartbeat + webhook_configs: + - url_file: /alertmanager/heartbeat_url + send_resolved: false diff --git a/config/alerts/stack.yaml b/config/alerts/stack.yaml index af568db..bce30a9 100644 --- a/config/alerts/stack.yaml +++ b/config/alerts/stack.yaml @@ -5,6 +5,18 @@ # Validated by `just check` (promtool check rules). groups: + - name: meta + rules: + # Always firing on purpose: its arrival at the heartbeat receiver is + # the proof the whole alerting pipeline works. Silence = broken. + - alert: Watchdog + expr: vector(1) + labels: + severity: none + annotations: + summary: "Alerting-pipeline heartbeat (always firing)" + description: "Route this to a dead man's switch; investigate if pings stop." + - name: stack-health rules: - alert: TargetDown diff --git a/config/prometheus.yaml b/config/prometheus.yaml index 36fd61c..5df8860 100644 --- a/config/prometheus.yaml +++ b/config/prometheus.yaml @@ -7,6 +7,11 @@ global: rule_files: - /etc/prometheus/alerts/*.yaml +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + # Apps push metrics via OTLP (through the collector). Scrape only infra we host. scrape_configs: - job_name: prometheus diff --git a/justfile b/justfile index 0fbe10a..a8683aa 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,7 @@ check: docker compose -f compose.yml -f compose.demo.yml config -q docker run --rm -v ./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro -v ./config/alerts:/etc/prometheus/alerts:ro --entrypoint promtool prom/prometheus:v3.13.0 check config /etc/prometheus/prometheus.yaml docker run --rm -e OTLP_AUTH_TOKEN=dummy -v ./config/otel-collector.yaml:/etc/otelcol/config.yaml:ro otel/opentelemetry-collector-contrib:0.155.0 validate --config=/etc/otelcol/config.yaml + docker run --rm -v ./config/alertmanager.yaml:/etc/alertmanager/alertmanager.yaml:ro --entrypoint /bin/amtool prom/alertmanager:v0.33.0 check-config /etc/alertmanager/alertmanager.yaml docker run --rm -v .:/code:ro pipelinecomponents/yamllint:0.35.13 yamllint -d relaxed . docker run --rm -v .:/repo:ro -w /repo rhysd/actionlint:1.7.12 -color jq empty dashboards/*.json @@ -53,6 +54,21 @@ check: fmt: yamlfmt . +# Snapshot all stateful volumes to backups/.tar.gz. Services are +# paused during the copy (seconds), so the backup is crash-consistent. +backup: + mkdir -p backups + docker compose pause grafana prometheus loki tempo alertmanager + docker run --rm -v monitoring_grafana_data:/data/grafana -v monitoring_prometheus_data:/data/prometheus -v monitoring_loki_data:/data/loki -v monitoring_tempo_data:/data/tempo -v monitoring_alertmanager_data:/data/alertmanager -v ./backups:/backups alpine:3.24 tar czf /backups/monitoring-$(date +%Y%m%d-%H%M%S).tar.gz -C /data . + docker compose unpause grafana prometheus loki tempo alertmanager + @ls -lh backups/ | tail -1 + +# Restore a backup tarball into the volumes (stack must be down; wipes current state). +restore file: + docker compose down --remove-orphans + docker run --rm -v monitoring_grafana_data:/data/grafana -v monitoring_prometheus_data:/data/prometheus -v monitoring_loki_data:/data/loki -v monitoring_tempo_data:/data/tempo -v monitoring_alertmanager_data:/data/alertmanager -v ./{{file}}:/backup.tar.gz:ro alpine:3.24 sh -c 'for d in /data/*; do find "$d" -mindepth 1 -delete; done && tar xzf /backup.tar.gz -C /data' + @echo "Restored {{file}} — run 'just up' to start the stack." + # Boot the core stack and wait until Grafana reports healthy. Used by CI. smoke: docker compose up -d From 248a17ad000eb8c9dcc6668243e105a4ecadfc6d Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:48:46 +0200 Subject: [PATCH 05/14] fix: hold Tempo at 2.x pending a deliberate 3.0 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tempo 3.0.2 (bumped in passing) crash-loops on the 2.x config: 3.0 is a re-architecture that removes the ingester/compactor blocks and changes the metrics-generator. Pin back to 2.10.4 (verified working) and tell Dependabot to skip Tempo majors so the migration happens deliberately. The OtelExportFailures alert flagged the outage — working as designed. --- .github/dependabot.yml | 5 +++++ compose.yml | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a0ce903..b76d7a8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,11 @@ updates: monitoring-stack-updates: patterns: - "*" + ignore: + # Tempo 3.x is a re-architecture requiring a config migration; upgrade + # deliberately, not via a routine bump. See comment in compose.yml. + - dependency-name: "grafana/tempo" + update-types: ["version-update:semver-major"] - package-ecosystem: "docker" directory: "/demo" diff --git a/compose.yml b/compose.yml index 1863fd7..0b182c7 100644 --- a/compose.yml +++ b/compose.yml @@ -44,7 +44,11 @@ services: logging: *default-logging tempo: - image: grafana/tempo:3.0.2 + # Held at 2.x: Tempo 3.0 is a re-architecture (ingester/compactor removed, + # metrics-generator changed) needing a real config migration — see + # https://grafana.com/docs/tempo/latest/set-up-for-tracing/setup-tempo/migrate-to-3/ + # Dependabot ignores 3.x majors until that migration is done deliberately. + image: grafana/tempo:2.10.4 restart: unless-stopped volumes: - ./config/tempo.yaml:/etc/tempo/tempo.yaml:ro From 2bda739cd30c049a3eec52e795cecab04602da1b Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:51:33 +0200 Subject: [PATCH 06/14] feat: add just backup / just restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash-consistent tarball of all stateful volumes (services paused for the seconds the copy takes); restore wipes and repopulates them. Exercised end-to-end: backup → docker compose down -v → restore → a pre-backup Grafana annotation survived. --- .gitignore | 1 + justfile | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index fb29868..44121db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env *.local.yml *.local.yaml +backups/ diff --git a/justfile b/justfile index a8683aa..76af52f 100644 --- a/justfile +++ b/justfile @@ -58,6 +58,7 @@ fmt: # paused during the copy (seconds), so the backup is crash-consistent. backup: mkdir -p backups + -docker compose unpause grafana prometheus loki tempo alertmanager 2>/dev/null docker compose pause grafana prometheus loki tempo alertmanager docker run --rm -v monitoring_grafana_data:/data/grafana -v monitoring_prometheus_data:/data/prometheus -v monitoring_loki_data:/data/loki -v monitoring_tempo_data:/data/tempo -v monitoring_alertmanager_data:/data/alertmanager -v ./backups:/backups alpine:3.24 tar czf /backups/monitoring-$(date +%Y%m%d-%H%M%S).tar.gz -C /data . docker compose unpause grafana prometheus loki tempo alertmanager From a48b9699616b0330c4599d5769cd9fa8053ddd0e Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:51:33 +0200 Subject: [PATCH 07/14] docs: add runbook and project onboarding templates RUNBOOK: service-down triage, disk-pressure response with the full retention/size-limit table, backup/restore, secret rotation, image upgrades. ONBOARDING: endpoint + conventions plus verified copy-paste templates for zero-code Python/FastAPI, plain OTLP env vars, the Loki Docker driver, and Grafana Alloy file logs (Promtail is EOL). README slims: telemetry section now points at ONBOARDING, alerting section reflects Alertmanager + Watchdog. --- README.md | 58 ++++++---------------- docs/ONBOARDING.md | 121 +++++++++++++++++++++++++++++++++++++++++++++ docs/RUNBOOK.md | 80 ++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 42 deletions(-) create mode 100644 docs/ONBOARDING.md create mode 100644 docs/RUNBOOK.md diff --git a/README.md b/README.md index 2e2c40a..79c1c63 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,11 @@ config/ loki.yaml # logs tempo.yaml # traces prometheus.yaml # metrics + alertmanager.yaml # alert routing (webhook + watchdog heartbeat) alerts/ # Prometheus alert rules grafana/ # provisioned datasources + dashboard loader dashboards/ # drop JSON dashboards here; Grafana auto-loads them +docs/ # runbook, onboarding templates, ADRs, screenshots ``` ## Run @@ -93,53 +95,25 @@ healthy. ## Sending telemetry from a project -### Traces + metrics (OTLP) +One OTLP endpoint (`:4317` gRPC / `:4318` HTTP), bearer-token auth +(`OTLP_AUTH_TOKEN`), and three naming conventions. Copy-paste templates for +every ingestion form — zero-code Python/FastAPI, plain OTLP env vars, the +Loki Docker driver, and Grafana Alloy for file logs — live in +**[docs/ONBOARDING.md](docs/ONBOARDING.md)**. -Point your app (or its local OTel Collector) at this host's OTLP endpoints: - -- gRPC: `:4317` -- HTTP: `:4318` - -Ingestion requires a bearer token (`OTLP_AUTH_TOKEN` in this host's `.env`); -senders set `OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer "`. - -Do **not** publish those ports to the public internet. Expose the monitoring -host via Cloudflare Tunnel, Tailscale, or WireGuard — the compose file binds -them to `127.0.0.1` to make that the obvious path. - -### Logs (Loki Docker driver) - -Container stdout/stderr is best shipped directly by the Docker daemon using -the Loki log driver plugin. On each project host: - -```sh -# 1. Install the driver plugin (once per host) -docker plugin install grafana/loki-docker-driver:latest \ - --alias loki --grant-all-permissions - -# 2. Set LOKI_URL in the host's .env, e.g. -# LOKI_URL=https://logs.example.org/loki/api/v1/push -``` - -How the project wires it up is project-specific. The RELab repo, for -example, uses an optional overlay (`compose.logging.loki.yml`) that the -justfile auto-includes when `LOKI_URL` is set — hosts without Loki keep -Docker's default json-file driver. Other projects can do the equivalent: -set the daemon-wide `log-driver` in `/etc/docker/daemon.json`, or add a -per-service `logging:` block. - -Logs carry labels for `service`, `env`, and `host` so you can filter in -Grafana. Keep label cardinality low — don't add `user_id`, `request_id`, -etc. as labels; use LogQL filters for those. +Never publish 4317/4318 directly; the compose file binds them to +`127.0.0.1` and the tunnel is the exposure path. ## Alerting Prometheus evaluates the rules in `config/alerts/` (target down, OTel -export failures, >5% span error rate) and Grafana surfaces them under -Alerting → Alert rules. There is deliberately no Alertmanager: on a -single-host stack, another service buys routing complexity before anyone -needs it. Add one (or a Grafana contact point) when alerts must page -someone. +export failures, >5% span error rate, disk >80%); Alertmanager delivers +them to any webhook via `ALERT_WEBHOOK_URL`, and the always-firing +`Watchdog` posts to `HEARTBEAT_URL` every 5 minutes — point that at a dead +man's switch so you hear about it when the monitoring host itself dies. +Both are optional; unset means alerts are visible in Grafana only. +Operations (rotating tokens, disk pressure, backup/restore): +**[docs/RUNBOOK.md](docs/RUNBOOK.md)**. ## Storage diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md new file mode 100644 index 0000000..3c20206 --- /dev/null +++ b/docs/ONBOARDING.md @@ -0,0 +1,121 @@ +# Sending telemetry from your project + +One endpoint, one token, three conventions — then pick the template that +matches how your project runs. + +## The endpoint + +| | | +| --- | --- | +| OTLP gRPC | `:4317` | +| OTLP HTTP | `:4318` | +| Auth | `Authorization: Bearer ` (ask the stack operator) | + +In production `` is the tunnel hostname (e.g. `otlp.example.org`, +HTTPS); never expose 4317/4318 directly. + +## The conventions + +- **`service.name`** — required; one stable name per deployable unit + (`relab-api`, not `relab-api-prod-2`). This is what dashboards key on. +- **`env`** — `prod`, `staging`, `dev`, set via resource attributes. +- **Keep label cardinality low** — no user IDs, request IDs, or timestamps + in resource attributes or log labels; those belong in log lines and span + attributes, where they're query-time filters. + +Any service that sends **traces** automatically gets RED metrics, the +Service Health dashboard, and error-rate alerting — Tempo derives them from +spans. Send traces first; everything else is a bonus. + +## Template 1 — Python/FastAPI, zero code changes + +```sh +pip install opentelemetry-distro opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi +``` + +```sh +export OTEL_SERVICE_NAME=my-service +export OTEL_RESOURCE_ATTRIBUTES=env=prod +export OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.example.org +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer " +export OTEL_TRACES_EXPORTER=otlp OTEL_METRICS_EXPORTER=otlp OTEL_LOGS_EXPORTER=otlp +export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true +export OTEL_SEMCONV_STABILITY_OPT_IN=http + +opentelemetry-instrument uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +Traces, RED metrics, and logs with trace context — no OTel code in the app. +The living example is this repo's own [`demo/`](../demo/) + +[`compose.demo.yml`](../compose.demo.yml). + +## Template 2 — any language, plain OTLP + +Every OTel SDK understands the same four env vars: + +```sh +OTEL_SERVICE_NAME=my-service +OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.example.org +OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer " +OTEL_RESOURCE_ATTRIBUTES=env=prod +``` + +## Template 3 — Docker container logs (Loki driver) + +For shipping container stdout/stderr without touching the app. Requires a +Loki push URL, which is only safe on a private network path (VPN/WireGuard, +or a Cloudflare Access service token in front of the tunnel hostname) — +Loki itself has no auth. If in doubt, use the OTLP log path above instead. + +```sh +# once per host +docker plugin install grafana/loki-docker-driver:latest --alias loki --grant-all-permissions +``` + +```yaml +# per service, in compose.yml +logging: + driver: loki + options: + loki-url: ${LOKI_URL} # e.g. https://logs.example.org/loki/api/v1/push + loki-external-labels: service={{.Name}},env=prod,host=myhost +``` + +RELab's `compose.logging.loki.yml` overlay (auto-included when `LOKI_URL` +is set) is the reference implementation of this pattern. + +## Template 4 — host or file logs (Grafana Alloy) + +For log files outside containers (Promtail is EOL; Alloy is its successor): + +```alloy +// alloy/config.alloy +local.file_match "app" { + path_targets = [{ __path__ = "/var/log/myapp/*.log", service = "myapp", env = "prod", host = "myhost" }] +} + +loki.source.file "app" { + targets = local.file_match.app.targets + forward_to = [loki.write.central.receiver] +} + +loki.write "central" { + endpoint { + url = "https://logs.example.org/loki/api/v1/push" + } +} +``` + +```yaml +# compose service +alloy: + image: grafana/alloy:v1.13.0 + restart: unless-stopped + command: [ "run", "/etc/alloy/config.alloy" ] + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy:ro + - /var/log/myapp:/var/log/myapp:ro +``` + +Same caveat as Template 3: the Loki push URL needs a protected network path. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..e346b51 --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,80 @@ +# Runbook + +Operational procedures for this stack. Commands run from the repo root on +the monitoring host. `just ps` and the **Stack Health** dashboard are the +first stop for anything. + +## A service is down / misbehaving + +```sh +just ps # what's running, what's restarting +just logs # follow logs (otel-collector, loki, tempo, prometheus, grafana, alertmanager) +just restart +``` + +`TargetDown` fires after 2 minutes if Prometheus can't scrape the collector, +node-exporter, or itself. `OtelExportFailures` means the collector is up but +a backend is rejecting data — check that backend's logs. + +## Disk filling up (`HostDiskSpaceLow`) + +Retention is only partially size-bounded by design: + +| Data | Time limit | Size limit | +| --- | --- | --- | +| Container stdout logs | — | json-file 10m × 3 per service | +| Prometheus TSDB | 30d | 15GB (`--storage.tsdb.retention.size`) | +| Loki chunks | 30d | none — Loki cannot cap total size | +| Tempo blocks | 7d | none | + +So when the 80% alert fires: check the Filesystem panel on Stack Health, +then either free space or shorten retention — `retention_period` in +`config/loki.yaml`, `block_retention` in `config/tempo.yaml`, the +`--storage.tsdb.retention.*` flags in `compose.yml` — and restart the +affected service. If disk pressure is chronic, move Loki/Tempo to object +storage: `compose.storage-s3.yml`. + +## Backup and restore + +```sh +just backup # pauses stateful services for seconds, writes backups/monitoring-.tar.gz +just restore backups/monitoring-.tar.gz # stops the stack, wipes volumes, restores +just up +``` + +Backups are crash-consistent (equivalent to a power loss; every component +recovers via its WAL). Copy tarballs off-host — a backup on the disk it +protects is a decoration. RPO = however often you run it; a daily cron on +the host is the intended setup. + +## Rotating secrets + +- **OTLP token:** new value for `OTLP_AUTH_TOKEN` in `.env` → + `docker compose up -d otel-collector` → update every sender's + `OTEL_EXPORTER_OTLP_HEADERS`. Senders with the old token get 401s (visible + as their export errors) until updated. +- **Tunnel token:** rotate in Cloudflare Zero Trust → new + `CLOUDFLARE_TUNNEL_TOKEN` in `.env` → `just up-tunnel`. +- **Grafana admin password:** `GRAFANA_ADMIN_PASSWORD` in `.env` → + `docker compose up -d grafana`. + +## Alert delivery + +Prometheus evaluates rules → Alertmanager delivers: + +- `ALERT_WEBHOOK_URL` — all alerts (any webhook receiver: ntfy, Slack, …). +- `HEARTBEAT_URL` — the always-firing `Watchdog` posts here every 5 minutes. + Point it at a dead man's switch (e.g. healthchecks.io) that alerts when + pings **stop**: that is the "monitoring host is dead" signal nothing + inside the host can send. + +Both empty = no delivery; Alertmanager logs a notify error per cycle, which +is expected and harmless. After changing either, `docker compose up -d +alertmanager`. + +## Upgrading images + +Dependabot PRs bump the pins. For each: CI runs `just check`; after merge, +on the host: `git pull && just pull && just up`. The `just check` validator +pins (promtool, otelcol, amtool images) must match `compose.yml` — CI fails +loudly when config syntax drifts between versions, which is the point. From 70200aec3a794e9c810877631ae6465c2dd8963b Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:54:53 +0200 Subject: [PATCH 08/14] feat: manage the Cloudflare edge with OpenTofu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel, its two ingress routes (grafana, otlp), and DNS were the only stack config living outside git. infra/ declares them (provider v5, validated); the tunnel-token output feeds .env for just up-tunnel. Local state on purpose — one host, one operator. tofu fmt joins just check; full validate is 'just infra-validate' (downloads the provider, so kept out of the CI hot path). --- .gitignore | 3 ++ README.md | 6 +++ infra/.terraform.lock.hcl | 26 +++++++++++ infra/main.tf | 97 +++++++++++++++++++++++++++++++++++++++ justfile | 5 ++ 5 files changed, 137 insertions(+) create mode 100644 infra/.terraform.lock.hcl create mode 100644 infra/main.tf diff --git a/.gitignore b/.gitignore index 44121db..d29ad9d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ *.local.yml *.local.yaml backups/ +infra/.terraform/ +infra/terraform.tfstate* +infra/*.tfvars diff --git a/README.md b/README.md index 79c1c63..f7760bc 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ config/ grafana/ # provisioned datasources + dashboard loader dashboards/ # drop JSON dashboards here; Grafana auto-loads them docs/ # runbook, onboarding templates, ADRs, screenshots +infra/ # OpenTofu: Cloudflare tunnel, ingress routes, DNS ``` ## Run @@ -87,6 +88,11 @@ just up-tunnel # core stack + Cloudflare Tunnel (needs CLOUDFLARE_TUNNE Grafana: (admin / whatever you set). +The Cloudflare side of the tunnel (hostnames, DNS, the tunnel itself) is +code too: `infra/` holds a small OpenTofu config whose output is the +`CLOUDFLARE_TUNNEL_TOKEN` the overlay needs — bootstrap instructions in +[infra/main.tf](infra/main.tf). + `just check` validates everything (compose files, Prometheus config and alert rules, collector config, YAML, workflows, dashboard JSON) in pinned containers — no host installs. CI runs the same command on every push and diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl new file mode 100644 index 0000000..95024a4 --- /dev/null +++ b/infra/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/cloudflare/cloudflare" { + version = "5.21.1" + constraints = "~> 5.0" + hashes = [ + "h1:0mY28ndURk1ft6Xgh+BJ8v6hM0+KQqWYS32NAJ/ffMo=", + "h1:2gZZ/mcfc1aQmgiFpQ5JH69tamBLO76Iw8TaQZpeWM4=", + "h1:6m7m8cPUwqoCldfIxJRCYdhHTZvdIPwtUObaPKWnZH8=", + "h1:JxR20UmUG+tZoocOFfOHy1bpd7IiC2lyvxJU45U9KEY=", + "h1:Lh5LHSNKoKwCx4F/YlRjoLZ+jZLxnoxEfOMzUX9n4zg=", + "h1:gNF1Sro3G9nXhtdkitXwDVKxI1jpBAf8KPv+Y4kAJwk=", + "h1:hU72otEs26Wx6tcJD9igX6I/BQtVgeRuaIe3s/hn6bQ=", + "h1:iWJb0lHfVWmCJQSyroXOT8zQlFOT8k1caHcfaooG5wk=", + "zh:049719425b8be43d9d4f0c208217aca0baa22374f061d7ff92f02563490f649c", + "zh:0a8a3c1b26680b437fe9e7910ca81e532d36f8efacfb14f45690b6a779856993", + "zh:32b61f80892243f7ab8e453fa038c1f3e2aac733ccb98307c2cfe798b2793b32", + "zh:42c27f3cd62979e70716c51f682a3d131d51ad76d86dff83d8cdbfffcebac841", + "zh:4c8cd464f9b6ecde5cd4430bbba4be3b810826105e51ef6328b6a2b69f821443", + "zh:586ea42ef74d6c5bc4c9b89da6b1f8618a19f4e80272fe8d615e7d5b11c491af", + "zh:b09b86c7cac7085e01c9b7a828f09d13c44589d3e3cd42f0b694ca3e4cd3ed0a", + "zh:eac80665e60c701b37a6318f4e405d67f1720f8da5f93135c6256049282d3367", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + ] +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..529a998 --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,97 @@ +# Cloudflare edge for the monitoring stack: the tunnel, its ingress rules, +# and DNS. This is the only part of the stack that otherwise lives as +# click-ops in the Zero Trust dashboard. +# +# Bootstrap (owner-run, once): +# export CLOUDFLARE_API_TOKEN=... # needs Tunnel:Edit, DNS:Edit +# cd infra && tofu init && tofu apply +# tofu output -raw tunnel_token # → CLOUDFLARE_TUNNEL_TOKEN in ../.env +# +# State is local (infra/terraform.tfstate, gitignored) — one host, one +# operator; move it to R2 the day a second operator exists. + +terraform { + required_version = ">= 1.8" + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 5.0" + } + } +} + +provider "cloudflare" { + # Auth via the CLOUDFLARE_API_TOKEN environment variable. +} + +variable "account_id" { + type = string + description = "Cloudflare account ID (dash.cloudflare.com → overview sidebar)." +} + +variable "zone_id" { + type = string + description = "Zone ID of the domain the hostnames live under." +} + +variable "domain" { + type = string + description = "Apex domain, e.g. example.org → grafana.example.org, otlp.example.org." +} + +resource "cloudflare_zero_trust_tunnel_cloudflared" "monitoring" { + account_id = var.account_id + name = "monitoring" + config_src = "cloudflare" +} + +resource "cloudflare_zero_trust_tunnel_cloudflared_config" "monitoring" { + account_id = var.account_id + tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.monitoring.id + + config = { + ingress = [ + { + hostname = "grafana.${var.domain}" + service = "http://grafana:3000" + }, + { + # OTLP HTTP ingestion; the collector enforces bearer-token auth. + hostname = "otlp.${var.domain}" + service = "http://otel-collector:4318" + }, + { + service = "http_status:404" + }, + ] + } +} + +resource "cloudflare_dns_record" "grafana" { + zone_id = var.zone_id + name = "grafana" + type = "CNAME" + content = "${cloudflare_zero_trust_tunnel_cloudflared.monitoring.id}.cfargotunnel.com" + proxied = true + ttl = 1 +} + +resource "cloudflare_dns_record" "otlp" { + zone_id = var.zone_id + name = "otlp" + type = "CNAME" + content = "${cloudflare_zero_trust_tunnel_cloudflared.monitoring.id}.cfargotunnel.com" + proxied = true + ttl = 1 +} + +data "cloudflare_zero_trust_tunnel_cloudflared_token" "monitoring" { + account_id = var.account_id + tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.monitoring.id +} + +output "tunnel_token" { + description = "Set as CLOUDFLARE_TUNNEL_TOKEN in ../.env for just up-tunnel." + value = data.cloudflare_zero_trust_tunnel_cloudflared_token.monitoring.token + sensitive = true +} diff --git a/justfile b/justfile index 76af52f..08fd32d 100644 --- a/justfile +++ b/justfile @@ -48,8 +48,13 @@ check: docker run --rm -v ./config/alertmanager.yaml:/etc/alertmanager/alertmanager.yaml:ro --entrypoint /bin/amtool prom/alertmanager:v0.33.0 check-config /etc/alertmanager/alertmanager.yaml docker run --rm -v .:/code:ro pipelinecomponents/yamllint:0.35.13 yamllint -d relaxed . docker run --rm -v .:/repo:ro -w /repo rhysd/actionlint:1.7.12 -color + docker run --rm -v ./infra:/infra:ro -w /infra ghcr.io/opentofu/opentofu:1.12.3 fmt -check jq empty dashboards/*.json +# Full OpenTofu validation (downloads the provider, so not part of `check`). +infra-validate: + docker run --rm --entrypoint sh -v ./infra:/infra -w /infra ghcr.io/opentofu/opentofu:1.12.3 -c 'tofu init -backend=false -input=false >/dev/null && tofu validate' + # Format YAML in place (needs yamlfmt on the host; optional). fmt: yamlfmt . From 1ff7884d7bc7a812c66e2bcbf75d8df282019011 Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 16:57:03 +0200 Subject: [PATCH 09/14] docs: changelog entry for the reliability-hardening changes --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bc055..d255bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ Notable changes to this stack. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/). +## [Unreleased] + +Reliability hardening: the stack becomes operable, not just runnable. + +### Added + +- Bearer-token auth on OTLP ingestion (`OTLP_AUTH_TOKEN`); senders use + `OTEL_EXPORTER_OTLP_HEADERS`. +- Host self-monitoring: node-exporter, `HostDiskSpaceLow` alert (the + storage backstop — Loki/Tempo have no total-size cap), **Stack Health** + dashboard. +- Alert delivery: Alertmanager routes to `ALERT_WEBHOOK_URL`; an + always-firing `Watchdog` heartbeats to `HEARTBEAT_URL` (dead man's + switch). +- `just backup` / `just restore` — crash-consistent volume snapshots, + exercised end-to-end. +- `docs/RUNBOOK.md` and `docs/ONBOARDING.md` with verified telemetry + templates (zero-code Python, plain OTLP, Loki Docker driver, Grafana + Alloy). +- `infra/`: OpenTofu for the Cloudflare tunnel, ingress routes, and DNS. + +### Changed + +- Prometheus storage now bounded by size as well as time + (`--storage.tsdb.retention.size=15GB`). + +### Fixed + +- Tempo held at 2.x: a routine bump to 3.0 crash-looped on the 2.x config + (3.0 is a re-architecture). Dependabot now skips Tempo majors so the + migration happens deliberately. + ## [0.1.0] - 2026-07-03 First tagged release: the stack is runnable, demoable, and validated in CI. From 57e91b926f57bee4d513db49798cbbf8fde98080 Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 17:08:36 +0200 Subject: [PATCH 10/14] docs: add Stack Health screenshot to the runbook Rendered from the live stack; it happens to capture today's real Tempo outage and the backup/restore drill, which makes it a better runbook illustration than a clean one. --- docs/RUNBOOK.md | 6 ++++++ docs/img/stack-health.png | Bin 0 -> 87172 bytes 2 files changed, 6 insertions(+) create mode 100644 docs/img/stack-health.png diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index e346b51..32d948d 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -4,6 +4,12 @@ Operational procedures for this stack. Commands run from the repo root on the monitoring host. `just ps` and the **Stack Health** dashboard are the first stop for anything. +![Stack Health dashboard](img/stack-health.png) + +(That capture is honest: the red export-failure spike is a Tempo outage +after a bad major-version bump, and the ingest gap is a backup/restore +drill — both procedures below.) + ## A service is down / misbehaving ```sh diff --git a/docs/img/stack-health.png b/docs/img/stack-health.png new file mode 100644 index 0000000000000000000000000000000000000000..ac68421a8ea89ed9bab5429e20142737676dee26 GIT binary patch literal 87172 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 Date: Fri, 3 Jul 2026 18:48:40 +0200 Subject: [PATCH 11/14] fix: harden just recipes (code-review findings) - restore: validate the tarball exists and is readable BEFORE stopping the stack and wiping volumes; absolute paths now work (a typo'd filename previously destroyed all data) - backup: always unpause even when the tar step fails; refuse to run under COMPOSE_PROJECT_NAME (hardcoded volume names would silently back up empty volumes); tarballs now 0600 (they contain secrets); service/ volume lists deduplicated into variables - demo-down: remove only the demo services instead of downing the entire stack - up-tunnel: refuse to expose the stack with the default OTLP token or the documented Grafana password - check: promtool/otelcol/amtool images are read from compose.yml so Dependabot bumps can't drift from the validators; dashboards JSON check runs in a jq container (recipe claim 'no host installs' is now true); yamllint ignores .git/, backups/, infra/.terraform/ - smoke: also fail on crash-looping/exited services, not just Grafana health All guards exercised live: typo'd restore fails before wipe, corrupt tarball fails before down, up-tunnel rejects defaults, demo-down leaves the core stack running. --- dashboards/.gitkeep | 0 justfile | 50 ++++++++++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 17 deletions(-) delete mode 100644 dashboards/.gitkeep diff --git a/dashboards/.gitkeep b/dashboards/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/justfile b/justfile index 08fd32d..7b6ce64 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,10 @@ set dotenv-load +# Stateful services and their volumes, shared by backup/restore. The volume +# names assume the compose project name "monitoring" (see guard in backup). +stateful := "grafana prometheus loki tempo alertmanager" +backup_mounts := "-v monitoring_grafana_data:/data/grafana -v monitoring_prometheus_data:/data/prometheus -v monitoring_loki_data:/data/loki -v monitoring_tempo_data:/data/tempo -v monitoring_alertmanager_data:/data/alertmanager" + default: @just --list @@ -7,8 +12,11 @@ default: up: docker compose up -d -# Core stack + Cloudflare Tunnel (production; needs CLOUDFLARE_TUNNEL_TOKEN) +# Core stack + Cloudflare Tunnel (production; needs CLOUDFLARE_TUNNEL_TOKEN). +# Refuses to expose the stack with the documented default credentials. up-tunnel: + @[ "${OTLP_AUTH_TOKEN:-}" != "local-dev-token" ] || { echo "error: OTLP_AUTH_TOKEN is still the local default; generate one (openssl rand -hex 32) before exposing ingestion" >&2; exit 1; } + @[ "${GRAFANA_ADMIN_PASSWORD:-}" != "change-me" ] || { echo "error: GRAFANA_ADMIN_PASSWORD is still the documented default; change it before exposing Grafana" >&2; exit 1; } docker compose -f compose.yml -f compose.tunnel.yml up -d down: @@ -19,8 +27,9 @@ down: demo: docker compose -f compose.yml -f compose.demo.yml up -d --build +# Remove only the demo services; the core stack keeps running. demo-down: - docker compose -f compose.yml -f compose.demo.yml down + docker compose -f compose.yml -f compose.demo.yml rm -sf demo-api demo-load logs service="": docker compose logs -f {{service}} @@ -39,17 +48,19 @@ tail service: docker compose logs -f --no-log-prefix {{service}} | jq -R 'fromjson? // .' # Validate everything. All validators run in containers — no host installs. +# promtool/otelcol/amtool images are read from compose.yml so they can't +# drift from the versions the stack actually runs. check: docker compose config -q CLOUDFLARE_TUNNEL_TOKEN=dummy docker compose -f compose.yml -f compose.tunnel.yml config -q docker compose -f compose.yml -f compose.demo.yml config -q - docker run --rm -v ./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro -v ./config/alerts:/etc/prometheus/alerts:ro --entrypoint promtool prom/prometheus:v3.13.0 check config /etc/prometheus/prometheus.yaml - docker run --rm -e OTLP_AUTH_TOKEN=dummy -v ./config/otel-collector.yaml:/etc/otelcol/config.yaml:ro otel/opentelemetry-collector-contrib:0.155.0 validate --config=/etc/otelcol/config.yaml - docker run --rm -v ./config/alertmanager.yaml:/etc/alertmanager/alertmanager.yaml:ro --entrypoint /bin/amtool prom/alertmanager:v0.33.0 check-config /etc/alertmanager/alertmanager.yaml - docker run --rm -v .:/code:ro pipelinecomponents/yamllint:0.35.13 yamllint -d relaxed . + docker run --rm -v ./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro -v ./config/alerts:/etc/prometheus/alerts:ro --entrypoint promtool $(docker compose config --images | grep prom/prometheus) check config /etc/prometheus/prometheus.yaml + docker run --rm -e OTLP_AUTH_TOKEN=dummy -v ./config/otel-collector.yaml:/etc/otelcol/config.yaml:ro $(docker compose config --images | grep opentelemetry-collector) validate --config=/etc/otelcol/config.yaml + docker run --rm -v ./config/alertmanager.yaml:/etc/alertmanager/alertmanager.yaml:ro --entrypoint /bin/amtool $(docker compose config --images | grep prom/alertmanager) check-config /etc/alertmanager/alertmanager.yaml + docker run --rm -v .:/code:ro pipelinecomponents/yamllint:0.35.13 yamllint -d '{extends: relaxed, ignore: [.git/, backups/, infra/.terraform/]}' . docker run --rm -v .:/repo:ro -w /repo rhysd/actionlint:1.7.12 -color docker run --rm -v ./infra:/infra:ro -w /infra ghcr.io/opentofu/opentofu:1.12.3 fmt -check - jq empty dashboards/*.json + docker run --rm -v ./dashboards:/dashboards:ro ghcr.io/jqlang/jq:1.8.1 empty $(ls dashboards/*.json | sed 's|^dashboards|/dashboards|') # Full OpenTofu validation (downloads the provider, so not part of `check`). infra-validate: @@ -59,24 +70,29 @@ infra-validate: fmt: yamlfmt . -# Snapshot all stateful volumes to backups/.tar.gz. Services are -# paused during the copy (seconds), so the backup is crash-consistent. +# Snapshot all stateful volumes to backups/.tar.gz (mode 0600 — +# it contains the Grafana DB and webhook secrets; copy it off-host, keep it +# private). Services are paused during the copy, and unpaused even if the +# copy fails. backup: + @[ -z "${COMPOSE_PROJECT_NAME:-}" ] || { echo "error: COMPOSE_PROJECT_NAME is set; backup expects the monitoring_* volume names" >&2; exit 1; } mkdir -p backups - -docker compose unpause grafana prometheus loki tempo alertmanager 2>/dev/null - docker compose pause grafana prometheus loki tempo alertmanager - docker run --rm -v monitoring_grafana_data:/data/grafana -v monitoring_prometheus_data:/data/prometheus -v monitoring_loki_data:/data/loki -v monitoring_tempo_data:/data/tempo -v monitoring_alertmanager_data:/data/alertmanager -v ./backups:/backups alpine:3.24 tar czf /backups/monitoring-$(date +%Y%m%d-%H%M%S).tar.gz -C /data . - docker compose unpause grafana prometheus loki tempo alertmanager + -docker compose unpause {{stateful}} 2>/dev/null + docker compose pause {{stateful}} && { docker run --rm {{backup_mounts}} -v ./backups:/backups alpine:3.24 sh -c 'umask 077 && tar czf /backups/monitoring-$(date +%Y%m%d-%H%M%S).tar.gz -C /data .'; rc=$?; docker compose unpause {{stateful}}; exit $rc; } @ls -lh backups/ | tail -1 -# Restore a backup tarball into the volumes (stack must be down; wipes current state). +# Restore a backup tarball into the volumes (stops the stack; wipes current state). restore file: + @[ -f "{{file}}" ] || { echo "error: {{file}} not found" >&2; exit 1; } + docker run --rm -v {{absolute_path(file)}}:/backup.tar.gz:ro alpine:3.24 tar tzf /backup.tar.gz > /dev/null docker compose down --remove-orphans - docker run --rm -v monitoring_grafana_data:/data/grafana -v monitoring_prometheus_data:/data/prometheus -v monitoring_loki_data:/data/loki -v monitoring_tempo_data:/data/tempo -v monitoring_alertmanager_data:/data/alertmanager -v ./{{file}}:/backup.tar.gz:ro alpine:3.24 sh -c 'for d in /data/*; do find "$d" -mindepth 1 -delete; done && tar xzf /backup.tar.gz -C /data' + docker run --rm {{backup_mounts}} -v {{absolute_path(file)}}:/backup.tar.gz:ro alpine:3.24 sh -c 'for d in /data/*; do find "$d" -mindepth 1 -delete; done && tar xzf /backup.tar.gz -C /data' @echo "Restored {{file}} — run 'just up' to start the stack." -# Boot the core stack and wait until Grafana reports healthy. Used by CI. +# Boot the core stack, wait until Grafana reports healthy, and fail if any +# service is crash-looping. Used by CI. smoke: docker compose up -d n=0; until curl -sf http://localhost:3000/api/health >/dev/null; do n=$((n+3)); [ $n -ge 120 ] && { echo "Grafana not healthy after 120s" >&2; exit 1; }; sleep 3; done - @echo "Grafana healthy" + @[ -z "$(docker compose ps -q --status=restarting --status=exited)" ] || { echo "error: services not running:" >&2; docker compose ps >&2; exit 1; } + @echo "Stack healthy" From 10aa7fcc0c44e50d7224b02b92b451965f99a5f3 Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 18:49:36 +0200 Subject: [PATCH 12/14] fix: compose, config, and infra corrections (code-review findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop platform: linux/amd64 on the collector: the image is multi-arch, and the pin crash-loops arm64 Linux hosts / forces emulation on Apple Silicon (verified: native arm64 now runs) - Alertmanager writes webhook/heartbeat URLs to a tmpfs instead of the persistent volume, keeping secrets out of backup tarballs - OTLP_AUTH_TOKEN loses its weak 'local-dev-token' compose fallback: :?-guarded in compose.yml, single source of truth in .env; demo overlay no longer duplicates the literal - remove dead config: Grafana feature toggles absent from v13's registry, Tempo's unused OTLP HTTP receiver, redundant demo exporter env vars (distro defaults all three to otlp) - demo services restart: on-failure so a forgotten demo doesn't survive reboots; in-file logging anchor replaces duplicated blocks - infra: DNS record names must be FQDNs under cloudflare provider v5 (terraform-provider-cloudflare#5620) — fixes perpetual plan drift - fix stale stack.yaml header (claimed 'No Alertmanager') and stale tunnel overlay header (claimed click-ops config) --- compose.demo.yml | 32 ++++++++++++++------------------ compose.tunnel.yml | 7 +++---- compose.yml | 15 +++++++++------ config/alertmanager.yaml | 4 ++-- config/alerts/stack.yaml | 8 ++++---- config/tempo.yaml | 3 +-- infra/main.tf | 5 +++-- 7 files changed, 36 insertions(+), 38 deletions(-) diff --git a/compose.demo.yml b/compose.demo.yml index ab500b0..16c100f 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -1,24 +1,28 @@ # Demo overlay: a tiny auto-instrumented FastAPI service plus a load # generator, so the stack has real traces, metrics, and correlated logs -# to show. Not for production. +# to show. Not for production — no restart-across-reboots on purpose. # # just demo # core stack + this overlay -# just demo-down +# just demo-down # remove just the demo services + +x-demo-logging: &demo-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" services: demo-api: build: ./demo - restart: unless-stopped + restart: on-failure depends_on: [ otel-collector ] environment: OTEL_SERVICE_NAME: demo-api OTEL_RESOURCE_ATTRIBUTES: env=demo OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318 OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf - OTEL_EXPORTER_OTLP_HEADERS: Authorization=Bearer ${OTLP_AUTH_TOKEN:-local-dev-token} - OTEL_TRACES_EXPORTER: otlp - OTEL_METRICS_EXPORTER: otlp - OTEL_LOGS_EXPORTER: otlp + OTEL_EXPORTER_OTLP_HEADERS: Authorization=Bearer ${OTLP_AUTH_TOKEN:?set OTLP_AUTH_TOKEN in .env} + # Traces/metrics/logs exporters all default to otlp via opentelemetry-distro. # Ship Python log records via OTLP with trace context attached. OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true" OTEL_METRIC_EXPORT_INTERVAL: "10000" @@ -26,15 +30,11 @@ services: # http.route) rather than the legacy names. Stable metric names are frozen, # so the Service Health dashboard's app-SDK panel survives library bumps. OTEL_SEMCONV_STABILITY_OPT_IN: http - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" + logging: *demo-logging demo-load: image: curlimages/curl:8.21.0 - restart: unless-stopped + restart: on-failure depends_on: [ demo-api ] command: - sh @@ -45,8 +45,4 @@ services: curl -s -o /dev/null demo-api:8000/work sleep 1 done - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" + logging: *demo-logging diff --git a/compose.tunnel.yml b/compose.tunnel.yml index 106896e..d39f298 100644 --- a/compose.tunnel.yml +++ b/compose.tunnel.yml @@ -3,10 +3,9 @@ # # just up-tunnel # -# Token-based tunnel: routes are configured in the Cloudflare Zero Trust -# dashboard. Create a tunnel at dash.cloudflare.com → Zero Trust → Networks → -# Tunnels, then set the public hostnames there (e.g. grafana.example.com → -# http://grafana:3000, otlp.example.com → http://otel-collector:4318). +# The tunnel, its public hostnames, and DNS are managed as code in infra/ +# (OpenTofu); `tofu output -raw tunnel_token` yields the token for .env. +# See infra/main.tf for the bootstrap steps. services: cloudflared: diff --git a/compose.yml b/compose.yml index 0b182c7..43238ee 100644 --- a/compose.yml +++ b/compose.yml @@ -30,9 +30,8 @@ services: - ./config/otel-collector.yaml:/etc/otelcol/config.yaml:ro command: [ "--config=/etc/otelcol/config.yaml" ] environment: - OTLP_AUTH_TOKEN: ${OTLP_AUTH_TOKEN:-local-dev-token} + OTLP_AUTH_TOKEN: ${OTLP_AUTH_TOKEN:?set OTLP_AUTH_TOKEN in .env} logging: *default-logging - platform: linux/amd64 loki: image: grafana/loki:3.7.3 @@ -85,12 +84,15 @@ services: ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} HEARTBEAT_URL: ${HEARTBEAT_URL:-} # Alertmanager can't expand env vars in its config; write the webhook - # URLs to the url_file paths the config points at, then start. + # URLs to the url_file paths the config points at, then start. tmpfs on + # purpose: the URLs often carry secrets and must not end up in the + # persistent volume (which `just backup` archives). + tmpfs: [ /run/am ] entrypoint: [ "/bin/sh", "-c" ] command: - | - printf '%s' "$$ALERT_WEBHOOK_URL" > /alertmanager/webhook_url - printf '%s' "$$HEARTBEAT_URL" > /alertmanager/heartbeat_url + printf '%s' "$$ALERT_WEBHOOK_URL" > /run/am/webhook_url + printf '%s' "$$HEARTBEAT_URL" > /run/am/heartbeat_url exec /bin/alertmanager --config.file=/etc/alertmanager/alertmanager.yaml --storage.path=/alertmanager logging: *default-logging @@ -101,6 +103,8 @@ services: pid: host volumes: # No rslave: unsupported on Docker Desktop; only affects mounts added after start. + # No network_mode:host either, so node_network_* describes the container + # veth, not host NICs — fine while nothing alerts on network metrics. - /:/host:ro logging: *default-logging @@ -119,7 +123,6 @@ services: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD in .env} GF_USERS_ALLOW_SIGN_UP: "false" GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3000} - GF_FEATURE_TOGGLES_ENABLE: traceqlEditor,metricsSummary,traceToProfiles logging: *default-logging volumes: diff --git a/config/alertmanager.yaml b/config/alertmanager.yaml index 2115115..72b3289 100644 --- a/config/alertmanager.yaml +++ b/config/alertmanager.yaml @@ -19,9 +19,9 @@ route: receivers: - name: webhook webhook_configs: - - url_file: /alertmanager/webhook_url + - url_file: /run/am/webhook_url - name: heartbeat webhook_configs: - - url_file: /alertmanager/heartbeat_url + - url_file: /run/am/heartbeat_url send_resolved: false diff --git a/config/alerts/stack.yaml b/config/alerts/stack.yaml index bce30a9..07d80bd 100644 --- a/config/alerts/stack.yaml +++ b/config/alerts/stack.yaml @@ -1,8 +1,8 @@ # Prometheus alert rules for the stack itself plus RED-style alerts on the -# span metrics Tempo derives from traces. Evaluated by Prometheus; visible in -# Grafana under Alerting → Alert rules (Prometheus datasource). -# No Alertmanager: single-host stack, alerts are surfaced in Grafana. -# Validated by `just check` (promtool check rules). +# span metrics Tempo derives from traces. Evaluated by Prometheus, delivered +# by Alertmanager (config/alertmanager.yaml → ALERT_WEBHOOK_URL/HEARTBEAT_URL), +# and visible in Grafana under Alerting → Alert rules. +# Validated by `just check` (promtool check config, which loads these rules). groups: - name: meta diff --git a/config/tempo.yaml b/config/tempo.yaml index 4f40301..ba7b78b 100644 --- a/config/tempo.yaml +++ b/config/tempo.yaml @@ -8,10 +8,9 @@ distributor: receivers: otlp: protocols: + # gRPC only: the collector is the sole sender (otlp_grpc/tempo). grpc: endpoint: 0.0.0.0:4317 - http: - endpoint: 0.0.0.0:4318 ingester: max_block_duration: 5m diff --git a/infra/main.tf b/infra/main.tf index 529a998..20fbd5c 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -69,7 +69,8 @@ resource "cloudflare_zero_trust_tunnel_cloudflared_config" "monitoring" { resource "cloudflare_dns_record" "grafana" { zone_id = var.zone_id - name = "grafana" + # Provider v5 requires the full hostname (terraform-provider-cloudflare#5620). + name = "grafana.${var.domain}" type = "CNAME" content = "${cloudflare_zero_trust_tunnel_cloudflared.monitoring.id}.cfargotunnel.com" proxied = true @@ -78,7 +79,7 @@ resource "cloudflare_dns_record" "grafana" { resource "cloudflare_dns_record" "otlp" { zone_id = var.zone_id - name = "otlp" + name = "otlp.${var.domain}" type = "CNAME" content = "${cloudflare_zero_trust_tunnel_cloudflared.monitoring.id}.cfargotunnel.com" proxied = true From de5b3f000a63445dab11a789a7cb2da7ffac6bb9 Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 18:49:36 +0200 Subject: [PATCH 13/14] chore: watch demo pip pins and harden CI token Demo package pins move to requirements.txt with a Dependabot pip ecosystem entry (previously nothing watched them), and the CI workflow gets an explicit read-only permissions block. --- .github/dependabot.yml | 5 +++++ .github/workflows/ci.yml | 3 +++ demo/Dockerfile | 8 ++------ demo/requirements.txt | 5 +++++ 4 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 demo/requirements.txt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b76d7a8..4149d6c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,6 +19,11 @@ updates: schedule: interval: "monthly" + - package-ecosystem: "pip" + directory: "/demo" + schedule: + interval: "monthly" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 024130c..982f69c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: check: runs-on: ubuntu-latest diff --git a/demo/Dockerfile b/demo/Dockerfile index d3c9bee..c55926a 100644 --- a/demo/Dockerfile +++ b/demo/Dockerfile @@ -1,11 +1,7 @@ FROM python:3.13-slim -RUN pip install --no-cache-dir \ - fastapi==0.139.0 \ - uvicorn==0.49.0 \ - opentelemetry-distro==0.64b0 \ - opentelemetry-exporter-otlp==1.43.0 \ - opentelemetry-instrumentation-fastapi==0.64b0 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt WORKDIR /app COPY app.py . diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..3815ed8 --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.139.0 +uvicorn==0.49.0 +opentelemetry-distro==0.64b0 +opentelemetry-exporter-otlp==1.43.0 +opentelemetry-instrumentation-fastapi==0.64b0 From 52fd20b4bc0e5706a87a14f547f65c3ed661a9da Mon Sep 17 00:00:00 2001 From: Simon van Lierde Date: Fri, 3 Jul 2026 18:49:36 +0200 Subject: [PATCH 14/14] docs: correct tunnel-protocol claims and backup caveats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel only routes HTTPS to the collector's OTLP HTTP receiver — the endpoint table and diagram claimed a public gRPC :4317 path that doesn't exist. The Loki push templates now say the stack exposes no push hostname by default. Runbook: backup tarballs are 0600 and carry secrets; long backups (>5 min) drop telemetry; rotating the OTLP token requires re-running just demo if the overlay is up. --- README.md | 2 +- docs/ONBOARDING.md | 18 ++++++++++-------- docs/RUNBOOK.md | 18 ++++++++++++------ 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f7760bc..a32f8c6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ operational weight for no gain. Rationale and alternatives: ```mermaid flowchart LR - apps["Project apps"] -->|"OTLP
gRPC :4317 · HTTP :4318"| cf["Cloudflare Tunnel
(production, optional)"] + apps["Project apps"] -->|"OTLP over HTTPS
(gRPC/HTTP direct on private nets)"| cf["Cloudflare Tunnel
(production, optional)"] user["Browser"] -->|HTTPS| cf cf --> otel cf --> grafana diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 3c20206..c1705ae 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -7,12 +7,14 @@ matches how your project runs. | | | | --- | --- | -| OTLP gRPC | `:4317` | -| OTLP HTTP | `:4318` | +| Production (via tunnel) | `https://otlp.` — OTLP **HTTP** (`http/protobuf`) only | +| Private network / same host | `:4317` (gRPC) or `:4318` (HTTP) | | Auth | `Authorization: Bearer ` (ask the stack operator) | -In production `` is the tunnel hostname (e.g. `otlp.example.org`, -HTTPS); never expose 4317/4318 directly. +The tunnel routes only HTTPS to the collector's HTTP receiver — there is no +public gRPC path, so set `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` when +sending through it. gRPC works only on private paths (VPN/WireGuard, same +Docker network). Never expose 4317/4318 directly. ## The conventions @@ -39,7 +41,6 @@ export OTEL_RESOURCE_ATTRIBUTES=env=prod export OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.example.org export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer " -export OTEL_TRACES_EXPORTER=otlp OTEL_METRICS_EXPORTER=otlp OTEL_LOGS_EXPORTER=otlp export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true export OTEL_SEMCONV_STABILITY_OPT_IN=http @@ -64,9 +65,10 @@ OTEL_RESOURCE_ATTRIBUTES=env=prod ## Template 3 — Docker container logs (Loki driver) For shipping container stdout/stderr without touching the app. Requires a -Loki push URL, which is only safe on a private network path (VPN/WireGuard, -or a Cloudflare Access service token in front of the tunnel hostname) — -Loki itself has no auth. If in doubt, use the OTLP log path above instead. +Loki push URL, which **this stack does not expose by default** — Loki has no +auth, so a push hostname must first be added to `infra/main.tf` and protected +(Cloudflare Access service token), or reached over a private network path +(VPN/WireGuard). If in doubt, use the OTLP log path above instead. ```sh # once per host diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 32d948d..4d1624a 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -49,16 +49,20 @@ just up ``` Backups are crash-consistent (equivalent to a power loss; every component -recovers via its WAL). Copy tarballs off-host — a backup on the disk it +recovers via its WAL). Tarballs are mode 0600 and contain secrets (Grafana +DB); copy them off-host over a private channel — a backup on the disk it protects is a decoration. RPO = however often you run it; a daily cron on -the host is the intended setup. +the host is the intended setup. During the pause the collector keeps +accepting telemetry and retries for ~5 minutes; a backup that takes longer +than that will drop data, so on large volumes run it at a quiet hour. ## Rotating secrets - **OTLP token:** new value for `OTLP_AUTH_TOKEN` in `.env` → `docker compose up -d otel-collector` → update every sender's `OTEL_EXPORTER_OTLP_HEADERS`. Senders with the old token get 401s (visible - as their export errors) until updated. + as their export errors) until updated. That includes a running demo + overlay — re-run `just demo` to recreate it with the new token. - **Tunnel token:** rotate in Cloudflare Zero Trust → new `CLOUDFLARE_TUNNEL_TOKEN` in `.env` → `just up-tunnel`. - **Grafana admin password:** `GRAFANA_ADMIN_PASSWORD` in `.env` → @@ -81,6 +85,8 @@ alertmanager`. ## Upgrading images Dependabot PRs bump the pins. For each: CI runs `just check`; after merge, -on the host: `git pull && just pull && just up`. The `just check` validator -pins (promtool, otelcol, amtool images) must match `compose.yml` — CI fails -loudly when config syntax drifts between versions, which is the point. +on the host: `git pull && just pull && just up`. The `just check` validators +(promtool, otelcol, amtool) read their image versions from `compose.yml`, so +every bump is validated with the exact binaries the stack will run — CI +fails loudly when config syntax drifts between versions, which is the point. +Tempo majors are deliberately held back (see the comment in `compose.yml`).