Instrumenting gdalwarp with Prometheus Counters
To instrument a reprojection, wrap the gdal.Warp call (or a subprocess gdalwarp invocation) so it increments a Counter for pixels reprojected and bytes read and observes a Histogram of warp seconds, tagging each sample with low-cardinality source and target EPSG labels. Read the output dimensions from the warped dataset to compute pixel counts, keep the EPSG labels drawn from a known projection set, and confirm the numbers land by curling the /metrics endpoint and running a PromQL rate() query. This page shows the full wrapper for both the Python-API and subprocess paths.
When to Use This Pattern
- You run reprojections with
gdal.Warpor shell out togdalwarpand need throughput visibility per CRS pair. - You want pixels-per-second and warp-duration percentiles broken down by source and target projection.
- Your warp step is a discrete task whose regressions currently only surface as slow downstream tiles.
- You need the counters to feed the same Prometheus stack described in Prometheus metrics for raster throughput.
Complete Working Example
The wrapper below runs a warp through the GDAL Python API, reads the resulting dataset to count reprojected pixels, and records three metrics. EPSG labels are stringified integer codes from a bounded set, so cardinality stays flat no matter how many rasters flow through.
import os
import time
import subprocess
from osgeo import gdal
from prometheus_client import Counter, Histogram
gdal.UseExceptions()
PIXELS_REPROJECTED = Counter(
"gdalwarp_pixels_reprojected_total",
"Pixels written by a warp, by source and target EPSG",
labelnames=("src_epsg", "dst_epsg"),
)
BYTES_READ = Counter(
"gdalwarp_source_bytes_read_total",
"Source file bytes read by a warp",
labelnames=("src_epsg",),
)
WARP_SECONDS = Histogram(
"gdalwarp_seconds",
"Wall-clock duration of a gdalwarp reprojection",
labelnames=("src_epsg", "dst_epsg"),
buckets=(0.5, 1, 2, 4, 8, 16, 32, 64),
)
def warp_with_metrics(src_path: str, dst_path: str,
dst_epsg: int, resample: str = "bilinear") -> int:
# Read source EPSG up front so the label is known before work starts.
src_ds = gdal.Open(src_path)
src_srs = src_ds.GetSpatialRef()
src_epsg: str = src_srs.GetAuthorityCode(None) or "unknown"
d_epsg: str = str(dst_epsg)
BYTES_READ.labels(src_epsg=src_epsg).inc(os.path.getsize(src_path))
start = time.perf_counter()
warp_opts = gdal.WarpOptions(
dstSRS=f"EPSG:{dst_epsg}",
resampleAlg=resample,
dstNodata=src_ds.GetRasterBand(1).GetNoDataValue(), # preserve nodata
multithread=True,
)
out_ds = gdal.Warp(dst_path, src_ds, options=warp_opts)
elapsed = time.perf_counter() - start
pixels: int = out_ds.RasterXSize * out_ds.RasterYSize * out_ds.RasterCount
WARP_SECONDS.labels(src_epsg=src_epsg, dst_epsg=d_epsg).observe(elapsed)
PIXELS_REPROJECTED.labels(src_epsg=src_epsg, dst_epsg=d_epsg).inc(pixels)
out_ds = None # flush to disk
src_ds = None
return pixels
def warp_subprocess_with_metrics(src_path: str, dst_path: str,
src_epsg: int, dst_epsg: int) -> None:
# Subprocess variant: gdalwarp emits no metrics itself, so we time the call.
s_epsg, d_epsg = str(src_epsg), str(dst_epsg)
cmd = [
"gdalwarp", "-s_srs", f"EPSG:{src_epsg}", "-t_srs", f"EPSG:{dst_epsg}",
"-r", "bilinear", "-dstnodata", "0", "-overwrite", src_path, dst_path,
]
BYTES_READ.labels(src_epsg=s_epsg).inc(os.path.getsize(src_path))
start = time.perf_counter()
subprocess.run(cmd, check=True, capture_output=True)
WARP_SECONDS.labels(src_epsg=s_epsg, dst_epsg=d_epsg).observe(
time.perf_counter() - start
)
ds = gdal.Open(dst_path)
PIXELS_REPROJECTED.labels(src_epsg=s_epsg, dst_epsg=d_epsg).inc(
ds.RasterXSize * ds.RasterYSize * ds.RasterCount
)
ds = None
Both paths preserve nodata — through dstNodata in the API options and -dstnodata on the command line — so fill pixels are never mistaken for valid data, and both derive labels from EPSG codes rather than free-form projection strings.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
dst_epsg |
int |
required | Target EPSG; becomes the dst_epsg label. Keep to a known set. |
resample |
str |
"bilinear" |
GDAL resampling algorithm; nearest for categorical rasters. |
dstNodata |
float |
source nodata | Must be set or reprojected edges corrupt band statistics. |
buckets |
tuple |
0.5–64 s | Histogram bounds; widen if warps exceed 64 s. |
src_epsg label |
str |
read from source | Authority code from the source SRS; "unknown" if absent. |
multithread |
bool |
True |
Uses multiple cores for the warp; does not affect metrics. |
Verification & Testing
First confirm the source and target CRS of a test warp with gdalinfo so you know the EPSG labels the wrapper should emit:
gdalinfo output.tif | grep -A2 "Coordinate System"
Then scrape the metrics endpoint the worker exposes and check the counters carry the expected labels and non-zero values:
curl -s localhost:9105/metrics | grep gdalwarp_
# gdalwarp_pixels_reprojected_total{dst_epsg="3857",src_epsg="4326"} 6.7108864e+07
# gdalwarp_seconds_bucket{dst_epsg="3857",le="8.0",src_epsg="4326"} 3
A minimal correctness check asserts the counter moved and the output CRS actually matches the requested target:
from osgeo import gdal
def test_warp_emits_metrics_and_correct_crs(tmp_path):
before = PIXELS_REPROJECTED.labels(src_epsg="4326", dst_epsg="3857")._value.get()
warp_with_metrics("fixture_4326.tif", str(tmp_path / "out.tif"), dst_epsg=3857)
after = PIXELS_REPROJECTED.labels(src_epsg="4326", dst_epsg="3857")._value.get()
assert after > before
ds = gdal.Open(str(tmp_path / "out.tif"))
assert ds.GetSpatialRef().GetAuthorityCode(None) == "3857"
Finally, once several warps have run, verify live throughput in the Prometheus expression browser with a rate() query over the counter:
rate(gdalwarp_pixels_reprojected_total{dst_epsg="3857"}[5m])
Common Pitfalls
- Labeling on the full PROJ string instead of the EPSG code. A PROJ pipeline string is effectively unbounded and detonates cardinality. Always reduce to the integer authority code before using it as a label.
- Counting pixels from the source instead of the output. A warp changes raster dimensions, so counting source pixels understates or overstates delivered work. Read
RasterXSize/RasterYSizefrom the warped dataset. - Omitting
dstNodata. Without it, reprojected fill areas become valid zero-value pixels that skew band statistics and downstream analytics — a silent correctness bug no latency metric will catch. - Forgetting to flush the output dataset. Leaving
out_dsopen (not setting it toNone) can leave the file partially written when you immediately reopen it to count pixels, producing wrong or zero counts.
Related: This wrapper feeds the metric family defined in Prometheus metrics for raster throughput, pairs with measuring tile generation latency percentiles for the tiling stage, and slots into the broader Observability & Monitoring for Geospatial Pipelines design. See the GDAL Warp documentation for the full option set.
← Back to Prometheus Metrics for Raster Throughput