Data-Quality SLOs for Spatial Pipelines
In short: a pipeline’s users care about the output, not the process. An SLO on run success rate promises nothing they can use; an SLO on “95% of the populated area is no more than 48 hours old” promises something they can plan around, and it is measurable from the ledger without any new instrumentation. Three objectives cover most spatial products: freshness, coverage and validity.
The distinction matters because a spatial pipeline can be perfectly healthy and produce unusable output. Every run succeeds, throughput is normal, no task fails — and one region has not been updated in six weeks because its source stopped publishing. Process metrics are all green throughout. An SLO defined on the data closes that gap, and it changes the conversation with consumers from “the pipeline ran” to “here is what you can rely on”.
There is a second, quieter benefit. Writing down what the output should look like forces a conversation that most spatial platforms never have: what, precisely, is being promised, to whom, about which area. Teams routinely discover during that conversation that two consumers have incompatible expectations of the same layer, or that a region everyone assumed was covered has never been in scope. Those findings arrive whether or not the objective is ever alerted on, and they are frequently worth more than the monitoring.
Prerequisites & Architecture Baseline
Core Principles
1. Measure the output, not the process. Run success rate is an internal signal. The SLI is computed from the artefacts: how old is the oldest tile in the serving area, what share of the area exists, what fraction of features are valid. Those are the numbers a consumer would check themselves.
2. Weight by what matters, not by area. Ninety-five per cent of a national tile pyramid is uninhabited, so an unweighted coverage SLO can be met while every city is stale. Weight by population, by request volume, or by an explicit priority band — and state the weighting, because it is where the objective’s honesty lives.
3. Freshness is per layer, not per pipeline. A road basemap may tolerate a week; a flood extent tolerates hours. One objective across every layer either over-promises on the slow ones or is unachievable for the fast ones. Group layers by their phenomenon’s rate of change.
4. The error budget is the useful half. “99% freshness” is a threshold nobody negotiates with. “1% of 30 days is 7.2 hours of staleness we can spend” is a quantity that makes a decision — whether to do a risky deployment, whether to defer a backfill — concrete and shared.
5. An objective needs an owner and a consequence. If nothing changes when the budget is exhausted, the SLO is a dashboard panel with ambitions. The consequence need not be dramatic; “we stop shipping new features and fix the source pipeline” is enough, provided it is agreed in advance.
6. Start with one objective per product. Three well-understood SLIs across a whole platform beat thirty per-layer objectives nobody can recite. Freshness is usually the right first one, because it is the property consumers notice and the one that degrades silently.
Weighting deserves more attention than it usually receives, because it is where an objective becomes either honest or decorative. An unweighted coverage figure over a national pyramid is dominated by sea, forest and mountain, so a pipeline can lose every city and still report 98%. Weighting by population inverts that, and it introduces its own bias — rural staleness becomes nearly invisible, which is a real trade-off that rural users would notice. There is no neutral choice, and the useful discipline is to publish the weighting alongside the target so that whoever reads the promise can see whose experience it describes.
Production Implementation
The SLI is a query, computed on a schedule and exported as a gauge so the error budget can be tracked with ordinary tooling.
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import psycopg
from prometheus_client import Gauge
SLI = Gauge("spatial_sli_ratio", "Data-quality SLI", ("layer", "objective"))
BUDGET = Gauge("spatial_slo_budget_remaining", "Error budget remaining, 0-1",
("layer", "objective"))
@dataclass(frozen=True)
class Objective:
layer: str
name: str # "freshness" | "coverage" | "validity"
target: float # 0.95 means 95% of weighted area must comply
window_days: int = 30
max_age_hours: float = 48.0
FRESHNESS_SQL = """
SELECT
-- The SLI: weighted share of the serving area meeting the age threshold.
-- Weighting by population is what stops an ocean-heavy pyramid flattering itself.
sum(t.weight) FILTER (
WHERE t.built_at > now() - (%(max_age_hours)s * interval '1 hour'))
/ NULLIF(sum(t.weight), 0) AS sli
FROM tile_ledger t
WHERE t.layer = %(layer)s
AND t.in_serving_area -- not every tile is promised
"""
def compute_freshness(conn: psycopg.Connection, obj: Objective) -> float:
with conn.cursor() as cur:
cur.execute(FRESHNESS_SQL,
{"layer": obj.layer, "max_age_hours": obj.max_age_hours})
(sli,) = cur.fetchone()
return float(sli or 0.0)
def budget_remaining(sli: float, target: float) -> float:
"""1.0 = untouched, 0.0 = exhausted, negative = the objective is missed."""
allowed_failure = 1.0 - target
if allowed_failure <= 0:
return 1.0 if sli >= 1.0 else 0.0
return max(-1.0, 1.0 - (1.0 - sli) / allowed_failure)
def publish(conn: psycopg.Connection, objectives: Iterable[Objective]) -> None:
for obj in objectives:
sli = compute_freshness(conn, obj) if obj.name == "freshness" else compute_other(conn, obj)
SLI.labels(layer=obj.layer, objective=obj.name).set(sli)
BUDGET.labels(layer=obj.layer, objective=obj.name).set(
budget_remaining(sli, obj.target)
)
OBJECTIVES = [
Objective("ortho", "freshness", target=0.95, max_age_hours=24 * 30),
Objective("roads", "freshness", target=0.99, max_age_hours=24 * 7),
Objective("flood_extent", "freshness", target=0.99, max_age_hours=2),
Objective("parcels", "validity", target=0.999),
Objective("ortho", "coverage", target=0.995),
]
Step-by-Step Walkthrough
- Define the serving area explicitly.
in_serving_areais a column, not an assumption. A pyramid covers the globe; the promise covers the part someone actually looks at, and stating which part is half the work of a defensible objective. - Attach a weight to every unit. Population is the usual choice for a public basemap; request counts are better where you have them. The weight is what makes the number mean something to a consumer.
- Compute the SLI as a ratio of weighted units. Share of weighted area meeting the threshold — not an average age, which one very stale region can hide inside a healthy mean.
- Convert to an error budget. The budget is the fraction of the allowance still unspent, and it is what makes the objective actionable rather than merely observable.
- Set the objectives per layer, from the phenomenon. Two hours for a flood extent and a month for orthophotos are both correct, because they are properties of the world rather than of the pipeline.
- Export both numbers. The SLI for the dashboard and the budget for the alert. Alerting on the SLI itself produces a page the moment a threshold is crossed; alerting on burn rate gives warning first.
- Recompute on a schedule, not per request. These are aggregate queries over a large table. Once every few minutes is ample, and it keeps the dashboard’s cost independent of how many people are looking at it.
Edge Cases & Failure Recovery
A serving area that changes. Adding a region to the promise changes the denominator, so the SLI moves for reasons unrelated to the pipeline. Version the serving-area definition and record which version each measurement used, or a boundary change looks like a regression and consumes budget it did not cause.
Weighting that nobody agreed to. Population weighting is defensible and it is a choice with consequences: it makes rural staleness nearly invisible. Whatever the weighting, it belongs in the published objective, so a consumer in a rural area knows the promise was never about them.
An objective that has never been missed. That usually means the target is too loose, not that the pipeline is exceptional. An SLO that cannot be breached provides no signal and no budget to spend. Tighten it until it is occasionally uncomfortable, which is where the information is.
Budget exhausted by one incident. A single bad week can consume a month’s allowance, after which the objective is missed regardless of what happens next. That is the intended behaviour and it is worth explaining in advance, because the first time it happens the instinct is to reset the window.
Freshness measured on the wrong timestamp. built_at is when the pipeline wrote the tile; the consumer cares when the source was captured. For imagery those differ by days or weeks. Where the distinction matters, carry the source’s acquisition time through and measure against that instead — the number will be worse and it will be the true one.
A ledger that is itself incomplete. The SLI is computed from the ledger, so a tile that was built but never recorded counts as stale, and one recorded but never built counts as fresh. Both directions are possible if the ledger write and the artefact write are not in the same transaction or the same ordered sequence. Before trusting an objective, verify a sample of the ledger against the object store — a hundred rows is enough to find a systematic discrepancy.
An SLO on a layer nobody uses. Objectives cost attention to maintain. A layer with no consumers does not need a promise, and removing its objective is a legitimate simplification rather than a retreat.
Configuration Reference
| Setting | Example | Spatial context |
|---|---|---|
| serving area | a column | Explicit, versioned. The promise covers a defined region, not the whole grid. |
| weighting | population | Stated in the objective. Unweighted area flatters any ocean-heavy pyramid. |
| freshness target | 95% at 30 d (ortho) | Per layer, from the phenomenon’s rate of change. |
| freshness target | 99% at 2 h (flood) | Same objective, four hundred times tighter, and both are right. |
| coverage target | 99.5% | Weighted share of the serving area that exists at all. |
| validity target | 99.9% | Share of features passing the validity check at load. |
| window | 30 days rolling | Long enough to absorb a bad night, short enough to reflect the present. |
The window length is the setting most often chosen carelessly. Too short and every incident breaches the objective, which trains everyone to ignore it; too long and a serious degradation is diluted into invisibility for weeks. Thirty days rolling is a reasonable default because it matches how most people think about a month, and because it means a single bad night costs roughly three per cent of the budget — noticeable, survivable, and cumulative if it keeps happening. Whatever the choice, it should be the same across objectives, or comparing their budgets becomes an exercise in arithmetic nobody performs.
Frequently Asked Questions
What should be alerted on — the SLI or the burn rate?
The burn rate, with two rules. A fast rule catches an acute incident: budget being consumed at, say, fourteen times the sustainable rate over an hour means the whole month’s allowance will be gone in two days, and that is worth waking someone for. A slow rule catches erosion: three times the sustainable rate over six hours is not an emergency and is exactly how a month’s budget disappears without anyone noticing. Alerting on the SLI crossing the target instead means the first notification arrives when the objective has already been missed, which is too late to be a warning.
How is this different from an alert threshold?
An alert fires when something is wrong now. An SLO describes what is acceptable over a period, and its budget quantifies how much has been used. Both are useful and they answer different questions: the alert says “go and look”; the budget says “we have spent two thirds of this month’s allowance, so the risky migration should wait”. A pipeline with alerts and no objectives can tell you it is broken and not whether it is good enough.
Who should the objective be agreed with?
Whoever would complain if it were missed, which is usually not the team that operates the pipeline. That conversation is uncomfortable precisely because it makes an implicit promise explicit — most consumers have an assumption about freshness that has never been stated and is often more optimistic than reality. Discovering that gap during the SLO discussion is far better than discovering it during an incident, and it frequently changes the target in both directions: some consumers need much less than they assumed, and one usually needs considerably more.
Should the SLO cover the pipeline or the product?
The product. If three pipelines feed one published layer, the consumer’s promise is about the layer, and an objective per pipeline distributes responsibility in a way nobody outside can use. Measure at the artefact and let the internal decomposition be internal.
What do we do when the budget runs out?
Whatever was agreed in advance, and agreeing in advance is the whole point. The common choice is to stop shipping changes to that pipeline until the objective recovers, which is unpopular and effective. What does not work is deciding in the moment, because in the moment there is always a reason to make an exception.
Does an SLO make sense for a pipeline with one consumer?
Yes, and it is often easier — the conversation has one participant and the target can be exact rather than negotiated. What changes is the ceremony: a single-consumer objective can live in a shared document rather than in a published catalogue, and the consequence of exhausting the budget can be an email rather than a change freeze. The mechanics above are unchanged; only the formality scales with the audience.
Can freshness and coverage be one objective?
They can be combined into a single “usable area” ratio, and it is usually worth keeping them separate. They fail for different reasons and have different fixes: coverage gaps mean a source was never delivered, staleness means updates stopped arriving. Merging them produces a number that is easy to report and hard to act on.
How do we set the first target?
Measure for a month, look at what the pipeline actually achieved, and set the target slightly below it. That gives an objective that is currently met, has a real budget, and will break if things get worse — all of which are properties of a useful SLO. Setting an aspirational target on day one produces an objective that is breached from the start and consequently ignored.
Related
- Defining freshness SLOs for tile layers — the freshness objective in detail
- Monitoring geometry validity rates as an SLO — the validity objective
- Visualizing tile coverage gaps on a geomap — localising a coverage breach
- Prometheus metrics for raster throughput — the process metrics these complement
- Spatial validation & sync tasks — where the validity numbers originate