Observability & SLOs
You cannot operate what you cannot see. This lesson turns a cluster from a black box into an instrumented system: the four signals (metrics, logs, traces, events), Prometheus for metrics + PromQL, Grafana for dashboards, Loki for logs, OpenTelemetry for distributed traces, and kube-state-metrics for object state. Then the part that matters to your users: defining SLIs/SLOs, spending an error budget, and wiring Alertmanager so you page on symptoms, not noise.
kubectl/Helm/PromQL/YAML sample here needs a cluster (a local minikube/kind for the Prometheus stack, or EKS) — terminal output is illustrative. The Prometheus Operator, kube-prometheus-stack, Loki, and OpenTelemetry Collector move fast: verify CRD names, field paths and chart values against their current docs. The SLO math (error budget, burn rate) is real arithmetic and is runnable offline in plain Python — that part does not need a cluster.Learning objectives
- Name the four signals (metrics, logs, traces, events) and what each answers.
- Deploy a Prometheus + Grafana stack (conceptually) and scrape a service with a ServiceMonitor.
- Write PromQL for a rate, an error ratio, and a p95 latency from a histogram.
- Explain kube-state-metrics vs node/cAdvisor metrics — object state vs resource usage.
- Aggregate logs with Loki and correlate a trace across services with OpenTelemetry.
- Define an SLI/SLO, compute an error budget and burn rate, and alert on symptoms via Alertmanager.
1 · The four signals — what each one answers
Observability is not one thing. Four kinds of telemetry answer four different questions, and a mature setup collects all four and correlates them. Metrics tell you that something is wrong; traces and logs tell you where and why.
| Signal | Answers | Cardinality / cost | Tool here |
|---|---|---|---|
| Metrics | Is it healthy? How much? How fast? | Cheap, aggregated, low-cardinality | Prometheus |
| Logs | What exactly happened on this request? | Expensive, high-volume text | Loki |
| Traces | Where did the latency go across services? | Sampled spans, medium cost | OpenTelemetry |
| Events | What did Kubernetes itself do (schedule, evict, OOM)? | Low volume, short TTL | kubectl events / kube-state-metrics |
2 · Prometheus: scrape, store, query
Prometheus is a pull-based time-series database: it periodically scrapes an HTTP /metrics endpoint on each target, stores samples, and answers PromQL queries. In Kubernetes the Prometheus Operator (shipped in the kube-prometheus-stack Helm chart) lets you declare scrape targets as CRDs — a ServiceMonitor — instead of editing raw config.
install.sh# Verify chart name/version and values against the current kube-prometheus-stack docs.
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install kps prometheus-community/kube-prometheus-stack \
--namespace monitoring --create-namespace
# This bundles Prometheus, Alertmanager, Grafana, node-exporter and kube-state-metrics.
Now point Prometheus at your app. Your app must expose /metrics (most languages have a Prometheus client). A ServiceMonitor selects the Service by label and tells Prometheus which port/path to scrape:
servicemonitor.yamlapiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: llm-api
namespace: monitoring
labels:
release: kps # must match the Prometheus serviceMonitorSelector
spec:
selector:
matchLabels:
app: llm-api # selects the Service to scrape
namespaceSelector:
matchNames: [default]
endpoints:
- port: http # the named Service port
path: /metrics
interval: 15s
serviceMonitorSelector (often release: <helm-release>), and its selector must match your Service's labels, and the port must be the Service's named port. If a target isn't scraped, check Status → Targets in the Prometheus UI first. Verify these field names against the current Operator docs.PromQL basics. Counters only go up, so you almost always wrap them in rate() over a window. Three queries you will write constantly:
queries.promql# 1) Requests per second, per route, averaged over 5m:
sum by (route) (rate(http_requests_total[5m]))
# 2) Error ratio (5xx as a fraction of all requests) — an SLI:
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
# 3) p95 latency from a histogram (buckets recorded by the client lib):
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
route value
/chat 42.7
/embed 11.3
# error ratio -> 0.004 (0.4%)
# p95 -> 0.82 (820 ms)
3 · kube-state-metrics, node metrics & Grafana
Two different metric sources are easy to confuse. node-exporter / cAdvisor report resource usage — CPU, memory, disk of nodes and containers. kube-state-metrics reports object state from the API server — how many replicas a Deployment wants vs has ready, whether a Pod is Pending, how many restarts a container has. You need both: usage tells you the machine is hot; state tells you the desired vs actual gap.
| Question | Which source | Example metric |
|---|---|---|
| Is a node out of memory? | node-exporter | node_memory_MemAvailable_bytes |
| Is a container using its whole limit? | cAdvisor | container_memory_working_set_bytes |
| Are all Deployment replicas ready? | kube-state-metrics | kube_deployment_status_replicas_ready |
| Is a Pod stuck Pending / CrashLooping? | kube-state-metrics | kube_pod_status_phase, kube_pod_container_status_restarts_total |
Grafana turns PromQL into dashboards. The stack ships useful defaults; the skill is building a focused service dashboard around your SLIs (rate, errors, latency — the 'RED' method) rather than 200 panels no one reads. Provision dashboards as code (a ConfigMap the Grafana sidecar picks up) so they live in Git.
4 · Logs with Loki, traces with OpenTelemetry
Loki is 'Prometheus for logs': it indexes only labels (app, namespace, pod) — not the full log text — which makes it cheap to run. You query with LogQL, which looks like PromQL. An agent (Promtail / Grafana Alloy) ships pod stdout to Loki, and because it uses the same labels as your metrics, you can jump from a spiking error graph straight to the matching logs in Grafana.
logs.logql# Label filter (indexed, fast) then a line filter (scanned):
{app="llm-api", namespace="default"} |= "error" | json | status >= 500
# Rate of error lines per pod (LogQL metric query):
sum by (pod) (rate({app="llm-api"} |= "error" [5m]))
Distributed tracing answers the question metrics can't: for this slow request, which service or call ate the time? A request is assigned a trace ID; each service records spans (start/end + attributes) that nest into a waterfall. OpenTelemetry (OTel) is the vendor-neutral standard — instrument once with the OTel SDK, export via the OTel Collector to any backend (Tempo, Jaeger). For a RAG/agent app this is gold: you see retrieve → rerank → LLM-call as separate spans and learn the LLM call is 90% of latency.
5 · SLIs, SLOs, error budgets & burn rate
Now the part that decides what you page on. An SLI (indicator) is a measured ratio of good events to total — e.g. the fraction of requests served under 500 ms without a 5xx. An SLO (objective) is a target for that SLI over a window — e.g. 99.9% over 28 days. The gap between 100% and the SLO is your error budget: at 99.9%, you may 'spend' 0.1% of requests failing before you've broken your promise. This reframes reliability from 'never fail' to 'fail within budget' — and gives you a data-driven brake on shipping.
| Term | Definition | Example |
|---|---|---|
| SLI | A measured quality ratio (good / total) | % requests < 500 ms and non-5xx |
| SLO | Target for the SLI over a window | 99.9% over 28 days |
| Error budget | Allowed failure = 1 − SLO | 0.1% of requests may fail |
| Burn rate | How fast you're spending the budget vs 'even' pace | 14.4× = budget gone in 2 days |
The error budget and burn rate are just arithmetic — here it is as offline-runnable Python (no cluster needed), the same math Prometheus alert rules encode:
error_budget.pySLO = 0.999 # 99.9% success objective
WINDOW_DAYS = 28
budget = 1 - SLO # 0.001 -> 0.1% of requests may fail
def burn_rate(bad, total):
"""How many times faster than the 'even' pace we are spending budget."""
if total == 0:
return 0.0
error_ratio = bad / total
return error_ratio / budget # 1.0 = exactly on pace to exhaust budget over the window
def budget_remaining(bad_28d, total_28d):
spent = (bad_28d / total_28d) / budget if total_28d else 0.0
return max(0.0, 1 - spent) # fraction of the error budget still available
# A fast-burn page: 2% errors over the last 5m is 20x the budget pace.
print(round(burn_rate(bad=20, total=1000), 1)) # -> 20.0 (page now)
print(round(budget_remaining(bad_28d=1200, total_28d=2_000_000) * 100, 1)) # -> 40.0% left
20.0
40.0
6 · Alerting that pages on symptoms, not causes
Prometheus records alert conditions as rules; Alertmanager handles them — grouping related alerts into one notification, routing by severity/team, silencing during maintenance, and inhibiting (suppress the 100 downstream alerts when the one root-cause alert fires). The philosophy that keeps on-call sane: alert on symptoms your users feel (SLO burn, high error ratio, high latency) — not on every cause (one node at 80% CPU is not a page).
prometheusrule.yamlapiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: llm-api-slo
labels: { release: kps }
spec:
groups:
- name: slo-burn
rules:
- alert: FastErrorBudgetBurn
# 5xx ratio > 14.4x the 0.1% budget, confirmed over 1h and 5m windows.
expr: |
(sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h])) > 14.4*0.001)
and
(sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 14.4*0.001)
for: 2m
labels: { severity: page }
annotations:
summary: "Fast error-budget burn on llm-api"
alertmanager.yamlroute:
receiver: default-ticket
group_by: [alertname, namespace]
routes:
- matchers: [ 'severity = page' ]
receiver: pagerduty-oncall
inhibit_rules:
- source_matchers: [ 'severity = page' ]
target_matchers: [ 'severity = ticket' ]
equal: [namespace] # if we're already paging, don't also file tickets for the same ns
receivers:
- name: default-ticket
- name: pagerduty-oncall
# pagerduty_configs / webhook_configs go here — verify fields vs current docs
✓ Checkpoint — you can move on when you can…
- Name the four signals and which one you alert on vs debug with.
- Write a ServiceMonitor and explain the label link to Prometheus and to the Service.
- Write PromQL for a rate, an error ratio, and a p95 from a histogram.
- Distinguish kube-state-metrics (object state) from node/cAdvisor (resource usage).
- Define an SLI/SLO, compute an error budget and burn rate, and say why you page on symptoms.
- Explain multi-window multi-burn-rate alerting and Alertmanager grouping/inhibition.
A teammate proposes an alert: node_cpu utilisation > 80% for 5m → page on-call. Why is this a poor page, and what should page instead?
Show answer
Your Prometheus keeps OOMing after a deploy. Someone added a request_id label to http_requests_total. What's the connection, and what's the fix?
Show answer
request_id is unbounded (a new value per request), so the series count grows without limit and Prometheus's memory blows up. Prometheus labels must be low-cardinality (route, method, status — bounded sets). The fix: remove request_id from the metric; if you need per-request detail, put it in logs (Loki) or traces (OTel), which are built for high cardinality, and correlate by trace_id.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Nothing is observable until something scrapes it.
Your task: Describe how to get an app's custom metrics into Prometheus in a kube-prometheus-stack cluster.
Requirements:
- App exposes a Prometheus
/metricsendpoint - A ServiceMonitor selects the Service and names the port/path
- Note the label that links the ServiceMonitor to Prometheus
- Label it as needing a cluster
💡 Hint: The ServiceMonitor's own labels must match Prometheus's serviceMonitorSelector.
Show solution
The app imports a Prometheus client and serves /metrics on a named Service port (e.g. http). Then a ServiceMonitor wires it in:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: llm-api
labels: { release: kps } # matches Prometheus serviceMonitorSelector
spec:
selector: { matchLabels: { app: llm-api } } # matches the Service
endpoints: [ { port: http, path: /metrics, interval: 15s } ]Three links must all line up: ServiceMonitor label → Prometheus selector; ServiceMonitor selector → Service labels; port → the Service's named port. If it isn't scraped, check Prometheus → Status → Targets. Needs a cluster; verify CRD fields vs current docs.
Context: The RED method (Rate, Errors, Duration) is the backbone of any service dashboard.
Your task: Write PromQL for request rate per route, the 5xx error ratio, and p95 latency, and say which is your SLI.
Requirements:
- A
rate()over a counter for throughput - An error ratio as a fraction of total
- A p95 from a histogram via
histogram_quantile - Identify which one(s) you'd base an SLO on
💡 Hint: Counters always need rate(); percentiles come from *_bucket series.
Show solution
# Rate per route
sum by (route) (rate(http_requests_total[5m]))
# Error ratio (an SLI)
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
# p95 latency (an SLI)
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m])))SLIs: the error ratio and p95 latency are the user-facing quality signals you'd set SLOs on (e.g. availability 99.9%, latency p95 < 500 ms). Raw rate is throughput context, not a quality target. Illustrative — needs live metrics.
Context: A metric tells you latency spiked; only traces+logs tell you why.
Your task: Design how you'd go from a p95-latency alert to the exact failing code path using all three signals.
Requirements:
- Start from a metric/SLO alert
- Use a trace to find where time went across services
- Jump to the matching logs by a shared ID
- Say what makes this correlation possible
💡 Hint: Propagate trace_id into log lines and keep labels consistent.
Show solution
Flow: (1) A p95-latency SLO alert fires (metric). (2) Open the service's traces and filter to slow traces in that window — the waterfall shows one span (say the LLM call, or a DB query) dominating. (3) That span carries a trace_id; in Grafana, pivot to Loki filtered by that trace_id to read the exact log lines for that request.
What makes it work: you must (a) instrument with OpenTelemetry so spans exist, (b) propagate trace_id and log it into every log line, and (c) use consistent labels (app, namespace) across metrics, traces and logs. Without shared IDs the three signals are three silos. Use tail-based sampling so slow/errored traces are always kept. Verify OTel Collector config vs current docs.
Context: Choosing what to page on is an engineering decision, not a default.
Your task: Define an availability SLO for an LLM API and design the burn-rate alerts, computing the fast-burn threshold.
Requirements:
- State the SLI, SLO target and window
- Compute the error budget
- Design fast-burn (page) and slow-burn (ticket) alerts with windows
- Explain why single-threshold alerting is wrong
💡 Hint: Fast burn ≈ 14.4× budget over 1h exhausts a 28-day budget in ~2 days.
Show solution
SLI: fraction of requests non-5xx. SLO: 99.9% over 28 days. Error budget = 1 − 0.999 = 0.1% of requests may fail.
Multi-window multi-burn-rate:
# Fast burn -> PAGE: 14.4x budget, confirmed on 1h AND 5m windows (~2 days to exhaust)
err_ratio_1h > 14.4*0.001 and err_ratio_5m > 14.4*0.001
# Slow burn -> TICKET: ~3x budget on 6h AND 30m windows (a slow leak)
err_ratio_6h > 3*0.001 and err_ratio_30m > 3*0.001Why single-threshold is wrong: a fixed 'errors > X%' either flaps on brief blips (too sensitive) or misses a slow leak that quietly drains the budget over days (too slow). Two windows confirm the burn is real (not a 5-min blip) and two burn rates separate 'wake a human now' from 'file work for tomorrow'. Verify multipliers vs the Google SRE workbook.
Context: A team is drowning: one bad deploy fires 60 alerts and pages on-call 12 times.
Your task: Redesign the alerting so a single incident produces one actionable page, using Alertmanager features.
Requirements:
- Move paging onto SLO symptoms, not causes
- Use grouping to collapse related alerts
- Use inhibition so root-cause suppresses downstream
- Keep non-actionable signals off the pager
💡 Hint: group_by + inhibit_rules + severity routing do most of the work.
Show solution
1 · Page on symptoms. Replace cause-based pages (CPU, single-pod restarts) with a small set of SLO burn-rate pages per service. A node at 80% CPU with healthy SLIs pages no one.
2 · Group. group_by: [alertname, namespace] so 30 pods failing the same way arrive as one notification, not 30.
3 · Inhibit. When the service-level severity=page alert fires, an inhibit rule suppresses the downstream severity=ticket alerts for the same namespace — you get the root cause, not the 60 symptoms.
route: { group_by: [alertname, namespace], routes:
[ { matchers: ['severity=page'], receiver: pagerduty } ] }
inhibit_rules:
- source_matchers: ['severity=page']
target_matchers: ['severity=ticket']
equal: [namespace]4 · Everything non-actionable becomes a dashboard panel or a low-severity ticket. Net effect: one incident → one page → one runbook. Verify Alertmanager syntax vs current docs.
Context: Representative scenario: you own an LLM/RAG platform on EKS serving several product teams, and leadership wants 'real reliability, not vanity dashboards'.
Your task: Produce an end-to-end observability and SLO design: signals collected, stack, SLOs, alerting, and the failure modes each choice defends against.
Requirements:
- All four signals with the right tool and cost stance
- SLIs/SLOs per user-facing surface with error budgets
- Symptom-based, multi-burn-rate alerting wired to on-call
- Trace/log correlation for fast RCA
- Name the top failure modes each choice defends against
💡 Hint: Metrics for alerting; traces+logs for RCA; SLOs decide what's worth a human.
Show solution
Signals & stack: kube-prometheus-stack for metrics (Prometheus + Grafana + kube-state-metrics + node-exporter), Loki for logs (label-indexed, cheap), OpenTelemetry → Tempo/Jaeger for traces with tail-based sampling (keep slow/errored). Keep high-cardinality IDs out of metrics; they live in logs/traces.
SLOs: per surface (chat, embeddings, RAG query) define availability + latency SLIs with explicit targets and 28-day windows; publish error budgets and use them as a release brake — if the budget is spent, freeze risky changes and spend the sprint on reliability.
Alerting: multi-window multi-burn-rate pages on SLO burn only; Alertmanager groups and inhibits so one incident = one page; everything else is dashboard/ticket. Every page links a K12 runbook.
RCA: shared trace_id across metrics→traces→logs lets on-call go from 'p95 alert' to the exact failing span and its logs in minutes — critical for RAG, where the LLM call often dominates latency.
Failure modes defended: cardinality explosion (IDs kept out of metrics) → Prometheus stays up; alert fatigue (symptom + grouping + inhibition) → on-call trusts pages; blind debugging (correlation) → fast RCA; shipping-into-the-ground (error budgets) → reliability has teeth. Verify every CRD/chart/Collector detail against current docs — this stack drifts.