Monitoring Geometry Validity Rates as an SLO
The validation boundary already classifies every feature into accepted, repaired or quarantined; making that an objective costs one recording rule and one target. The SLI is the weighted share of features that reached the table without needing intervention, the budget is the small fraction that may not, and the burn rate is what tells you a publisher’s export changed before the quarantine queue is large enough to notice. Unlike freshness, this objective is about what the source delivers rather than what the pipeline does — which is exactly why it is worth promising separately.
When to Use This Pattern
- A validation boundary exists and records outcomes per feature, per source.
- Repairs are common enough to be a policy rather than an occasional exception.
- Consumers care about completeness, so quarantined features are a visible loss rather than an internal detail.
- Publishers can be influenced, since the objective’s breaches are usually theirs to fix.
Complete Working Example
The counters come from the validation boundary; the SLI aggregates them; the alert watches the burn.
from __future__ import annotations
from dataclasses import dataclass
from prometheus_client import Counter, Gauge
# Emitted once per feature at the boundary. Three outcomes, bounded labels.
VALIDATION = Counter(
"feature_validation_total",
"Validation outcomes at the ingest boundary",
("layer", "source_group", "outcome"), # accepted | repaired | quarantined
)
VALIDITY_SLI = Gauge("geometry_validity_sli", "Share accepted without repair",
("layer",))
VALIDITY_TARGET = Gauge("geometry_validity_target", "The objective", ("layer",))
@dataclass(frozen=True)
class ValidityObjective:
layer: str
target: float # share accepted without repair
repair_ceiling: float # share that may be repaired before it is a problem
window_days: int = 30
OBJECTIVES = [
# A cadastral register is authoritative: repairs there are a real concern.
ValidityObjective("parcels", target=0.999, repair_ceiling=0.0009),
# Hand-digitised habitat polygons are messier by nature, and that is expected.
ValidityObjective("habitats", target=0.98, repair_ceiling=0.019),
ValidityObjective("buildings", target=0.995, repair_ceiling=0.0045),
]
def record_outcome(layer: str, source_group: str, outcome: str) -> None:
VALIDATION.labels(layer=layer, source_group=source_group, outcome=outcome).inc()
The rules compute the SLI once and derive everything else from it, so the dashboard and the alerts cannot disagree:
groups:
- name: validity-slo
rules:
- record: slo:validity_sli
expr: |
sum by (layer) (rate(feature_validation_total{outcome="accepted"}[1h]))
/ sum by (layer) (rate(feature_validation_total[1h]))
- record: slo:validity_error_ratio
expr: 1 - slo:validity_sli
# Repairs are not failures, but a rising repair share is an early warning
# that a publisher's export has changed. Worth a ticket well before the
# quarantine share moves.
- record: slo:validity_repair_ratio
expr: |
sum by (layer) (rate(feature_validation_total{outcome="repaired"}[1h]))
/ sum by (layer) (rate(feature_validation_total[1h]))
- alert: ValidityBudgetBurningFast
expr: slo:validity_error_ratio > 14.4 * (1 - geometry_validity_target)
for: 20m
labels: { severity: page, team: data-stewardship }
annotations:
summary: "{{ $labels.layer }} validity budget burning at 14x"
- alert: RepairShareClimbing
expr: slo:validity_repair_ratio > on (layer) geometry_validity_repair_ceiling
for: 2h
labels: { severity: ticket, team: data-stewardship }
annotations:
summary: >
{{ $labels.layer }} is repairing {{ $value | humanizePercentage }} of features —
check whether the publisher's export changed
The reason repairs make such a good early-warning signal is that they sit on the boundary between “fine” and “broken”, and a source drifting across that boundary crosses the repair threshold long before it crosses the quarantine one. A publisher who changes an export setting typically produces geometry that is still repairable — an unclosed ring, a repeated vertex — for many deliveries before the change produces something no repair can save. Watching only the outcome that hurts means watching the last stage of a process that has been visible from its beginning.
Parameter & Option Reference
| Parameter | Example | Spatial notes |
|---|---|---|
target |
0.999 (parcels) | Share accepted without repair. Authoritative registers deserve a tighter number than digitised surveys. |
repair_ceiling |
0.0009 | The early-warning threshold. Repairs are not failures and a rising share is still a signal. |
outcome label |
3 values | accepted, repaired, quarantined. Bounded, and the three are genuinely different states. |
source_group |
bounded | A dozen values. It is what turns “validity fell” into “this publisher changed something”. |
| window | 30 days | The same as the freshness objective, so the two budgets are comparable. |
| fast burn | 14.4× | Pages. A sudden collapse in validity usually means a wholesale export change. |
| repair alert | for: 2h |
A ticket. This is a trend, and a two-hour hold keeps a single odd delivery quiet. |
Verification & Testing
The tests worth having assert that the three outcomes are exhaustive and that the alert fires on the right one.
def test_outcomes_are_exhaustive(sample_delivery) -> None:
"""Every feature must land in exactly one bucket, or the SLI is wrong."""
counts = validate_delivery(sample_delivery)
assert counts["accepted"] + counts["repaired"] + counts["quarantined"] == \
sample_delivery.feature_count
def test_sli_excludes_repairs_from_the_numerator() -> None:
"""A repaired feature loaded successfully and still counts against validity."""
sli = compute_sli(accepted=980, repaired=15, quarantined=5)
assert sli == pytest.approx(0.98) # not 0.995
def test_repair_alert_fires_before_the_quarantine_alert(promtool) -> None:
series = ramp_repair_share(start=0.0005, end=0.004, days=21)
fired = promtool.evaluate(series)
repair_at = fired["RepairShareClimbing"]
validity_at = fired.get("ValidityBudgetBurningFast")
assert repair_at is not None
assert validity_at is None or repair_at < validity_at
Once the counters exist, the query that turns a breach into a conversation groups by source and by defect class, which is what a publisher can actually act on:
SELECT source_group, defect_class,
count(*) FILTER (WHERE outcome = 'repaired') AS repaired,
count(*) FILTER (WHERE outcome = 'quarantined') AS quarantined,
min(checked_at) AS first_seen
FROM validation_log
WHERE checked_at > now() - interval '14 days' AND outcome <> 'accepted'
GROUP BY source_group, defect_class
ORDER BY repaired + quarantined DESC
LIMIT 15;
Grouping by defect class as well as by source is what makes the resulting message specific enough to act on. “Your export is producing invalid geometry” invites a shrug; “since the eleventh, 4% of your polygons have an unclosed outer ring, which our repair step closes — here are three examples” identifies a setting somebody can change. The columns needed for that message are already in the validation log, which is another reason to record the defect class at the boundary rather than only the outcome.
Common Pitfalls
- Counting repairs as successes. They are successes for the load and failures for the source. Excluding them from the SLI’s numerator is what makes the objective an early-warning system rather than a lagging one.
- A target copied between layers. A cadastral register and a set of hand-digitised habitat polygons have genuinely different validity expectations. One number across both either excuses the register or condemns the survey.
- No
source_grouplabel. Without it a breach says “validity fell” and nothing more. With it the alert names the publisher, which is who has to change something. - Alerting only on quarantines. By the time features are being dropped, the export has been wrong for a fortnight. The repair share moves first and costs nothing extra to watch.
- Outcomes that are not exhaustive. A feature that fails in a way the boundary does not classify vanishes from the denominator, and the SLI silently improves. Assert that the three counts sum to the delivery’s feature count.
- Treating the objective as the pipeline’s problem. The pipeline is reporting what arrived. The remedy is nearly always upstream, which is why the alert routes to whoever owns the relationship with the publisher.
Frequently Asked Questions
Should repaired features count against the objective?
Yes. The objective is about what the source delivers, and a repaired feature is one the source got wrong. Counting repairs as successes produces an objective that only moves when things are already bad, which forfeits the main advantage of measuring at the boundary at all. Where a repair is genuinely routine and expected — a format that always needs ring closure — raise the target rather than reclassifying the outcome.
How do I set the initial target?
Measure for a month per layer and set the target just below what is currently achieved, exactly as with freshness. Validity rates are usually very stable per source, so the measurement converges quickly, and a target set this way will move when a publisher changes something — which is the only time you want it to move.
What about validity failures we cause ourselves?
They exist — a reprojection near a projection’s edge can produce an invalid polygon from a valid input — and they should be separated by labelling the outcome with the stage that produced it. Mixing them with source defects makes the objective unactionable, because the two have different owners and different fixes.
Does this need a separate log table?
Not for the SLI, which is computed from the counters. The table earns its place at triage time, when the question becomes “which source, which defect, since when” — and those are exactly the columns the dead-letter queue already stores for quarantined features. Extending it to record repairs as well is usually cheaper than a second table.
How does this relate to the coverage objective?
Quarantined features are missing output, so a validity breach is also a coverage breach, and the two budgets burn together. That overlap is acceptable and worth being aware of: an incident that quarantines a percentage of a delivery consumes both allowances, which is a fair reflection of the fact that it hurt consumers twice.
Related
- Data-quality SLOs for spatial pipelines — the framework this instantiates
- Defining freshness SLOs for tile layers — the freshness objective alongside it
- Repairing invalid geometries before load — where the repair outcome originates
- Spatial validation & sync tasks — the boundary being measured