Alerting on a Stalled OSM Update Pipeline Jump to heading
Write alert rules that distinguish a dead loop from a stalled upstream, page someone only when they can act, and survive a normal month without being muted.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
An alert is a decision to interrupt a person, so every rule needs three things settled before it is written: what it fires on, what it means, and who responds. A rule missing the third is a rule that will be acknowledged and ignored.
The four rules above cover a replication pipeline. Two of them page, and the split is not about severity in the abstract — it is about whether the person woken up can do anything. An upstream stall is genuinely bad and there is no action available beyond waiting, so it becomes a ticket rather than a page.
That distinction only works if the rules are wired to suppress each other. An upstream stall drives both DataStale and UpstreamStalled at once, because the data really is getting old. Inhibition is what makes the alert that fires the one that explains the situation.
Runnable solution Jump to heading
# replication-rules.yml — Prometheus alerting rules
groups:
- name: osm-replication
interval: 30s
rules:
# --- paging ---------------------------------------------------------
- alert: OsmReplicationLoopDead
expr: |
time() - osm_replication_last_success_timestamp_seconds > 300
for: 2m
labels:
severity: page
team: geodata
annotations:
summary: "Diff-sync loop {{ $labels.region }}/{{ $labels.stream }} has stopped"
description: >-
No successful iteration for {{ $value | humanizeDuration }}.
The process is wedged, crashed, or blocked on its lock.
runbook: "https://runbooks.internal/osm/loop-dead"
- alert: OsmDataStale
expr: |
osm_replication_timestamp_lag_seconds > 1800
for: 5m
labels:
severity: page
team: geodata
annotations:
summary: "OSM data for {{ $labels.region }} is {{ $value | humanizeDuration }} old"
description: >-
Consumers are reading data older than the 30 minute contract.
runbook: "https://runbooks.internal/osm/data-stale"
# --- ticket only ----------------------------------------------------
- alert: OsmUpstreamStalled
expr: |
osm_replication_upstream_age_seconds > 1200
for: 5m
labels:
severity: ticket
team: geodata
annotations:
summary: "Upstream {{ $labels.stream }} stream has not published for {{ $value | humanizeDuration }}"
description: >-
The replication server is behind, not this pipeline. Nothing to do
locally; the loop will catch up on its own when publishing resumes.
- alert: OsmDiffApplyFailing
expr: |
rate(osm_replication_diff_failures_total[15m]) > 0
for: 15m
labels:
severity: ticket
team: geodata
annotations:
summary: "Diffs failing to apply for {{ $labels.region }} ({{ $labels.reason }})"
description: >-
Failures are being retried but not clearing. Check the reason label
before the retry budget runs out and the loop falls behind.
# --- a slow leak, worth seeing before it becomes DataStale ----------
- alert: OsmReplicationFallingBehind
expr: |
deriv(osm_replication_timestamp_lag_seconds[30m]) > 0.5
and osm_replication_timestamp_lag_seconds > 300
for: 30m
labels:
severity: ticket
team: geodata
annotations:
summary: "Lag for {{ $labels.region }} is growing steadily"
description: >-
The loop is applying diffs more slowly than the stream publishes
them. It will breach the staleness contract if nothing changes.
# alertmanager.yml — the inhibition that keeps the page actionable
inhibit_rules:
# An upstream stall makes the data stale. Say so once, as the upstream alert.
- source_matchers: [ 'alertname = OsmUpstreamStalled' ]
target_matchers: [ 'alertname = OsmDataStale' ]
equal: [ 'stream', 'region' ]
# A dead loop explains everything else about that pipeline.
- source_matchers: [ 'alertname = OsmReplicationLoopDead' ]
target_matchers: [ 'alertname =~ "OsmDataStale|OsmReplicationFallingBehind" ' ]
equal: [ 'stream', 'region' ]
Step-by-step walkthrough Jump to heading
OsmReplicationLoopDead subtracts an absolute timestamp from time(). This is the pattern that makes a dead exporter visible: if the process stops, the gauge stops updating, time() keeps moving, and the expression keeps growing. A rule written against a “seconds since last success” gauge would freeze at a healthy value and never fire — the failure mode described in Exporting Diff-Sync Metrics to Prometheus.
OsmDataStale uses timestamp lag rather than sequence lag deliberately. Sequence lag reads zero during an upstream stall, so a staleness contract expressed in sequence numbers is silently unenforced exactly when the data is going stale.
OsmReplicationFallingBehind is the only rule using a derivative, and it is guarded by a level condition. A pure rate-of-change rule fires during recovery, when lag is falling fast after an outage — the and clause keeps it quiet unless the lag is both growing and already elevated.
The for clauses are doing more work than the thresholds. A signal sampled every thirty seconds will produce single-sample spikes from publishing jitter, a slow scrape, or a GC pause, and firing on one sample turns all of that into pages.
Verification Jump to heading
Test the expressions against recorded data rather than waiting for an incident. promtool evaluates rules against a synthetic series:
# rules_test.yml
rule_files: [ replication-rules.yml ]
evaluation_interval: 30s
tests:
- interval: 30s
input_series:
# Heartbeat advances for 5 minutes, then freezes — a crashed loop.
- series: 'osm_replication_last_success_timestamp_seconds{stream="minute",region="ireland"}'
values: '1000+30x10 1300x20'
alert_rule_test:
- eval_time: 12m
alertname: OsmReplicationLoopDead
exp_alerts:
- exp_labels: { severity: page, team: geodata, stream: minute, region: ireland }
promtool test rules rules_test.yml
promtool check rules replication-rules.yml
Then do the thing most teams skip: replay the last month of real lag data through the rules and count how often each would have fired. A rule that would have fired forty times in a month where nothing happened is a rule that will be muted in week two.
Finally, confirm the inhibition works, because a misconfigured equal list silently suppresses nothing:
amtool alert add alertname=OsmUpstreamStalled stream=minute region=ireland
amtool alert add alertname=OsmDataStale stream=minute region=ireland
amtool alert query # DataStale should show as suppressed
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Loop dies, no alert | Rule written against a “seconds since” gauge | Use time() - <absolute timestamp> |
| Page during every upstream outage | No inhibition rule | Inhibit DataStale under UpstreamStalled |
| Alerts fire on recovery | Derivative rule with no level guard | Add and <metric> > threshold |
| Dozens of alerts a month, none real | No for clause |
Add for: 5m; measure against history |
| One alert covers two regions | Labels not in equal: |
List every identifying label |
Alert has no runbook |
Rule written during an incident | A rule with no documented response is a ticket, not a page |
Frequently Asked Questions Jump to heading
What staleness threshold should DataStale use?
Whatever the consumer contract says, not a number derived from the metric distribution. If a routing service is rebuilt hourly, data thirty minutes old is fine and the threshold belongs somewhere above an hour. If a live map promises minute-freshness, the threshold is minutes. Setting it from what the pipeline usually achieves rather than from what anyone needs produces an alert about the pipeline being unusual rather than about anything being wrong.
Should target-down replace the LoopDead rule?
It complements it. up == 0 catches the process disappearing entirely; LoopDead catches the process still serving metrics while its loop is wedged — blocked on a lock, stuck on a socket read, or spinning without progress. The second is the more common failure and the one target-down cannot see.
How do I alert on a pipeline that runs hourly rather than every minute?
Scale every duration, but not linearly. The heartbeat threshold has to exceed the interval by enough to survive one skipped run — for an hourly job, something over two hours. The staleness threshold, though, is still set by the consumer contract, and for an hourly job that contract has to tolerate at least an hour by construction.
Is it worth alerting on the sequence lag at all?
As a ticket, yes. Sequence lag above a handful means the loop is not keeping up, which is a capacity signal worth acting on before it becomes staleness. It should not page, because by the time it matters DataStale will have fired anyway, and because it reads zero during the upstream stall case.
Keeping the rules honest over time Jump to heading
Alert rules decay. Thresholds set against last year’s behaviour drift as the pipeline changes, and a rule nobody has seen fire is a rule nobody knows is broken. Three habits keep the set trustworthy.
Review the fire history quarterly. For each rule, count how often it fired, how often the response was “nothing to do”, and how often something was genuinely wrong. A rule with a high nothing-to-do rate is training people to ignore the channel and should be retuned or demoted to a ticket; a rule that has never fired at all should be tested deliberately rather than assumed working.
Test the paging rules on purpose. Stopping the timer in a staging environment and confirming that LoopDead fires, routes and pages the right rotation takes ten minutes and is the only way to know the whole chain works. The failure mode this catches is not a wrong expression but a routing rule that sends the page nowhere.
Version the rules with the code that produces the metrics. A metric rename that lands without the corresponding rule change leaves an expression matching nothing, and an expression matching nothing never fires — silently, and indistinguishably from everything being fine.
Specification reference Jump to heading
A Prometheus alerting rule fires when its
exprhas evaluated to a non-empty vector continuously for the duration infor. Alertmanagerinhibit_rulessuppress a target alert while a matching source alert is firing; theequallist names the labels that must match between them for the suppression to apply.
Related Jump to heading
- Replication Monitoring & Lag Alerting — the topic these rules belong to.
- Exporting Diff-Sync Metrics to Prometheus — the series these expressions read.
- Measuring OSM Replication Lag in Seconds — how the lag number is computed.
- Recovering from a Replication Sequence Gap — the runbook these alerts should link to.
- Scheduling OSM Diff Sync with systemd Timers — the lock contention
ApplyFailingoften reflects.
Up one level: Replication Monitoring & Lag Alerting.