A small, self-contained sandbox for learning the OpenTelemetry Collector → Grafana Tempo trace pipeline on a laptop. Everything runs inside a single k3d cluster on your Docker daemon — no cloud, no external services.
┌──────────────┐ OTLP/gRPC ┌──────────────────┐ OTLP/traces ┌─────────┐
│ demo-app │ ─────────────▶ │ otel-collector │ ──────────────▶ │ tempo │
│ (FastAPI) │ │ (contrib) │ └────┬────┘
└──────────────┘ │ │ remote_write │
│ spanmetrics ───┼─────────────▶ ┌────────────┐
│ servicegraph ───┘ │ prometheus │
└──────────────────┘ └─────┬──────┘
│
query traces │ query metrics
│
┌──────┴──────┐
│ grafana │
└─────────────┘
The collector enriches every span with host.* and k8s.* attributes so you can see which machine and which pod produced it — that's the "data describing the local machine" angle, attached as resource metadata to traces from a real app.
| Path | What it is |
|---|---|
Makefile |
All operator-facing commands. make help for the menu. |
scripts/ |
The shell scripts the Makefile drives (prereqs.sh, up.sh, down.sh, traffic.sh). |
helm/ |
Values files for the four Helm charts: tempo, prometheus, otel-collector, grafana. |
demo-app/ |
A ~50-line FastAPI service with three endpoints, fully OTel-instrumented. |
You need a Linux shell with Docker access. WSL2 Ubuntu is the target — instructions below assume that.
| Tool | Why | Auto-installable? |
|---|---|---|
| Docker | runs k3d's containers | No — install Docker Desktop on Windows, then enable WSL Integration for your distro |
| k3d | spins up a k3s cluster inside Docker | yes |
| kubectl | talks to the cluster | yes |
| helm | installs the charts | yes |
On a fresh WSL2 Ubuntu, the fastest path is:
make install-tools # installs k3d, helm, kubectl (sudo prompt for kubectl)
make prereqs # verify all four are now okinstall-tools is idempotent — anything already on PATH is skipped. Docker is the one piece it can't fix from inside WSL: install Docker Desktop on Windows, then Settings → Resources → WSL Integration, toggle on for your Ubuntu distro, Apply & Restart, and run wsl --shutdown from PowerShell before reopening the terminal.
make upThis will:
- Create a k3d cluster called
otel-demo(one server, no agents). - Publish host port
3000onto the cluster's loadbalancer at port80, where Traefik (built into k3s) listens. The Grafana ingress then routeslocalhost:3000→ Traefik → Grafana. - Install Tempo, Prometheus, the OpenTelemetry Collector, and Grafana via Helm into the
observabilitynamespace. - Build the demo app image, import it into the cluster, and deploy it into the
demonamespace.
First run takes a few minutes (image pulls). Re-runs are quick — helm upgrade --install is idempotent.
You'll see
WARNING: This chart is deprecatedwhen Tempo installs. That's expected: Grafana retired the simplegrafana/tempochart in favor ofgrafana/tempo-distributed. For a single-binary laptop demo it still works fine.
After make up finishes, generate some traffic:
make trafficThen open Grafana:
http://localhost:3000
You're logged in as anonymous Admin (no password — see helm/grafana-values.yaml).
- Left sidebar → Explore.
- Top-left datasource picker → Tempo.
- Search tab → click Run query. You should see your recent traces.
- Click any trace.
For a /work request you should see a tree like:
GET /work ← created by FastAPIInstrumentor
├── fake-db-query ← manual span in app.py
└── fake-http-call ← manual span in app.py
Click the root span and look at the Resource attributes panel:
| Attribute | Source |
|---|---|
service.name |
the SDK, from OTEL_SERVICE_NAME env var |
service.namespace, deployment.environment |
the SDK, from OTEL_RESOURCE_ATTRIBUTES |
host.name, host.os.type, host.arch |
collector — resourcedetection processor |
k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.deployment.name |
collector — k8sattributes processor |
The app didn't set any of the host.* or k8s.* ones. The collector added them on the way through, by reading its own environment and by looking up the sender's pod in the k8s API. That's the part of OTel that often feels magical the first time you see it.
curl http://localhost:8080/error # if you've port-forwardedOr just let make traffic hit it randomly. In Grafana those traces show with a red status icon, and the Events tab on the buggy-operation span has a recorded exception event with the stack trace.
Open helm/otel-collector-values.yaml and look at config.service.pipelines:
traces:
receivers: [otlp]
processors: [k8sattributes, resourcedetection, batch]
exporters: [otlp/tempo, spanmetrics, servicegraph, debug]
metrics:
receivers: [spanmetrics, servicegraph]
processors: [batch]
exporters: [prometheusremotewrite]That's the whole story:
- otlp receiver — listens on
4317(gRPC) and4318(HTTP). The demo app sends gRPC. - k8sattributes — adds the
k8s.*attributes by looking up the sender's IP. - resourcedetection — adds
host.*from the node the collector is running on. - batch — buffers spans for up to 2 seconds, then flushes. Standard hygiene.
- otlp/tempo exporter — forwards raw spans to Tempo.
- spanmetrics / servicegraph connectors — these are the magic pieces. They sit at the trace pipeline's exporter slot, but they also act as a receiver for the metrics pipeline. They watch traces fly by and emit derived metrics: span rate/error/duration histograms (
spanmetrics) and edge counts/latencies between services (servicegraph). - prometheusremotewrite exporter — pushes those metrics into Prometheus.
- debug exporter — also dumps a one-line summary to stdout for
kubectl logs.
Watch the debug output flow by:
make logs-collectorYou'll see each batch as it goes through.
In Grafana Explore → Tempo, click the Service Graph tab and Run query. You should see demo-app as a node. The data behind that view comes from Prometheus, populated by the servicegraph connector in the OTel collector — Tempo itself doesn't compute it.
Because our demo has only one service, the graph is unexciting (a single node, self-edge if the app calls itself). To see edges between services, you'd add a second service that calls demo-app (or have demo-app call out via real HTTP — the in-process fake-http-call span isn't a real edge because there's no remote receiver).
You can also query the raw metrics directly: in Explore → Prometheus, try:
sum by (service_name) (rate(traces_spanmetrics_calls_total[1m]))
That's RPS per service. Or:
histogram_quantile(0.95, sum by (le, service_name) (rate(traces_spanmetrics_duration_milliseconds_bucket[1m])))
p95 request duration. Both are produced by the spanmetrics connector with no app-side instrumentation — it derives them from spans.
- Change the service name. Edit
OTEL_SERVICE_NAMEindemo-app/k8s.yaml,kubectl applythe file, and watch new traces appear under the new name. - Add an attribute processor. In
helm/otel-collector-values.yaml, add anattributesprocessor that injects something likedeployment.environment: kasper-laptopon every span. Re-apply withhelm upgrade(or justmake up). - Break the exporter. Change the Tempo endpoint to a wrong port. Watch
make logs-collectorshow retries; watch the debug exporter still print spans (the receiver and pipeline are fine — only the export fails). - Crank up the verbosity. Switch the debug exporter from
verbosity: basictodetailedand see the full span content in the collector logs. Don't leave it that way — it's chatty. - Add a second service so the service graph actually has multiple nodes. The simplest way: deploy a second copy of
demo-appunder a differentOTEL_SERVICE_NAME(call itdemo-caller), and have it loop calls intohttp://demo-app/work. Two services, one real edge, a graph worth looking at.
make help # list all targets
make status # pods in both namespaces
make logs-collector # tail the OTel collector
make logs-tempo # tail Tempo
make logs-prom # tail Prometheus
make logs-app # tail demo-app
make app # rebuild + reload demo-app after a code change
make restart-app # rollout restart without rebuilding
make traffic # blast 30 mixed requests at the demo app
make down # destroy the clustermake up hangs on a Helm install. Charts pull images on first run. If your network is slow, give it a minute. If a pod is stuck, make status and kubectl -n observability describe pod <name>.
No traces appearing in Grafana.
make logs-app— is the SDK printing connection errors?make logs-collector— is the debug exporter printing batches? If yes, the path app→collector is fine and the issue is collector→tempo.kubectl -n observability get svc— confirm the Service names match what the configs reference.
Grafana isn't on localhost:3000. The path is host:3000 → k3d loadbalancer:80 → Traefik → ingress rule → grafana svc. If any link breaks (e.g., the loadbalancer port mapping was set wrong, or another process on your host already owns port 3000), fall back to:
kubectl -n observability port-forward svc/grafana 3001:80…and use http://localhost:3001 instead. (We use 3001 here so it doesn't clash with whatever already holds 3000.)
Docker Desktop is on, but docker info fails inside WSL. Docker Desktop → Settings → Resources → WSL Integration → toggle your distro on (and restart the distro: wsl --shutdown from Windows, then re-open).
make downThat deletes the entire k3d cluster — every pod, image cache, and volume goes with it. Nothing is left running on your host.