Skip to content

feat(metrics): per-endpoint RPC attempt metrics + timeout reasons#156

Merged
vincent-k2026 merged 6 commits into
mainfrom
krabat/feat/per-endpoint-rpc-metrics
Jul 22, 2026
Merged

feat(metrics): per-endpoint RPC attempt metrics + timeout reasons#156
vincent-k2026 merged 6 commits into
mainfrom
krabat/feat/per-endpoint-rpc-metrics

Conversation

@vincent-k2026

@vincent-k2026 vincent-k2026 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Witness fetches hit the primary (witness-generator) and fall back to Cloudflare/R2 under one shared 3s deadline, but the metrics collapsed every provider — and every failure reason — into a single method-keyed series, and the retry loop's failure logs named neither the endpoint nor the block. There was no way to answer "did this 3s go to the witness-generator or the failover?", "how many were timeouts vs errors?", or "which block stalled on which endpoint?".

This PR attributes upstream RPC latency, failure reason, timeout counts, and per-attempt logs per endpoint, for any number of fallback endpoints. The mechanism lives in the shared round_robin_with_backoff retry loop, so both binaries benefit; the debug-trace-server (the witness path the on-call was investigating) gets the full per-endpoint Prometheus treatment.

What changed

stateless-common (shared retry loop + trait)

  • RpcMetrics::on_rpc_complete(method, success, duration)on_rpc_attempt(method, provider, outcome, duration), where outcome is a new RpcAttemptOutcome { Success, Error, Timeout } — splits a provider that returned an error from one that stalled and had to be timed out.
  • New on_rpc_deadline_exceeded(method, elapsed) (default no-op) — the operator-facing "the whole logical call blew its deadline" signal, recorded exactly once per call.
  • round_robin_with_backoff classifies each attempt, tags it with a bounded, credential-stripped endpoint label (endpoint_label, format {idx}:{host}, strips user:pass@/path/query), and threads that label into the per-attempt WARN/DEBUG logs.
  • timeout counts genuine provider stalls only — a timeout that fired merely because the caller's deadline clamped the attempt window (deadline pressure, not an upstream stall) is attributed to deadline_exceeded, not blamed on the provider.
  • Successful witness fetches log the serving endpoint and split request_ms (RPC round trip) from decode_ms (CPU decode).
  • A warn-level span carries block_number/block_hash around the witness retry, so the generic loop's per-attempt failure logs inherit the block identifier (they have no block context of their own).
  • set_validated_blocks (single attempt, outside the loop) classifies its own outcome the same way.

debug-trace-server

  • Replaced the method-only UpstreamMetrics (a success bool) with (method, provider, outcome)-keyed attempt series plus a per-method deadline-exceeded counter.

stateless-validator

  • Adapted ValidatorMetrics to the new trait; added stateless_validator_rpc_deadline_exceeded_total{method}.

Metrics

debug-trace-server:

  • debug_trace_upstream_requests_total{method, provider, outcome} — one per provider attempt that ran its course (outcomesuccess|error|timeout).
  • debug_trace_upstream_duration_seconds{method, provider, outcome} — per-attempt latency, per endpoint.
  • debug_trace_upstream_deadline_exceeded_total{method}new: logical calls that exhausted their deadline budget (also absorbs deadline-pressure timeouts).

stateless-validator:

  • stateless_validator_rpc_deadline_exceeded_total{method}new.

provider is {idx}:{host} — the host is human-readable, and the leading index keeps two endpoints that share an authority distinct while encoding primary (0) vs failover (1+). So sum by (provider) gives the per-endpoint split for any number of fallbacks.

Logs

Per-attempt failure logs (stall / error) carry method, provider=<idx:host>, provider_idx, and — via the span — block_number. Successful witness fetches trace provider, request_ms, decode_ms. So a stall reads directly: which block, which endpoint, which method, how long. (A stall now logs once with its reason instead of also emitting a redundant "trying next".)

Operational note (dashboards)

Same class of change as #154 — dashboards/alerts need a small rebase when this deploys:

  • debug_trace_upstream_errors_total is removed; error count is now debug_trace_upstream_requests_total{outcome=~"error|timeout"}, timeouts separable as {outcome="timeout"} (genuine stalls; deadline pressure lives in debug_trace_upstream_deadline_exceeded_total).
  • debug_trace_upstream_requests_total / debug_trace_upstream_duration_seconds gained provider + outcome labels — aggregating queries keep working; add by (...) where needed.
  • Per-attempt upstream series carry a dynamic provider label, so they appear on first attempt; the deadline-exceeded counter is pre-registered per method.

Testing

  • cargo test --workspace: passes (network integration tests ignored as usual); fmt / clippy --workspace --all-targets --all-features clean.
  • New tests: endpoint_label host extraction + credential/userinfo stripping + same-host {idx} disambiguation; RpcAttemptOutcome; a capturing RpcMetrics mock asserting per-endpoint attempt outcomes on a primary-fails/backup-succeeds failover and exactly one on_rpc_deadline_exceeded per deadline-exhausted call; and a captured-log test asserting the stall WARN carries block_number at the default warn filter (deadline-independent so it can't flake on CI timing).

Notes

  • mega-reth consumes stateless-common but does not implement RpcMetrics, so the trait change is contained to this repo; it needs no change when it next bumps the stateless-common tag.
  • provider_labels are parallel-by-index to providers; a debug_assert_eq! at the retry-loop entry makes that invariant explicit.

Attribute upstream RPC latency, failure reason, and timeout counts per
endpoint instead of collapsing every provider into one method-keyed series,
so operators can tell the witness-generator primary from the failover and
see where a witness fetch's deadline budget goes.

- stateless-common: `RpcMetrics::on_rpc_complete` becomes `on_rpc_attempt`,
  carrying the endpoint `provider` label and an `RpcAttemptOutcome`
  (success/error/timeout); add `on_rpc_deadline_exceeded` for the
  logical-call give-up. `round_robin_with_backoff` classifies each attempt,
  tags it with a credential-stripped endpoint host (`endpoint_label`), and
  records the deadline give-up once at every bail site. Per-attempt WARN/DEBUG
  logs now include the endpoint host.
- debug-trace-server: replace `UpstreamMetrics` (method-only, success bool)
  with `(method, provider, outcome)`-keyed attempt series plus a
  `upstream_deadline_exceeded_total{method}` counter.
- stateless-validator: adapt `ValidatorMetrics` to the new trait and add
  `stateless_validator_rpc_deadline_exceeded_total{method}`.

Tests: `endpoint_label` host/credential handling, and a capturing
`RpcMetrics` mock asserting per-endpoint attempt outcomes and a single
deadline-exceeded per logical call.
Failure attribution was already per-endpoint (metrics + WARN/DEBUG logs
carry the host), but a *successful* witness fetch only logged decode time
without saying which of the fallback endpoints served it. Thread the
endpoint label through the per-attempt closure so `fetch_witness_with`
tags its success trace with `provider`, and split `request_ms` (RPC round
trip) from `decode_ms` (CPU decode) so a slow fetch across multiple
witness fallbacks can be attributed to the right endpoint and stage.
The generic retry loop's per-attempt failure logs (endpoint stalled /
failed) carried `method` and the endpoint host but no block identifier —
the loop is generic and has no block context. Wrap the witness retry in a
`warn`-level span carrying `block_number`/`block_hash` so those logs
inherit it and an endpoint stall is traceable to its block. The span is
`warn` level so it stays enabled at the default `warn` filter; a captured-
log test asserts the stall WARN carries `block_number` at that filter.
The retry loop indexes `provider_labels[provider_idx]` where `provider_idx`
is bounded by `providers.len()`, so a future edit that let the two slices
desync would panic in the hot path. Both slices are built from the same
endpoint list today; a `debug_assert_eq!` at the loop entry makes that
parallel-by-index invariant explicit and trips it in tests/CI if it breaks.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c20157a705

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
let host = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
if host.is_empty() { Arc::from(format!("provider_{idx}")) } else { Arc::from(host) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make provider labels unique for same-host endpoints

When two configured RPC URLs share the same authority but differ by path or credentials (for example two provider keys under the same SaaS host), this returns the same provider label for both, so the new (method, provider, outcome) metrics aggregate their successes, errors, and timeouts instead of attributing them per configured endpoint. Including the stable provider index in the sanitized label would keep credentials out while preserving the per-endpoint split this change is meant to expose.

Useful? React with 👍 / 👎.

Comment on lines +958 to +960
match tokio::time::timeout(
attempt_timeout,
f(providers[provider_idx].clone(), Arc::clone(&provider_labels[provider_idx])),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid blaming providers for non-RPC timeouts

When a caller supplies a tight deadline, attempt_timeout can be just the remaining caller budget, and for witness/block calls this f(...) future also includes local decode or verification work after the response arrives. If that budget or CPU work expires, the attempt is still recorded as outcome="timeout" for the current provider, so the new per-provider timeout metric can falsely attribute deadline pressure or local decode slowness to an upstream stall. Only classify timeouts around the actual provider round trip as provider timeouts, or use a separate outcome for deadline/decode expiry.

Useful? React with 👍 / 👎.

The witness-failure-log test used per_attempt_timeout=80ms vs a 120ms
deadline (40ms margin), so on a loaded CI runner the per-attempt timeout
fired after the deadline, taking the deadline-exceeded branch (no log)
instead of emitting the stall WARN — a flaky pass/fail. Switch to
deadline=None so every per-attempt timeout emits the stall WARN (no
deadline branch can steal it), bounded by an outer tokio timeout; the
assertion no longer depends on runner timing.
Two accuracy fixes from PR review:

- Deadline-pressure timeouts no longer blame the provider. When a caller's
  deadline clamps the per-attempt window below `per_attempt_timeout`, a
  timeout there is the caller's exhausted budget, not a provider stall.
  Record it against the deadline (`on_rpc_deadline_exceeded`) only, not as a
  per-provider `outcome="timeout"`, so the timeout metric reflects genuine
  upstream stalls (full window elapsed with budget remaining).

- Endpoint labels are now `{idx}:{host}` so two endpoints sharing an
  authority (same SaaS host, different path/credentials) stay distinct
  instead of aggregating into one series; the index also encodes primary (0)
  vs failover (1+). Credentials are still stripped.

@flyq flyq left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@vincent-k2026
vincent-k2026 merged commit 51c41b3 into main Jul 22, 2026
14 of 15 checks passed
@flyq
flyq deleted the krabat/feat/per-endpoint-rpc-metrics branch July 22, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants