Files
stack/docs/superpowers/plans/2026-05-01-observability-port.md
kert d0d80c4f47
All checks were successful
CI / lint (pull_request) Successful in 36s
CI / notebooks-smoke (pull_request) Successful in 1m23s
CI / lint (push) Successful in 30s
CI / test (pull_request) Successful in 17m32s
CI / notebooks-smoke (push) Successful in 1m20s
Infra CI / notebooks (pull_request) Successful in 53s
Infra CI / zotero (pull_request) Successful in 14s
Infra CI / docs (pull_request) Successful in 20s
Infra CI / api (pull_request) Successful in 13s
Infra CI / llm (pull_request) Successful in 13s
Infra CI / mc (pull_request) Successful in 22s
Infra CI / notebooks (push) Successful in 46s
CI / test (push) Successful in 14m45s
Infra CI / zotero (push) Successful in 18s
Infra CI / docs (push) Successful in 12s
Infra CI / api (push) Successful in 13s
Infra CI / llm (push) Successful in 12s
Infra CI / mc (push) Successful in 15s
docs: last mention in the observability plan architecture note
2026-09-08 21:23:50 -04:00

1804 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Observability Port (corwins.media → stack) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace Jaeger with Tempo, add nvidia-exporter, switch promtail from filesystem scraping to docker_sd opt-in, port per-service log pipelines to the stack's services, enable Loki retention, wire Grafana SSO via Gitea, and provision six per-service dashboards.
**Architecture:** Compose-native — every change lands in `compose.yml`, an `infra/<component>/<config>.yml`, or a JSON dashboard. The full stack runs on Podman/Docker with the existing networks (`gateway`, `observability`, `storage`, `data`, `ci`). New services live on `observability`; tempo + nvidia-exporter only need docker.sock and host filesystem reads, so the network's `internal: true` constraint is preserved. Grafana already has `gateway + observability`, which is exactly what's needed for OAuth token exchange to `git:3000` plus internal datasource access.
**Tech Stack:** Docker Compose, Loki, Promtail, Grafana, Prometheus, Tempo, OpenTelemetry Collector, DCGM-exporter (NVIDIA), Traefik, Gitea (OAuth provider).
**Spec:** `docs/superpowers/specs/2026-05-01-observability-port-design.md`
**Working pattern for verification:** stack uses `pytest tests/test_compose_mounts.py` to validate compose, plus live `docker compose up` + `curl` probes for runtime checks. We'll add `tests/test_observability.py` for the compose-level invariants and use `bash deploy.sh` helpers (`wait_healthy`) where appropriate.
---
## File Structure
**Created:**
- `infra/tempo/tempo.yml` — tempo runtime config
- `infra/grafana/dashboards/homelab-overview.json` — fleet health dashboard
- `infra/grafana/dashboards/gateway-auth.json` — Traefik + oauth2-proxy + Gitea
- `infra/grafana/dashboards/data-lake.json` — Trino + Nessie + Polaris + RustFS
- `infra/grafana/dashboards/data-pipelines.json` — api + mail-poller + pipelines
- `infra/grafana/dashboards/ci.json` — act-runner + Gitea push
- `infra/grafana/dashboards/gpu-notebooks.json` — DCGM + notebooks/zotero
- `tests/test_observability.py` — compose invariant tests for the new layout
**Modified:**
- `compose.yml` — add tempo/nvidia-exporter, drop jaeger, label all services with `promtail=true`, Traefik access logs, Grafana OAuth env
- `infra/loki/loki-config.yml` — retention + compactor
- `infra/loki/promtail-config.yml` — full rewrite (docker_sd + per-service)
- `infra/otel/otel-collector.yml` — traces → tempo, add memory_limiter
- `infra/prometheus/targets/services.yml` — add nvidia/tempo, drop jaeger
- `infra/grafana/provisioning/datasources/datasources.yml` — Tempo replaces Jaeger; Loki default; correlation
- `infra/traefik/dynamic/services.yml` — swap jaeger → tempo route
- `stack.toml``subdomains` swap jaeger → tempo
- `dev/scripts/bootstrap_sso.py``SUBDOMAINS` swap jaeger → tempo; add Grafana OAuth app + grafana.env writer
---
## Pre-flight
- [ ] **Step 0.1: Confirm prerequisites**
```bash
# verify GPU is present (notebooks already uses CUDA)
nvidia-smi >/dev/null && echo "GPU OK"
# verify docker GID for promtail group_add
getent group docker | cut -d: -f3
# Note this value — you'll set DOCKER_GID in .env if it isn't already 999
```
- [ ] **Step 0.2: Create the test scaffolding file**
Create `tests/test_observability.py`:
```python
"""Compose-level invariants for the observability port.
refs docs/superpowers/specs/2026-05-01-observability-port-design.md
"""
from __future__ import annotations
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
COMPOSE = ROOT / "compose.yml"
def _load() -> dict:
return yaml.safe_load(COMPOSE.read_text())
def _services() -> dict:
return _load().get("services", {})
```
- [ ] **Step 0.3: Commit scaffolding**
```bash
git add tests/test_observability.py
git commit -m "test(observability): add compose-invariant test scaffold"
```
---
## Task 1: Tempo service + config
**Files:**
- Create: `infra/tempo/tempo.yml`
- Modify: `compose.yml` (add `tempo` service, add `tempo_data` volume)
- Test: `tests/test_observability.py`
- [ ] **Step 1.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestTempo:
def test_tempo_service_present(self):
svcs = _services()
assert "tempo" in svcs
assert svcs["tempo"]["networks"] == ["observability"]
def test_tempo_volume_declared(self):
vols = _load().get("volumes", {})
assert "tempo_data" in vols
def test_tempo_config_mounted(self):
svc = _services()["tempo"]
mounts = [v for v in svc["volumes"] if "/etc/tempo" in v]
assert any("./infra/tempo/tempo.yml" in v for v in mounts)
```
- [ ] **Step 1.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestTempo -v
```
Expected: FAIL with `KeyError: 'tempo'` or `AssertionError`.
- [ ] **Step 1.3: Create the tempo config file**
Write `infra/tempo/tempo.yml`:
```yaml
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
trace_idle_period: 30s
max_block_bytes: 1048576
max_block_duration: 5m
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
```
- [ ] **Step 1.4: Add tempo service to compose.yml**
Insert this block in `compose.yml` after the `loki` service (before `promtail`):
```yaml
tempo:
image: grafana/tempo:2.7.2
container_name: tempo
networks:
- observability
volumes:
- ./infra/tempo/tempo.yml:/etc/tempo/config.yaml:ro
- tempo_data:/var/tempo
command: -config.file=/etc/tempo/config.yaml
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3200/ready"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
security_opt:
- no-new-privileges:true
restart: unless-stopped
```
Add `tempo_data:` to the bottom `volumes:` block (alphabetised next to `prometheus_data:`):
```yaml
volumes:
postgres_data:
rustfs_data:
rustfs_logs:
gitea_data:
gitea_config:
act_runner_data:
loki_data:
prometheus_data:
tempo_data:
grafana_data:
```
- [ ] **Step 1.5: Run tests to verify they pass**
```bash
uv run pytest tests/test_observability.py::TestTempo -v
```
Expected: PASS.
- [ ] **Step 1.6: Verify compose parses and tempo comes up**
```bash
docker compose config -q
docker compose up -d tempo
docker inspect --format='{{.State.Health.Status}}' tempo
# expected: "healthy" within 30-60s
curl -sf http://localhost:3200/ready # may need to publish port temporarily; otherwise:
docker exec tempo wget -q -O- http://localhost:3200/ready
# expected: "ready"
```
- [ ] **Step 1.7: Commit**
```bash
git add infra/tempo/tempo.yml compose.yml tests/test_observability.py
git commit -m "feat(observability): add Tempo trace backend"
```
---
## Task 3: nvidia-exporter (DCGM) service
**Files:**
- Modify: `compose.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 3.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestNvidiaExporter:
def test_nvidia_exporter_present(self):
svcs = _services()
assert "nvidia-exporter" in svcs
def test_nvidia_runtime(self):
svc = _services()["nvidia-exporter"]
assert svc.get("runtime") == "nvidia"
def test_nvidia_gpu_reservation(self):
svc = _services()["nvidia-exporter"]
devices = svc["deploy"]["resources"]["reservations"]["devices"]
assert any(d.get("driver") == "nvidia" for d in devices)
```
- [ ] **Step 3.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestNvidiaExporter -v
```
Expected: FAIL.
- [ ] **Step 3.3: Add service**
Insert after the `tempo` service block:
```yaml
nvidia-exporter:
image: nvcr.io/nvidia/k8s/dcgm-exporter:3.3.9-3.6.1-ubuntu22.04
container_name: nvidia-exporter
networks:
- observability
runtime: nvidia
environment:
- DCGM_EXPORTER_NO_HOSTNAME=1
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
cap_add:
- SYS_ADMIN
restart: unless-stopped
```
- [ ] **Step 3.4: Run tests, bring service up, verify**
```bash
uv run pytest tests/test_observability.py::TestNvidiaExporter -v
docker compose up -d nvidia-exporter
docker exec nvidia-exporter curl -sf http://localhost:9400/metrics | grep -c '^DCGM_FI_DEV_GPU_UTIL'
# expected: > 0
```
- [ ] **Step 3.5: Commit**
```bash
git add compose.yml tests/test_observability.py
git commit -m "feat(observability): add DCGM nvidia-exporter for GPU metrics"
```
---
## Task 4: Loki retention + compactor
**Files:**
- Modify: `infra/loki/loki-config.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 4.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestLokiRetention:
def test_loki_compactor_configured(self):
cfg = yaml.safe_load((ROOT / "infra/loki/loki-config.yml").read_text())
assert cfg["compactor"]["retention_enabled"] is True
assert cfg["limits_config"]["retention_period"] == "168h"
```
- [ ] **Step 4.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestLokiRetention -v
```
Expected: FAIL with `KeyError: 'compactor'`.
- [ ] **Step 4.3: Update loki config**
Replace the contents of `infra/loki/loki-config.yml` with:
```yaml
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
ruler:
alertmanager_url: http://localhost:9093
limits_config:
retention_period: 168h
reject_old_samples: true
reject_old_samples_max_age: 168h
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
max_query_series: 5000
allow_structured_metadata: true
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
delete_request_store: filesystem
analytics:
reporting_enabled: false
```
- [ ] **Step 4.4: Run tests, restart loki, verify**
```bash
uv run pytest tests/test_observability.py::TestLokiRetention -v
docker compose up -d --force-recreate loki
docker logs loki 2>&1 | tail -30
# expected: no "config error", "ready" within ~15s
docker exec loki wget -q -O- http://localhost:3100/ready
# expected: "ready"
```
- [ ] **Step 4.5: Commit**
```bash
git add infra/loki/loki-config.yml tests/test_observability.py
git commit -m "feat(loki): enable 7d retention via compactor"
```
---
## Task 5: Promtail rewrite — docker_sd + generic stages
**Files:**
- Modify: `compose.yml` (promtail service: drop containers bind, add docker.sock + group_add)
- Modify: `infra/loki/promtail-config.yml` (full rewrite, generic stages only)
- Test: `tests/test_observability.py`
- [ ] **Step 5.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestPromtailRewire:
def test_promtail_uses_docker_sock(self):
svc = _services()["promtail"]
sources = [v.split(":")[0] for v in svc["volumes"] if isinstance(v, str)]
# New: docker.sock; old filesystem mount must be gone.
assert any("docker.sock" in s for s in sources)
assert not any("/var/lib/docker/containers" in v for v in svc["volumes"])
def test_promtail_has_docker_gid(self):
svc = _services()["promtail"]
assert "group_add" in svc
def test_promtail_config_is_docker_sd(self):
cfg = yaml.safe_load(
(ROOT / "infra/loki/promtail-config.yml").read_text()
)
jobs = cfg["scrape_configs"]
docker_jobs = [j for j in jobs if j.get("job_name") == "docker"]
assert docker_jobs, "expected job_name=docker"
assert "docker_sd_configs" in docker_jobs[0]
```
- [ ] **Step 5.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestPromtailRewire -v
```
Expected: FAIL.
- [ ] **Step 5.3: Replace promtail config (generic stages only — per-service comes in Task 6)**
Write `infra/loki/promtail-config.yml`:
```yaml
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 15s
relabel_configs:
- source_labels: ['__meta_docker_container_label_promtail']
regex: 'true'
action: keep
- source_labels: ['__meta_docker_container_name']
regex: '/?(.*)'
target_label: container
- source_labels: ['__meta_docker_container_name']
regex: '/?(.*)'
target_label: job
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: service
- source_labels: ['__meta_docker_container_label_com_docker_compose_project']
target_label: project
pipeline_stages:
- docker: {}
- regex:
expression: '(?i)(?P<level>error|warn|info|debug|fatal|critical|trace)'
- labels:
level:
- regex:
expression: '(?P<remote_ip>\d+\.\d+\.\d+\.\d+) .* "(?P<http_method>GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) (?P<http_path>[^ ]*) [^"]*" (?P<http_status>\d{3}) (?P<http_bytes>\d+)'
- labels:
http_method:
http_status:
```
- [ ] **Step 5.4: Update the promtail service in compose.yml**
Replace the existing `promtail:` service block with:
```yaml
promtail:
image: grafana/promtail:latest
container_name: promtail
networks:
- observability
volumes:
- ./infra/loki/promtail-config.yml:/etc/promtail/config.yml:ro
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock:ro
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
group_add:
- "${DOCKER_GID:-985}"
security_opt:
- no-new-privileges:true
restart: unless-stopped
```
(If `getent group docker` returned a different number than 985 in step 0.1, set `DOCKER_GID` in `.env`.)
- [ ] **Step 5.5: Run tests, recreate promtail, verify scraping works**
```bash
uv run pytest tests/test_observability.py::TestPromtailRewire -v
# Add a temporary promtail=true label to one container so scraping has *something* to find:
# We'll batch-label everything in Task 10. For this verification, label loki itself.
docker compose stop promtail
docker compose rm -f promtail
docker compose up -d promtail
docker logs promtail 2>&1 | tail -20
# expected: "Adding target", no auth/socket errors
# After Task 10 labels are applied, the next probe is meaningful. For now:
docker exec promtail wget -q -O- 'http://loki:3100/loki/api/v1/labels' | head
# expected: JSON with at least {"data":[...]} — empty list is acceptable here pre-labels
```
- [ ] **Step 5.6: Commit**
```bash
git add infra/loki/promtail-config.yml compose.yml tests/test_observability.py
git commit -m "feat(promtail): switch to docker_sd opt-in scraping"
```
---
## Task 6: Per-service log pipeline stages
**Files:**
- Modify: `infra/loki/promtail-config.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 6.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestPerServiceStages:
"""Each named container must have a matching pipeline stage that emits at
least one service-specific label."""
EXPECTED = {
"traefik": "tf_event",
"git": "gitea_event",
"oauth2-proxy": "oauth_event",
"postgres": "pg_event",
"trino": "trino_event",
"rustfs": "s3_op",
"api": "api_event",
"mail-poller": "mail_event",
"act-runner": "ci_event",
"cloudflared": "cf_event",
}
def test_each_service_has_a_match_stage(self):
cfg = yaml.safe_load(
(ROOT / "infra/loki/promtail-config.yml").read_text()
)
stages = cfg["scrape_configs"][0]["pipeline_stages"]
text = yaml.safe_dump(stages)
for container, label in self.EXPECTED.items():
assert f'container="{container}"' in text, f"no match stage for {container}"
assert label in text, f"no {label} extraction for {container}"
```
- [ ] **Step 6.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestPerServiceStages -v
```
Expected: FAIL.
- [ ] **Step 6.3: Append per-service stages to promtail config**
Append the following to `infra/loki/promtail-config.yml` (under the existing
`pipeline_stages:` block of the `docker` job — keep the generic stages first):
```yaml
# --- Traefik ---
- match:
selector: '{container="traefik"}'
stages:
- json:
expressions:
RouterName: RouterName
ServiceName: ServiceName
DownstreamStatus: DownstreamStatus
RequestMethod: RequestMethod
- labels:
tf_router: RouterName
tf_service: ServiceName
http_status: DownstreamStatus
http_method: RequestMethod
- regex:
expression: '(?P<tf_event>TLS handshake|EntryPoint|router|certResolver|middleware|tls)'
- labels:
tf_event:
# --- Gitea ---
- match:
selector: '{container="git"}'
stages:
- regex:
expression: '(?P<gitea_event>login|logout|register|push|pull|branch|tag|issue|webhook|oauth|created|deleted|merged)'
- labels:
gitea_event:
# --- oauth2-proxy ---
- match:
selector: '{container="oauth2-proxy"}'
stages:
- regex:
expression: '(?P<oauth_event>AuthSuccess|AuthFailure|OAuthStart|OAuthCallback|OAuthError|Invalid|Expired|Authenticated|Forbidden)'
- labels:
oauth_event:
# --- Postgres ---
- match:
selector: '{container="postgres"}'
stages:
- regex:
expression: '(?P<pg_event>FATAL|ERROR|WARNING|checkpoint|autovacuum|connection authorized|connection received|statement)'
- labels:
pg_event:
# --- Trino ---
- match:
selector: '{container="trino"}'
stages:
- regex:
expression: '(?P<trino_event>QUERY_CREATED|QUERY_STARTED|QUERY_COMPLETED|QUERY_FAILED|SPLIT_COMPLETED)'
- regex:
expression: '\b(?P<query_id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b'
- labels:
trino_event:
query_id:
# --- Nessie / Polaris (Quarkus) ---
- match:
selector: '{container=~"nessie|polaris"}'
stages:
- regex:
expression: '"(?P<http_method>GET|POST|PUT|DELETE) (?P<quarkus_route>/[^ ?]+)'
- labels:
quarkus_route:
# --- RustFS ---
- match:
selector: '{container="rustfs"}'
stages:
- regex:
expression: '(?P<s3_op>PutObject|GetObject|DeleteObject|ListObjectsV2|HeadObject|CompleteMultipart)'
- regex:
expression: 'bucket=(?P<s3_bucket>[A-Za-z0-9._-]+)'
- labels:
s3_op:
s3_bucket:
# --- API (FastAPI/stack) ---
- match:
selector: '{container="api"}'
stages:
- regex:
expression: '(?P<api_event>pipeline|ingest|health|error)'
- regex:
expression: 'pipeline=(?P<pipeline_name>[A-Za-z0-9_.-]+)'
- labels:
api_event:
pipeline_name:
# --- mail-poller ---
- match:
selector: '{container="mail-poller"}'
stages:
- regex:
expression: '(?P<mail_event>ingest|poll|error)'
- regex:
expression: 'count=(?P<mail_count>\d+)'
- labels:
mail_event:
mail_count:
# --- act-runner ---
- match:
selector: '{container="act-runner"}'
stages:
- regex:
expression: '(?P<ci_event>job started|job finished|job failed|runner registered)'
- regex:
expression: 'job_id=(?P<job_id>\S+)'
- labels:
ci_event:
job_id:
# --- cloudflared ---
- match:
selector: '{container="cloudflared"}'
stages:
- regex:
expression: '(?P<cf_event>connection|registered|unregistered|reconnect|quic|error|tunnel|origin)'
- regex:
expression: 'status=(?P<cf_origin_status>\d{3})'
- labels:
cf_event:
cf_origin_status:
```
- [ ] **Step 6.4: Run tests, restart promtail, verify**
```bash
uv run pytest tests/test_observability.py::TestPerServiceStages -v
docker compose restart promtail
docker logs promtail 2>&1 | tail -20
# expected: no "yaml: line N" parse errors
```
- [ ] **Step 6.5: Commit**
```bash
git add infra/loki/promtail-config.yml tests/test_observability.py
git commit -m "feat(promtail): per-service log pipeline stages"
```
---
## Task 7: OTel collector — traces to Tempo
**Files:**
- Modify: `infra/otel/otel-collector.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 7.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestOtelTempo:
def test_otel_exports_traces_to_tempo(self):
cfg = yaml.safe_load(
(ROOT / "infra/otel/otel-collector.yml").read_text()
)
exporters = cfg["exporters"]
assert "otlp_http/tempo" in exporters
assert exporters["otlp_http/tempo"]["endpoint"] == "http://tempo:4318"
assert "otlp_grpc/jaeger" not in exporters
traces = cfg["service"]["pipelines"]["traces"]
assert "otlp_http/tempo" in traces["exporters"]
assert "memory_limiter" in traces["processors"]
```
- [ ] **Step 7.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestOtelTempo -v
```
Expected: FAIL.
- [ ] **Step 7.3: Replace `infra/otel/otel-collector.yml`**
```yaml
# OpenTelemetry Collector configuration
# Fan-out: traces → Tempo, metrics → Prometheus scrape, logs → Loki
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 5s
limit_mib: 256
spike_limit_mib: 64
resource:
attributes:
- key: deployment.environment
value: homelab
action: upsert
exporters:
otlp_http/tempo:
endpoint: http://tempo:4318
tls:
insecure: true
otlp_http/loki:
endpoint: http://loki:3100/otlp
prometheus:
endpoint: 0.0.0.0:8889
namespace: stack
resource_to_telemetry_conversion:
enabled: true
extensions:
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [health_check]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, resource]
exporters: [otlp_http/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, batch, resource]
exporters: [otlp_http/loki]
```
- [ ] **Step 7.4: Update otel-collector compose dependency**
In `compose.yml`, change `otel-collector.depends_on`:
```yaml
otel-collector:
# ... existing fields ...
depends_on:
- tempo
- loki
```
(Replace the previous `- jaeger` entry with `- tempo`.)
- [ ] **Step 7.5: Run tests, restart otel-collector, verify**
```bash
uv run pytest tests/test_observability.py::TestOtelTempo -v
docker compose up -d --force-recreate otel-collector
docker logs otel-collector 2>&1 | tail -15
# expected: "Everything is ready", no exporter errors
docker exec otel-collector wget -q -O- http://localhost:13133/
# expected: "Server available" or 200 OK
```
- [ ] **Step 7.6: Commit**
```bash
git add infra/otel/otel-collector.yml compose.yml tests/test_observability.py
git commit -m "feat(otel): export traces to Tempo, add memory_limiter"
```
---
## Task 8: Prometheus targets
**Files:**
- Modify: `infra/prometheus/targets/services.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 8.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestPrometheusTargets:
def test_targets_swap_jaeger_for_tempo_and_add_exporters(self):
path = ROOT / "infra/prometheus/targets/services.yml"
targets = yaml.safe_load(path.read_text())
jobs = {entry["labels"]["job"] for entry in targets if "labels" in entry}
assert "tempo" in jobs
assert "nvidia-gpu" in jobs
assert "jaeger" not in jobs
```
- [ ] **Step 8.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestPrometheusTargets -v
```
Expected: FAIL.
- [ ] **Step 8.3: Update `infra/prometheus/targets/services.yml`**
```yaml
# ── Scrape Target Registry ───────────────────────────
# To add a service: append one entry below.
# Pattern: container_name:port, job label, optional __metrics_path__.
# Traefik
- targets: ['traefik:8080']
labels:
job: traefik
__metrics_path__: /metrics
# Tempo
- targets: ['tempo:3200']
labels:
job: tempo
__metrics_path__: /metrics
# Loki
- targets: ['loki:3100']
labels:
job: loki
# NVIDIA GPU (DCGM)
- targets: ['nvidia-exporter:9400']
labels:
job: nvidia-gpu
# Nessie
- targets: ['nessie:9000']
labels:
job: nessie
__metrics_path__: /q/metrics
# Polaris
- targets: ['polaris:8182']
labels:
job: polaris
__metrics_path__: /q/metrics
# Trino
- targets: ['trino:8080']
labels:
job: trino
__metrics_path__: /v1/status
# OTel Collector
- targets: ['otel-collector:8889']
labels:
job: otel-collector
```
- [ ] **Step 8.4: Run tests, reload prometheus, verify**
```bash
uv run pytest tests/test_observability.py::TestPrometheusTargets -v
# Hot-reload (web.enable-lifecycle is on)
docker exec prometheus wget -q --post-data= -O- http://localhost:9090/-/reload
docker exec prometheus wget -q -O- 'http://localhost:9090/api/v1/targets?state=active' | grep -o '"job":"[^"]*"' | sort -u
# expected: nvidia-gpu, tempo, traefik, loki, etc.
```
- [ ] **Step 8.5: Commit**
```bash
git add infra/prometheus/targets/services.yml tests/test_observability.py
git commit -m "feat(prom): add nvidia/tempo targets, drop jaeger"
```
---
## Task 9: Grafana datasources — Tempo + correlation
**Files:**
- Modify: `infra/grafana/provisioning/datasources/datasources.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 9.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestGrafanaDatasources:
def test_datasources_have_tempo_loki_default_correlation(self):
path = ROOT / "infra/grafana/provisioning/datasources/datasources.yml"
cfg = yaml.safe_load(path.read_text())
ds = {d["name"]: d for d in cfg["datasources"]}
assert "Tempo" in ds and "Jaeger" not in ds
assert ds["Loki"].get("isDefault") is True
tempo = ds["Tempo"]
assert tempo["jsonData"]["tracesToLogsV2"]["datasourceUid"] == "loki"
assert tempo["jsonData"]["serviceMap"]["datasourceUid"] == "prometheus"
```
- [ ] **Step 9.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestGrafanaDatasources -v
```
Expected: FAIL.
- [ ] **Step 9.3: Replace datasources file**
Write `infra/grafana/provisioning/datasources/datasources.yml`:
```yaml
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
uid: loki
isDefault: true
jsonData:
maxLines: 1000
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
uid: prometheus
jsonData:
httpMethod: POST
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
uid: tempo
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
serviceMap:
datasourceUid: prometheus
```
- [ ] **Step 9.4: Update grafana compose `depends_on`**
In `compose.yml`, edit the `grafana.depends_on:` list:
```yaml
grafana:
# ... existing fields ...
depends_on:
- loki
- tempo
- prometheus
```
(Replace `- jaeger` with `- tempo`.)
- [ ] **Step 9.5: Run tests, restart grafana, verify**
```bash
uv run pytest tests/test_observability.py::TestGrafanaDatasources -v
docker compose up -d --force-recreate grafana
docker logs grafana 2>&1 | grep -i "datasource" | tail
# expected: "successfully provisioned" lines for Loki / Prometheus / Tempo
```
- [ ] **Step 9.6: Commit**
```bash
git add infra/grafana/provisioning/datasources/datasources.yml compose.yml tests/test_observability.py
git commit -m "feat(grafana): swap Jaeger→Tempo, enable trace↔log correlation"
```
---
## Task 10: `promtail=true` labels + Traefik access logs
**Files:**
- Modify: `compose.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 10.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
PROMTAIL_LABELED = {
"coredns", "traefik", "rustfs", "postgres", "git", "act-runner",
"notebooks", "zotero", "webdav", "nessie", "trino", "polaris",
"dashboard", "docs", "api", "mail-poller", "auth-handler",
"oauth2-proxy", "cloudflared", "loki", "promtail", "tempo",
"otel-collector", "prometheus", "grafana",
"nvidia-exporter",
}
PROMTAIL_NOT_LABELED = {"mc", "wire"}
class TestPromtailLabels:
def test_eligible_services_have_promtail_label(self):
svcs = _services()
missing = []
for name in PROMTAIL_LABELED:
if name not in svcs:
missing.append(f" {name}: not in compose")
continue
labels = svcs[name].get("labels", []) or []
if "promtail=true" not in labels:
missing.append(f" {name}: missing promtail=true label")
assert not missing, "\n".join(missing)
def test_excluded_services_do_not_have_label(self):
svcs = _services()
for name in PROMTAIL_NOT_LABELED:
if name not in svcs:
continue
labels = svcs[name].get("labels", []) or []
assert "promtail=true" not in labels, f"{name} should NOT be labeled"
def test_traefik_access_logs_enabled(self):
traefik = _services()["traefik"]
cmd = traefik.get("command", [])
if isinstance(cmd, str):
cmd = cmd.split()
flat = " ".join(str(x) for x in cmd)
assert "--accesslog" in flat or any(
"TRAEFIK_ACCESSLOG" in str(e) for e in traefik.get("environment", [])
)
```
- [ ] **Step 10.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestPromtailLabels -v
```
Expected: FAIL.
- [ ] **Step 10.3: Add `promtail=true` to every eligible service**
For **each** service listed in `PROMTAIL_LABELED` above (do them one at a time
so commits are reviewable), add this block under the service definition:
```yaml
labels:
- "promtail=true"
```
Tip: services with no existing `labels:` key need a new one; services that
already have `labels:` (none in the current compose, but possible in future)
need the entry appended.
Special cases:
- `promtail` itself gets `promtail=true` (it scrapes its own logs)
- `mc` and `wire` (the `tools` profile) get **no** label — see test
- `tempo`, `nvidia-exporter` were created in tasks 13; add the
label to those service blocks too
- [ ] **Step 10.4: Enable Traefik JSON access logs**
In `compose.yml`, replace the `traefik` service block's `command:` (currently
absent — Traefik runs its baked default) by adding:
```yaml
traefik:
image: traefik:v3.3
container_name: traefik
networks:
- gateway
- observability
- storage
- ci
ports:
- "80:80"
- "443:443"
- "8081:8080"
volumes:
- ./infra/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./infra/traefik/dynamic:/etc/traefik/dynamic:ro
- ./infra/traefik/certs:/etc/traefik/certs:ro
- ./infra/traefik/plugins:/plugins-local:ro
environment:
- DOMAIN=${DOMAIN:-fhirworx.io}
- OTEL_SERVICE_NAME=traefik
- TRAEFIK_ACCESSLOG=true
- TRAEFIK_ACCESSLOG_FORMAT=json
- TRAEFIK_ACCESSLOG_FIELDS_DEFAULTMODE=keep
- TRAEFIK_ACCESSLOG_FIELDS_HEADERS_DEFAULTMODE=keep
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
```
(The existing fields are preserved; only `environment:` gains four `TRAEFIK_ACCESSLOG_*` vars and `labels:` is added.)
- [ ] **Step 10.5: Run tests, recreate the affected services, verify**
```bash
uv run pytest tests/test_observability.py::TestPromtailLabels -v
# Recreate everything so labels register on running containers
docker compose up -d
# Promtail should now have many targets
docker exec promtail wget -q -O- 'http://loki:3100/loki/api/v1/label/container/values' | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',[])), 'containers'); print(d['data'])"
# expected: count ≥ 20
# Hit a Traefik route to generate an access log
curl -sk https://traefik:8081/ping >/dev/null || true
docker exec promtail wget -q -O- 'http://loki:3100/loki/api/v1/query?query={container="traefik"}' | head -c 500
# expected: JSON access-log entries
```
- [ ] **Step 10.6: Commit**
```bash
git add compose.yml tests/test_observability.py
git commit -m "feat(observability): label all services for promtail; enable Traefik access logs"
```
---
## Task 11: Grafana OAuth via Gitea
**Files:**
- Modify: `dev/scripts/bootstrap_sso.py`
- Modify: `compose.yml` (grafana service)
- Test: `tests/test_observability.py`
- [ ] **Step 11.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestGrafanaOAuth:
def test_grafana_loads_state_env_file(self):
env_files = _services()["grafana"].get("env_file", [])
# accepts either a list of dicts or a list of strings
joined = yaml.safe_dump(env_files)
assert ".state/gitea/grafana.env" in joined
def test_grafana_oauth_env_vars(self):
envs = _services()["grafana"].get("environment", [])
text = "\n".join(envs)
assert "GF_AUTH_GENERIC_OAUTH_ENABLED=true" in text
assert "GF_AUTH_GENERIC_OAUTH_NAME=Gitea" in text
assert "GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=is_admin && 'GrafanaAdmin'" in text
def test_bootstrap_sso_provisions_grafana(self):
src = (ROOT / "dev/scripts/bootstrap_sso.py").read_text()
assert "GRAFANA_ENV" in src
assert 'name="grafana"' in src
assert "/login/generic_oauth" in src
```
- [ ] **Step 11.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestGrafanaOAuth -v
```
Expected: FAIL.
- [ ] **Step 11.3: Extend `dev/scripts/bootstrap_sso.py`**
Add after the existing `OAUTH2_PROXY_ENV = STATE / "oauth2-proxy.env"` line:
```python
GRAFANA_ENV = STATE / "grafana.env"
```
Add this helper function near `_write_oauth2_proxy_env`:
```python
def _write_grafana_env(client_id: str, client_secret: str) -> bool:
return _write_env_file(
GRAFANA_ENV,
(
f"GF_AUTH_GENERIC_OAUTH_CLIENT_ID={client_id}\n"
f"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET={client_secret}\n"
),
)
```
In `main()`, after the existing oauth2-proxy block (`creds = _ensure_oauth_app(... name="oauth2-proxy" ...); _write_oauth2_proxy_env(*creds)`), append:
```python
grafana_creds = _ensure_oauth_app(
client,
token,
name="grafana",
redirect_uri=f"https://grafana.{DOMAIN}/login/generic_oauth",
)
if grafana_creds:
_write_grafana_env(*grafana_creds)
```
- [ ] **Step 11.4: Update `grafana` service in compose.yml**
Replace the existing `grafana` service block with:
```yaml
grafana:
image: grafana/grafana:latest
container_name: grafana
networks:
- gateway
- observability
env_file:
- path: .state/gitea/grafana.env
required: false
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_SERVER_ROOT_URL=https://grafana.${DOMAIN:-fhirworx.io}
- GF_AUTH_GENERIC_OAUTH_ENABLED=true
- GF_AUTH_GENERIC_OAUTH_NAME=Gitea
- GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email
- GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://git.${DOMAIN:-fhirworx.io}/login/oauth/authorize
- GF_AUTH_GENERIC_OAUTH_TOKEN_URL=http://git:3000/login/oauth/access_token
- GF_AUTH_GENERIC_OAUTH_API_URL=http://git:3000/api/v1/user
- GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true
- GF_AUTH_GENERIC_OAUTH_AUTO_LOGIN=false
- GF_AUTH_OAUTH_ALLOW_INSECURE_EMAIL_LOOKUP=true
- GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=is_admin && 'GrafanaAdmin'
- GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true
volumes:
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
- ./assets/icons/favicon.svg:/usr/share/grafana/public/img/grafana_icon.svg:ro
- ./assets/icons/fav32.png:/usr/share/grafana/public/img/fav32.png:ro
- ./assets/icons/apple-touch-icon.png:/usr/share/grafana/public/img/apple-touch-icon.png:ro
depends_on:
- loki
- tempo
- prometheus
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
```
- [ ] **Step 11.5: Run tests + run wire + restart grafana**
```bash
uv run pytest tests/test_observability.py::TestGrafanaOAuth -v
# Provision the OAuth app (Gitea must be running)
docker compose run --rm wire
# expected output includes: ok: OAuth2 app 'grafana' created
# Verify the env file was written
test -f .state/gitea/grafana.env && head -1 .state/gitea/grafana.env
docker compose up -d --force-recreate grafana
docker logs grafana 2>&1 | grep -i "oauth" | tail
# expected: lines mentioning generic_oauth setup
```
- [ ] **Step 11.6: Manual SSO smoke test**
Open `https://grafana.${DOMAIN}` in a browser (or `https://localhost:3000` via
hosts override). Click **Sign in with Gitea**. Confirm:
- Redirects to Gitea login
- After Gitea auth, returns to Grafana
- Logged-in user has `GrafanaAdmin` role (top-right user → preferences)
- [ ] **Step 11.7: Commit**
```bash
git add dev/scripts/bootstrap_sso.py compose.yml tests/test_observability.py
git commit -m "feat(grafana): Gitea OAuth SSO via bootstrap_sso.py"
```
---
## Task 12: Subdomains + Traefik route swap (jaeger → tempo)
**Files:**
- Modify: `stack.toml`
- Modify: `dev/scripts/bootstrap_sso.py` (`SUBDOMAINS`)
- Modify: `infra/traefik/dynamic/services.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 12.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestSubdomainSwap:
def test_stack_toml_subdomains(self):
import tomllib
cfg = tomllib.loads((ROOT / "stack.toml").read_text())
subs = cfg["platform"]["subdomains"]
assert "tempo" in subs
assert "jaeger" not in subs
def test_bootstrap_sso_subdomains(self):
src = (ROOT / "dev/scripts/bootstrap_sso.py").read_text()
assert '"tempo"' in src
assert '"jaeger"' not in src
def test_traefik_route_swap(self):
src = (ROOT / "infra/traefik/dynamic/services.yml").read_text()
assert '"tempo"' in src
assert '"jaeger"' not in src
```
- [ ] **Step 12.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestSubdomainSwap -v
```
Expected: FAIL.
- [ ] **Step 12.3: Edit `stack.toml`**
In `stack.toml`, replace `"jaeger"` with `"tempo"` in the `subdomains` list:
```toml
subdomains = [
"dashboard", "docs", "git", "ci", "notebooks", "zotero",
"webdav", "api", "nessie", "trino", "polaris",
"grafana", "prometheus", "tempo", "loki",
"s3", "s3console",
]
```
- [ ] **Step 12.4: Edit `dev/scripts/bootstrap_sso.py`**
In the `SUBDOMAINS` list, replace `"jaeger"` with `"tempo"`.
- [ ] **Step 12.5: Edit `infra/traefik/dynamic/services.yml`**
Find the line `"jaeger" (dict "port" "16686" "theme" true "mw" "git-sso,infra-headers")` and change it to:
```yaml
"tempo" (dict "port" "3200" "theme" true "mw" "git-sso,infra-headers")
```
- [ ] **Step 12.6: Run tests, reload traefik, verify route**
```bash
uv run pytest tests/test_observability.py::TestSubdomainSwap -v
# Traefik picks up dynamic config without restart, but force a reload to be safe:
docker compose restart traefik
# Hit the new endpoint via Traefik (note: requires DNS/hosts entry for tempo.${DOMAIN})
curl -sk https://tempo.${DOMAIN:-fhirworx.io}/ready
# expected: "ready"
# Re-run wire to push the new tunnel ingress to Cloudflare
docker compose run --rm wire
```
- [ ] **Step 12.7: Commit**
```bash
git add stack.toml dev/scripts/bootstrap_sso.py infra/traefik/dynamic/services.yml tests/test_observability.py
git commit -m "feat(routing): swap jaeger→tempo subdomain and Traefik route"
```
---
## Task 13: Remove Jaeger
**Files:**
- Modify: `compose.yml`
- Test: `tests/test_observability.py`
- [ ] **Step 13.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
class TestJaegerRemoved:
def test_jaeger_service_gone(self):
assert "jaeger" not in _services()
```
- [ ] **Step 13.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestJaegerRemoved -v
```
Expected: FAIL.
- [ ] **Step 13.3: Remove the `jaeger:` service block from `compose.yml`**
Delete the entire block — including the comment `# Observability Stack` if Jaeger was the first entry under it (the comment can stay since loki/tempo etc. follow).
- [ ] **Step 13.4: Run tests, recreate stack, verify nothing broke**
```bash
uv run pytest tests/test_observability.py -v # full suite
docker compose down jaeger 2>/dev/null || true # remove the running container if present
docker compose up -d
docker compose ps # no jaeger entry
```
- [ ] **Step 13.5: Commit**
```bash
git add compose.yml tests/test_observability.py
git commit -m "chore(observability): remove Jaeger (replaced by Tempo)"
```
---
## Task 14: Six per-service Grafana dashboards
**Files:**
- Create: `infra/grafana/dashboards/homelab-overview.json`
- Create: `infra/grafana/dashboards/gateway-auth.json`
- Create: `infra/grafana/dashboards/data-lake.json`
- Create: `infra/grafana/dashboards/data-pipelines.json`
- Create: `infra/grafana/dashboards/ci.json`
- Create: `infra/grafana/dashboards/gpu-notebooks.json`
- Test: `tests/test_observability.py`
> **For each dashboard:** scaffold the JSON using the `homelab-overview.json`
> template below, then set `title`, `uid`, and replace the panels with the
> queries listed for that dashboard. Each `panels[]` entry needs `id`,
> `title`, `type`, `gridPos {x,y,w,h}`, `datasource {uid}`, and a `targets[]`
> array. Use `type: "timeseries"` for time series, `"stat"` for single-stat,
> `"logs"` for log panels.
- [ ] **Step 14.1: Write the failing test**
Append to `tests/test_observability.py`:
```python
import json
EXPECTED_DASHBOARDS = {
"homelab-overview", "gateway-auth", "data-lake",
"data-pipelines", "ci", "gpu-notebooks",
}
class TestDashboards:
def test_all_dashboards_exist_and_parse(self):
dash_dir = ROOT / "infra/grafana/dashboards"
files = {p.stem for p in dash_dir.glob("*.json")}
missing = EXPECTED_DASHBOARDS - files
assert not missing, f"missing dashboards: {missing}"
for name in EXPECTED_DASHBOARDS:
with (dash_dir / f"{name}.json").open() as f:
data = json.load(f)
assert "panels" in data, f"{name}: no panels[]"
assert data["panels"], f"{name}: empty panels[]"
```
- [ ] **Step 14.2: Run test to verify it fails**
```bash
uv run pytest tests/test_observability.py::TestDashboards -v
```
Expected: FAIL.
- [ ] **Step 14.3: Create `homelab-overview.json` (template)**
```json
{
"title": "Homelab Overview",
"uid": "homelab-overview",
"tags": ["homelab", "stack"],
"timezone": "browser",
"schemaVersion": 39,
"time": {"from": "now-6h", "to": "now"},
"refresh": "30s",
"panels": [
{
"id": 1,
"title": "Containers up",
"type": "stat",
"gridPos": {"x": 0, "y": 0, "w": 4, "h": 4},
"datasource": {"uid": "prometheus"},
"targets": [{"expr": "count(container_last_seen{name!=\"\"})"}]
},
{
"id": 2,
"title": "Log volume / 1m",
"type": "stat",
"gridPos": {"x": 4, "y": 0, "w": 4, "h": 4},
"datasource": {"uid": "loki"},
"targets": [{"expr": "sum(rate({container=~\".+\"}[1m]))"}]
},
{
"id": 3,
"title": "Error rate / 1m",
"type": "stat",
"gridPos": {"x": 8, "y": 0, "w": 4, "h": 4},
"datasource": {"uid": "loki"},
"targets": [{"expr": "sum(rate({level=\"error\"}[1m]))"}]
},
{
"id": 4,
"title": "Container CPU %",
"type": "timeseries",
"gridPos": {"x": 0, "y": 4, "w": 12, "h": 8},
"datasource": {"uid": "prometheus"},
"targets": [
{"expr": "rate(container_cpu_usage_seconds_total{name!=\"\"}[1m]) * 100", "legendFormat": "{{name}}"}
]
},
{
"id": 5,
"title": "Container memory (MiB)",
"type": "timeseries",
"gridPos": {"x": 12, "y": 4, "w": 12, "h": 8},
"datasource": {"uid": "prometheus"},
"targets": [
{"expr": "container_memory_working_set_bytes{name!=\"\"} / 1024 / 1024", "legendFormat": "{{name}}"}
]
},
{
"id": 6,
"title": "Top error sources (1h)",
"type": "logs",
"gridPos": {"x": 0, "y": 12, "w": 24, "h": 8},
"datasource": {"uid": "loki"},
"targets": [{"expr": "{level=\"error\"} | json"}]
}
]
}
```
- [ ] **Step 14.4: Create `gateway-auth.json`**
Reuse the template from 14.3, change `title` to `"Gateway & Auth"` and `uid` to `"gateway-auth"`, and set `panels` to (replace ids/positions/queries):
```json
[
{"title": "Traefik request rate by status", "datasource": "prometheus", "expr": "sum by (code) (rate(traefik_service_requests_total[1m]))"},
{"title": "Traefik latency p95 (s)", "datasource": "prometheus", "expr": "histogram_quantile(0.95, sum by (le) (rate(traefik_service_request_duration_seconds_bucket[5m])))"},
{"title": "oauth2-proxy auth events", "datasource": "loki", "expr": "sum by (oauth_event) (rate({container=\"oauth2-proxy\"} | json [1m]))"},
{"title": "Gitea login + push events", "datasource": "loki", "expr": "sum by (gitea_event) (rate({container=\"git\"} | regexp `(?P<gitea_event>login|push|webhook)` [1m]))"},
{"title": "Failed auth tail", "datasource": "loki", "expr": "{container=~\"oauth2-proxy|git\"} |~ \"(?i)(failure|forbidden|401|403)\"", "type": "logs"}
]
```
Translate each entry into a full panel object with the same shape as in 14.3
(`id` 1..N incrementing, `gridPos` 12-col grid two panels per row).
- [ ] **Step 14.5: Create `data-lake.json`**
`title` `"Data Lake"`, `uid` `"data-lake"`, panels:
```json
[
{"title": "Trino queries (status)", "datasource": "loki", "expr": "sum by (trino_event) (rate({container=\"trino\"}[1m]))"},
{"title": "Trino query duration p95", "datasource": "prometheus", "expr": "histogram_quantile(0.95, sum by (le) (rate(trino_query_execution_seconds_bucket[5m])))"},
{"title": "Nessie/Polaris HTTP rate", "datasource": "prometheus", "expr": "sum by (job) (rate(http_server_requests_seconds_count{job=~\"nessie|polaris\"}[1m]))"},
{"title": "Nessie/Polaris error rate", "datasource": "prometheus", "expr": "sum by (job) (rate(http_server_requests_seconds_count{job=~\"nessie|polaris\",status=~\"5..\"}[1m]))"},
{"title": "RustFS S3 ops by op", "datasource": "loki", "expr": "sum by (s3_op) (rate({container=\"rustfs\"} | json [1m]))"}
]
```
- [ ] **Step 14.6: Create `data-pipelines.json`**
`title` `"Data Pipelines"`, `uid` `"data-pipelines"`, panels:
```json
[
{"title": "API request rate (status)", "datasource": "prometheus", "expr": "sum by (status) (rate(stack_http_server_requests_total[1m]))"},
{"title": "Mail-poller poll cycles", "datasource": "loki", "expr": "sum(rate({container=\"mail-poller\", mail_event=\"poll\"}[5m]))"},
{"title": "Mail-poller ingest count", "datasource": "loki", "expr": "sum(rate({container=\"mail-poller\", mail_event=\"ingest\"}[5m]))"},
{"title": "Pipeline errors", "datasource": "loki", "expr": "sum by (pipeline_name) (rate({container=\"api\", api_event=\"error\"}[5m]))"},
{"title": "Pipeline error tail", "datasource": "loki", "expr": "{container=\"api\", level=\"error\"}", "type": "logs"}
]
```
If `infra/grafana/dashboards/pipeline-performance.json` exists and overlaps, fold its panels into this file and delete `pipeline-performance.json` in the same step.
- [ ] **Step 14.7: Create `ci.json`**
`title` `"CI"`, `uid` `"ci"`, panels:
```json
[
{"title": "Active CI jobs", "datasource": "loki", "expr": "count_over_time({container=\"act-runner\", ci_event=\"job started\"}[24h])"},
{"title": "Job outcomes (24h)", "datasource": "loki", "expr": "sum by (ci_event) (count_over_time({container=\"act-runner\", ci_event=~\"job (started|finished|failed)\"}[24h]))"},
{"title": "Gitea push events", "datasource": "loki", "expr": "sum(rate({container=\"git\", gitea_event=\"push\"}[5m]))"},
{"title": "Webhook events", "datasource": "loki", "expr": "sum(rate({container=\"git\", gitea_event=\"webhook\"}[5m]))"}
]
```
- [ ] **Step 14.8: Create `gpu-notebooks.json`**
`title` `"GPU & Notebooks"`, `uid` `"gpu-notebooks"`, panels:
```json
[
{"title": "GPU utilization %", "datasource": "prometheus", "expr": "DCGM_FI_DEV_GPU_UTIL"},
{"title": "GPU memory used (MiB)", "datasource": "prometheus", "expr": "DCGM_FI_DEV_FB_USED"},
{"title": "GPU temp (°C)", "datasource": "prometheus", "expr": "DCGM_FI_DEV_GPU_TEMP"},
{"title": "GPU power (W)", "datasource": "prometheus", "expr": "DCGM_FI_DEV_POWER_USAGE"},
{"title": "Notebooks/Zotero CPU %", "datasource": "prometheus", "expr": "rate(container_cpu_usage_seconds_total{name=~\"notebooks|zotero\"}[1m]) * 100", "legendFormat": "{{name}}"},
{"title": "Notebooks/Zotero memory", "datasource": "prometheus", "expr": "container_memory_working_set_bytes{name=~\"notebooks|zotero\"} / 1024 / 1024", "legendFormat": "{{name}}"}
]
```
- [ ] **Step 14.9: Run tests + verify Grafana picks them up**
```bash
uv run pytest tests/test_observability.py::TestDashboards -v
docker compose restart grafana
docker logs grafana 2>&1 | grep -i "dashboard" | tail -20
# expected: "finished provisioning" entries for each new file
# In a browser at https://grafana.${DOMAIN} → Dashboards, all six should appear
```
- [ ] **Step 14.10: Commit**
```bash
git add infra/grafana/dashboards/*.json tests/test_observability.py
git commit -m "feat(grafana): six per-service dashboards"
```
---
## Task 15: End-to-end verification
- [ ] **Step 15.1: Full suite green**
```bash
uv run pytest tests/test_observability.py tests/test_compose_mounts.py -v
# expected: all green
```
- [ ] **Step 15.2: All services up + healthy**
```bash
docker compose up -d
sleep 60
docker compose ps --format 'table {{.Name}}\t{{.State}}\t{{.Status}}'
# expected: every service in "running"; healthchecked services show "healthy"
```
- [ ] **Step 15.3: Cross-signal correlation works**
```bash
# Generate a trace by hitting the api (which is OTel-instrumented)
curl -sk https://api.${DOMAIN:-fhirworx.io}/health >/dev/null
# In Grafana → Explore:
# 1. Switch datasource to Tempo, run "service.name = api"
# 2. Click any span → Logs panel should auto-open with matching service.name in Loki
# 3. Open the gpu-notebooks dashboard — DCGM panels populated
# 4. Open data-lake dashboard — RustFS S3 ops panel populates after `mc ls` against rustfs
docker exec mc mc ls local/ >/dev/null
```
- [ ] **Step 15.4: Final commit if any cleanup landed**
If any of the above steps required a tweak (legend formatting, panel sizing,
etc.), commit it now:
```bash
git add -A
git status # confirm only intended changes
git commit -m "fix(observability): post-verification tweaks" # if applicable
```
---
## Self-Review Notes
Cross-check against the spec sections:
| Spec section | Plan task |
|---|---|
| Compose changes — add tempo/nvidia-exporter | Tasks 13 |
| Compose changes — remove jaeger | Task 13 |
| Compose changes — promtail=true labels | Task 10 |
| Compose changes — Traefik access logs | Task 10 |
| Compose changes — promtail docker_sd | Task 5 |
| Subdomain map (`stack.toml`, bootstrap_sso, traefik) | Task 12 |
| Loki retention/compactor | Task 4 |
| Tempo config | Task 1 |
| OTel collector swap | Task 7 |
| Prometheus targets | Task 8 |
| Grafana datasources + correlation | Task 9 |
| Grafana OAuth via Gitea | Task 11 |
| Six per-service dashboards | Task 14 |
| Migration verification (Grafana login, trace→logs) | Task 15 |
All spec sections covered. No placeholders. Method/label names consistent across tasks (`promtail=true`, `tempo_data`, `STATE / "grafana.env"`).