Prometheus Metrics for Raster Throughput
In short: measure raster work in pixels and bytes rather than in files, because a “file” varies by three orders of magnitude and a pixel does not. Keep labels bounded — layer, zoom, resampling, outcome, never tile id — and size histogram buckets from measured latency rather than from the client library’s defaults, which are tuned for web requests and useless for a four-minute warp.
Throughput is the number a raster pipeline is judged on, and it is easy to measure in a way that means nothing. “Tiles per minute” rises when the work gets easier and falls when it gets harder, without either being a change in the pipeline. “Megapixels per second” is stable across a job whose tiles differ hundredfold in size, which makes it comparable between nights, between regions and between worker types — and comparability is the entire point of a metric.
There is a second reason the unit matters, beyond comparability. Capacity planning for a raster pipeline is an exercise in pixels per worker-second: how many megapixels can one worker produce, how many megapixels does tomorrow’s backlog contain, therefore how many worker-hours are needed. Every step of that arithmetic works in pixels, and none of it works in tiles — a backlog of “forty thousand tiles” is not a quantity of work until you know which tiles. Choosing the metric’s unit is therefore also choosing whether capacity questions can be answered from the dashboard or require a bespoke analysis every time.
Prerequisites & Architecture Baseline
Core Principles
1. Count pixels, not files. A tile is not a unit of work in any meaningful sense: a 256×256 tile and an 8192×8192 window are both “one tile”. raster_pixels_processed_total divided by time is a throughput figure that stays comparable when the tiling scheme changes, when a new region with denser imagery arrives, and when someone doubles the tile size to reduce overhead.
2. Every label must have a bounded, known cardinality. layer has a dozen values, zoom has twenty, resampling has six, outcome has three. tile_id has millions, and adding it creates millions of time series that will make the metric unusable and Prometheus unhappy. The rule is that you should be able to state the maximum number of values a label can take, from the domain, before you add it.
3. Histogram buckets come from measurement. The default buckets in most client libraries top out around ten seconds, which puts every raster operation in the +Inf bucket and makes every percentile meaningless. Measure a real run, then choose buckets that bracket the observed p50 through p99 with a few above it.
4. Distinguish work from waiting. A task that takes four minutes may have spent thirty seconds warping and three and a half minutes waiting for an object store. Those are different problems with different fixes, and one histogram cannot tell them apart. Instrument the phases separately, even coarsely.
5. Errors are a label on the work, not a separate metric. raster_tiles_total{outcome="ok|failed|skipped"} gives the rate, the ratio and the total from one series family. A separate raster_errors_total cannot answer “what fraction failed?” without a second query and an assumption that the two metrics cover the same population.
6. Short-lived processes need a push gateway or an exporter. A task that runs for ninety seconds and exits will never be scraped on a fifteen-second interval reliably. Either push on completion, or have a long-lived exporter read the pipeline’s own state — the approach alerting on dead-letter queue growth uses.
The same argument applies to vector work with a different unit. “Features per second” is closer to useful than “files per second”, but a feature can be a point or a coastline with a million vertices, so the stable unit is vertices — or, where that is awkward to count, bytes of geometry. The test for a good unit is whether its rate stays flat when the workload’s composition changes and the pipeline does not. Anything that fails that test will generate incidents that resolve themselves, and a team that has seen a few of those stops trusting the dashboard.
Production Implementation
The instrumentation below is deliberately small: three metric families, four labels, and buckets measured rather than defaulted.
from __future__ import annotations
import time
from contextlib import contextmanager
from typing import Iterator
from prometheus_client import Counter, Gauge, Histogram
# Label vocabulary, fixed and documented. Maximum cardinality:
# layer 12 × zoom 20 × resampling 6 × outcome 3 = 4 320 series. Acceptable.
LABELS = ("layer", "zoom", "resampling", "outcome")
TILES = Counter(
"raster_tiles_total",
"Tiles processed, by outcome",
LABELS,
)
PIXELS = Counter(
"raster_pixels_processed_total",
"Output pixels written — the unit that stays comparable across tilings",
("layer", "zoom", "resampling"),
)
# Buckets measured from a week of real runs: p50 ~9 s, p95 ~48 s, p99 ~140 s.
# The library default tops out at 10 s, which would put 40% of tiles in +Inf.
WARP_SECONDS = Histogram(
"raster_warp_duration_seconds",
"Wall-clock of one warp, excluding source fetch",
("layer", "zoom", "resampling"),
buckets=(1, 2, 5, 10, 20, 40, 80, 160, 320, float("inf")),
)
FETCH_SECONDS = Histogram(
"raster_source_fetch_seconds",
"Time spent waiting for the source, separate from the warp",
("layer", "source_scheme"), # scheme, not URI: bounded cardinality
buckets=(0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, float("inf")),
)
INFLIGHT = Gauge(
"raster_tiles_inflight",
"Tiles currently being processed by this worker",
("layer",),
)
@contextmanager
def observe_tile(layer: str, zoom: int, resampling: str) -> Iterator[dict]:
"""Times the phases separately and records the outcome exactly once."""
ctx: dict = {"pixels": 0, "outcome": "failed"}
labels = {"layer": layer, "zoom": str(zoom), "resampling": resampling}
INFLIGHT.labels(layer=layer).inc()
started = time.monotonic()
try:
yield ctx
ctx["outcome"] = ctx.get("outcome_override", "ok")
finally:
INFLIGHT.labels(layer=layer).dec()
WARP_SECONDS.labels(**labels).observe(time.monotonic() - started)
PIXELS.labels(**labels).inc(ctx["pixels"])
TILES.labels(outcome=ctx["outcome"], **labels).inc()
def process_tile(item, layer: str) -> None:
with observe_tile(layer, item.z, item.resampling) as ctx:
fetch_start = time.monotonic()
source = fetch_source(item.source_uri)
FETCH_SECONDS.labels(
layer=layer, source_scheme=scheme_of(item.source_uri)
).observe(time.monotonic() - fetch_start)
if already_done(item.work_key):
ctx["outcome_override"] = "skipped"
return
width, height = warp_window(source, item)
ctx["pixels"] = width * height # the unit that matters
Step-by-Step Walkthrough
- Write the label vocabulary down before the first metric. Four labels with stated maxima gives a computable series count. Adding a fifth “just for debugging” is how a metric becomes a cardinality incident, and it is far easier to refuse at review time than to remove later.
- Make
outcomea label, not a metric.ok,failedandskippedon one counter means the failure ratio is a single expression, and it means “skipped” is visible — which is the number that tells you the cache and the skip branch are working. - Separate fetch from warp. Two histograms rather than one is the difference between “tiles are slow” and “the object store is slow”. They usually have different owners and always have different fixes.
- Label the source by scheme, not by URI.
s3,https,fileis three values. The URI is unbounded, and it is the single most common cardinality mistake in spatial pipelines. - Measure buckets from a real run. The library defaults assume web-request latencies. A histogram whose top bucket is below your p50 reports percentiles that are pure interpolation, and the resulting dashboard is confidently wrong.
- Record the outcome exactly once, in a
finally. A metric incremented on the happy path only under-counts precisely when things are going badly, which is when the numbers matter. - Track in-flight work as a gauge. It is the fastest way to see that a worker has stalled: throughput falls to zero while in-flight stays at eight.
Edge Cases & Failure Recovery
Short-lived processes that are never scraped. A task running for ninety seconds on a fifteen-second scrape interval is sometimes observed and sometimes not, so counters appear to jump and rates are wrong. Either push to a gateway on completion, or aggregate in a long-lived process. This is the single most common reason raster metrics look implausible.
Counters that reset on every task. A fresh process starts every counter at zero, and Prometheus interprets a decrease as a counter reset — which it is, but the semantics are wrong when the “reset” is one worker of many. Aggregating in a sidecar or gateway that persists across tasks avoids inventing throughput at every task boundary.
A label that is bounded in theory and not in practice. zoom looks like twenty values until someone adds a per-scene pyramid with zooms up to 22 and a second grid with its own numbering. Assert the bound in code — a lookup that raises on an unexpected value — rather than trusting the domain to stay as it was.
Histograms that outlive their calibration. Buckets sized for a 2-vCPU worker are wrong on a 16-vCPU one, and the percentiles quietly degrade as everything shifts into fewer buckets. Re-measure after any change in worker class, and treat bucket boundaries as configuration that has an owner.
Aggregation across workers that hides a bad one. Fleet-wide megapixels per second looks fine while one worker does nothing, because the others compensate. Keep a worker label only if the fleet is small and stable; otherwise rely on the in-flight gauge and on per-worker up to catch stalls.
A skipped tile counted as throughput. When the skip branch is working well, most tiles are skipped, and a pixel counter that increments for skips reports a throughput the pipeline is not achieving. Increment PIXELS only on real work — the outcome label on the tile counter is where skips are visible — or the capacity arithmetic above silently assumes a fleet several times more productive than it is.
Metrics that exist and are never looked at. The most common failure of all. A metric earns its place by appearing on a dashboard or in an alert; one that does neither is cost without benefit, and pruning it is a legitimate maintenance activity rather than a loss.
Configuration Reference
| Setting | Default | Spatial context |
|---|---|---|
| unit | pixels | Comparable across tiling schemes and regions; tile counts are not. |
| labels | 4, bounded | layer, zoom, resampling, outcome. Every one has a stated maximum. |
| source label | scheme | s3 / https / file — never the URI. |
| warp buckets | 1 s … 320 s | Measured. Library defaults top out at 10 s and are useless here. |
| fetch buckets | 0.25 s … 120 s | A different distribution from the warp; a shared histogram serves neither. |
| collection | push or exporter | Short-lived tasks are not reliably scraped. |
| in-flight gauge | per layer | The fastest signal that a worker has stalled rather than slowed. |
There is one more decision that is not a setting: what the metrics are for. Throughput metrics answer capacity questions — is the pipeline keeping up, does it need more workers, did the last change help. They are poor at answering correctness questions, because a pipeline can process pixels at full speed while producing the wrong ones. Keeping that boundary clear stops the dashboard from accumulating metrics that look like data quality and are not; those belong with the data-quality SLOs, measured from the output rather than from the process.
Frequently Asked Questions
How do I instrument work that happens inside GDAL?
You cannot, directly — a gdalwarp subprocess does not emit Prometheus metrics, and its Python bindings do not either. What you can measure is the boundary: when the process started, when it exited, what it was asked to do, and what it produced. That is enough for throughput and latency, and it is what instrumenting gdalwarp with Prometheus counters covers. For anything finer — where inside the warp the time went — the tool is a profiler on a single run, not a metric on every run.
Push gateway or exporter?
An exporter if the pipeline already has a durable place to read state from — a ledger table, a queue — because it is simpler and survives worker restarts. A push gateway if the numbers only exist inside the task process. The failure mode of a push gateway is stale series from workers that died mid-run, so set a TTL on pushed groups and clear them on completion.
How do I compute throughput correctly?
rate(raster_pixels_processed_total[5m]) summed over the labels you care about. Use rate, not increase, so a counter reset from a restarting worker is handled; and pick a window at least four times the scrape interval, or the rate will be noisy in a way that generates false alerts.
What does a healthy raster dashboard actually show?
Four panels, in this order: megapixels per second over the last day with the previous week overlaid, so a regression is visible against its own baseline; the outcome ratio as a stacked area, so skips and failures are proportions rather than counts; the warp-duration heatmap, which shows the whole distribution rather than three percentiles and makes a bimodal workload obvious; and the in-flight gauge per worker, which is where a stall appears first. Everything beyond those four is usually answering a question somebody asked once.
Should each pipeline stage have its own metric?
Its own labels, usually, rather than its own metric. One stage label with five known values keeps the queries uniform and lets a dashboard show the stages side by side. Separate metric names per stage means five queries to answer any question about the pipeline as a whole.
How do I retire a metric nobody uses?
Check whether it appears in any dashboard panel or alert rule — both are greppable if the definitions live in version control, which is a good reason to keep them there. If it appears in neither for a quarter, remove the instrumentation and note it in the change. The reluctance to do this is understandable and misplaced: an unused metric costs storage, adds a line of code to every path that touches it, and dilutes the set of metrics people actually read. Adding it back later is a one-line change if it turns out to be needed.
What is the minimum useful set?
Three: a counter of units with an outcome label, a histogram of duration, and a counter of the natural unit of volume — pixels for raster, features for vector. That trio answers “is it working, how fast, and how much”, which covers most of what anyone asks during an incident. Everything else can be added when a specific question demands it.
Related
- Instrumenting gdalwarp with Prometheus counters — measuring an out-of-process GDAL step
- Measuring tile generation latency percentiles — sizing buckets and reading the result
- Exporting per-tile metrics without cardinality blowups — when you genuinely need per-tile detail
- Building a raster pipeline Grafana dashboard — what to do with these series
- Data-quality SLOs for spatial pipelines — the correctness questions these metrics cannot answer