“During the outage, three engineers opened three different 'ShopLite' dashboards. Two were broken, and the third used the old metric names.”
case name: 214 dashboards and none of them right
- Service
- grafana
- Impact
- 15 minutes of the incident spent finding and fixing dashboards
- Detected by
- Incident review
- Time to resolve
- A sprint of cleanup
Skills you'll use on this case
01 The investigation
- Incident
DEAD END
'ShopLite Overview', 'ShopLite Overview (copy)', 'ShopLite NEW', 'priya-test-shoplite'…
Dashboards created by clicking accumulate like console-created infrastructure did in the Terraform course: no owner, no review, and nothing notices when the metrics they query are renamed.
- Review
RESOLVED
One provisioned, version-controlled dashboard per service; the rest archived
Root cause
Dashboards were created and edited by hand in the UI, with no source control, ownership, or review. Copies drifted, queries broke silently after metric renames, and nobody knew which one was authoritative.
02 The concepts behind it
A dashboard answers a question, top to bottom
A service dashboard should read like triage. Row 1: are we meeting our SLOs (SLI, budget remaining, burn rate)? Row 2: RED per route (rate, error ratio, p50/p99). Row 3: saturation (CPU vs limit, memory vs limit, event-loop lag, DB pool). Row 4: dependencies (payments latency and errors, DB query time). Each panel links onward: to logs filtered to the service, to traces via exemplars, and to the runbook.
Use template VARIABLES ($service, $route) so one dashboard serves every service with the same RED metrics, which is the payoff of consistent metric names since Case 0.1.
Dashboards as code
Treat dashboards like any other configuration: JSON (or Jsonnet/Grafonnet, or the Terraform grafana provider) in Git, reviewed in pull requests, deployed automatically. Grafana's file PROVISIONING loads dashboards from a directory at startup; UI edits then can't silently diverge, and a changed metric name shows up in code review alongside the change that renamed it.
03 The fix
01Provision dashboards from files
A provider tells Grafana to load every JSON file in a folder. Export a dashboard you like from the UI (Share → Export → 'Export for sharing externally' off), save it in the repo, and delete the UI copy.
obs-lab/grafana/provisioning/dashboards/dashboards.ymlwhole fileyaml apiVersion: 1 providers: - name: services folder: Services type: file allowUiUpdates: false # the file is the source of truth options: path: /etc/grafana/provisioning/dashboards/json02The ShopLite service dashboard (abbreviated)
A
servicevariable driven by thejoblabel, and SLO, RED, and saturation rows using the queries from this course. Put the full JSON indashboards/json/service.json.obs-lab/grafana/provisioning/dashboards/json/service.jsonwhole filejson { "title": "Service overview", "uid": "service-overview", "templating": { "list": [{ "name": "service", "type": "query", "datasource": { "uid": "prometheus" }, "query": "label_values(http_request_duration_seconds_count, job)" }] }, "panels": [ { "type": "row", "title": "SLO" }, { "type": "stat", "title": "Error budget remaining (28d)", "targets": [{ "expr": "1 - (sum(increase(http_request_duration_seconds_count{job=\"$service\", status=~\"5..\"}[28d])) / sum(increase(http_request_duration_seconds_count{job=\"$service\"}[28d]))) / 0.005" }] }, { "type": "row", "title": "RED" }, { "type": "timeseries", "title": "Requests/s by route", "targets": [{ "expr": "sum by (route) (rate(http_request_duration_seconds_count{job=\"$service\"}[$__rate_interval]))" }] }, { "type": "timeseries", "title": "Error ratio by route", "targets": [{ "expr": "sum by (route) (rate(http_request_duration_seconds_count{job=\"$service\", status=~\"5..\"}[$__rate_interval])) / sum by (route) (rate(http_request_duration_seconds_count{job=\"$service\"}[$__rate_interval]))" }] }, { "type": "timeseries", "title": "p99 latency by route", "targets": [{ "expr": "histogram_quantile(0.99, sum by (le, route) (rate(http_request_duration_seconds_bucket{job=\"$service\"}[$__rate_interval])))" }] }, { "type": "row", "title": "Saturation" }, { "type": "timeseries", "title": "Event-loop lag p99", "targets": [{ "expr": "nodejs_eventloop_lag_p99_seconds{job=\"$service\"}" }] } ] }03Or manage Grafana with Terraform
If your team already uses Terraform (as ShopLite does), the
grafana/grafanaprovider manages dashboards, folders, data sources, and alert rules alongside the infrastructure they observe, reviewed in the same pull requests.infra/grafana.tfwhole filehcl terraform { required_providers { grafana = { source = "grafana/grafana", version = "~> 4.0" } } } provider "grafana" { url = "http://localhost:3001" auth = var.grafana_token } resource "grafana_folder" "services" { title = "Services" } resource "grafana_dashboard" "service_overview" { folder = grafana_folder.services.uid config_json = file("${path.module}/dashboards/service.json") }
04 Make sure it never surprises you again
01Close the case — tear the lab down
That's the course.
-valso removes the volumes (Prometheus, Loki, Tempo, and Mimir data). Keep theobs-lab/folder: everything you've built is in its files, anddocker compose up -dbrings it all back.terminal$ cd obs-lab && docker compose down -v── expected output ──✔ Container obs-lab-k6-1 Removed✔ Container shoplite Removed...✔ Network obs-lab_default Removed
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
Add a panel query to the service dashboard showing how many restarts each service had in the selected time range.
How would you make a dashboard link from a latency spike directly to example traces?
06 Interview questions from this case
What makes a good service dashboard?
Why manage dashboards as code?