Skip to content

Commit 6daf58b

Browse files
Merge pull request #2 from CMLPlatform/feat/runnable-stack-demo-ci
feat: Make the stack runnable, demoable, and tested
2 parents 39f7d5c + f090c99 commit 6daf58b

19 files changed

Lines changed: 620 additions & 22 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ GRAFANA_ADMIN_PASSWORD=change-me
66
# GRAFANA_ROOT_URL=https://grafana.example.com
77
GRAFANA_ROOT_URL=http://localhost:3000
88

9+
# Only needed for `just up-tunnel` (production exposure via Cloudflare Tunnel).
910
# From: Cloudflare Zero Trust → Networks → Tunnels → <your tunnel> → Configure → token
1011
CLOUDFLARE_TUNNEL_TOKEN=

.github/dependabot.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
version: 2
22
updates:
33
- package-ecosystem: "docker-compose"
4-
directory: "/"
4+
directory: "/"
55
schedule:
66
interval: "weekly"
77
groups:
88
monitoring-stack-updates:
99
patterns:
1010
- "*"
1111

12+
- package-ecosystem: "docker"
13+
directory: "/demo"
14+
schedule:
15+
interval: "monthly"
16+
1217
- package-ecosystem: "github-actions"
1318
directory: "/"
1419
schedule:

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Thin wrapper around the `just` contract: everything CI runs, you can run
2+
# locally with the same command.
3+
name: CI
4+
5+
on:
6+
push:
7+
branches: [main]
8+
pull_request:
9+
10+
jobs:
11+
check:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v7
15+
- uses: extractions/setup-just@v4
16+
- run: cp .env.example .env
17+
- run: just check
18+
19+
smoke:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v7
23+
- uses: extractions/setup-just@v4
24+
- run: cp .env.example .env
25+
- run: just smoke
26+
- run: just down

CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Changelog
2+
3+
Notable changes to this stack. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
4+
versions follow [SemVer](https://semver.org/).
5+
6+
## [0.1.0] - 2026-07-03
7+
8+
First tagged release: the stack is runnable, demoable, and validated in CI.
9+
10+
### Added
11+
12+
- `just demo`: one-command demo — an auto-instrumented FastAPI service under
13+
constant load populates Grafana with correlated traces, metrics, and logs.
14+
- `just check`: validation gate (compose syntax, Prometheus config + alert
15+
rules, collector config, YAML, workflows, dashboard JSON), all in pinned
16+
containers. Run by GitHub Actions on every push/PR, plus a `just smoke`
17+
boot test.
18+
- Provisioned **Service Health (RED)** dashboard driven by Tempo span
19+
metrics — works for any service that sends traces.
20+
- Prometheus alert rules: target down, OTel export failures, high span
21+
error rate.
22+
- README: architecture diagram, demo quickstart, screenshot, design
23+
decisions.
24+
- ADR 0001 recording the single-host OTLP-native architecture, and a
25+
commented `compose.storage-s3.yml` stub documenting the storage
26+
scale-out path.
27+
28+
### Changed
29+
30+
- `cloudflared` moved to an optional `compose.tunnel.yml` overlay and pinned
31+
(was `:latest`); the core stack no longer needs a tunnel token to run.
32+
33+
### Fixed
34+
35+
- OTLP metrics ingestion: Prometheus 3.x renamed the OTLP receiver flag,
36+
so the collector's metrics export 404ed and OTLP metrics were dropped.
37+
38+
## [0.0.1] - 2026-06-30
39+
40+
Initial stack: OTel Collector → Loki / Tempo / Prometheus → Grafana wired
41+
for cross-signal correlation, exposed via Cloudflare Tunnel, with RELab
42+
dashboards.

README.md

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,58 @@
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
1150
config/
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
1757
dashboards/ # 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
2363
cp .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

2768
Grafana: <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
64111
Grafana. Keep label cardinality low — don't add `user_id`, `request_id`,
65112
etc. 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

69125
Everything 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.

compose.demo.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Demo overlay: a tiny auto-instrumented FastAPI service plus a load
2+
# generator, so the stack has real traces, metrics, and correlated logs
3+
# to show. Not for production.
4+
#
5+
# just demo # core stack + this overlay
6+
# just demo-down
7+
8+
services:
9+
demo-api:
10+
build: ./demo
11+
restart: unless-stopped
12+
depends_on: [ otel-collector ]
13+
environment:
14+
OTEL_SERVICE_NAME: demo-api
15+
OTEL_RESOURCE_ATTRIBUTES: env=demo
16+
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
17+
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
18+
OTEL_TRACES_EXPORTER: otlp
19+
OTEL_METRICS_EXPORTER: otlp
20+
OTEL_LOGS_EXPORTER: otlp
21+
# Ship Python log records via OTLP with trace context attached.
22+
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true"
23+
OTEL_METRIC_EXPORT_INTERVAL: "10000"
24+
# Emit the *stable* HTTP semconv (http.server.request.duration in seconds,
25+
# http.route) rather than the legacy names. Stable metric names are frozen,
26+
# so the Service Health dashboard's app-SDK panel survives library bumps.
27+
OTEL_SEMCONV_STABILITY_OPT_IN: http
28+
logging:
29+
driver: json-file
30+
options:
31+
max-size: "10m"
32+
max-file: "3"
33+
34+
demo-load:
35+
image: curlimages/curl:8.21.0
36+
restart: unless-stopped
37+
depends_on: [ demo-api ]
38+
command:
39+
- sh
40+
- -c
41+
- |
42+
while true; do
43+
curl -s -o /dev/null demo-api:8000/
44+
curl -s -o /dev/null demo-api:8000/work
45+
sleep 1
46+
done
47+
logging:
48+
driver: json-file
49+
options:
50+
max-size: "10m"
51+
max-file: "3"

compose.storage-s3.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# STUB — the storage scale-out path (not wired up; see ADR 0001).
2+
#
3+
# When local volumes stop fitting, Loki and Tempo move their object storage
4+
# to any S3-compatible backend (Cloudflare R2, Backblaze B2, Hetzner, MinIO)
5+
# without touching the collector, Prometheus, or any client project.
6+
#
7+
# This is deliberately a commented stub: it documents the concrete shape of
8+
# the change so the README's scale-out claim is real, but it is not meant to
9+
# be started until credentials and a bucket exist. To activate:
10+
#
11+
# 1. Create s3 variants of the configs, e.g. config/loki.s3.yaml replacing
12+
# `common.storage.filesystem` with:
13+
#
14+
# common:
15+
# storage:
16+
# s3:
17+
# endpoint: ${S3_ENDPOINT} # e.g. <account>.r2.cloudflarestorage.com
18+
# bucketnames: cml-loki
19+
# access_key_id: ${S3_ACCESS_KEY_ID}
20+
# secret_access_key: ${S3_SECRET_ACCESS_KEY}
21+
# s3forcepathstyle: true
22+
#
23+
# and config/tempo.s3.yaml replacing `storage.trace.backend: local` with:
24+
#
25+
# storage:
26+
# trace:
27+
# backend: s3
28+
# s3:
29+
# endpoint: ${S3_ENDPOINT}
30+
# bucket: cml-tempo
31+
# access_key: ${S3_ACCESS_KEY_ID}
32+
# secret_key: ${S3_SECRET_ACCESS_KEY}
33+
#
34+
# 2. Uncomment the overlay below and run:
35+
# docker compose -f compose.yml -f compose.storage-s3.yml up -d
36+
#
37+
# services:
38+
# loki:
39+
# volumes:
40+
# - ./config/loki.s3.yaml:/etc/loki/loki.yaml:ro
41+
# - loki_data:/loki
42+
# environment:
43+
# S3_ENDPOINT: ${S3_ENDPOINT:?set S3_ENDPOINT in .env}
44+
# S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:?set S3_ACCESS_KEY_ID in .env}
45+
# S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY in .env}
46+
#
47+
# tempo:
48+
# volumes:
49+
# - ./config/tempo.s3.yaml:/etc/tempo/tempo.yaml:ro
50+
# - tempo_data:/var/tempo
51+
# environment:
52+
# S3_ENDPOINT: ${S3_ENDPOINT:?set S3_ENDPOINT in .env}
53+
# S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:?set S3_ACCESS_KEY_ID in .env}
54+
# S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY in .env}

compose.tunnel.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Production overlay: expose Grafana (and the OTLP endpoints) via Cloudflare
2+
# Tunnel. Requires CLOUDFLARE_TUNNEL_TOKEN in .env.
3+
#
4+
# just up-tunnel
5+
#
6+
# Token-based tunnel: routes are configured in the Cloudflare Zero Trust
7+
# dashboard. Create a tunnel at dash.cloudflare.com → Zero Trust → Networks →
8+
# Tunnels, then set the public hostnames there (e.g. grafana.example.com →
9+
# http://grafana:3000, otlp.example.com → http://otel-collector:4318).
10+
11+
services:
12+
cloudflared:
13+
image: cloudflare/cloudflared:2026.6.1
14+
restart: unless-stopped
15+
depends_on: [ grafana, otel-collector ]
16+
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?set CLOUDFLARE_TUNNEL_TOKEN in .env}
17+
logging:
18+
driver: json-file
19+
options:
20+
max-size: "10m"
21+
max-file: "3"

compose.yml

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,16 @@ services:
5555
restart: unless-stopped
5656
volumes:
5757
- ./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
58+
- ./config/alerts:/etc/prometheus/alerts:ro
5859
- prometheus_data:/prometheus
5960
command:
6061
- --config.file=/etc/prometheus/prometheus.yaml
6162
- --storage.tsdb.path=/prometheus
6263
- --storage.tsdb.retention.time=30d
6364
- --web.enable-remote-write-receiver
64-
- --enable-feature=native-histograms,otlp-write-receiver,exemplar-storage
65+
# Prometheus 3.x: OTLP ingestion is its own flag, no longer a feature flag.
66+
- --web.enable-otlp-receiver
67+
- --enable-feature=native-histograms,exemplar-storage
6568
logging: *default-logging
6669

6770
grafana:
@@ -82,18 +85,6 @@ services:
8285
GF_FEATURE_TOGGLES_ENABLE: traceqlEditor,metricsSummary,traceToProfiles
8386
logging: *default-logging
8487

85-
cloudflared:
86-
image: cloudflare/cloudflared:latest
87-
restart: unless-stopped
88-
depends_on: [ grafana, otel-collector ]
89-
# Token-based tunnel: routes are configured in the Cloudflare Zero Trust dashboard.
90-
# Create a tunnel at dash.cloudflare.com → Zero Trust → Networks → Tunnels,
91-
# then set the public hostnames there (e.g. grafana.example.com → http://grafana:3000,
92-
# otlp.example.com → http://otel-collector:4318).
93-
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?set CLOUDFLARE_TUNNEL_TOKEN in .env}
94-
logging: *default-logging
95-
pull_policy: always
96-
9788
volumes:
9889
loki_data:
9990
tempo_data:

0 commit comments

Comments
 (0)