Alert Rules That Survive a Bursty Spatial Stream
Alert on how fast the error budget is being consumed, over a short and a long window that must both be burning — and add an absent() rule per shard, because a consumer that dies stops emitting entirely and improves the fleet aggregate on its way out.
This guide sits under SLOs & Alerting for Spatial Webhook Pipelines, within Monitoring & Observability for Spatial Pipelines. It turns the objective from Defining a Freshness SLO for a Spatial Pipeline into pages worth answering.
When to use this pattern
- Alerts on this pipeline are muted, routed to a channel nobody reads, or answered with “it cleared on its own”.
- Traffic has predictable bursts — shift changes, weather events, scheduled imports — that are not incidents.
- An objective exists, because burn rate is defined against a budget and there is no budget without one.
One signal, two very different events
Complete runnable implementation
# 99% freshness over 30 days → a 1% error budget.
# Burn rate = observed error ratio ÷ budget. A rate of 1 exhausts the budget
# exactly at the end of the window; 14.4 exhausts it in about 50 hours.
groups:
- name: spatial-freshness-alerts
interval: 30s
rules:
# ---- fast burn: page. Short window makes it quick, long makes it stick.
- alert: SpatialFreshnessBudgetBurningFast
expr: |
(1 - fleet:freshness_ratio:rate5m) > (14.4 * 0.01)
and
(1 - fleet:freshness_ratio:rate1h) > (14.4 * 0.01)
for: 2m
labels:
severity: page
annotations:
summary: "Freshness budget burning 14x — worst shard is behind"
runbook: "Check per-shard lag before touching the consumer fleet"
# ---- slow burn: ticket. Catches the regression nobody notices.
- alert: SpatialFreshnessBudgetBurningSlow
expr: |
(1 - fleet:freshness_ratio:rate6h) > (3 * 0.01)
and
(1 - fleet:freshness_ratio:rate1d) > (3 * 0.01)
for: 15m
labels:
severity: ticket
# ---- a shard that stops emitting IMPROVES the aggregate. No burn-rate
# rule can see that, because nothing is burning — the events simply
# stopped being counted.
- alert: SpatialShardSilent
expr: |
absent_over_time(
spatial_event_freshness_seconds_count{shard=~"metro-.*"}[10m]
)
for: 5m
labels:
severity: page
annotations:
summary: "Shard {{ $labels.shard }} has emitted nothing for 10 minutes"
# ---- one shard failing while the fleet looks fine. min() catches this
# in the aggregate, but naming the shard is what makes the page actionable.
- alert: SpatialShardBehind
expr: |
shard:freshness_ratio:rate30m < 0.90
and
shard:freshness_ratio:rate6h < 0.95
for: 10m
labels:
severity: ticket
annotations:
summary: "Shard {{ $labels.shard }} below objective for 6 hours"
The burn-rate multipliers are not arbitrary. A rate of 14.4 against a 30-day window exhausts the budget in about 50 hours, which is short enough to warrant waking someone; a rate of 3 exhausts it in about 10 days, which warrants a ticket. Choosing them by how long the budget would last is what makes the severity defensible.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
| Fast burn multiplier | float |
Budget exhausted in ~50 h; page-worthy | 14.4 |
| Slow burn multiplier | float |
Budget exhausted in ~10 days; ticket-worthy | 3 |
| Short window | duration | Fast enough to catch a regression in minutes | 5m / 6h |
| Long window | duration | Long enough that a burst does not move it | 1h / 1d |
for |
duration | Additional damping; small, since the windows already damp | 2m / 15m |
Shard list in absent |
regex | Must enumerate known shards explicitly | metro-.* |
Gotchas and spatial edge cases
-
absent_over_timeneeds the shards named. A rule matching whatever series exist cannot notice a series that stopped existing, so the shard set has to come from configuration and be updated when a region is added. A newly onboarded region with no absence rule is a region that can go dark silently. -
Quiet shards produce
NaNand poisonmin(). A shard with no traffic in the evaluation window divides by a near-zero rate, andNaNpropagates differently across Prometheus versions. Gate the per-shard recording rule on a minimum event rate, which also stops overnight quiet periods from firing the shard-behind alert. -
Every alert resolving at once is a signal. A pipeline whose entire alert set clears simultaneously has usually lost its metrics rather than fixed its problem. A meta-alert on the count of series reporting is cheap and catches an exporter outage that otherwise reads as a clean bill of health.
-
Burn-rate thresholds must be recomputed when the objective changes. The multipliers are relative to the budget, so tightening from 99% to 99.5% halves the budget and doubles the effective burn for the same error rate. Shipping a new target without new alert thresholds either floods or silences the pager.
-
Test the rules against recorded bursts, not synthetic ones.
promtool test rulesaccepts input series, and a real shift-change burst has a shape that a hand-written ramp does not — in particular it usually comes with a simultaneous traffic increase, which changes the denominator as well as the numerator. -
A per-shard page needs the shard in the annotation. “Freshness budget burning” sends an operator to a dashboard; “shard metro-3 below objective for 6 hours” sends them to a consumer. The label is available and omitting it costs the first ten minutes of every incident.
Verification
import subprocess
import textwrap
def promtool(cases: str) -> None:
open("/tmp/tests.yml", "w").write(cases)
subprocess.run(["promtool", "test", "rules", "/tmp/tests.yml"], check=True)
def test_ninety_second_burst_does_not_page():
"""The case that mutes a threshold alert."""
promtool(textwrap.dedent("""
rule_files: [/etc/prometheus/spatial-freshness.yml]
tests:
- interval: 30s
input_series:
- series: 'fleet:freshness_ratio:rate5m'
values: '1+0x8 0.2+0x3 1+0x40'
- series: 'fleet:freshness_ratio:rate1h'
values: '1+0x8 0.98+0x3 1+0x40'
alert_rule_test:
- eval_time: 6m
alertname: SpatialFreshnessBudgetBurningFast
exp_alerts: []
"""))
def test_sustained_regression_pages_within_five_minutes():
promtool(textwrap.dedent("""
rule_files: [/etc/prometheus/spatial-freshness.yml]
tests:
- interval: 30s
input_series:
- series: 'fleet:freshness_ratio:rate5m'
values: '1+0x4 0.5+0x60'
- series: 'fleet:freshness_ratio:rate1h'
values: '1+0x4 0.5+0x60'
alert_rule_test:
- eval_time: 5m
alertname: SpatialFreshnessBudgetBurningFast
exp_alerts:
- exp_labels: {severity: page}
"""))
def test_silent_shard_pages_even_though_the_ratio_improved():
"""The failure no burn-rate rule can see."""
promtool(textwrap.dedent("""
rule_files: [/etc/prometheus/spatial-freshness.yml]
tests:
- interval: 30s
input_series:
- series: 'spatial_event_freshness_seconds_count{shard="metro-3"}'
values: '0+10x10 _x40'
alert_rule_test:
- eval_time: 16m
alertname: SpatialShardSilent
exp_alerts:
- exp_labels: {severity: page, shard: metro-3}
"""))
The first test is the one that justifies the whole design, and it is worth running in CI rather than once at review time — a later edit that drops the long-window clause from the expression passes every other test and reintroduces the threshold alert exactly.
Related
- SLOs & Alerting for Spatial Webhook Pipelines — the topic this guide belongs to
- Defining a Freshness SLO for a Spatial Pipeline — the objective these rules are written against
- An Error-Budget Policy for Tile Pipelines — what happens after the slow-burn ticket is filed
- Detecting Partition Skew in H3-Sharded Streams — where the shard-behind alert sends the operator