This guide is part of Monitoring & Observability for Spatial Pipelines, within the broader Orchestrating Spatial ETL Pipelines reference.

Emitting Metrics for Spatial Record Counts

Counting rows sounds trivial and is the instrumentation most pipelines get subtly wrong — usually by counting in the wrong place, or in a way that a retry corrupts.

Why Naive Counting Misleads

  • Counting inside the transform double-counts on retry. A monotonic counter incremented mid-task adds again on the second attempt, so a routine retry looks like a traffic spike.
  • Counting only the output hides the loss. Ten thousand rows written is meaningless without knowing whether twelve thousand or ten thousand arrived.
  • Absolute counts cannot be thresholded. Batch sizes vary; a rule that fires below 5 000 rows is wrong the first week the source publishes a small update.
  • Per-tile labels destroy the metrics store. Four hundred tiles times five metrics times a year of runs is millions of series for information nobody queries that way.

Version and Environment Compatibility

Component Version Note
GeoPandas >=1.0 estimate_utm_crs for area computation
Shapely >=2.0 Vectorized area and validity counts
Python 3.10+ Dataclass syntax used below
Any metrics store The emitter interface is deliberately backend-agnostic
pip install "geopandas>=1.0" "shapely>=2.0"

Where the Counters Belong

Counting at stage boundaries makes losses attributable Four pipeline stages with a counter drawn at each boundary. The ingest stage receives 12400 features and emits 12400. Cleaning emits 12180 after quarantining 220. Transform emits 12180. Load writes 12180. Because each boundary is counted, the 220 lost rows are attributable to the cleaning stage rather than to the pipeline as a whole. ingest 12 400 clean 12 180 transform 12 180 load 220 quarantined — attributable to this stage alone Counting only at the ends gives you a loss of 220 with no idea which transform caused it, which is most of an investigation.

The stage_counts Recipe

from __future__ import annotations

import logging
from dataclasses import dataclass, asdict

import geopandas as gpd
import shapely

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class StageCounts:
    dataset: str
    stage: str
    run_id: str
    rows_in: int
    rows_out: int
    rows_quarantined: int
    invalid_geometries: int
    empty_geometries: int
    total_area_m2: float
    area_crs: str

    @property
    def retention(self) -> float:
        return self.rows_out / self.rows_in if self.rows_in else 0.0

    @property
    def quarantine_rate(self) -> float:
        return self.rows_quarantined / self.rows_in if self.rows_in else 0.0


def stage_counts(
    before: gpd.GeoDataFrame,
    after: gpd.GeoDataFrame,
    quarantined: int,
    dataset: str,
    stage: str,
    run_id: str,
) -> StageCounts:
    """Measure one stage boundary, computing area in a metric CRS."""
    geoms = after.geometry.values
    measure = after if (after.crs and after.crs.is_projected) else after.to_crs(
        after.estimate_utm_crs() if len(after) else 3857
    )

    counts = StageCounts(
        dataset=dataset,
        stage=stage,
        run_id=run_id,
        rows_in=len(before),
        rows_out=len(after),
        rows_quarantined=quarantined,
        invalid_geometries=int((~shapely.is_valid(geoms)).sum()),
        empty_geometries=int(shapely.is_empty(geoms).sum()),
        total_area_m2=float(measure.geometry.area.sum()),
        area_crs=str(measure.crs),
    )
    logger.info("%s/%s: %d → %d (retention %.3f, quarantine %.4f)",
                dataset, stage, counts.rows_in, counts.rows_out,
                counts.retention, counts.quarantine_rate)
    return counts


def publish(counts: StageCounts, sinks: list) -> None:
    """Fan the measurement out; a sink failure must never fail the run."""
    payload = asdict(counts) | {
        "retention": counts.retention,
        "quarantine_rate": counts.quarantine_rate,
    }
    for sink in sinks:
        try:
            sink.upsert(key=(counts.dataset, counts.stage, counts.run_id), values=payload)
        except Exception:  # noqa: BLE001 — telemetry is best effort by design
            logger.exception("metrics sink %s failed", type(sink).__name__)

Key Implementation Notes

  • upsert keyed on run and stage, not increment. A retried task rewrites its measurement rather than adding to it, so retries never produce phantom spikes.
  • Ratios are derived at emission, not at query time. Storing retention alongside the raw counts means an alert rule does not depend on two series arriving together.
  • Area is computed in a metric CRS and the CRS is recorded. A series that silently switched from UTM to Web Mercator would show a step change that looks like a data problem and is not.
  • Validity and emptiness are counted after the stage. They describe what this stage produced, which is the quantity a stage owner can act on.
  • The sink interface is two methods wide. Keeping it minimal is what allows the same call sites to write to the orchestrator’s metadata, a metrics store and a JSON file beside the output.
  • Zero-row batches yield zero ratios rather than raising. An empty batch is a legitimate state that the alerting layer, not the counter, should judge.
Retries and the difference between counters and gauges One task processes a twelve thousand row batch and is retried twice. With a monotonic counter the total climbs to thirty-six thousand, which reads as a volume spike. With a gauge keyed on the run identifier each attempt overwrites the previous value, so the metric reports twelve thousand regardless of the number of attempts. monotonic counter · three attempts 12 000 24 000 36 000 — a spike that never happened gauge keyed on run id · three attempts 12 000 12 000 12 000 the batch is what it is Batch pipelines want gauges. Counters belong to streaming systems where every event genuinely is a new event.

Labels, Cardinality and What to Keep Elsewhere

Every label multiplies the series count, and spatial pipelines have unusually tempting high-cardinality dimensions. Tile identifier, scene id, feature id and source URI all feel like natural labels and all of them are traps.

How quickly a label set explodes Three label sets and the number of time series each produces for five metrics. Dataset and stage give about sixty series. Adding a source dimension gives around four hundred. Adding a tile identifier gives seven hundred and thirty thousand, which exceeds what most metric stores will accept and answers a question nobody asks that way. series produced by 5 metrics dataset, stage 60 · keep indefinitely + source 400 · still fine + tile identifier 730 000 · rejected, or ruinous Tile detail belongs in the run metadata, where it is stored once per run and queried by run rather than by series.

The workable label set is small: dataset, stage, and optionally source where a pipeline reads from a handful of named providers. That is a few dozen series per metric, which any store handles indefinitely.

Partition-level detail belongs in the orchestrator’s run metadata, where it is stored once per run and queried by run rather than by series. Feature-level detail belongs in the quarantine table. Neither belongs in a metrics label, and the discipline is easiest to hold if the emitter simply does not accept arbitrary labels — a fixed signature is a better guard than a convention.

Alerting on Ratios Rather Than Counts

The counts exist so that ratios can be computed, and it is the ratios that carry the alerting rules.

Retention below its normal band means a transform is dropping rows it did not use to drop. This catches a filter whose predicate stopped matching after a schema change, which produces no error anywhere.

Quarantine rate above its threshold means the input got worse. Trend matters more than level here, as described in validating spatial data quality in pipelines.

Area per row is the most spatially specific of the three, and the most sensitive to a reprojection: an unintended CRS change moves it by orders of magnitude while leaving every row count untouched.

Each rule needs a floor on the batch size to avoid firing on tiny updates, where a single quarantined row is 20% of the batch.

Troubleshooting Metric Emission

Symptom Likely cause Fix
Volume spikes matching retry counts Monotonic counters incremented per attempt Switch to run-keyed gauges
Metrics store rejecting writes High-cardinality labels Move partition detail into run metadata
Area series shows an unexplained step CRS changed between runs Record area_crs; pin the measurement CRS
Ratios missing for some runs Raw counts emitted, ratios computed downstream Derive ratios at emission time
Pipeline failing on telemetry errors Emission inside the critical path Wrap sinks; log and continue

Integration Note

Call stage_counts at each stage boundary and publish once at the end of the task, so a single failure path covers all sinks. The counts also belong in the orchestrator’s own metadata — Dagster asset metadata, Airflow task instance fields, Prefect artifacts — which is where an operator investigating one run will look first, as described in monitoring and observability for spatial pipelines.

Backfilling the Metric History

A new metric starts with no history, which means no baseline and therefore no threshold. Waiting a month to find out what normal looks like is the honest approach and rarely the practical one.

Where the pipeline writes its outputs to durable storage, the history can often be reconstructed. Row counts come from reading the row group metadata of each historical Parquet file — cheap, since it needs no data pages. Coverage area needs the geometries, so it is more expensive, but a sample per historical partition is usually enough to establish a range. Quarantine rates generally cannot be reconstructed, because the rejected rows were never written; that metric has to accumulate forward.

Backfilled points should be marked as such, with a flag or a separate series name. A reconstructed count is not identical to a measured one — it reflects what survived rather than what arrived — and a threshold derived from a mixed series will be subtly wrong in the direction that matters.