Alerting on Cross-Shard Replication Lag with Prometheus
This walkthrough builds end-to-end lag alerting for a sharded PostgreSQL fleet — custom postgres_exporter queries, shard-labelled metrics, recording rules, two-tier alerts, and per-shard routing — implementing the design from replication lag & capacity alerting within the Partition Monitoring & Failover Automation section.
Prerequisites
Step 1 — Write the custom lag query file
postgres_exporter’s built-in metrics do not include LSN-diff byte lag or an idle-safe replay-time lag, so define both in a custom queries file. The primary-side query reads pg_stat_replication (one row per attached replica); the replica-side query computes its own staleness with the LSN-equality guard that prevents idle shards from reporting phantom lag. Deploy the same file to every host — each query no-ops harmlessly on the wrong role.
# /etc/postgres_exporter/lag_queries.yml
pg_replication_primary:
query: |
SELECT application_name AS replica,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::float8 AS lag_bytes,
COALESCE(EXTRACT(EPOCH FROM replay_lag), 0)::float8 AS replay_lag_seconds,
COALESCE(EXTRACT(EPOCH FROM flush_lag), 0)::float8 AS flush_lag_seconds
FROM pg_stat_replication
metrics:
- replica: { usage: "LABEL", description: "Replica application_name" }
- lag_bytes: { usage: "GAUGE", description: "WAL bytes not yet replayed" }
- replay_lag_seconds: { usage: "GAUGE", description: "Server-computed replay lag" }
- flush_lag_seconds: { usage: "GAUGE", description: "Server-computed flush lag" }
pg_replication_replica:
query: |
SELECT CASE
WHEN NOT pg_is_in_recovery() THEN 0
WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0
ELSE GREATEST(0, EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp()))
END::float8 AS staleness_seconds
metrics:
- staleness_seconds: { usage: "GAUGE", description: "Idle-safe replica read staleness" }
Operational note: The CASE guard is the load-bearing part. Without the LSN-equality check, now() - pg_last_xact_replay_timestamp() climbs one second per second on any shard whose primary is momentarily idle, and the resulting false pages will train the on-call to ignore the alert that matters.
SRE tip: Keep this file in version control and deploy it via configuration management, not by hand. A shard whose exporter runs an older query file emits subtly different series — the fleet-wide max by (shard) in Step 4 then silently excludes it, which is invisible until the day that shard is the one lagging.
Step 2 — Run postgres_exporter on each shard
Run one exporter alongside each PostgreSQL instance (primary and replicas) as a systemd service. The shard identity is attached in Prometheus (Step 3), so the exporter itself stays identical everywhere — one unit file template for the entire fleet.
sudo tee /etc/systemd/system/postgres_exporter.service > /dev/null <<'EOF'
[Unit]
Description=Prometheus postgres_exporter
After=network-online.target postgresql.service
[Service]
User=postgres_exporter
EnvironmentFile=/etc/postgres_exporter/env
ExecStart=/usr/local/bin/postgres_exporter \
--extend.query-path=/etc/postgres_exporter/lag_queries.yml \
--web.listen-address=:9187
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
echo 'DATA_SOURCE_NAME=postgresql://monitor:CHANGE_ME@localhost:5432/postgres?sslmode=disable' \
| sudo tee /etc/postgres_exporter/env > /dev/null
sudo chmod 600 /etc/postgres_exporter/env
sudo systemctl daemon-reload
sudo systemctl enable --now postgres_exporter
Operational note: The exporter connects over localhost, so a network partition between shards and the monitoring host takes out the scrape, not the measurement — Prometheus sees the target go down, which Step 5 turns into its own alert instead of a silent gap.
SRE tip: Grant the monitoring role pg_monitor and nothing more. It provides read access to pg_stat_replication and the recovery functions without superuser, and a leaked exporter DSN then exposes statistics rather than data.
Step 3 — Label every target with its shard in the scrape config
Attach shard and role labels at scrape time so every series from a host carries its fleet coordinates. Static configuration keeps the example self-contained; with service discovery you would derive the same labels via relabel_configs from instance tags instead.
# prometheus.yml (fragment)
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/rules/replication_lag.yml
scrape_configs:
- job_name: postgres_shards
static_configs:
- targets: ["10.0.1.10:9187"]
labels: { shard: "s01", role: "primary" }
- targets: ["10.0.1.11:9187"]
labels: { shard: "s01", role: "replica" }
- targets: ["10.0.2.10:9187"]
labels: { shard: "s02", role: "primary" }
- targets: ["10.0.2.11:9187"]
labels: { shard: "s02", role: "replica" }
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
Operational note: Never encode the shard in the metric name (pg_lag_s01_seconds). Labels let one alert expression, one dashboard query, and one routing tree cover the whole fleet; per-shard metric names require editing every rule each time you reshard.
SRE tip: Keep the shard label values zero-padded and stable (s01, not 1). Alertmanager regex routes and dashboard sorting both depend on lexical order, and renaming label values later breaks series continuity in long-range dashboards.
Step 4 — Add recording rules for max lag per shard
Failover safety is bounded by the worst replica in each shard, so precompute shard-level maxima. Alerts, dashboards, and any failover gate then read one authoritative series instead of re-deriving it with slightly different expressions.
# /etc/prometheus/rules/replication_lag.yml (part 1)
groups:
- name: replication-lag-recording
interval: 15s
rules:
- record: shard:replication_lag_seconds:max
expr: >
max by (shard) (
pg_replication_primary_replay_lag_seconds
or
pg_replication_replica_staleness_seconds
)
- record: shard:replication_lag_bytes:max
expr: max by (shard) (pg_replication_primary_lag_bytes)
- record: shard:replication_replicas_connected:count
expr: count by (shard) (pg_replication_primary_replay_lag_seconds)
Operational note: The or merges the primary-side and replica-side views, so the recorded series survives either vantage point disappearing — if the primary dies, the replica-side staleness gauge keeps the shard’s series alive exactly when you need it most.
SRE tip: Point everything downstream — Grafana panels, the failover orchestrator’s gate, capacity reports — at these recorded series. When alerting and promotion logic read different lag expressions, they eventually disagree during an incident, and that disagreement is how stale replicas get promoted.
Step 5 — Write the two-tier alert rules
Two severities with different thresholds and for: windows: a 30-second warning that tolerates brief bursts, and a five-minute critical that means the failover data-loss window is open. A third rule fires when a shard’s lag series disappears entirely — the failure mode that otherwise looks like health.
# /etc/prometheus/rules/replication_lag.yml (part 2)
- name: replication-lag-alerts
rules:
- alert: ShardReplicationLagWarning
expr: shard:replication_lag_seconds:max > 30
for: 5m
labels: { severity: warning }
annotations:
summary: "Shard {{ $labels.shard }} replication lag above 30s"
description: "Worst replica on {{ $labels.shard }} is {{ $value | humanize }}s behind (5m sustained). Cross-shard reads touching this shard are stale."
- alert: ShardReplicationLagCritical
expr: shard:replication_lag_seconds:max > 300
for: 2m
labels: { severity: critical }
annotations:
summary: "Shard {{ $labels.shard }} lag exceeds failover RPO gate"
description: "Worst replica on {{ $labels.shard }} is {{ $value | humanize }}s behind. Promotion of this replica would lose that write window; failover automation should be gated."
- alert: ShardLagMetricsAbsent
expr: >
count by (shard) (shard:replication_lag_seconds:max) unless
count by (shard) (shard:replication_lag_seconds:max offset 10m)
or
(shard:replication_lag_seconds:max offset 10m unless shard:replication_lag_seconds:max)
for: 5m
labels: { severity: critical }
annotations:
summary: "Shard {{ $labels.shard }} stopped reporting lag metrics"
description: "Lag series for {{ $labels.shard }} vanished — exporter down, replica detached, or scrape blocked. Treat as a lag incident, not missing data."
Operational note: The for: windows do the burst-filtering: a bulk load that pushes lag to 45 seconds for three minutes never fires the warning, while a replay stall that holds 30+ seconds for five minutes does. Tighten for: before tightening thresholds when you need faster detection.
SRE tip: Keep the critical threshold at or below the promotion gate configured in your failover orchestration tooling (for Patroni, maximum_lag_on_failover). The page should always arrive before the orchestrator starts refusing to fail over — never the other way around.
Step 6 — Route and group alerts by shard in Alertmanager
Group notifications by shard so one degraded shard produces one grouped notification, and route shard ranges to their owning teams with a warning-severity branch that files tickets instead of paging.
# alertmanager.yml
route:
receiver: dba-fallback
group_by: ["alertname", "shard"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers: ['severity = "warning"']
receiver: dba-ticket-queue
- matchers: ['shard =~ "s(0[1-9]|1[0-2])"'] # s01-s12: payments team
receiver: payments-oncall
- matchers: ['shard =~ "s(1[3-9]|2[0-4])"'] # s13-s24: marketplace team
receiver: marketplace-oncall
receivers:
- name: dba-fallback
webhook_configs: [{ url: "http://oncall-bridge:8080/dba" }]
- name: dba-ticket-queue
webhook_configs: [{ url: "http://ticketer:8080/create" }]
- name: payments-oncall
webhook_configs: [{ url: "http://oncall-bridge:8080/payments" }]
- name: marketplace-oncall
webhook_configs: [{ url: "http://oncall-bridge:8080/marketplace" }]
Operational note: Route order matters — the warning matcher sits first so warnings from any shard become tickets, and only criticals fall through to the team pager routes. Reversing the order pages teams for every 31-second blip.
SRE tip: For planned bulk loads, create a silence scoped to shard="s07" (plus the alertname) rather than silencing the whole rule. A fleet-wide silence during one shard’s maintenance window is how a second shard’s genuine incident goes unseen.
Verification
Confirm the exporter emits the custom metrics on any shard host:
curl -s http://10.0.1.11:9187/metrics | grep -E '^pg_replication_(primary|replica)'
Expected output on a replica (primary hosts show the pg_replication_primary_* family instead):
pg_replication_replica_staleness_seconds 0.42
Validate the rule files before reloading Prometheus:
promtool check rules /etc/prometheus/rules/replication_lag.yml
promtool check config /etc/prometheus/prometheus.yml
Expected output:
Checking /etc/prometheus/rules/replication_lag.yml
SUCCESS: 6 rules found
Finally, prove the pipeline end to end: pause replay on one replica with SELECT pg_wal_replay_pause();, watch shard:replication_lag_seconds:max{shard="s01"} climb in the Prometheus expression browser, confirm ShardReplicationLagWarning fires after its five-minute for: window and lands with the right team in Alertmanager, then resume with SELECT pg_wal_replay_resume(); and verify the alert resolves.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Idle shard pages with steadily climbing lag | Replay-timestamp arithmetic without the LSN-equality guard; the last commit ages on a write-idle primary while the replica is fully caught up | Use the Step 1 CASE guard; on shards that idle for hours, add a 1 Hz heartbeat write on the primary so replay timestamps stay fresh |
| Lag alert never fires although replication is broken | Replica detached, so the primary-side pg_stat_replication row — and the metric — vanished; dashboards show “no data,” alerts see nothing above threshold |
Keep the ShardLagMetricsAbsent rule and the shard:replication_replicas_connected:count series; alert when connected replica count drops below the expected number per shard |
| One shard silently missing from every fleet aggregate | Scrape target for that shard lost its shard label after a config edit, so max by (shard) excludes it |
Alert on count(count by (shard) (shard:replication_lag_seconds:max)) < 24 (expected shard count); lint scrape configs in CI with promtool check config |
FAQ
Why does my lag metric climb forever on an idle shard?
Because now() - pg_last_xact_replay_timestamp() measures the age of the last replayed commit, and on a shard with no new writes that commit ages while the replica is perfectly caught up. The custom query in Step 1 guards against this by comparing pg_last_wal_receive_lsn() with pg_last_wal_replay_lsn() first and reporting zero when they are equal. If a shard can be idle for long stretches, add a once-per-second heartbeat write on the primary so there is always a fresh transaction to replay.
Can one postgres_exporter instance scrape all shards?
Yes — recent postgres_exporter versions support multi-target scraping where Prometheus passes the shard DSN as a target parameter, which reduces deployment footprint. The sidecar-per-shard pattern shown here is still preferable for lag monitoring: the exporter fails independently per shard, a network partition between a central exporter host and one shard cannot blank out that shard’s metrics unnoticed, and replica-side staleness queries run local to each replica. If you do centralize, keep one absent-metrics alert per shard so a missing target is treated as an incident.
Do recording rules delay alert firing?
Marginally. Recording rules are evaluated on the rule evaluation interval (15 seconds in this walkthrough), so an alert built on a recorded series sees data at most one evaluation interval older than the raw scrape. Against for: windows of two to five minutes, that latency is negligible — and the payoff is that the alert expression, the dashboard, and the failover gate all read the same shard-level series instead of three subtly different queries.
Related
- Replication Lag & Capacity Alerting — parent guide covering lag measurement semantics, capacity signals, and multi-window alert design across engines
- Automating PostgreSQL Shard Failover with Patroni — wiring the lag gate these alerts monitor into the promotion decision itself
- Partition Skew Detection & Monitoring — finding the shard imbalance that shows up here first as one shard’s chronic lag