feat(relayer): make the settlement path observable, and alert on it - #48
Conversation
The 20 August incident — two days of deliveries succeeding while relay fees silently never settled — is still unalertable, and not for want of counters. The reason is structural: `acknowledge_tx` returns early when there is nothing to settle and increments *nothing*, and an unincremented Prometheus family emits zero bytes. So "settlement is idle", "settlement is not configured" and "the ack worker is dead" are all byte-identical from the outside. No expression over relayer_ack_submissions / relayer_claim_submissions can separate them. Worse, the outage shape does not show up in the outcome counters at all: a failed proof fetch *defers* the tx rather than resolving it, so during those two days the tx sat in the queue while the counters barely moved. Two additions close both holes. `relayer_settlement_queue_depth` (gauge, per chain_key) — delivered txs discovered but not yet settled, published on every ack tick whether or not there is work. Being a gauge it encodes at zero, so `absent()` genuinely means "the worker has never ticked" rather than "nothing happened yet". And because a deferred tx stays counted, a queue that never returns to empty is precisely the 20 August signature. `SettlementOutcome::NothingToSettle` — the no-op early return is now counted instead of silent, so an idle route is distinguishable from a stopped one in the counters too. Claim is only incremented in AckAndClaim mode, since an Ack-only route has no claim to make. `PendingTxs` gains `len`/`is_empty` to feed the gauge. Also adds deploy/alerts/usc-devnet-relayer.yaml with three rules, following the existing "usc-devnet - attestor peer lag" rule for folder, labels and receiver: 1. Relayer message stuck in pool — `min_over_time(relayer_pool_messages_pending [1h]) > 0`. Works with today's metrics. This is a live true positive: that gauge has been pinned at exactly 1 since 24 Aug on one under-funded message (estimate=208408 funded=100000), retried 469 times, needing a topUpGasLimit, with no alert. It was 0 for the 4.5 days of live traffic before that. min_over_time rather than a bare `> 0` so normal in-flight traffic does not flap it. 2. Relayer settlement attempts failing — `Failed` outcomes over an hour. Sound today because `Failed` cannot increment on an idle route. Threshold 2, so a single transient RPC blip self-heals without paging. 3. Relayer settlement queue not draining — the rule that actually catches 20 August, plus an `absent()` arm for the worker never starting. Shipped PAUSED because it needs the gauge above deployed; unpausing is one line. Every expression ends in `or vector(0)` so a rule always has a value and cannot silently degrade to NoData — the same masking that left "usc-devnet - Attestor Peer Lag - ck 7" green for six weeks. I could not create these rules via the API (403 on /v1/provisioning/alert-rules with this token), so they are checked in ready to apply rather than applied.
PR SummaryLow Risk Overview Each ack tick now publishes Reviewed by Cursor Bugbot for commit 4d9f9b3. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 75fe0a6. Configure here.
…fire
Bugbot caught that the queue-not-draining rule's absent() arm never fires. The
mechanism is worse than "returns empty":
`absent()` emits a series carrying the selector's equality matchers
(`{cluster="usc-devnet-cluster"}`), while `vector(0)` is unlabelled. `or` keeps
right-hand series whose labels match nothing on the left, so when the gauge is
missing `absent(...) or vector(0)` yields TWO series: `{cluster="..."} 1` and
`{} 0`. The `+` then matches on the full label set, pairs `{}` with `{}`,
discards the labelled absent signal, and returns a hard 0.
So the rule would not have gone to NoData — it would have evaluated as HEALTHY
while the settlement worker was entirely absent. A false negative that looks
green, which is the failure mode this whole file exists to avoid.
Verified against the live stack, where the gauge does not exist yet:
unwrapped -> {} 0 (never fires)
max() -> {} 1 (fires)
and with a metric that does exist (relayer_pool_messages_pending, currently
stuck at 1), the fixed shape returns 1 from the left arm with the absent arm
contributing 0 — so the present-and-stuck case is unaffected.
`max()` with no `by` drops all labels, so both sides are unlabelled and the arms
add. Comment explains why it is load-bearing so it does not get "simplified"
away later.
Rules 1 and 2 were checked for the same class of bug and are clean: rule 1 has
no binary op, and rule 2 uses sum() on both sides, which also drops labels.
Per review: `aks-grafana-alloy-iac` is the home for deployed alert rules, and two sources of truth for what is live is worse than one that arrives late. These were only here because `/v1/provisioning/alert-rules` 403s with my token, so I could not apply them and did not want three verified rules to evaporate. The metric side of this PR is unchanged and is where the reasoning now lives: the rustdoc on `set_settlement_queue_depth` states the alert shape and why a gauge rather than a counter is what makes `absent()` meaningful, so a reader of the metric finds the intent without needing the YAML. Two ordering constraints travel with the rules to wherever they land: - the queue-not-draining rule must stay paused until a relayer carrying `relayer_settlement_queue_depth` is deployed to usc-devnet; - the stuck-pool rule needs #49 merged first, or its first act is to page about the message stuck since 24 Aug that cannot be rescued.

Why the 20 August incident is still unalertable
Two days of deliveries succeeding while relay fees silently never settled. Adding the
relayer_ack_submissions/relayer_claim_submissionscounters in #42 was necessary but not sufficient, for a structural reason:acknowledge_txreturns early when there is nothing to settle and increments nothing:An unincremented Prometheus family emits zero bytes. So these three states are byte-identical from outside the process:
No expression over those counters can separate them. And the outage shape does not appear in them anyway: a failed proof fetch defers the tx rather than resolving it, so during those two days the tx sat in the queue while the counters barely moved.
What this measures on usc-devnet right now
relayer_ack_submissions_total, all timerelayer_claim_submissions_total, all time226 deliveries, zero settlement attempts recorded. I cannot tell from metrics whether that is correct (soak traffic requires no ack and has no claimable fee) or a repeat of 20 August. That ambiguity is the bug this PR fixes.
Two additions
relayer_settlement_queue_depth(gauge, perchain_key) — delivered txs discovered but not yet settled, published on every ack tick whether or not there is work. Two properties matter:absent()therefore genuinely means "the worker has never ticked", not "nothing happened yet".SettlementOutcome::NothingToSettle— the no-op early return is counted instead of silent, so an idle route is distinguishable from a stopped one in the counters too. Claim is only incremented inAckAndClaimmode, since anAck-only route has no claim to make.PendingTxsgainslen/is_emptyto feed the gauge.Alert rules: not in this repo
Three rules were written and verified live against usc-devnet, then removed from this PR per review —
aks-grafana-alloy-iacis the home for deployed alerts, and two sources of truth for what is live is worse than one that arrives late. They are parked outside git until someone with access to that repo picks them up. I could not apply them directly:/v1/provisioning/alert-rulesreturns 403 with my token.What stays here is the metric side, and the reasoning with it: the rustdoc on
set_settlement_queue_depthstates the alert shape and why a gauge rather than a counter is what makesabsent()meaningful.Two ordering constraints travel with the rules wherever they land:
relayer_settlement_queue_depthis deployed to usc-devnet;While writing them I did hit one thing worth recording, because it also applies to any future rule in this shape:
absent()emits a series carrying the selector's equality matchers whilevector(0)is unlabelled, soabsent(...) or vector(0)returns two series and a following+silently discards the absent signal and evaluates to a hard0— reading healthy while the thing is entirely gone. Any aggregation withoutby(max(),sum()) drops labels and fixes it. Verified both ways against the live stack.Tests
prom::tests::an_idle_settlement_path_is_still_visibleasserts the two properties the alerting depends on: that a zero queue depth still encodes, and that the no-op outcome carries its own label value.metrics_encode_round_tripsextended. Workspace green, clippy clean, fmt applied.