Building a Raster Pipeline Grafana Dashboard
A raster pipeline’s dashboard is nine panels in four rows, and the value is in which nine. The top row says whether to keep reading; the second compares throughput against the same time last week; the third reports what the output actually looks like; the fourth puts the failures on a map. Each panel carries its own description of what healthy means, thresholds that agree with that description, and a query that someone can paste into Prometheus or psql unchanged. Everything else goes into a collapsed row at the bottom.
When to Use This Pattern
- A raster pipeline is in production and the current dashboard is either absent or an accumulation.
- More than one person is on call, so the dashboard has to be readable by someone who did not build it.
- The metrics and tables already exist — this recipe assembles them rather than defining them.
- You want the dashboard in version control, which is the only way its history is reviewable.
Complete Working Example
The provisioning file and the dashboard JSON live in the repository; Grafana reads them at start-up and on change.
# /etc/grafana/provisioning/dashboards/raster.yaml
apiVersion: 1
providers:
- name: raster-pipelines
folder: Pipelines
type: file
disableDeletion: false
# The UI becomes read-only for these, which is the point: changes go through
# the repository, so a panel that moved has a commit explaining why.
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true
The nine panels, with the queries that back them. Row one is the only row that needs to be read every day:
{
"title": "Raster pipeline — $layer",
"time": { "from": "now-2d", "to": "now" },
"refresh": "1m",
"templating": { "list": [
{ "name": "layer", "type": "query", "datasource": "Prometheus",
"query": "label_values(raster_pixels_processed_total, layer)" }
]},
"panels": [
{ "gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
"title": "Did the last run finish, and when?",
"description": "Green if a run completed within the last 26 h. Red means the schedule stopped, which no other panel detects.",
"type": "stat",
"targets": [{ "expr": "time() - max(raster_run_completed_timestamp{layer=\"$layer\"})" }],
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "steps": [
{ "color": "green", "value": null }, { "color": "red", "value": 93600 }]}}}},
{ "gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
"title": "What share of tiles failed?",
"description": "Healthy is under 0.5%. Above 2% the mosaic will have visible gaps.",
"type": "stat",
"targets": [{ "expr":
"sum(rate(raster_tiles_total{layer=\"$layer\",outcome=\"failed\"}[1h])) / sum(rate(raster_tiles_total{layer=\"$layer\"}[1h]))" }],
"fieldConfig": { "defaults": { "unit": "percentunit", "thresholds": { "steps": [
{ "color": "green", "value": null }, { "color": "orange", "value": 0.005 },
{ "color": "red", "value": 0.02 }]}}}},
{ "gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
"title": "Is the dead-letter queue growing?",
"description": "The 24 h delta. Sustained growth means a failure class nobody has triaged.",
"type": "stat",
"targets": [{ "expr": "delta(geotask_dlq_open_entries{pipeline=\"$layer\"}[24h])" }],
"fieldConfig": { "defaults": { "thresholds": { "steps": [
{ "color": "green", "value": null }, { "color": "orange", "value": 50 }]}}}},
{ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"title": "Is it keeping up? (Mpx/s, vs. last week)",
"description": "Healthy is 40-60 Mpx/s on the 12-worker pool. Below 25 the run misses 06:00.",
"type": "timeseries",
"targets": [
{ "expr": "sum(rate(raster_pixels_processed_total{layer=\"$layer\"}[5m]))/1e6",
"legendFormat": "now" },
{ "expr": "sum(rate(raster_pixels_processed_total{layer=\"$layer\"}[5m] offset 1w))/1e6",
"legendFormat": "last week" }]},
{ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"title": "Where is the time going? (warp duration)",
"description": "A heatmap, not percentiles: this workload is bimodal and a p95 sits in the valley.",
"type": "heatmap",
"targets": [{ "expr":
"sum by (le) (rate(raster_warp_duration_seconds_bucket{layer=\"$layer\"}[10m]))",
"format": "heatmap" }]}
]
}
Parameter & Option Reference
| Panel | Query source | Threshold | Spatial notes |
|---|---|---|---|
| last run finished | Prometheus gauge | 26 h | Catches a stopped schedule, which no throughput panel detects. |
| failure share | ratio of counters | 0.5% / 2% | A ratio, not a count — the count is meaningless without the denominator. |
| dead-letter growth | delta(...[24h]) |
+50 | The trend, never the depth. |
| Mpx/s vs last week | rate + offset 1w |
25 / 40 | The comparison is what makes the number a judgement. |
| warp duration | histogram buckets | — | A heatmap, because the distribution is bimodal. |
| tile coverage | PostGIS | 99.5% | From the ledger, not from metrics — coverage is a property of the output. |
| oldest tile age | PostGIS | per layer | The freshness number, which differs by layer by orders of magnitude. |
| failure map | PostGIS geomap | — | Aggregated server-side, fitted to the layer’s extent. |
Verification & Testing
Dashboards are JSON, so they can be linted and their queries can be tested before anyone sees them.
import json
from pathlib import Path
def test_every_panel_has_a_description() -> None:
dash = json.loads(Path("dashboards/raster.json").read_text())
missing = [p["title"] for p in dash["panels"] if not p.get("description")]
assert not missing, f"panels without a description: {missing}"
def test_thresholds_agree_with_descriptions() -> None:
"""A description saying 'below 25' must have a threshold at 25."""
dash = json.loads(Path("dashboards/raster.json").read_text())
panel = next(p for p in dash["panels"] if "keeping up" in p["title"])
steps = panel["fieldConfig"]["defaults"]["thresholds"]["steps"]
assert any(s.get("value") == 25 for s in steps)
def test_queries_are_valid(prometheus_client) -> None:
dash = json.loads(Path("dashboards/raster.json").read_text())
for panel in dash["panels"]:
for target in panel.get("targets", []):
if "expr" not in target:
continue
expr = target["expr"].replace("$layer", "ortho")
# A parse error here is a broken panel that would otherwise be
# discovered by whoever opens the dashboard during an incident.
assert prometheus_client.parse(expr), f"invalid: {expr}"
def test_panels_fit_one_screen() -> None:
dash = json.loads(Path("dashboards/raster.json").read_text())
visible = [p for p in dash["panels"] if not p.get("collapsed")]
bottom = max(p["gridPos"]["y"] + p["gridPos"]["h"] for p in visible)
assert bottom <= 24, "the map has been pushed below the fold"
The last test is the one that stops the dashboard growing. Grafana’s grid is 24 units tall on a typical screen, so asserting that the visible panels fit within it turns “we should keep this focused” from a good intention into a build failure. Anyone adding a tenth panel then has to decide which of the nine it replaces, or put it in the collapsed row — which is exactly the conversation the assertion is there to force.
Common Pitfalls
- Panels titled with metric names.
raster_pixels_processed_totalrequires the reader to translate. The title should be the question, and the metric name belongs in the query where it is needed. - Thresholds that disagree with the description. The text says 25 and the colour changes at 40, so the panel is amber when the description says it is fine. People resolve the contradiction by ignoring both.
- A tenth panel. The map goes below the fold and stops being looked at, which removes the one panel that distinguishes this from a generic pipeline dashboard. Collapse or replace.
- Percentiles where the distribution is bimodal. The warp-duration panel is a heatmap for a reason. A p95 line on this workload sits between the two modes and describes no tile that ever ran.
- Editing in the UI with
allowUiUpdates: true. Changes accumulate untracked and diverge from the repository, and the next provisioning run either loses them or entrenches them. Keep the UI read-only. - A refresh interval faster than the data. A nightly pipeline refreshed every thirty seconds runs its SQL panels thousands of times a day to display a number that changes once.
Frequently Asked Questions
Where does the coverage panel's query come from?
The pipeline’s own ledger, not from metrics. count(*) FILTER (WHERE built_at > now() - interval '24 hours') / count(*) over the expected tile set, grouped by layer. Coverage is a property of the output and the metrics only describe the process — a pipeline can process pixels at full speed and still be missing a region, which is precisely the case this panel exists to catch.
Should each layer get its own dashboard?
No — that is what the $layer variable is for. One dashboard with a variable stays consistent; eleven copies drift within a quarter, and the drift is invisible until someone compares two of them during an incident. If a layer genuinely needs different panels, that is a signal about the layer, not about dashboards.
What goes in the collapsed row?
Everything that is useful during an investigation and noise the rest of the time: per-zoom breakdowns, source-scheme splits, worker-level in-flight, GDAL peak-memory distribution. Collapsed rows cost nothing when closed and are one click away, which is the right price for detail that is needed monthly.
How do I test a dashboard change before merging?
Point a local Grafana at the branch’s provisioning directory with a read-only copy of the datasources. The four assertions above catch structural problems in CI; a local render catches the ones that only appear visually, like a panel whose legend covers the data. Both together take under a minute and prevent the class of change that is only discovered when someone opens the dashboard under pressure.
Should alerts be defined in the same file?
Yes, in Grafana’s unified alerting format alongside the dashboard, referencing the same queries. Keeping them together is what stops the alert and the panel diverging — and divergence between them is the failure that produces an alert nobody can corroborate from the dashboard it supposedly came from.
Related
- Grafana dashboards for GIS workflows — the design principles behind these nine panels
- Alerting on CRS validation failures in Grafana — turning a panel into an alert
- Visualizing tile coverage gaps on a geomap — the map panel in full
- Prometheus metrics for raster throughput — the series behind rows one and two