Observability & Operations Labs
Reading about the four golden signals takes ten minutes. Being handed a cluster where a ServiceMonitor is silently selecting nothing, and having to find out why before the clock runs out — that’s the exam. These twelve labs run on one throwaway local cluster and take you the whole way: install the stack, get a real target scraping, ask it the four questions that matter, precompute an answer with a recording rule, make an alert fire on purpose, route and silence it, ship the dashboard as code, push traces through an OpenTelemetry Collector into Jaeger and follow one request across a service hop, query your logs with LogQL, defend an SLO with a multi-window burn-rate alert, and finally measure your own DORA numbers. Every lab ends with a command that proves it worked. Tick them off — your progress saves in this browser.
Right now your cluster is a dark room. In these labs you install the light switch (Prometheus), point a lamp at one specific corner (a ServiceMonitor), learn the four questions worth asking the room (“how busy? how broken? how slow? how full?”), rig a bell that rings when something is actually wrong (an alert), teach the bell not to wake everyone at once (Alertmanager), and finally clip a little GPS tracker to a single visitor so you can watch exactly where they went and where they got stuck (a trace). At the end you flip every switch at once and — for the first time — you can see your platform.
Everything here runs on a local, disposable cluster — kind or minikube — with no persistence, no ingress, no TLS and no cost. Do not point any of it at a real environment. The Prometheus, Grafana, OpenTelemetry, Jaeger and Loki projects all move fast: chart names, values keys, flags and image tags drift between releases, and an archived chart can vanish. Treat every command below as the shape of the answer, not scripture — if something 404s or a values key is rejected, open that project’s current quickstart and adapt. Learning to read a chart’s values.yaml when the copy-paste fails is itself an exam skill. When you’re done: kind delete cluster --name obs and it’s all gone.
⚖ CNPA vs CNPE — Twelve hands-on reps on a live cluster is a CNPE-only format — CNPA has no lab component whatsoever, it's a fully closed-book multiple-choice exam with zero external lookups of any kind. But the observability concepts these labs drill — golden signals, recording and alerting rules, Alertmanager routing, traces, LogQL, SLO burn-rate math — are exactly the kind of platform-engineering knowledge CNPA's closed-book recall still tests, just as multiple-choice recognition rather than something you install and break yourself.
Before you start
You need Docker (or Podman), kind or minikube, kubectl, helm, and ideally jq for reading API responses. About 4 GB of free RAM — the metrics stack is the heaviest thing you’ll install, and the tracing labs add a little more. Everything else is pulled from public registries.
Two habits will save you the entire track. First, keep a terminal parked on kubectl -n monitoring get pods -w so you see restarts as they happen. Second, learn the port-forward trio and keep them in three tabs — Prometheus on 9090, Grafana on 3000, Alertmanager on 9093. Nearly every “done when” below is a curl against one of those, because a screenshot of a graph proves nothing and a query result proves everything.
These labs assume the vocabulary from Observability & Operations — pillars, golden signals, SLOs, burn rates. You don’t have to have read it first, but when a lab makes you go “why that query?”, that’s the page to open.
How these labs fit together
They’re in order for a reason, and it’s the same order a real platform team builds in. Labs 0–2 are “can I see anything at all?” — install, scrape one target, ask the four questions. Labs 3–5 turn seeing into acting: precompute, alert, and route the alert to a human without drowning them. Lab 6 makes it durable by moving the dashboard out of a browser tab and into Git. Labs 7–9 add the other two pillars — traces and logs — so you can answer questions metrics can’t. Labs 10–11 are the platform-engineering layer: an SLO that decides when to stop shipping, and DORA numbers that prove the platform is helping.
Everything after Lab 0 reuses one sandbox service called checkout — same name the lessons use — so you build one mental model instead of twelve. Work them in order; each is 5–15 minutes. If a lab breaks, that is the lab. The troubleshooting playbook and command reference are your two side tabs.
The labs
# sandbox.yaml — the service you will watch for the rest of the track.
# A tiny app that serves 200s on /, 404s on /err and 500s on /internal-err, plus
# two traffic generators. Read the app's routes, not their names: /err is a 404,
# so the endpoint you hammer for 5xx is /internal-err.
apiVersion: v1
kind: Namespace
metadata:
name: checkout
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout
namespace: checkout
spec:
replicas: 2
selector:
matchLabels: { app: checkout }
template:
metadata:
labels: { app: checkout }
spec:
containers:
- name: app
image: quay.io/brancz/prometheus-example-app:v0.5.0
ports:
- { name: metrics, containerPort: 8080 } # NAMED port — the ServiceMonitor needs this name
resources:
requests: { cpu: 20m, memory: 32Mi }
limits: { memory: 64Mi }
---
apiVersion: v1
kind: Service
metadata:
name: checkout
namespace: checkout
labels:
app: checkout # ← the label the ServiceMonitor will select on
spec:
selector: { app: checkout }
ports:
- { name: metrics, port: 8080, targetPort: metrics }
---
# steady, healthy traffic — ~20 good requests/sec
apiVersion: apps/v1
kind: Deployment
metadata:
name: loadgen
namespace: checkout
spec:
replicas: 1
selector: { matchLabels: { app: loadgen } }
template:
metadata:
labels: { app: loadgen }
spec:
containers:
- name: curl
image: curlimages/curl:8.10.1
command: ["sh", "-c"]
args:
- "while true; do i=0; while [ $i -lt 20 ]; do curl -s -o /dev/null http://checkout:8080/; i=$((i+1)); done; sleep 1; done"
---
# your fault injector — starts switched OFF. Scale it up to break things on purpose.
# Each replica adds ~20 500s/sec, so 1 replica ≈ a 50% error ratio against loadgen.
apiVersion: apps/v1
kind: Deployment
metadata:
name: errgen
namespace: checkout
spec:
replicas: 0
selector: { matchLabels: { app: errgen } }
template:
metadata:
labels: { app: errgen }
spec:
containers:
- name: curl
image: curlimages/curl:8.10.1
command: ["sh", "-c"]
args:
- "while true; do i=0; while [ $i -lt 20 ]; do curl -s -o /dev/null http://checkout:8080/internal-err; i=$((i+1)); done; sleep 1; done"kind create cluster --name obs (or minikube start). 2. helm repo add prometheus-community https://prometheus-community.github.io/helm-charts && helm repo update. 3. Install with the release name kube-prometheus-stack — every label and Service name below assumes it: helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack -n monitoring --create-namespace --wait. 4. Look at what you got: kubectl -n monitoring get pods,svc and kubectl get crd | grep monitoring.coreos.com — those CRDs (Prometheus, ServiceMonitor, PrometheusRule, Alertmanager) are the whole reason this stack is GitOps-friendly. 5. Open three port-forwards in three tabs: kubectl -n monitoring port-forward svc/kube-prometheus-stack-prometheus 9090:9090, … svc/kube-prometheus-stack-grafana 3000:80, … svc/kube-prometheus-stack-alertmanager 9093:9093. Grafana’s password: kubectl -n monitoring get secret kube-prometheus-stack-grafana -o jsonpath='{.data.admin-password}' | base64 -d (user admin). 6. Apply sandbox.yaml above: kubectl apply -f sandbox.yaml.curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=up' | jq '.data.result | length' returns a number well above zero (the stack scrapes itself, kubelet, and kube-state-metrics out of the box), and kubectl -n checkout get pods shows checkout ×2 and loadgen Running.# servicemonitor.yaml — four things must line up. Get any one wrong and you get
# no error, no event, no warning: just a target that silently never appears.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: checkout
namespace: monitoring # lives beside Prometheus; watches another namespace
labels:
release: kube-prometheus-stack # 1. must match Prometheus's serviceMonitorSelector
spec:
selector:
matchLabels:
app: checkout # 2. must match a label on the SERVICE (not on the pods)
namespaceSelector:
matchNames: [ checkout ] # 3. must name the namespace the Service lives in
endpoints:
- port: metrics # 4. the Service port NAME — never the port number
path: /metrics
interval: 15skubectl -n checkout port-forward svc/checkout 8080:8080 then curl -s localhost:8080/metrics | head -30. Note the exact metric names you see — you’ll query them in Lab 2. 2. kubectl apply -f servicemonitor.yaml. 3. Wait ~30s, then open Status → Target health in the Prometheus UI, or query up{job="checkout"}. It should be 1. 4. Now the real lesson — break it four times and fix it four times, checking the target list after each: (a) kubectl -n monitoring patch servicemonitor checkout --type=merge -p '{"metadata":{"labels":{"release":"nope"}}}' — Prometheus stops adopting the object entirely; kubectl -n monitoring get prometheus -o yaml | grep -A5 serviceMonitorSelector shows you why. (b) Point spec.selector.matchLabels at app: checkout-svc — selects no Service. Debug it the way you would live: kubectl -n checkout get svc --show-labels. (c) Change namespaceSelector.matchNames to [ default ]. (d) Change endpoints[0].port to "8080" or http instead of the name metrics — note the field is a string, so an unquoted number is rejected by the CRD schema outright, and a quoted one is accepted and silently matches nothing. Restore the correct value each time.curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=up{job="checkout"}' | jq '.data.result[].value[1]' prints "1" for both pods — and you can name, from memory, the four fields that must agree.# The four golden signals, as real queries. Run each in the Prometheus UI.
# TRAFFIC — requests per second, last 5 minutes
sum(rate(http_requests_total{job="checkout"}[5m]))
# ERRORS — fraction of responses that are 5xx (0 until you scale errgen up)
sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m]))
/ sum(rate(http_requests_total{job="checkout"}[5m]))
# LATENCY — p99 from a histogram. The sandbox app is too simple to export one,
# so practise the function against something that does: the API server.
histogram_quantile(0.99,
sum(rate(apiserver_request_duration_seconds_bucket{verb="GET"}[5m])) by (le))
# SATURATION — two flavours worth knowing
1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) # node memory in use
sum by (pod) (rate(container_cpu_cfs_throttled_seconds_total{namespace="checkout"}[5m]))localhost:9090), on the Graph tab so you see shape, not just a number. If http_requests_total doesn’t exist, use the real metric name you read off /metrics in Lab 1 — this happens constantly on the job. 2. Feel the difference between the pieces: run http_requests_total{job="checkout"} (a raw, ever-climbing counter), then rate(...[5m]) (per-second slope), then sum(rate(...)) (all pods collapsed into one line). Say out loud what each one does. 3. Turn on the fire: kubectl -n checkout scale deploy/errgen --replicas=2. Watch the errors query climb within a minute. 4. Slice it: sum by (code) (rate(http_requests_total{job="checkout"}[5m])) — now you can see 200s and 500s side by side. 5. Turn it off: kubectl -n checkout scale deploy/errgen --replicas=0, and watch the 5-minute window decay the ratio back toward zero rather than dropping instantly. That lag is why alerts use for:.curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m])) / sum(rate(http_requests_total{job="checkout"}[5m]))' returns a value above 0.1, and back near 0 a few minutes after you scale errgen down.# recording-rules.yaml — precompute the expensive bits once, reuse them everywhere.
# Naming convention: level:metric:operations
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: checkout-recording
namespace: monitoring
labels:
release: kube-prometheus-stack # same adoption rule as the ServiceMonitor
spec:
groups:
- name: checkout.recording
interval: 30s
rules:
- record: job:checkout_requests:rate5m
expr: sum(rate(http_requests_total{job="checkout"}[5m]))
- record: job:checkout_errors:rate5m
expr: sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m]))
- record: job:checkout_errors:ratio_rate5m
expr: |
sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m]))
/ sum(rate(http_requests_total{job="checkout"}[5m]))kubectl apply -f recording-rules.yaml. 2. Confirm Prometheus adopted the object — not just that Kubernetes stored it: kubectl -n monitoring get prometheusrule shows it exists, but Status → Rules in the Prometheus UI shows whether it’s actually evaluating. A rule with the wrong release label sits in etcd forever doing nothing. 3. Wait one evaluation interval (30s), then query the brand-new series by name: job:checkout_errors:ratio_rate5m. It behaves exactly like a metric, because now it is one. 4. Understand why you did it: compare the query time of the raw two-rate() expression against the recorded series on the Prometheus Graph tab over a 6-hour range. 5. Break it on purpose once — introduce a typo in the expr, re-apply, and watch the rule group report an error in Status → Rules rather than failing silently.curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=job:checkout_errors:ratio_rate5m' | jq '.data.result | length' returns 1, and the rule group shows a recent evaluation timestamp with no errors.# alert-rule.yaml — a symptom-based alert. Note: it consumes the RECORDED series.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: checkout-alerts
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: checkout.alerts
rules:
- alert: CheckoutHighErrorRate
expr: job:checkout_errors:ratio_rate5m > 0.05
for: 2m # must stay true for 2m — kills flapping
labels:
severity: page # ← Alertmanager routes on this in Lab 5
team: payments
annotations:
summary: "Checkout is serving too many 5xx"
description: "5xx ratio is {{ $value | humanizePercentage }} over 5m (threshold 5%)."
runbook_url: "https://example.internal/runbooks/checkout-5xx"
- alert: CheckoutTargetDown
expr: up{job="checkout"} == 0
for: 1m
labels:
severity: ticket
team: payments
annotations:
summary: "Prometheus cannot scrape checkout"kubectl apply -f alert-rule.yaml, and confirm it’s evaluating in Status → Rules. 2. Break the service: kubectl -n checkout scale deploy/errgen --replicas=3. 3. Now watch the three states in the Prometheus Alerts tab, because the exam loves this: inactive → pending (the expression is true, but for: 2m hasn’t elapsed) → firing. Time it. 4. When it fires, follow it downstream: open Alertmanager on localhost:9093 and confirm the same alert arrived there with its severity=page and team=payments labels intact. 5. Fix the service — kubectl -n checkout scale deploy/errgen --replicas=0 — and watch it resolve. 6. Bonus failure mode, and a trap worth meeting now: up{job="checkout"} == 0 only fires while the target still exists and its scrape fails. Scaling deploy/checkout to zero does the opposite — the Endpoints vanish, Prometheus drops the targets, up{job="checkout"} returns no series at all, and the alert stays silent. (That blind spot is exactly what absent() is for.) To fire it for real, keep the pods and break the scrape — point the Service at a port nothing is listening on: kubectl -n checkout patch svc checkout --type=json -p '[{"op":"replace","path":"/spec/ports/0/targetPort","value":9999}]'. Both targets go to up=0 with connection refused within a scrape or two, and CheckoutTargetDown fires 1m later. Put it back with the same patch and "value":"metrics". Two different alerts for two genuinely different outages — that’s the design.curl -s http://localhost:9090/api/v1/alerts | jq '.data.alerts[] | {name:.labels.alertname, state:.state}' shows CheckoutHighErrorRate in state firing, and it returns to inactive after you scale errgen back to zero.# am-values.yaml — apply with:
# helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
# -n monitoring --reuse-values -f am-values.yaml
alertmanager:
config:
global:
resolve_timeout: 5m
route:
receiver: default # the catch-all leaf
group_by: ["alertname", "namespace"] # one notification per alert per namespace
group_wait: 10s # hold the first alert briefly, hoping friends arrive
group_interval: 1m # then batch new members of an existing group
repeat_interval: 12h # don't re-nag about the same firing group
routes:
- matchers: [ "severity = page" ]
receiver: oncall
- matchers: [ "severity = ticket" ]
receiver: default
receivers:
- name: default
webhook_configs:
- url: http://sink.monitoring.svc/ticket
send_resolved: true
- name: oncall
webhook_configs:
- url: http://sink.monitoring.svc/page
send_resolved: true
inhibit_rules: # a page mutes the ticket-level noise it caused
- source_matchers: [ "severity = page" ]
target_matchers: [ "severity = ticket" ]
equal: [ "namespace" ] # BOTH alerts must carry this label or the rule
# silently never matches — see step 7.kubectl -n monitoring create deploy sink --image=mendhak/http-https-echo:31 then kubectl -n monitoring expose deploy sink --port=80 --target-port=8080. It echoes every request it receives into its own logs. 2. Apply the config above, then confirm Alertmanager reloaded it — the Status page at localhost:9093 shows the live config. 3. Fire both alerts, in this order. First kubectl -n checkout scale deploy/errgen --replicas=3 and wait for CheckoutHighErrorRate to reach firing. Then break the scrape exactly as in Lab 4 — kubectl -n checkout patch svc checkout --type=json -p '[{"op":"replace","path":"/spec/ports/0/targetPort","value":9999}]' — which fires CheckoutTargetDown once per pod: two alert instances sharing one alertname. You have a few minutes before the now-stale request metrics resolve the first alert, which is plenty. 4. Read the delivery: kubectl -n monitoring logs deploy/sink --tail=100. Look at the /ticket body — one notification whose alerts array holds both pod-level instances, not one request per alert. That array is grouping. Now compare paths: severity=page landed on /page, severity=ticket on /ticket. That’s routing. Undo the break when you’re done: same patch with "value":"metrics". 5. Now go on holiday. Create a silence from inside the Alertmanager pod: kubectl -n monitoring exec sts/alertmanager-kube-prometheus-stack-alertmanager -c alertmanager -- amtool silence add alertname=CheckoutHighErrorRate --duration=1h --comment="lab" --alertmanager.url=http://localhost:9093. 6. Verify with … -- amtool silence query --alertmanager.url=http://localhost:9093 and confirm the sink goes quiet while Prometheus still shows the alert firing — silences mute notification, not truth. 7. One last trap, because you just wrote it: the inhibit_rules block says a page mutes the ticket-level noise it caused, but it matches on equal: [namespace] — and CheckoutHighErrorRate comes from a sum() that threw the namespace label away, so nothing is inhibited here. Add namespace: checkout to that alert’s labels: in alert-rule.yaml, re-apply, fire both again, and watch the /ticket notification disappear. An equal: label that isn’t on both sides is a silently dead inhibit rule.kubectl -n monitoring logs deploy/sink | grep -o '"path": *"[^"]*"' | sort | uniq -c shows both /page and /ticket, the /ticket payload carries two entries in its alerts array, and after the silence curl -s http://localhost:9093/api/v2/silences | jq '.[].status.state' returns "active" while curl -s http://localhost:9090/api/v1/alerts still reports firing.# dashboard-cm.yaml — a dashboard that survives a laptop dying.
# The kube-prometheus-stack Grafana runs a sidecar that watches for this label.
apiVersion: v1
kind: ConfigMap
metadata:
name: checkout-golden-signals
namespace: monitoring
labels:
grafana_dashboard: "1" # ← the sidecar's trigger. No label, no dashboard.
# Replace the whole JSON below with your own exported dashboard once you've built it.
# Careful: EVERY line indented under the `|` is part of the file, comments included —
# a stray `# note to self` after the closing brace is invalid JSON and the sidecar
# will refuse the dashboard without telling Grafana why.
data:
checkout-golden-signals.json: |
{
"uid": "checkout-golden",
"title": "Checkout · Golden Signals",
"schemaVersion": 39,
"time": { "from": "now-1h", "to": "now" },
"panels": [
{
"type": "timeseries", "title": "Traffic — req/s",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [ { "refId": "A", "expr": "job:checkout_requests:rate5m" } ]
},
{
"type": "timeseries", "title": "Errors — 5xx ratio",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"targets": [ { "refId": "A", "expr": "job:checkout_errors:ratio_rate5m" } ]
}
]
}localhost:3000), build it by hand first: New → Dashboard, four panels, one per golden signal, using your Lab 2 and Lab 3 queries against the Prometheus data source. Set the two ratio panels’ unit to percent (0.0–1.0) so they read like humans expect. Save it. 2. Now make it real: Dashboard settings → JSON Model (or Export → Save to file) and copy the JSON. 3. Paste it into dashboard-cm.yaml in place of the skeleton, then kubectl apply -f dashboard-cm.yaml. 4. Watch the sidecar pick it up: kubectl -n monitoring logs deploy/kube-prometheus-stack-grafana -c grafana-sc-dashboard --tail=20 — you’ll see it write the file into Grafana. 5. The proof: delete the hand-built dashboard in the UI, then reload. The ConfigMap version is still there, because it isn’t stored in a browser or a pod’s disk — it’s stored in a manifest. 6. Say the sentence out loud: this ConfigMap belongs in the same Git repo Argo CD reconciles, which is how every new service in a golden path gets a dashboard on day one without asking.kubectl -n monitoring get cm -l grafana_dashboard=1 lists your ConfigMap and the dashboard renders live data in Grafana after you deleted the UI-created copy — bonus points if it also survives kubectl -n monitoring rollout restart deploy/kube-prometheus-stack-grafana.# jaeger.yaml — all-in-one, in-memory storage. Perfect for a lab, never for prod.
apiVersion: v1
kind: Namespace
metadata: { name: tracing }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: jaeger, namespace: tracing }
spec:
replicas: 1
selector: { matchLabels: { app: jaeger } }
template:
metadata: { labels: { app: jaeger } }
spec:
containers:
- name: jaeger
image: jaegertracing/all-in-one:1.62.0
env:
- { name: COLLECTOR_OTLP_ENABLED, value: "true" }
ports:
- { name: ui, containerPort: 16686 }
- { name: otlp-grpc, containerPort: 4317 }
- { name: otlp-http, containerPort: 4318 }
---
apiVersion: v1
kind: Service
metadata: { name: jaeger, namespace: tracing }
spec:
selector: { app: jaeger }
ports:
- { name: ui, port: 16686, targetPort: ui }
- { name: otlp-grpc, port: 4317, targetPort: otlp-grpc }
- { name: otlp-http, port: 4318, targetPort: otlp-http }# otel-values.yaml — helm upgrade --install otelcol \
# open-telemetry/opentelemetry-collector -n tracing -f otel-values.yaml
mode: deployment
replicaCount: 1
image:
repository: otel/opentelemetry-collector-contrib # the chart makes you choose explicitly
config:
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
batch: {}
exporters:
otlp/jaeger:
endpoint: jaeger.tracing.svc:4317
tls: { insecure: true } # lab only — no TLS inside a throwaway cluster
debug: { verbosity: normal } # prints span counts to the collector's own log
service:
pipelines:
traces:
receivers: [ otlp ]
processors: [ batch ]
exporters: [ otlp/jaeger, debug ]kubectl apply -f jaeger.yaml and port-forward the UI: kubectl -n tracing port-forward svc/jaeger 16686:16686. 2. helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts && helm repo update, then install with the values above. 3. Read the collector’s three-part shape in your own file until it’s obvious: receivers (how telemetry gets in) → processors (what happens to it) → exporters (where it goes), wired together by a named pipeline. Nothing is active unless it appears in service.pipelines — a classic silent-failure trap: a perfectly valid exporter that no pipeline references simply never runs. 4. Prove the pipe with synthetic spans, before involving any real app: kubectl -n tracing run tgen --rm -i --restart=Never --image=ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest -- traces --otlp-endpoint otelcol-opentelemetry-collector:4317 --otlp-insecure --traces 20 --service lab-probe. 5. Watch it land: kubectl -n tracing logs deploy/otelcol-opentelemetry-collector --tail=20 should show the debug exporter counting spans, and the Jaeger UI service dropdown should now offer lab-probe.curl -s http://localhost:16686/api/services | jq '.data' includes "lab-probe". If it doesn’t, the collector log tells you which half of the pipe is broken — receiver never got the spans, or exporter can’t reach Jaeger.kubectl -n tracing create deploy hotrod --image=jaegertracing/example-hotrod:1.62.0 -- all, then point it at your collector with the standard OTel environment variable — this is the only wiring a properly instrumented service needs: kubectl -n tracing set env deploy/hotrod OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol-opentelemetry-collector:4318 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf. 2. kubectl -n tracing port-forward deploy/hotrod 8080:8080 and click one of the customer buttons two or three times. 3. In Jaeger, pick service frontend, operation HTTP GET /dispatch, and open a trace. 4. Now actually read it: expand the tree and find the hop — frontend called customer, then fanned out to driver and several parallel route calls. Note which spans run in parallel (their bars overlap) and which are serialised. 5. Answer the question metrics could never answer: which single span owns most of the wall-clock time? Click it and read its tags. 6. Copy the trace ID out of the URL bar — you’ll paste it into Loki in Lab 9. That handoff, metric → trace → log, is the whole point of the three pillars.curl -s http://localhost:16686/api/services | jq '.data' lists frontend, customer, driver and route, and you can point at one trace and say in one sentence where its time went.# LogQL — it deliberately looks like PromQL. Start with labels, then filter, then parse.
# 1. the stream selector: labels only. Always start here — it's the cheap part.
{namespace="monitoring"}
# 2. line filter: |= contains, != excludes, |~ regex
{namespace="monitoring"} |= "error"
# 3. parser + label filter: this is why structured logging pays off. Pick the parser
# that matches the format — logfmt for most CNCF components, json for JSON loggers.
{namespace="monitoring"} | logfmt | level = "error"
# same shape, different parser, for an app that emits JSON lines:
# {namespace="checkout"} | json | level = "error"
# 4. metrics FROM logs — a range aggregation turns lines into a graph
sum by (pod) (rate({namespace="monitoring"} |= "error" [5m]))
count_over_time({namespace="tracing"} |= "error" [1h])
# 5. THE CORRELATION MOVE: paste the trace ID you copied in Lab 8
{namespace="tracing"} |= "4bf92f3577b34da6a3ce929d0e0e4736"helm repo add grafana https://grafana.github.io/helm-charts && helm repo update. 2. Install Loki plus a node-level collector. The fastest lab path is the bundled chart: helm upgrade --install loki grafana/loki-stack -n monitoring --set grafana.enabled=false --set promtail.enabled=true. If that chart has been archived out from under you — likely, and exactly the drift the warning above is about — use the current pair instead: grafana/loki with deploymentMode=SingleBinary and filesystem storage, plus grafana/alloy as the DaemonSet collector, following Grafana’s current quickstart. 3. Find the service you actually got: kubectl -n monitoring get svc | grep -i loki. 4. Add it to Grafana: Connections → Data sources → Loki, URL http://loki.monitoring.svc:3100 (adjust to the Service name you just found), Save & test. 5. In Explore, run the five queries above in order — the point is the progression from cheap label selector, to line filter, to parser, to a graph built out of log lines. Then open the label browser and look at which values namespace actually has: a stream exists only if some pod wrote to stdout. Query {namespace="checkout"} and you’ll get almost nothing — the sandbox app never logs a line per request. That absence is the first thing to check when a LogQL query comes back empty, and it’s why “add a log line” is sometimes the fix. 6. Finish with the correlation move: paste your Lab 8 trace ID and find the log lines belonging to that exact request — HotROD stamps the trace ID into its own log lines, which is the whole trick.kubectl -n monitoring port-forward svc/loki 3100:3100 running, curl -sG http://localhost:3100/loki/api/v1/query_range --data-urlencode 'query={namespace="monitoring"}' | jq '.data.result | length' returns a number greater than zero, and curl -s http://localhost:3100/loki/api/v1/label/namespace/values | jq '.data' lists the namespaces Loki is actually ingesting.# slo-burn.yaml — 99.9% availability SLO over 30 days ⇒ 0.1% error budget (≈43 min/month).
# Two windows per alert: the long one says "this is real", the short one says "it's still happening".
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: checkout-slo
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: checkout.slo
rules:
# ratio_rate5m is NOT repeated here — Lab 3 already records it. Recording the
# same series name in two rule groups writes duplicate samples for one series.
# Define each recorded series exactly once, then reuse it.
- record: job:checkout_errors:ratio_rate30m
expr: sum(rate(http_requests_total{job="checkout",code=~"5.."}[30m]))
/ sum(rate(http_requests_total{job="checkout"}[30m]))
- record: job:checkout_errors:ratio_rate1h
expr: sum(rate(http_requests_total{job="checkout",code=~"5.."}[1h]))
/ sum(rate(http_requests_total{job="checkout"}[1h]))
- record: job:checkout_errors:ratio_rate6h
expr: sum(rate(http_requests_total{job="checkout",code=~"5.."}[6h]))
/ sum(rate(http_requests_total{job="checkout"}[6h]))
# FAST BURN — 14.4x budget burn: a month's budget gone in ~2 days ⇒ page a human
- alert: CheckoutErrorBudgetFastBurn
expr: job:checkout_errors:ratio_rate1h > (14.4 * 0.001)
and job:checkout_errors:ratio_rate5m > (14.4 * 0.001)
for: 2m
labels: { severity: page, team: payments }
annotations:
summary: "Checkout is burning its error budget 14.4x too fast"
# SLOW BURN — 6x: real, but not a 3am problem ⇒ open a ticket
- alert: CheckoutErrorBudgetSlowBurn
expr: job:checkout_errors:ratio_rate6h > (6 * 0.001)
and job:checkout_errors:ratio_rate30m > (6 * 0.001)
for: 15m
labels: { severity: ticket, team: payments }
annotations:
summary: "Checkout is slowly draining its error budget"kubectl apply -f slo-burn.yaml — it deliberately reuses the job:checkout_errors:ratio_rate5m series from Lab 3, so that PrometheusRule has to still be applied (kubectl -n monitoring get prometheusrule should list both). 3. Understand the two multipliers before you trigger anything: burning at 14.4× exhausts a 30-day budget in about two days, so it pages; 6× exhausts it in about five days, so it files a ticket. That’s the entire multi-window, multi-burn-rate recipe. 4. Trigger the fast burn: kubectl -n checkout scale deploy/errgen --replicas=3. The 5-minute window crosses almost immediately; the 1-hour window takes longer — watch the alert sit in pending until both windows agree. That deliberate reluctance is the feature. 5. Now compare designs directly: your Lab 4 alert (a flat 5% threshold) and this one fire on the same outage, but only one of them tells you how much of the month you just spent. 6. Track the spend: query 1 - (job:checkout_errors:ratio_rate6h / 0.001) — the fraction of budget left at the current rate. Watch it go negative while errgen runs.curl -s http://localhost:9090/api/v1/alerts | jq '.data.alerts[] | select(.labels.alertname=="CheckoutErrorBudgetFastBurn") | .state' reports firing with errgen up, and returns to inactive within minutes of scaling it down.# dora.promql — four delivery metrics, measured from the cluster you already have.
# kube-state-metrics ships with kube-prometheus-stack, so these work with zero extra install.
# 1. DEPLOYMENT FREQUENCY — every change to a Deployment's spec bumps its generation
sum(changes(kube_deployment_metadata_generation{namespace="checkout"}[1d]))
# 2. CHANGE FAILURE RATE — deploys that left the Deployment unavailable / all deploys
sum(changes(kube_deployment_status_condition{namespace="checkout",
condition="Available",status="false"}[1d]))
/ sum(changes(kube_deployment_metadata_generation{namespace="checkout"}[1d]))
# 3. LEAD TIME — the cluster only knows the last mile (pod created → pod Ready).
# HEADS UP: kube_pod_status_ready_time is an OPT-IN kube-state-metrics series. If the
# query returns nothing, that's why. Turn it on with a values file and --reuse-values:
# kube-state-metrics:
# extraArgs: [ "--metric-opt-in-list=kube_pod_status_ready_time" ]
max(kube_pod_status_ready_time{namespace="checkout"}
- kube_pod_created{namespace="checkout"})
# True commit→prod lead time needs a number only CI knows. Push it from the pipeline:
# echo "platform_deploy_lead_time_seconds $SECONDS_SINCE_COMMIT" | curl --data-binary @- \
# http://pushgateway.monitoring:9091/metrics/job/deploy/service/checkout
avg_over_time(platform_deploy_lead_time_seconds{service="checkout"}[7d])
# 4. MTTR — how long the current page has been firing, right now
time() - ALERTS_FOR_STATE{alertname="CheckoutErrorBudgetFastBurn"}kubectl -n checkout set env deploy/checkout BUILD=1, then BUILD=2, then BUILD=3, watching kubectl -n checkout rollout status deploy/checkout each time. 2. Count them from Prometheus: changes(kube_deployment_metadata_generation{namespace="checkout",deployment="checkout"}[1h]) should equal 3. That single query is deployment frequency. 3. Now ship a bad one: kubectl -n checkout set image deploy/checkout app=quay.io/brancz/prometheus-example-app:does-not-exist. Watch the rollout stall, then diagnose it exactly as you would in the exam — kubectl -n checkout get pods (ImagePullBackOff), kubectl -n checkout describe pod … (read the events at the bottom). Roll back: kubectl -n checkout rollout undo deploy/checkout. You just generated a change failure and an MTTR. 4. Run the change-failure-rate query and sanity-check the number against what you actually did. 5. Read the honest limitation in query 3 — twice over. First, kube_pod_status_ready_time is opt-in, so an empty result means kube-state-metrics simply isn’t exporting it yet (the values snippet above switches it on). Second, and more important: the cluster can time pod-created → Ready, but only your pipeline knows when the commit happened, so full lead time must be pushed in — a Pushgateway is the standard trick for jobs too short-lived to scrape. 6. Add all four to your Lab 6 dashboard as stat panels and commit the ConfigMap. That panel is what you show a director who asks whether the platform is worth it.curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=changes(kube_deployment_metadata_generation{namespace="checkout",deployment="checkout"}[1h])' returns a count that matches the number of deploys you made, and you can state which of the four DORA metrics the cluster genuinely knows and which one needs data from CI.“Here’s my test for whether you’ve built observability or just installed it. My service is slow. Can I — a developer with no cluster admin rights and no Prometheus expertise — open one dashboard, see which of the four signals moved, click through to the traces for that minute, find the slow span, and read the logs for that exact request? If the answer is ‘yes, in about ninety seconds,’ you’ve built me a platform. If the answer is ‘first, learn PromQL,’ you’ve built yourself a hobby.”
What you’ll have built
☺ Like you’re 10: By the end, the dark room has lights, gauges, an alarm that only rings for real problems, and a GPS tracker for any visitor who gets lost.
Finish all twelve and your laptop holds a complete, if tiny, observability platform — and, more importantly, you’ve done every motion the exam can ask for. You installed and verified a metrics stack; you made a scrape target work and then deliberately broke it four different ways so the silent failures are no longer mysterious. You wrote the four golden signals as real PromQL, precomputed one with a recording rule, and turned it into an alert you drove from inactive to pending to firing on demand. You routed that alert by label, watched grouping collapse a storm into one notification, and silenced it without lying to Prometheus. You moved a dashboard out of a browser and into a ConfigMap that GitOps can reconcile. You built a full OTLP pipeline — collector in, Jaeger out — and read a real distributed trace across a service hop. You queried logs with LogQL and pivoted from a trace ID straight to the lines that belong to it. And you closed the loop like a platform engineer rather than an SRE hobbyist: an SLO with a two-window burn-rate alert that knows the difference between “page me” and “file it,” and four DORA numbers measured from your own cluster.
That’s CNPE Domain 4 — Observability & Platform Efficiency covered end to end, and a good chunk of the “diagnose and remediate” competency that shows up across every other domain. The exam won’t ask you to define a golden signal; it will hand you a cluster where something is scraping nothing, and start the clock. Pair this with the main lab track for the other domains, then pressure-test yourself on practice tasks and the observability drills.
Comfortable? Level up. (1) Put the whole monitoring stack under GitOps — one Argo CD Application for a monitoring/ folder holding your ServiceMonitor, rules, and dashboard ConfigMaps, so the watchtower rebuilds itself on a fresh cluster in one command. (2) Turn on exemplars so a spike on a Grafana latency panel is one click from the trace that caused it. (3) Swap Jaeger for Tempo and wire trace-to-logs and logs-to-trace correlation in Grafana — the full three-pillar pivot in one UI. (4) Generate your SLO rules with Sloth instead of hand-writing four recording rules per service, and see how a platform team scales SLOs to fifty services. (5) Feed your Lab 3 recording rule into an Argo Rollouts AnalysisTemplate, so a canary promotes or aborts on your error ratio — that’s observability graduating from a dashboard into a control loop.
Foxy: I installed the stack, the pods are all Running, Grafana loads. Observability: done. ✅
Ellie: Lovely. Now query up{job="checkout"} for me.
Foxy: …it returns nothing.
Ellie: Right. You installed a telescope and left the lens cap on. Four things must agree — the release label, the Service’s labels, the namespace, and the port name. Nothing warns you. Nothing logs. The target just isn’t there.
Gizmo: Easy fix — alert on everything. CPU over 70%, memory over 60%, any restart, every namespace. You’ll never miss an outage again! 🤑
Timmy: And by week three nobody reads the pages, so you’ll miss the one that mattered. Alert on symptoms, Gizmo. If it doesn’t hurt a user right now, it’s a dashboard — not a 3am phone call.
Pip: My favourite part is Lab 11. Four queries, and suddenly “is the platform working?” has an actual number instead of a vibe.
Dot: And my favourite is Lab 8 — the first time I watched one of my own requests hop between services, I finally understood what my code was doing.
Where to go next
Three doors from here. If you want the theory behind everything you just typed, read Observability & Operations — pillars, RED vs USE, alert design, error budgets and DORA, with the diagrams. If you want more reps, the main lab track builds the other four domains (GitOps, pipelines and canaries, platform CRDs, self-service, policy) around this same cluster, and practice tasks plus the observability drills are timed, exam-shaped versions of these motions. If you want to get faster, live in the command reference and the troubleshooting playbook — especially workload triage, because half of observability under exam pressure is knowing that describe comes before logs, and logs --previous comes before panic. The per-tool deep dives — Prometheus, Grafana, OpenTelemetry, Jaeger, Loki — are the reference shelf beside all of it. And when you’re ready to be graded, know-cold then the exam guide.
1. A ServiceMonitor exists, the app serves /metrics, and the target never appears in Prometheus. Name the four fields that must line up. 2. Why does an alert use for:, and what are the three alert states you watched in Lab 4? 3. What’s the difference between a silence and an inhibit rule? 4. Why is a recording rule worth the extra object, and what’s the naming convention? 5. In Lab 10, why does the fast-burn alert require two windows to be true at once? 6. Which of the four DORA metrics could your cluster not measure on its own, and how did you get it in?
Check your answers
- The ServiceMonitor’s labels must match Prometheus’s
serviceMonitorSelector(usuallyrelease: <helm-release>);spec.selector.matchLabelsmust match labels on the Service (not the pods);namespaceSelector.matchNamesmust include the Service’s namespace; andendpoints[].portmust be the Service port’s name, not its number. All four fail silently — no event, no error, just an absent target. for:requires the expression to stay true for a duration, which kills flapping alerts caused by a single bad scrape. The states are inactive → pending → firing (and back to inactive on resolve).- A silence is a time-boxed, label-matched mute that you create for a known or planned event (maintenance). An inhibit rule is standing config that automatically suppresses lower-severity alerts while a bigger related alert is firing — cause-suppresses-effect, no human involved. Neither changes what Prometheus believes; both change what reaches a human.
- It precomputes an expensive expression once per interval, so dashboards and alerts query a cheap single series instead of recomputing two
rate()s over a long range — and it gives you one canonical definition of “the error ratio” that every alert and panel shares. Convention:level:metric:operations, e.g.job:checkout_errors:ratio_rate5m. - The long window (1h) confirms the problem is real and not a momentary blip; the short window (5m) confirms it is still happening right now, so the alert resolves quickly once you fix it. One window alone gives you either a flappy page or one that keeps ringing long after recovery.
- Lead time for changes — the cluster can only see pod-created → Ready; the commit timestamp lives in CI. You push it in from the pipeline as a metric (via a Pushgateway, which exists precisely for jobs too short-lived to scrape).