Skip to content

feat(relayer): make the settlement path observable, and alert on it - #48

Merged
DylanVerstraete merged 3 commits into
mainfrom
feat/settlement-observability
Aug 27, 2026
Merged

feat(relayer): make the settlement path observable, and alert on it#48
DylanVerstraete merged 3 commits into
mainfrom
feat/settlement-observability

Conversation

@DylanVerstraete

@DylanVerstraete DylanVerstraete commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

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_submissions counters in #42 was necessary but not sufficient, for a structural reason:

acknowledge_tx returns early when there is nothing to settle and increments nothing:

if !needs_ack && claim_ids.is_empty() {
    return Ok(AckOutcome::Terminal("nothing left to settle (...)".into()));
}

An unincremented Prometheus family emits zero bytes. So these three states are byte-identical from outside the process:

  • settlement is idle (nothing required an ack, no unsettled fee)
  • settlement is not configured for this route
  • the ack worker is dead

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

messages indexed, 24h 226
messages delivered, 24h 226
relayer_ack_submissions_total, all time 1
relayer_claim_submissions_total, all time 4

226 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, per chain_key) — delivered txs discovered but not yet settled, published on every ack tick whether or not there is work. Two properties matter:

  • It is a gauge, so it encodes at zero. absent() therefore genuinely means "the worker has never ticked", not "nothing happened yet".
  • A deferred tx stays counted, so a queue that never returns to empty is exactly the 20 August signature — the one signal that stays pinned for the whole duration of such an outage.

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 in AckAndClaim mode, since an Ack-only route has no claim to make.

PendingTxs gains len/is_empty to 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-iac is 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-rules returns 403 with my token.

What stays here is the metric side, and the reasoning with it: the rustdoc on set_settlement_queue_depth states the alert shape and why a gauge rather than a counter is what makes absent() meaningful.

Two ordering constraints travel with the rules wherever they land:

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 while vector(0) is unlabelled, so absent(...) or vector(0) returns two series and a following + silently discards the absent signal and evaluates to a hard 0 — reading healthy while the thing is entirely gone. Any aggregation without by (max(), sum()) drops labels and fixes it. Verified both ways against the live stack.

Tests

prom::tests::an_idle_settlement_path_is_still_visible asserts 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_trips extended. Workspace green, clippy clean, fmt applied.

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.
@cursor

cursor Bot commented Aug 27, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Metrics-only changes with no settlement or delivery behavior changes beyond counting no-op paths.

Overview
Adds observability so an idle settlement worker can be told apart from a stuck or dead one—addressing gaps where per-outcome counters stay empty during proof-fetch deferrals or when there is legitimately nothing to settle.

Each ack tick now publishes relayer_settlement_queue_depth (from PendingTxs::len()), including zero, so scrapes show liveness and a non-draining queue is visible while txs are re-deferred. When acknowledge_tx exits early with nothing to ack or claim, it increments SettlementOutcome::NothingToSettle on ack (and claim in AckAndClaim mode) instead of emitting no metrics. PendingTxs exposes len/is_empty; Prometheus wiring and a test assert zero depth and the new outcome label encode on scrape.

Reviewed by Cursor Bugbot for commit 4d9f9b3. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread deploy/alerts/usc-devnet-relayer.yaml Outdated
…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.
Comment thread deploy/alerts/usc-devnet-relayer.yaml Outdated
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.
@DylanVerstraete
DylanVerstraete merged commit c97d05f into main Aug 27, 2026
10 checks passed
@DylanVerstraete
DylanVerstraete deleted the feat/settlement-observability branch August 27, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants