Alerting on Spatial Data Freshness

This guide builds a freshness monitor for spatial tables that distinguishes a stalled feed from a quiet one, accounts for the geographic and temporal patterns real spatial sources exhibit, and pages only when queries are currently returning misleading answers.

Context and prerequisites

Freshness is the one spatial metric that justifies waking somebody, because stale data answers queries confidently and wrongly. It is also the metric most often implemented badly, because a naive “no data in the last hour” check fires constantly on sources that are legitimately quiet at night, in winter, or in one region. This recipe reads table metadata through PyIceberg 0.7+ and needs no data scan; the wider metric set is in spatial data observability.

Three different questions, often conflated

Three questions that all get called “freshness” write freshness when did a commit last land? source: snapshot timestamp cost: metadata only catches: pipeline stopped misses: pipeline running, empty event freshness how recent is the newest row? source: max of the time column cost: metadata, if tracked catches: upstream stalled the one users actually mean coverage freshness is every region reporting? source: per-partition maxima cost: metadata per partition catches: one region lost unique to spatial data

The third question is the one that only exists for spatial data, and it catches the failure that the other two miss entirely: a feed covering forty regions loses one, and the table keeps receiving commits, keeps having recent events, and silently stops knowing anything about one part of the world. Aggregate freshness looks perfect. Every query scoped to that region returns stale results with no indication.

Complete working solution

python
from datetime import datetime, timedelta, timezone
from pyiceberg.catalog import load_catalog

def freshness(catalog_name: str, identifier: str,
              time_column: str = "event_ts",
              region_column: str = "region_code") -> dict:
    table = load_catalog(catalog_name).load_table(identifier)
    now = datetime.now(timezone.utc)

    # 1. Write freshness — newest snapshot commit time.
    snapshots = list(table.history())
    last_commit = datetime.fromtimestamp(
        snapshots[-1].timestamp_ms / 1000, tz=timezone.utc) if snapshots else None

    # 2 & 3. Event and coverage freshness — from per-file upper bounds.
    newest_event, per_region = None, {}
    for task in table.scan().plan_files():
        f = task.file
        hi = f.upper_bounds.get(time_column)
        if hi is None:
            continue
        ts = _decode_timestamp(hi)
        newest_event = max(newest_event or ts, ts)
        region = _partition_value(f.partition, region_column)
        if region is not None:
            per_region[region] = max(per_region.get(region, ts), ts)

    stale_regions = {
        r: (now - t).total_seconds() / 3600
        for r, t in per_region.items()
        if (now - t) > timedelta(hours=6)
    }

    return {
        "table": identifier,
        "write_age_minutes": (now - last_commit).total_seconds() / 60 if last_commit else None,
        "event_age_minutes": (now - newest_event).total_seconds() / 60 if newest_event else None,
        "regions_total": len(per_region),
        "regions_stale": len(stale_regions),
        "stale_regions": dict(sorted(stale_regions.items(),
                                     key=lambda kv: -kv[1])[:20]),
    }

Step-by-step walkthrough

  1. Read the snapshot history for write freshness. It is exact, requires nothing from the schema, and is the only signal available for a table with no time column. It is also the weakest, because a pipeline that runs successfully and writes zero rows produces a fresh commit and no new data.

  2. Read the time column’s upper bounds from file statistics. This gives event freshness without a scan, provided the column is inside the statistics window — which is another reason the column-ordering discipline described elsewhere matters. Where it is not, fall back to a cheap SELECT max(event_ts) scoped to the newest partition.

  3. Group by the region dimension. Any partition column that carries geographic meaning works: a region code, a grid cell, a tenant. The finer it is, the more precisely a coverage gap is located and the more rows the check produces.

  4. Compare against a per-region expectation, not a global one. The next section covers why a single threshold cannot work across regions with different reporting rhythms.

  5. Cap the stale-region list. A source that has failed entirely will report every region as stale, and a list of four hundred is noise. The twenty worst by age convey the same information.

Common errors and fixes

Symptom Cause Fix
Alerts fire nightly for quiet regions One global threshold across differing rhythms Derive a per-region threshold from that region’s own history
Event freshness needs a full scan Time column outside the statistics window Move it earlier in the schema, or raise the statistics limit
Write freshness fine, users report stale data Pipeline running, producing no rows Alert on event freshness as well, never on write alone
A new region alerts immediately No history to derive a threshold from Exempt regions younger than the baseline window
Freshness jumps backwards Late-arriving data with older timestamps Track both maximum event time and commit time; a gap between them is the lateness

Deriving per-region thresholds

One threshold cannot fit three rhythms normal gap between arrivals → urban: 2 min regional: 45 min remote: 14 h a single global threshold misses failures here fires constantly here

The workable derivation is percentile-based and needs no domain knowledge: for each region, take the distribution of gaps between arrivals over the last thirty days, and set the alert threshold at some multiple of the 99th percentile. A region that normally goes at most twenty minutes without data alerts at an hour; one that normally goes fourteen hours alerts at two days. Both are correct, and neither required anyone to know what the region is.

Recompute the thresholds weekly and store them, rather than deriving them at alert time. Storing them makes the alert logic trivial, makes a threshold change reviewable, and — importantly — prevents an outage from widening its own threshold, which is what happens when a threshold is derived from a window that includes the outage.

Verification

python
# Assert the monitor's behaviour on synthetic histories before trusting it.
def test_quiet_region_does_not_alert():
    history = [t for t in hourly_gaps(days=30, mean_gap_h=14)]
    threshold = percentile(history, 0.99) * 2
    assert threshold > timedelta(hours=24), "quiet region threshold too tight"

def test_busy_region_alerts_promptly():
    history = [t for t in hourly_gaps(days=30, mean_gap_h=0.03)]
    threshold = percentile(history, 0.99) * 2
    assert threshold < timedelta(hours=1), "busy region threshold too loose"

def test_coverage_gap_is_detected():
    per_region = {"A": now - timedelta(minutes=5), "B": now - timedelta(hours=30)}
    stale = [r for r, t in per_region.items() if now - t > thresholds[r]]
    assert stale == ["B"]

The second test is the one that matters most and is the one a global-threshold implementation fails: a busy region that stops must be caught in minutes, and it will not be if the threshold was set to accommodate the quietest region on the platform.

Wire the resulting alert to a page rather than a ticket, because unlike every other metric in this topic, stale data means queries running right now are returning something misleading — and unlike the others, the remedy is usually upstream and time-sensitive.

Seasonality and Other Legitimate Silence

Percentile thresholds handle steady rhythms. Several real spatial sources are not steady, and treating their silence as failure produces alerts nobody can act on.

Silence that is not a failure diurnal vehicle fleets sleep at night threshold per hour-of-day weekly surveys run on weekdays threshold per day-of-week seasonal agricultural sensors overwinter suppress within a declared window revisit-driven satellite passes every N days alert on missed passes, not on hours

The bottom-right case deserves a note because it inverts the usual approach. For a satellite-derived layer, elapsed time is the wrong unit entirely: the correct question is whether the expected number of passes have been ingested, and the expected number is computable from the orbit and the area. A region that should have received three acquisitions this week and received one has a problem, even though the newest data is two days old and would pass any time-based check.

The first two cases are handled by conditioning the percentile on the cycle: compute a separate threshold per hour-of-day, or per day-of-week, from the same thirty-day history. That multiplies the number of stored thresholds by twenty-four or by seven, which is still a small table, and it removes the entire class of “it always alerts at 3 a.m.” complaints.

The seasonal case is the only one that genuinely needs human input, because thirty days of history cannot predict an annual cycle. A declared suppression window per source, reviewed yearly, is the honest solution — and recording it explicitly is better than the alternative, which is somebody widening the global threshold until the winter alerts stop and leaving it wide.

What to Put in the Alert

An alert that says “table X is stale” sends the responder to a dashboard. An alert that carries its context sends them to the cause.

Include the three ages — write, event and the worst region — because their pattern identifies the failure class immediately. All three old means the pipeline stopped. Write fresh and event old means the pipeline is running and producing nothing. Write and event fresh with one region old means an upstream source was lost.

Include the affected regions with their ages, because that is what determines urgency and who to contact. Three adjacent regions going stale together points at a shared upstream; twenty scattered ones points at something in the pipeline.

Include the last known good time and the expected interval, so the responder can judge severity without querying anything. “Region DE-BY last reported 31 hours ago; expected every 45 minutes” is a complete problem statement.

Finally, include a link to the query that produced the numbers. Alerts age badly, and the first thing anyone does is re-run the check to see whether it is still true; making that a click rather than an archaeology exercise saves several minutes at exactly the moment they are most expensive.

Freshness is the one place on a spatial platform where a page is justified, so the rest of the observability layer should stay firmly in the ticket and dashboard tiers described in spatial data observability. Keeping the paging surface this narrow is what makes it credible when it fires.