Grafana Dashboards for DR Drill Observability
This page implements the visualization layer for the parent guide on validation telemetry and metrics: a Python script that builds a Grafana dashboard as a JSON model, POSTs it and its alert rules through the Grafana HTTP API, and versions the resulting definition in git so the dashboard is reproducible rather than hand-clicked. The panels render the series a drill emits after they land in Prometheus via exporting validation metrics to Prometheus, the thresholds encode the windows your RTO and RPO mapping defines, and the rendered panels double as the visual evidence surface your compliance evidence & control mapping work references during an audit.
Architecture and Execution Model
Figure. The dashboard is generated in code, its panels carry real PromQL, and the definition is POSTed, alerted, versioned and verified in one repeatable run.
Treating the dashboard as a build artifact rather than a hand-edited screen matters because a DR observability surface is itself a control: if a panel silently changes or an alert threshold drifts, the evidence it produces becomes untrustworthy. Generating the JSON from code means the dashboard is diffable, reviewable, and reconstructable byte-for-byte from a commit.
Prerequisites
-
Python 3.8+ on the host that runs the provisioning script.
-
The HTTP client library:
bash pip install requests -
A reachable Grafana instance (Grafana 9+) with a Prometheus data source already configured, and its data-source UID.
-
A Grafana service-account token with dashboard-write permission, supplied out-of-band as an environment variable — never hardcoded.
-
The Prometheus series in place — the drill export job must already be pushing
restore_duration_seconds,corrupt_pages_found_total,replication_lag_seconds, andartifacts_validated_total.
Production Implementation
The script builds the dashboard model as plain Python dictionaries, which keeps the PromQL expressions readable and lets each panel be unit-tested in isolation before it is ever sent to Grafana. Each panel targets one validation question and carries the exact PromQL that answers it.
#!/usr/bin/env python3
"""Provision a DR-drill observability dashboard into Grafana as code.
Usage:
GRAFANA_TOKEN=... python3 provision_dashboard.py <grafana_url> <ds_uid>
Exit codes:
0 dashboard (and alert rule) provisioned successfully
1 Grafana rejected the dashboard or alert payload
2 usage / configuration error (missing token or arguments)
"""
import json
import os
import sys
from typing import Dict, List
import requests
RTO_P95_SECONDS = 600 # restore-duration objective for this workload
RPO_LAG_SECONDS = 30 # replication-lag ceiling for this workload
def _target(expr: str, legend: str, ds_uid: str) -> Dict:
"""One Prometheus query target inside a panel."""
return {
"expr": expr,
"legendFormat": legend,
"refId": "A",
"datasource": {"type": "prometheus", "uid": ds_uid},
}
def build_panels(ds_uid: str) -> List[Dict]:
"""Four PromQL panels covering the DR-drill validation SLOs."""
return [
{
"id": 1, "title": "Restore duration p95 (s)", "type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {
"mode": "absolute",
"steps": [{"color": "green", "value": None},
{"color": "red", "value": RTO_P95_SECONDS}]}}},
"targets": [_target(
"histogram_quantile(0.95, sum by (le, engine) "
"(rate(restore_duration_seconds_bucket[30m])))",
" p95", ds_uid)],
},
{
"id": 2, "title": "Corrupt page rate (per hour)", "type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"targets": [_target(
"sum by (engine) (increase(corrupt_pages_found_total[1h]))",
"", ds_uid)],
},
{
"id": 3, "title": "Replication lag vs RPO (s)", "type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {
"mode": "absolute",
"steps": [{"color": "green", "value": None},
{"color": "red", "value": RPO_LAG_SECONDS}]}}},
"targets": [_target(
"max by (engine) (replication_lag_seconds)",
" lag", ds_uid)],
},
{
"id": 4, "title": "Drill success ratio", "type": "stat",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"fieldConfig": {"defaults": {"unit": "percentunit"}},
"targets": [_target(
"sum(rate(artifacts_validated_total{verdict=\"valid\"}[6h])) "
"/ sum(rate(artifacts_validated_total[6h]))",
"success ratio", ds_uid)],
},
]
def build_dashboard(ds_uid: str) -> Dict:
"""Assemble the full dashboard model Grafana's API expects."""
return {
"dashboard": {
"uid": "dr-drill-observability",
"title": "DR Drill Observability",
"tags": ["dr", "backup-validation"],
"timezone": "utc",
"schemaVersion": 39,
"version": 0,
"refresh": "1m",
"panels": build_panels(ds_uid),
},
"overwrite": True,
"message": "provisioned from provision_dashboard.py",
}
The four panels answer four distinct questions. Restore-duration p95 uses histogram_quantile over the bucket rate to surface the tail latency that actually threatens the RTO, not the mean that hides it. Corrupt-page rate uses increase(...[1h]) so a single detection is visible rather than smoothed away. Replication lag is a simple max gauge read against the RPO threshold line, and the drill success ratio divides the rate of valid verdicts by the rate of all verdicts to give a rolling reliability figure.
With the model assembled, the transport layer POSTs the dashboard and then provisions a matching alert rule, treating any non-2xx response as a hard failure that returns exit 1.
def _headers() -> Dict[str, str]:
token = os.environ.get("GRAFANA_TOKEN")
if not token:
raise KeyError("GRAFANA_TOKEN not set")
return {"Authorization": f"Bearer {token}",
"Content-Type": "application/json"}
def post_dashboard(base_url: str, model: Dict) -> int:
"""POST the dashboard model to Grafana; return its numeric id."""
resp = requests.post(f"{base_url}/api/dashboards/db",
headers=_headers(), data=json.dumps(model),
timeout=30)
resp.raise_for_status()
return resp.json()["id"]
def provision_alert(base_url: str, ds_uid: str) -> None:
"""Create a burn-style alert rule on the restore-duration SLO."""
rule = {
"title": "Restore p95 over RTO",
"condition": "C",
"data": [{
"refId": "A",
"datasourceUid": ds_uid,
"model": {"expr": "histogram_quantile(0.95, sum by (le) "
"(rate(restore_duration_seconds_bucket[30m])))",
"instant": True, "refId": "A"},
}],
"noDataState": "NoData",
"execErrState": "Alerting",
"for": "15m",
}
resp = requests.post(f"{base_url}/api/v1/provisioning/alert-rules",
headers=_headers(), data=json.dumps(rule),
timeout=30)
resp.raise_for_status()
def main(argv: List[str]) -> int:
if len(argv) != 3:
print("usage: provision_dashboard.py <grafana_url> <ds_uid>",
file=sys.stderr)
return 2
base_url, ds_uid = argv[1].rstrip("/"), argv[2]
model = build_dashboard(ds_uid)
# Version the exact JSON that will be sent, before sending it.
with open("dr_drill_dashboard.json", "w", encoding="utf-8") as fh:
json.dump(model, fh, indent=2, sort_keys=True)
try:
dashboard_id = post_dashboard(base_url, model)
provision_alert(base_url, ds_uid)
except KeyError as exc:
print(f"config error: {exc}", file=sys.stderr)
return 2
except requests.RequestException as exc:
print(f"grafana rejected provisioning: {exc}", file=sys.stderr)
return 1
print(f"provisioned dashboard id={dashboard_id} uid=dr-drill-observability")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
The dashboard JSON is written to disk before the POST, so the committed file is exactly the payload Grafana received — the git history becomes the change log for the observability control itself, and a git diff reveals any threshold or query edit under review.
Step-by-Step Execution Walkthrough
- Install
requestsinto the provisioning environment withpip install requests. - Mint a service-account token in Grafana with dashboard and alert write scope, and export it as
GRAFANA_TOKENin the shell — do not pass it on the command line. - Find the Prometheus data-source UID from Grafana’s data-source settings; it is the second script argument.
- Run the script:
GRAFANA_TOKEN=... python3 provision_dashboard.py https://grafana.dr.internal <ds_uid>. - Read the exit code.
0means both the dashboard and the alert rule provisioned;1means Grafana rejected a payload;2means a missing token or argument. - Commit the emitted
dr_drill_dashboard.jsonso the dashboard definition is versioned alongside the code that produced it.
Verification and Expected Output
A successful run prints the dashboard id and exits 0:
$ GRAFANA_TOKEN=*** python3 provision_dashboard.py https://grafana.dr.internal ceabc123
provisioned dashboard id=42 uid=dr-drill-observability
$ echo $?
0
Success means Grafana accepted the dashboard model (a 200 from /api/dashboards/db), the alert rule POST also returned 2xx, and dr_drill_dashboard.json was written locally for versioning. Opening the dashboard shows four populated panels; the restore-p95 and replication-lag panels display their red threshold lines at the RTO and RPO values, and the success-ratio stat reads near 1.0 on a healthy fleet.
Failure Modes and Troubleshooting
| Symptom | Cause | Remediation |
|---|---|---|
Exit 2, config error: 'GRAFANA_TOKEN' |
Token not exported into the environment | Export a valid service-account token before running; never inline it in the command |
Exit 1, 401 Unauthorized |
Token lacks dashboard/alert write scope or is expired | Reissue the service-account token with editor-level permission |
| Panels render “No data” | Data-source UID wrong, or series not yet pushed | Confirm the UID matches the Prometheus source and that the export job has pushed at least once |
Alert rule POST returns 404 |
Grafana version predates unified alerting provisioning API | Upgrade to Grafana 9+, or provision the rule through the matching legacy endpoint |
| Restore-p95 panel is empty | restore_duration_seconds_bucket has no samples in the window |
Widen the rate() window or confirm drills ran inside the dashboard time range |
| Dashboard overwrites a manual edit | overwrite: true replaced a hand-changed copy |
Treat the JSON as the source of truth; make edits in code and re-provision |
| Threshold line missing | fieldConfig.thresholds omitted on that panel |
Add the absolute threshold steps to the panel’s fieldConfig.defaults |
Integration Notes
The script is idempotent — the stable dashboard uid and overwrite: true mean re-running it updates the same dashboard rather than creating duplicates — so it slots cleanly into CI. Run it from a GitHub Actions or GitLab CI job on merge to main so the observability surface is redeployed whenever a panel or threshold changes, with the token injected as a masked secret. In an Airflow maintenance DAG it can run on a schedule to reconcile drift, failing the task on a non-zero exit. Because it versions the exact payload, the commit that changed a query and the deploy that applied it are the same reviewable unit.
Wire the alert rule’s notification policy so a fired “Restore p95 over RTO” routes through the same channel your error categorization frameworks use, and treat the rendered dashboard as the human-facing companion to the machine-readable series defined in validation telemetry and metrics.
Frequently Asked Questions
Why build the dashboard in code instead of editing it in the Grafana UI?
A DR observability surface is a control, and a control must be reproducible and reviewable. Generating the JSON from Python makes the dashboard diffable in git, so a threshold or query change is a reviewable commit rather than an untracked click. It also lets the same definition be redeployed byte-for-byte across staging and production Grafana instances.
Why use histogram_quantile for restore duration rather than an average?
An average restore time hides the tail, and the tail is exactly what breaches an RTO — a mean of four minutes can conceal a p95 of eleven. histogram_quantile(0.95, ...) over the bucketed rate() surfaces the 95th-percentile restore that a real recovery would actually experience, which is the number the objective is written against.
How does the script stay idempotent across re-runs?
The dashboard model carries a stable uid and is POSTed with overwrite: true, so every run updates the same dashboard in place instead of creating a new copy. That property is what makes it safe to run from CI on every merge — the observability surface converges on the committed definition rather than accumulating duplicates.
Where should the Grafana token live?
In an environment variable injected at runtime — a masked CI secret or a secrets manager — never in the command line or the committed code. The script reads GRAFANA_TOKEN and exits with a configuration error if it is absent, so a missing token fails fast rather than provisioning against the wrong credentials.
Related
- Validation telemetry and metrics — the parent guide defining the taxonomy these panels render.
- Exporting validation metrics to Prometheus — the export path that populates the series this dashboard queries.
- Compliance evidence & control mapping — using the rendered panels as an audit evidence surface.
- Immutable audit trails — the tamper-evident store behind the visualized outcomes.
This script is one component of the broader validation telemetry and metrics workflow.
For authoritative API behavior, consult the Grafana HTTP API documentation and the Prometheus querying documentation for the PromQL functions used in the panels.