11# Observability stack
22
3- Central Grafana + Loki + Tempo + Prometheus + OTel Collector, wired together
4- with logs ↔ traces ↔ metrics correlation. Meant to run on one host and
5- receive telemetry from multiple projects over OTLP.
3+ One host running Grafana, Loki, Tempo, Prometheus, and an OpenTelemetry
4+ Collector, wired so logs, traces, and metrics cross-link. Point any number of
5+ projects at it over OTLP and their telemetry lands in one place.
6+
7+ ``` mermaid
8+ flowchart LR
9+ apps["Project apps<br/>(OTLP gRPC 4317 / HTTP 4318)"] --> otel["OTel Collector<br/>(ingestion gateway)"]
10+ otel -- logs --> loki[Loki]
11+ otel -- traces --> tempo[Tempo]
12+ otel -- metrics --> prom[Prometheus]
13+ tempo -- "span metrics (RED)" --> prom
14+ loki --> grafana[Grafana]
15+ tempo --> grafana
16+ prom --> grafana
17+ grafana --- cf["Cloudflare Tunnel<br/>(optional overlay)"]
18+ ```
619
7- ## Layout
20+ ## Demo: see it work in one command
821
22+ ``` sh
23+ cp .env.example .env
24+ just demo
925```
10- compose.yml # all services
26+
27+ This starts the full stack plus a small auto-instrumented FastAPI service
28+ under constant load (` compose.demo.yml ` ). Give it a minute, then open
29+ Grafana at < http://localhost:3000 > (admin / change-me):
30+
31+ - ** Dashboards → Service Health (RED)** — request rate, error rate, and
32+ latency for the demo service. Latency dots are exemplars: click one to
33+ open that exact trace in Tempo.
34+ - ** Explore → Loki** — ` {service_name="demo-api"} ` ; expand an error line
35+ and follow its ` trace_id ` to the trace.
36+ - ** Alerting → Alert rules** — stack-health and error-rate rules,
37+ evaluated by Prometheus.
38+
39+ ![ Service Health (RED) dashboard] ( docs/img/service-health.png )
40+
41+ Tear it down with ` just demo-down ` .
42+
43+ ## Layout
44+
45+ ``` sh
46+ compose.yml # core services
47+ compose.tunnel.yml # production overlay: Cloudflare Tunnel
48+ compose.demo.yml # demo overlay: sample telemetry source
49+ demo/ # the demo FastAPI service
1150config/
1251 otel-collector.yaml # ingestion gateway (OTLP in → Loki/Tempo/Prometheus out)
1352 loki.yaml # logs
1453 tempo.yaml # traces
1554 prometheus.yaml # metrics
55+ alerts/ # Prometheus alert rules
1656 grafana/ # provisioned datasources + dashboard loader
1757dashboards/ # drop JSON dashboards here; Grafana auto-loads them
1858```
@@ -21,11 +61,18 @@ dashboards/ # drop JSON dashboards here; Grafana auto-loads them
2161
2262``` sh
2363cp .env.example .env # set GRAFANA_ADMIN_PASSWORD
24- just up
64+ just up # core stack, local only
65+ just up-tunnel # core stack + Cloudflare Tunnel (needs CLOUDFLARE_TUNNEL_TOKEN)
2566```
2667
2768Grafana: < http://localhost:3000 > (admin / whatever you set).
2869
70+ ` just check ` validates everything (compose files, Prometheus config and
71+ alert rules, collector config, YAML, workflows, dashboard JSON) in pinned
72+ containers — no host installs. CI runs the same command on every push and
73+ PR, plus a smoke test that boots the stack and waits for Grafana to report
74+ healthy.
75+
2976## Sending telemetry from a project
3077
3178### Traces + metrics (OTLP)
@@ -64,8 +111,35 @@ Logs carry labels for `service`, `env`, and `host` so you can filter in
64111Grafana. Keep label cardinality low — don't add ` user_id ` , ` request_id ` ,
65112etc. as labels; use LogQL filters for those.
66113
114+ ## Alerting
115+
116+ Prometheus evaluates the rules in ` config/alerts/ ` (target down, OTel
117+ export failures, >5% span error rate) and Grafana surfaces them under
118+ Alerting → Alert rules. There is deliberately no Alertmanager: on a
119+ single-host stack, another service buys routing complexity before anyone
120+ needs it. Add one (or a Grafana contact point) when alerts must page
121+ someone.
122+
67123## Storage
68124
69125Everything persists to local Docker volumes (` loki_data ` , ` tempo_data ` ,
70126` prometheus_data ` , ` grafana_data ` ). Swap Loki / Tempo storage to S3-compatible
71- (Backblaze B2, Cloudflare R2, Hetzner, MinIO) when you outgrow local disk.
127+ (Backblaze B2, Cloudflare R2, Hetzner, MinIO) when you outgrow local disk —
128+ ` compose.storage-s3.yml ` documents the concrete shape of that change, and
129+ ` docs/adr/0001-observability-stack.md ` records the architecture rationale.
130+
131+ ## Design decisions
132+
133+ Everything enters through one gateway — the collector — so a project
134+ configures a single OTLP endpoint, and a backend can be swapped without
135+ touching any app. It runs on one host on purpose: CML's telemetry is a handful
136+ of services at single-digit requests per second, so distributed ingest would
137+ add operational weight for no gain, while one compose file stays auditable by a
138+ single person. When local disk runs short, S3-backed Loki and Tempo (above)
139+ are the documented way out.
140+
141+ Logs carry only low-cardinality labels — ` service ` , ` env ` , ` host ` — and
142+ everything else is a query-time filter; high-cardinality labels are the usual
143+ way a Loki install falls over. And because Tempo's metrics generator derives
144+ RED metrics from spans, any service that sends traces gets the Service Health
145+ dashboard whether or not it emits metrics of its own.
0 commit comments