Defining Freshness SLOs for Tile Layers
A freshness objective is two numbers and a weighting: how old a tile may be, and what share of the weighted serving area must comply. Derive the age from how fast the underlying phenomenon changes — hours for a flood extent, weeks for a road network, months for orthophotos — and set the share from a month of measurement rather than from aspiration. Then alert on how fast the error budget is burning, so the first notification arrives while there is still budget left rather than after the objective has already been missed.
When to Use This Pattern
- A tile layer is published to consumers who make decisions based on how current it is.
- Sources update at different rates, so one number cannot serve every layer.
- Staleness has gone unnoticed before — the failure this objective exists to make visible.
- A ledger exists with a build time per tile, since the SLI is computed from it.
Complete Working Example
The objective is data; the SLI is a query; the burn rate is a recording rule. Nothing here needs new instrumentation.
from __future__ import annotations
from dataclasses import dataclass
import psycopg
from prometheus_client import Gauge
FRESHNESS_SLI = Gauge("tile_freshness_sli", "Weighted share within the age target",
("layer",))
FRESHNESS_TARGET = Gauge("tile_freshness_target", "The objective, for the dashboard",
("layer",))
@dataclass(frozen=True)
class FreshnessObjective:
layer: str
max_age_hours: float # from the phenomenon, not from the pipeline
target: float # weighted share that must comply
window_days: int = 30
@property
def budget_hours(self) -> float:
"""How much non-compliance the window permits, as a wall-clock figure."""
return (1.0 - self.target) * self.window_days * 24
OBJECTIVES = [
# A flood extent is useless at four hours old; imagery is fine at a month.
FreshnessObjective("flood_extent", max_age_hours=2, target=0.99),
FreshnessObjective("traffic", max_age_hours=0.25, target=0.98),
FreshnessObjective("roads", max_age_hours=24 * 7, target=0.99),
FreshnessObjective("ortho", max_age_hours=24 * 30, target=0.95),
]
SLI_SQL = """
SELECT
sum(weight) FILTER (
WHERE built_at > now() - (%(max_age_hours)s * interval '1 hour')
) / NULLIF(sum(weight), 0) AS sli,
-- Reported alongside, because it is what a human wants to see next.
max(EXTRACT(EPOCH FROM now() - built_at)) / 3600 AS oldest_hours
FROM tile_ledger
WHERE layer = %(layer)s AND in_serving_area
"""
def publish_freshness(conn: psycopg.Connection) -> None:
for obj in OBJECTIVES:
with conn.cursor() as cur:
cur.execute(SLI_SQL, {"layer": obj.layer, "max_age_hours": obj.max_age_hours})
sli, oldest = cur.fetchone()
FRESHNESS_SLI.labels(layer=obj.layer).set(float(sli or 0.0))
FRESHNESS_TARGET.labels(layer=obj.layer).set(obj.target)
The alerting is two burn-rate rules per layer — one fast, one slow — which is what turns the objective into a warning rather than a post-mortem:
groups:
- name: freshness-slo
rules:
# The SLI is computed once and reused, so both rules agree by construction.
- record: slo:freshness_error_ratio
expr: 1 - tile_freshness_sli
# Fast burn: at this rate the month's budget is gone in about two days.
- alert: FreshnessBudgetBurningFast
expr: |
slo:freshness_error_ratio
> 14.4 * (1 - tile_freshness_target)
for: 15m
labels: { severity: page }
annotations:
summary: "{{ $labels.layer }} freshness budget burning at 14x — 2 days to exhaustion"
# Slow burn: not urgent, and this is how a month's budget disappears unnoticed.
- alert: FreshnessBudgetEroding
expr: |
slo:freshness_error_ratio
> 3 * (1 - tile_freshness_target)
for: 6h
labels: { severity: ticket }
annotations:
summary: "{{ $labels.layer }} freshness budget eroding at 3x for six hours"
Expressing the budget in hours rather than as a percentage is a small change that does a lot of work in conversation. “Ninety-five per cent freshness” invites a debate about whether ninety-four is really so bad; “thirty-six hours of permitted staleness this month, of which we have used twenty-two” is a quantity people reason about the way they reason about money. It also makes the trade-off explicit when someone proposes a risky change: the question becomes whether the change is worth some of the fourteen hours remaining, which is a decision rather than an argument.
Parameter & Option Reference
| Parameter | Example | Spatial notes |
|---|---|---|
max_age_hours |
2 to 720 | From the phenomenon’s rate of change. The single most important number and the only one nobody can compute for you. |
target |
0.95–0.99 | From a month of measurement, set slightly below what the pipeline achieves today. |
window_days |
30 | The same across objectives, so budgets are comparable. |
weight |
population | Stated in the published objective. Unweighted area flatters any large pyramid. |
in_serving_area |
boolean column | The promise covers a defined region, versioned, not the whole grid. |
| fast burn | 14.4× | Exhausts a 30-day budget in about two days. Pages. |
| slow burn | 3× | Exhausts it in ten days. Tickets, and this is the one that catches erosion. |
Verification & Testing
Test the arithmetic and the boundary, because both are easy to get subtly wrong and neither is visible once deployed.
def test_budget_hours_arithmetic() -> None:
obj = FreshnessObjective("ortho", max_age_hours=720, target=0.95, window_days=30)
# 5% of 720 hours in the window = 36 hours of permitted non-compliance.
assert obj.budget_hours == pytest.approx(36.0)
def test_sli_is_weighted_not_counted(conn, seeded_ledger) -> None:
"""One stale city must cost more than a thousand stale ocean tiles."""
make_stale(conn, layer="ortho", region="oslo")
weighted = compute_sli(conn, OBJECTIVES[3])
reset(conn)
make_stale(conn, layer="ortho", region="north_sea", tiles=1000)
ocean = compute_sli(conn, OBJECTIVES[3])
assert weighted < ocean, "the weighting is not being applied"
def test_boundary_is_inclusive_and_consistent(conn) -> None:
"""A tile exactly at the age limit must be treated the same way every run."""
insert_tile(conn, layer="ortho", age_hours=720.0)
first = compute_sli(conn, OBJECTIVES[3])
second = compute_sli(conn, OBJECTIVES[3])
assert first == second
def test_empty_serving_area_does_not_divide_by_zero(conn) -> None:
assert compute_sli(conn, FreshnessObjective("empty_layer", 24, 0.99)) == 0.0
The reporting query is worth having as a saved view, because “how are we doing against the objective” is asked more often than any alert fires — usually by someone outside the team, and usually about one layer:
SELECT l.layer,
round(100 * l.sli::numeric, 2) AS sli_pct,
round(100 * o.target::numeric, 2) AS target_pct,
round(l.oldest_hours::numeric, 1) AS oldest_hours,
round((1 - (1 - l.sli) / (1 - o.target))::numeric, 3) AS budget_remaining
FROM freshness_sli l
JOIN freshness_objective o USING (layer)
ORDER BY budget_remaining;
Common Pitfalls
- One target for every layer. A number that suits orthophotos is meaningless for a flood extent. The target belongs to the phenomenon, and layers with similar dynamics can share one.
- Measuring average age. An average hides a stale region inside a healthy mean. The SLI is a share of compliant area, not a central tendency.
- Unweighted area. For a national pyramid, most tiles are uninhabited. Without weighting the objective can be met while every city is a month behind.
- Alerting on the SLI crossing the target. By then the objective is already missed. Burn rate gives warning while there is still budget, which is the whole reason to express it as a budget.
- Measuring
built_atwhen consumers care about capture time. For imagery those differ by weeks. If the promise is about the world rather than about the pipeline, carry the acquisition time through and measure that. - Never revisiting the target. A target set when the pipeline was slower may now be trivially met, which means the objective has stopped providing signal. Review it when the window’s shape changes, not on a fixed schedule.
Frequently Asked Questions
Where does the max-age number come from?
From how quickly the thing being mapped changes, and from what a wrong answer costs. Traffic changes minute to minute and a stale answer sends someone down a closed road. A road network changes over months and a week-old copy is indistinguishable from current for almost every use. Asking “what decision does this support, and how fast would that decision change” produces the number more reliably than any benchmark.
Should the objective count tiles that have never been built?
Yes — they are infinitely stale, and excluding them lets a pipeline meet its freshness objective on a layer that is half missing. That does mean freshness and coverage interact, which is a reason some teams combine them into one “usable” ratio. Keeping them separate and letting missing tiles count against freshness is the simpler arrangement and it fails safely.
What if a source legitimately publishes less often than the target?
Then the target is wrong, or the layer needs a different promise. An objective that cannot be met by a correctly-functioning pipeline is a broken objective, and it will be silenced within a month. Where a source publishes quarterly, the freshness promise is about quarters, however much anyone would prefer otherwise.
How does this interact with the tile cache's staleness budget?
They are the same idea at different layers, and the numbers should agree. A cache that serves tiles up to 48 hours old while the SLO promises 24 is quietly breaching the objective every time it does so. Deriving the cache’s budget from the SLO — rather than choosing both independently — keeps them consistent; see falling back to cached tiles when a breaker opens.
Can the objective vary by region?
It can, and it is usually not worth the complexity. A single target with a sensible weighting expresses “we care more about cities” adequately, and per-region targets multiply the objectives without changing many decisions. The exception is a genuinely different product in one region — a high-frequency layer over a single city — which is better modelled as its own layer with its own objective.
Related
- Data-quality SLOs for spatial pipelines — the framework this instantiates
- Monitoring geometry validity rates as an SLO — the validity objective
- Visualizing tile coverage gaps on a geomap — localising a freshness breach
- Skipping tiles with no new source data — the ledger this measures