Grafana
1. Architecture & Data Sources
Grafana is a visualization layer that queries data sources and renders panels. It does not store metrics — it proxies queries to backends.
flowchart LR
subgraph Sources
PR["Prometheus<br/>metrics"]
LK["Loki<br/>logs"]
TP["Tempo<br/>traces"]
PG["PostgreSQL<br/>relational"]
end
subgraph Grafana
DS["Data Source<br/>plugins"] --> QE[Query Engine]
QE --> PN[Panel Renderer]
end
Sources --> DS
PN --> DB[Dashboard]
| Data Source | Query Language | Best For |
|---|---|---|
| Prometheus | PromQL | Metrics, counters, gauges |
| Loki | LogQL | Log streams, structured logs |
| Tempo | TraceQL | Distributed traces, spans |
| PostgreSQL | SQL | Business data, audit logs |
A panel doesn't talk to a backend directly — every query takes the same path from the panel editor to pixels on screen:
Does Grafana store the metrics, logs, and traces it displays?
2. Panel Types
| Panel | Use When |
|---|---|
| Time series | Metrics over time (latency, RPS, CPU) |
| Stat | Single current value with threshold color |
| Gauge | Value within a min/max range (SLO burn) |
| Table | Multi-dimensional comparison, top-N |
| Heatmap | Latency distribution over time |
| Logs | Log stream output from Loki |
Picking the right panel:
- Trending over time → Time series
- "Is it OK right now?" → Stat or Gauge
- "Which pods are slowest?" → Table
- "Where are latency outliers?" → Heatmap
- "What did the app log?" → Logs
You need to see which of 50 pods has the highest P99 latency right now. Why is a table a better fit here than a time series panel?
3. Variables
Variables make dashboards reusable across environments, clusters, and services.
Dashboard URL: /d/abc?var-cluster=prod&var-namespace=payments
Query variable — populated from a data source:
# Variable: cluster
# Query (Prometheus label_values):
label_values(kube_node_info, cluster)
Custom variable — static list:
name: env
values: dev,staging,prod
Interval variable — for $__interval in rate() calls:
name: interval
values: 1m,5m,10m,30m
auto: true
Use in panels:
rate(http_requests_total{cluster="$cluster", namespace="$namespace"}[$interval])
label_values() query (or equivalent) asks the data source itself for the current list of values — new clusters or namespaces show up automatically as they appear, no dashboard edit required.
dev,staging,prod are fixed in the variable definition. Simple and predictable, but adding a new environment means editing the dashboard.
$__interval. Panels reference it inside rate() calls, so one dashboard can be viewed at a coarse 30m resolution or a fine 1m resolution without editing a single query.
A dashboard hardcodes rate(http_requests_total[5m]) in every panel instead of using the interval variable. What's lost?
4. USE Method Dashboard
Per resource (CPU, memory, disk, network):
| Row | Metric | PromQL sketch |
|---|---|---|
| Utilization | % busy | rate(node_cpu_seconds_total{mode!="idle"}[5m]) |
| Saturation | Run-queue / pressure | node_pressure_cpu_waiting_seconds_total |
| Errors | Hardware / kernel errors | node_disk_io_time_seconds_total |
Layout (4 rows × 3 panels):
[CPU Util] [CPU Saturation] [CPU Errors]
[Mem Util] [Mem Saturation] [OOM Kills ]
[Disk Util] [Disk Saturation] [Disk Errors]
[Net Util] [Net Saturation] [Net Errors ]
Each panel uses $node variable to filter by host.
In the USE method layout, the Memory row's third column is "OOM Kills" instead of a generic error metric. Why does that still count as the row's Errors column?
5. RED Method Dashboard
Per service/endpoint:
| Panel | Metric | PromQL sketch |
|---|---|---|
| Rate | Requests/sec | sum(rate(http_requests_total[$interval])) by (service) |
| Errors | Error rate % | sum(rate(http_requests_total{status=~"5.."}[$interval])) / sum(rate(http_requests_total[$interval])) |
| Duration | P50/P95/P99 latency | histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[$interval])) by (le, service)) |
Layout:
[RPS - all services (time series)]
[Error Rate % (time series)] [Top errors by service (table)]
[P50 latency] [P95 latency] [P99 latency]
In the RED method, which single panel type is used to show P50, P95, and P99 together, and via which PromQL function?
histogram_quantile() run against the request-duration histogram buckets — once at 0.50, once at 0.95, once at 0.99 — each as its own Duration panel, laid out side by side.
6. SLO Dashboard
SLO: 99.9% availability over 30-day rolling window
Error budget: 0.1% = ~43 minutes/month
| Panel | Formula |
|---|---|
| Availability % | 1 - (errors / total) over 30d |
| Error budget remaining | budget_total - errors_consumed |
| Burn rate (1h) | error_rate_1h / (1 - SLO_target) |
| Burn rate (6h) | same, 6h window |
| Budget exhaustion forecast | linear projection |
Burn rate thresholds (Google SRE):
| Window | Burn rate | Action |
|---|---|---|
| 1h | > 14x | Page immediately |
| 6h | > 6x | Page |
| 3d | > 1x | Ticket |
# 1-hour burn rate
(
sum(rate(http_requests_total{status=~"5.."}[1h]))
/
sum(rate(http_requests_total[1h]))
) / (1 - 0.999)
A 99.9% availability SLO over a 30-day window gives roughly how much error budget, in minutes?
7. Provisioning Dashboards as Code
flowchart LR
GH["Git repo<br/>dashboard JSON"] -->|CI push| CM[K8s ConfigMap]
CM -->|volume mount| SC["Grafana sidecar<br/>container"]
SC -->|watches /dashboards| GF["Grafana<br/>auto-reloads"]
grafana_dashboard: "1".
searchNamespace: ALL is set.
ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboards
labels:
grafana_dashboard: "1" # sidecar watches this label
data:
red-dashboard.json: |
{ "title": "RED Dashboard", ... }
Grafana Helm values:
grafana:
sidecar:
dashboards:
enabled: true
label: grafana_dashboard
searchNamespace: ALL
datasources:
datasources.yaml:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus-server:9090
isDefault: true
- name: Loki
type: loki
url: http://loki:3100
Grafana sidecar watches all ConfigMaps with grafana_dashboard: "1" and hot-reloads dashboards without restart.
What ConfigMap label does the Grafana sidecar watch for to know a ConfigMap contains a dashboard to load?
grafana_dashboard: "1". Any ConfigMap carrying that label gets picked up and hot-reloaded by the sidecar — no Grafana restart needed.
8. Alerting
Grafana-native alerting (Grafana 9+) replaces the old panel-level alerts.
Components:
| Component | Role |
|---|---|
| Alert rule | PromQL/LogQL condition with for: duration |
| Contact point | Destination (Slack, PagerDuty, email, webhook) |
| Notification policy | Routes alerts to contact points by labels |
| Silence | Mutes matching alerts for a time range |
for: duration. The condition has to stay breached for the whole for: window before the rule actually fires — a single noisy sample doesn't page anyone.
severity = critical) and decides which contact point actually receives it.
Contact point (Slack):
# provisioning/alerting/contact-points.yaml
apiVersion: 1
contactPoints:
- orgId: 1
name: slack-oncall
receivers:
- uid: slack-oncall-uid
type: slack
settings:
url: 'https://hooks.slack.com/services/XXX/YYY/ZZZ'
recipient: '#alerts'
title: '{{ .CommonLabels.alertname }}'
Notification policy:
# provisioning/alerting/notification-policies.yaml
apiVersion: 1
policies:
- orgId: 1
receiver: slack-oncall
group_by: [alertname, cluster]
routes:
- receiver: pagerduty-critical
matchers:
- severity = critical
Alert rule (provisioning):
apiVersion: 1
groups:
- orgId: 1
name: RED Alerts
folder: SRE
interval: 1m
rules:
- title: High Error Rate
condition: C
for: 5m
labels:
severity: warning
annotations:
summary: "Error rate above 1% for {{ $labels.service }}"
data:
- refId: A
datasourceUid: prometheus
model:
expr: sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
- refId: C
datasourceUid: __expr__
model:
type: threshold
conditions:
- evaluator: { params: [0.01], type: gt }
query: { params: [A] }
A rule evaluating true once doesn't fire immediately — the for: duration exists precisely to filter out single noisy samples:
flowchart LR
EV["Rule evaluates<br/>every interval"] -->|condition breached| PD["Pending<br/>for-duration timer starts"]
PD -->|condition clears early| OK[Normal]
PD -->|still breached after<br/>full for-duration| FR[Firing]
FR --> NP["Notification policy<br/>matches alert labels"]
NP --> CP["Contact point<br/>Slack, PagerDuty, email"]
FR -->|condition clears| OK
interval tick and the condition isn't breached.
for: timer starts — the alert doesn't fire yet, it just starts the clock.
for: duration has elapsed. Only now does the rule actually fire.
An alert rule has for: 5m. The condition breaches, then clears again after 3 minutes. Does the alert fire?