Skip to content

Fix Pectra/EIP-7549 attestation over-counting, add Erigon (Caplin) compatibility, repo & test rework - #28

Merged
stakepeter merged 222 commits into
stakefish:masterfrom
stakepeter:eth2-monitor/staging-troubleshooting
May 16, 2026
Merged

Fix Pectra/EIP-7549 attestation over-counting, add Erigon (Caplin) compatibility, repo & test rework#28
stakepeter merged 222 commits into
stakefish:masterfrom
stakepeter:eth2-monitor/staging-troubleshooting

Conversation

@stakepeter

@stakepeter stakepeter commented Apr 2, 2026

Copy link
Copy Markdown
Collaborator

TL;DR

  • This service watches a set of Ethereum validators and alerts when they miss attestations or proposals (via Slack + Prometheus). After Ethereum's Pectra hard fork (May 2025), it began inflating "served attestations" by ~34% and firing false "delayed attestation" alerts on healthy validators.
  • Root cause: Pectra's EIP-7549 changed how validator votes are packaged into blocks. The same vote can now legitimately appear in multiple blocks, but this service counted every appearance as a separate event. See the deep-dive section below for diagrams.
  • Fix-and-cleanup: dedup attestation inclusions (the headline fix), plus add support for our Erigon-based staging endpoint, restructure the repo, replace the test suite with one driven by real captured beacon-chain JSON, add request-level metrics, and apply ~70 small reliability fixes that fell out of the triage.

What this project does (for reviewers unfamiliar with it)

eth2-monitor is a small Go CLI that connects to an Ethereum beacon-chain node (a "consensus layer" RPC endpoint), watches a configured list of validator public keys, and reports:

  • missed block proposals,
  • missed attestations,
  • attestations that were eventually included but later than expected,
  • blocks the proposer left empty (no transactions / no blobs / no MEV value),
  • proposed blocks that don't match any known MEV-relay bid.

Reports go to a Slack webhook; structured metrics go to Prometheus on port 1337. It runs as a long-lived daemon and is typically deployed alongside a beacon node operated by the same team.

Why this PR exists

The branch began as triage of staging alerts firing on real, healthy validators. Root-causing the alerts revealed two things:

  1. Pectra/EIP-7549 broke the original attestation counter. This service was written before Pectra and assumed each vote appeared in exactly one block.
  2. An unrelated crash on our Erigon staging endpoint. Erigon's bundled beacon-chain client ("Caplin") returns 404 in a slightly different way than the Prysm/Lighthouse clients the original code was tested against, and the resulting nil-deref crashed the monitor at startup whenever the first slot of an epoch was missed.

Fixing those two things confidently required better test coverage — which made it worth restructuring the monolithic monitoring package and capturing real beacon-chain responses as test fixtures. From there the branch picked up ~70 small reliability and ergonomics fixes. End result: ~165 commits / +10,889 / −1,557 lines across 121 files.

Glossary

One-line definitions of the jargon used below
  • Slot / Epoch — A slot is 12 seconds. 32 slots make an epoch (~6.4 minutes). Each slot has at most one block.
  • Attestation — A validator's vote on the chain head; broadcast once per epoch, then included by other validators in their block at slot+1 (or later if there's congestion / missed slots).
  • Attestation inclusion distance — How many slots elapsed between when the vote was emitted and the block that recorded it. 0 is optimal (next slot), higher = worse.
  • Pectra — Ethereum's May-2025 hard fork.
  • EIP-7549 — Pectra-era change that moved committee-selection metadata outside the signed portion of an attestation, allowing aggregators to re-package the same votes into different committee groupings. Result: the same vote can legitimately appear in multiple blocks.
  • Caplin — Erigon's bundled beacon-chain client (an alternative to running a separate Prysm/Lighthouse/Teku/Nimbus/Lodestar node).
  • Hoodi — A public Ethereum testnet, used by our staging environment.
  • MEV — Maximal Extractable Value, the surplus a validator collects for ordering transactions in a particular way. Validators get it via "MEV relays" — third-party services that auction blocks and publish bid traces.
  • Must() — Internal panic-on-error helper. We're converting "should-never-happen" call sites to detect ctx.Canceled explicitly so a clean shutdown doesn't panic through it.

What changes for operators of this service

Visible in dashboards / alerts (deploy will change graphs):

  • Attestation counts drop ~34%. That's the over-counting going away — not a regression. The same validators are doing the same work.
  • "Delayed attestation" false-positive alerts stop firing in normal operation. Real delays (e.g. attestations stranded by a long run of missed proposals) still alert.
  • Attestation-distance histogram concentrates near 0–1 instead of having a long synthetic tail at 4–7 caused by re-inclusion.
  • New top-level metric: ETH2_beaconAPIRequestsTotal / …DurationSeconds — every call to the beacon-chain RPC is now counted by endpoint, HTTP method, and status class. Use this to spot upstream-RPC issues.
  • New diagnostics: ETH2_duplicateAttestationsSkipped (the dedup counter — confirms the fix is filtering anything), ETH2_crossEpochAttestations (votes recorded in a later epoch than they were emitted), ETH2_missedSlotsInEpoch (correlates directly with distance spikes), ETH2_rawAttestationDistances (raw distance before correcting for missed slots — compare against the corrected histogram to separate network delay from validator delay), ETH2_totalProposedEmptyBlocks, ETH2_totalMissingBidTraces, ETH2_lastProposedEmptyBlockSlot.

Not visible in dashboards (but matter):

  • No longer crashes on Erigon (Caplin) staging. Fixes a real panic from 2026-05-13.
  • No longer hangs on shutdown or double-processes the last epoch after a restart.
  • Cache writes are crash-durable. Local validator-index cache uses atomic tmpfile + fsync + rename + dir-fsync. 30-minute TTL means newly-active or newly-slashed validators become visible within the window without a restart.
  • MEV-relay calls are bounded (4 MiB body cap, per-relay HTTP timeout, ctx-aware retry, capped pagination). Pubkey + blockhash comparisons are now case-insensitive so vanilla-block detection isn't fooled by hex case.

Bigger picture, drill-down

Erigon (Caplin) support
  • go.mod redirected to a stakefish fork of go-eth2-client via replace directive: github.com/stakefish/go-eth2-client@feat/erigon-caplin-support (commit 781f0c7f). The fork natively tolerates a few JSON shapes that Caplin emits and the upstream library rejected. Application imports are unchanged. Revertible by removing the replace directive once upstream merges the fix.
  • GetValidatorIndexes now probes forward through the slots of an epoch when Caplin returns 404 on the first slot — Caplin resolves "validators at slot N" by walking to the block at that exact slot, which doesn't exist if slot N was missed. Walking forward to the next existing block in the same epoch gives the same answer (validator set is stable within an epoch).
  • Regression test TestGetValidatorIndexes_ProbeForwardOnMissedFirstSlot plus the live-beacon E2E test get_validator_indexes_roundtrip cover this.
Repo restructure and monitoring decomposition
  • Moved to golang-standards/project-layout. Entrypoint is cmd/eth2-monitor/main.go; library code lives under internal/{beaconchain,monitoring,cli,opts,spec}/. The old pkg/ directory is removed.
  • The monolithic pkg/monitoring.go (553 lines, all concerns mixed) is split into per-concern files in internal/monitoring/:
    • monitoring.go — orchestrator loop + SSE head-stream subscription.
    • epoch_context.go — per-epoch state fetch (validator keys, attester duties, proposer duties, committee lookup, blocks, MEV bids).
    • attestations.go — attestation-issue detection (dedup, distance computation, delayed-over-tolerance).
    • proposals.go — proposal-issue detection (missed, empty, vanilla, MEV mismatch).
    • metrics.goMonitorMetrics struct + factory accepting a prometheus.Registerer (production uses default; tests inject isolated registries).
    • reporting.go — Slack + log fanout helpers.
    • mev.go — MEV relay bid-trace fetching with concurrent paginated retrieval.
    • cache.go — disk-backed JSON cache for validator-index lookups.
    • plus set.go, profiling.go, utilities.go, doc.go.
Test infrastructure rewrite
  • New tools/fixturegen/ records real beacon-chain JSON by subscribing to /eth/v1/events?topics=head on the configured RPC endpoint, then tailing new heads and recording every block until a "scenario predicate" matches.
  • Captured fixtures land under internal/beaconchain/testdata/beacon/<chain>/<scenario>/ (currently hoodi; new chains = new sub-tree, no code changes needed).
  • Six scenarios are captured, each exercising one of the validator-failure-detection paths: happy_path, missed_proposal, empty_block, delayed_attestation, cross_epoch_attestation, plus a _shared/ directory of chain-invariant startup probes.
  • Each scenario carries a meta.json recording the anchor slot/epoch + scenario-specific fields (e.g. missed_slot, max_distance, prev_canonical_slot). Tests anchor assertions to these fields instead of hard-coded slot numbers so they stay green across refresh runs. No upstream endpoint identifier is recorded in the committed fixtures.
  • Most monitoring + beaconchain tests are now fixture-backed integration tests driving the real BeaconChain client against an httptest server that replays captured JSON / SSE / MEV-relay responses. Synthetic unit tests are retained only for narrow classification edge cases (cross-epoch dedup, AggregationBits offset drift).
  • Live-beacon end-to-end tests are gated behind a build tag (e2e) and excluded from CI; run locally with make test-e2e.
CI rework
  • golangci-lint bumped to v2.12.2 (was v1) via golangci/golangci-lint-action@v8.
  • New test job (go test ./...) gates the release build.
  • New coverage job prints per-package coverage to the job log (no artifact, no third-party service).
  • Multi-arch build matrix (amd64+arm64; linux/darwin/freebsd/windows).
  • Go bumped to 1.25.10 (.tool-versions, go.mod, both workflows).
Reliability / correctness fixes (the long tail)
  • ctx.Canceled / context.DeadlineExceeded detected explicitly in SubscribeToEpochs, startup finality fetch, BuildEpochContext, and MEV requests — returned cleanly rather than panicking through Must.
  • Defer-order deadlock on metrics-server shutdown fixed; SSE goroutine drains on orchestrator exit; epochs channel closed via defer.
  • Off-by-one re-emit of the persisted epoch on restart fixed (counters no longer drift after a restart).
  • Cache write uses atomic tmpfile + fsync + rename + dir-fsync, tmpfile co-located with destination to avoid EXDEV. 30-minute TTL on cached validator indices, including the VALIDATOR_INDEX_INVALID sentinel.
  • MEV relay: 4 MiB body cap, per-relay HTTP timeout, case-insensitive pubkey + blockhash compare, ctx-aware retry backoff, 64-page pagination cap with underflow guard.
  • LoadCache capped at 64 MiB; null-body cache file no longer panics on next save.
  • Slack POST bounded by 5-second client timeout; non-2xx responses are logged; nil-deref on transport failure fixed.
  • Many nil-guards across attestation / proposal / SSE / cache / MEV paths (block / message / body / data / execution-payload / event-pointer / duty-entry).
  • Slashed validators silently excluded from monitoring (now documented as a gotcha — surprising during incident response).
  • Log calls migrated from printf-style to zerolog structured fields (Uint64("slot", ...).Msg(...)) for greppability.
  • Grafana dashboard rewrite — test-env/grafana/dashboards/eth2-monitor.json (+1890 lines), new panels for the new metrics and a general restyle.
  • CLAUDE.md expanded by ~275 lines (architecture, fixtures, gotchas, debugging workflows). Per-package doc comments throughout internal/monitoring/.
Full metric reference (before/after table)

New metrics:

Metric Type Purpose
ETH2_duplicateAttestationsSkipped Counter (validator, slot) dedup hits — confirms the EIP-7549 fix is filtering re-aggregated inclusions.
ETH2_crossEpochAttestations Counter Attestations included in a different epoch than the one they attested to.
ETH2_rawAttestationDistances Histogram Raw inclusion distance before the missed-slot adjustment (compare against ETH2_canonicalAttestationDistances to isolate network-vs-validator delay).
ETH2_missedSlotsInEpoch Gauge Slots without a proposed block in the most recently processed epoch.
ETH2_totalProposedEmptyBlocks Counter Blocks with no execution-layer payload of value (no transactions, no blobs, no Pectra exec requests).
ETH2_totalMissingBidTraces Counter Proposed blocks where no tracked MEV relay returned any bid trace.
ETH2_lastProposedEmptyBlockSlot Gauge Last empty-block slot.
ETH2_beaconAPIRequestsTotal CounterVec Beacon-chain RPC requests by endpoint, method, status_class.
ETH2_beaconAPIRequestDurationSeconds HistogramVec Beacon-chain RPC request latency by endpoint, method.

Behavior-corrected metrics (same name, more accurate values):

Metric Before After
ETH2_totalServedAttestations Inflated ~34% by duplicate inclusions Accurate (deduped)
ETH2_totalDelayedAttestationsOverTolerance False positives from later re-inclusions of the same vote Only genuine delays (e.g. attestations stranded by a long run of missed proposals)
ETH2_canonicalAttestationDistances Polluted with re-inclusion distances (d=4–7) Concentrated at d=0–1; tail only from real network delay

Removed API calls (no longer needed):

Endpoint Reason
GET /eth/v1/beacon/states/{state}/committees Replaced by duty-based committee info built from AttesterDuty responses. Saves ~30s/epoch on networks with large validator sets.
GET /eth/v1/beacon/headers/{block_id} GetBlockHeader was dead code.

Dedup deep-dive (EIP-7549)

This is the headline fix. Worth understanding even if you skip the rest.

How EIP-7549 changed attestations

Before Pectra (May 2025), each attestation was locked to one committee:

Attestation = signed(slot=100, committee=5, votes=[1,0,1,1])
                              ^^^^^^^^^^^
                              part of signature — can't change

EIP-7549 moved the committee selection outside the signature for ~64× faster BLS verification:

Attestation = {
    signed:  (slot=100, votes=[1,0,1,1])     ← committee NOT signed
    unsigned: CommitteeBits=[0,0,0,0,0,1,0]  ← committee 5, can be re-aggregated
}

The problem

Since committee selection is no longer signed, the same vote can be re-packaged and included in multiple blocks:

sequenceDiagram
    participant V as Validator 42
    participant S100 as Slot 100
    participant B101 as Block 101
    participant B105 as Block 105
    participant B108 as Block 108

    V->>S100: Attests (committee 5)

    Note over B101: Attestation A<br/>committees: [5]
    S100-->>B101: val 42 included (d=0 ✅)

    Note over B105: Attestation B (re-aggregated)<br/>committees: [2, 5, 7]
    S100-->>B105: val 42 included again (d=4 ⚠️)

    Note over B108: Attestation C (re-aggregated)<br/>committees: [5]
    S100-->>B108: val 42 included again (d=7 🚨)
Loading

The Ethereum protocol allows this — re-including an attestation is a no-op for rewards (has_flag() is idempotent in the consensus spec). But this service counted every inclusion as if it were a separate event:

flowchart LR
    subgraph OLD ["Old code (no dedup)"]
        direction TB
        O1["Block 101: d=0"] --> OC["Counted: 3 inclusions"]
        O2["Block 105: d=4"] --> OC
        O3["Block 108: d=7"] --> OC
        OC --> OA["served=3, max d=7<br/>🚨 ALERT fires"]
    end

    subgraph NEW ["Fixed code (with dedup)"]
        direction TB
        N1["Block 101: d=0 ✅ first seen"] --> NC["Counted: 1 inclusion"]
        N2["Block 105: SKIP ↩️ already seen"] -.-> NC
        N3["Block 108: SKIP ↩️ already seen"] -.-> NC
        NC --> NA["served=1, d=0<br/>✅ No alert"]
    end
Loading

The fix

Track which (validator, slot) pairs have been seen. Process blocks in slot order so the earliest (canonical) inclusion wins:

flowchart TD
    START["For each block (sorted by slot)"] --> ATT["For each attestation"]
    ATT --> VAL["For each validator in attestation"]
    VAL --> CHECK{"(validator, slot)<br/>already seen?"}
    CHECK -->|Yes| SKIP["Skip duplicate<br/>DuplicateAttestationsSkipped++"]
    CHECK -->|No| RECORD["Record distance<br/>Mark as seen"]
    RECORD --> DIST{"distance > 2?"}
    DIST -->|Yes| ALERT["DelayedOverTolerance++"]
    DIST -->|No| OK["CanonicalAttestations++"]
    SKIP --> VAL
    ALERT --> VAL
    OK --> VAL
Loading

stakepeter and others added 10 commits April 1, 2026 14:41
- **Extract metrics into testable `MonitorMetrics` struct** — Moved all 14 Prometheus metric declarations from inline locals in `MonitorAttestationsAndProposals()` into a new `MonitorMetrics` struct with a `NewMonitorMetrics(reg)` factory in `pkg/metrics.go`. This enables test isolation by accepting a `prometheus.Registerer` instead of hardcoding `prometheus.DefaultRegisterer`.

- **Fix duplicate attestation counting (H11)** — Extracted attestation processing into `processAttestations()`, which iterates blocks in sorted slot order and deduplicates `(validator, slot)` pairs. Adds a `DuplicateAttestationsSkipped` counter to track how many duplicates are filtered. This was the primary cause of false `ValidatorsDelayedOverTolerance` alerts.

- **Fix cross-epoch attestation blindness (H09)** — `ListEpochBlocks()` now fetches 4 extra slots past the epoch boundary, catching attestations from the last epoch slots that are routinely included in the next epoch's first blocks. Adds a `CrossEpochAttestations` counter.

- **Fix silent error logging (H07)** — Two `log.Error().Err(err)` calls were missing `.Msg()` terminators, causing errors to be silently swallowed. Added descriptive messages at `ListEpochBlocks` (block fetch) and `ListBestBids` (MEV bid traces).

- **Add new observability metrics** — `RawAttestationDistances` histogram (pre-dedup), `MissedSlotsInEpoch` gauge, `CrossEpochAttestations` counter, `DuplicateAttestationsSkipped` counter.

- **Bump `testify` to v1.9.0** — Added as a direct dependency for new unit/integration tests.
The SSE handler dropped every epoch in (lastEpoch, thisEpoch) when a
head event jumped by more than one. This fires every startup —
`lastEpoch` is seeded from the justified epoch (~head-2), so the first
head event always skips at least one epoch.

Attestation tracking requires consecutive epoch processing: an
attestation for the last slot of epoch N can only be included in
epoch N+1's blocks, so skipping N+1 produces false "did not attest"
reports for late-slot duties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile: golang:1.25.10-alpine3.23, alpine:3.23
- go-eth2-client v0.28.0 -> v0.28.1
- testify v1.9.0 -> v1.11.1
- demote bitfield/client_model/testify/yaml.v3 to indirect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stakepeter
stakepeter marked this pull request as ready for review May 12, 2026 07:21
stakepeter and others added 19 commits May 12, 2026 15:28
processAttestations had a within-call dedup but the seenAttestations map
was rebuilt every iteration, so an attestation observed inside epoch
N's lookahead window and again in epoch N+1's main scan double-counted
TotalCanonicalAttestations, CanonicalAttestationDistances,
CrossEpochAttestations, and TotalDelayedOverTolerance.

Lift the map into MonitorAttestationsAndProposals so it persists
across epochs, and prune entries older than EpochLowestSlot(epoch-1)
each iteration to bound memory. Cross-epoch duplicates now flip
DuplicateAttestationsSkipped instead of inflating the canonical
counters.

Add two tests pinning the across-call and within-call dedup paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
golangci-lint v1.60 (built with Go 1.23) cannot lint a Go 1.25 target,
so CI failed before running any checks. Bump the action to @v8 and pin
the linter to v2.12.2 (built with Go 1.25). Drop the deprecated
skip-pkg-cache input.

v2 has fewer default exclusions than v1.60, which surfaces 11
pre-existing issues. Fix them in place rather than re-introducing the
old exclusions:

- errcheck on best-effort cleanup paths: wrap defer Close/Remove in
  func() { _ = X() }(); drop ignored Register error to _.
- staticcheck S1005: drop redundant blank identifiers in range loops.
- staticcheck ST1005: lowercase error-string prefixes in mev.go.
- staticcheck QF1012: fmt.Fprint(&sb, v) instead of WriteString+Sprint.

Verified locally with golangci-lint v2.12.2: 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues showed up running against Erigon Caplin on Hoodi staging:

1. Caplin emits Gwei amounts and the deposit index in execution_requests
   as bare JSON numbers, instead of the spec-required quoted strings.
   go-eth2-client's strict unmarshal then fails the whole block parse.
   Add an HTTP RoundTripper for the beacon-block endpoint that quotes
   "amount" and "index" before the parser sees them; pass-through for
   SSZ responses and non-block paths.

2. After the AttesterDuties-only committee lookup landed, attestations
   that aggregated across committees containing no tracked validators
   (EIP-7549) walked AggregationBits with the wrong offset, since we
   skipped untracked committees without advancing past their bits.
   Caplin aggregates across many committees per attestation, so this
   surfaced as a steady stream of false "did not attest" warnings.
   Fetch full committee lengths via BeaconCommittees for the epoch
   window and seed BuildCommitteeLookup with them; processAttestations
   now advances the offset by every committee's real length and skips
   any attestation referencing a committee we still don't know about
   instead of corrupting state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ListEpochBlocks scans blocks in [low(E), high(E)+4]; the 4-slot
lookahead routinely sees blocks in epoch E+1 that include attestations
for E+1's first few slots. processAttestations would:

1. Walk the attestation, find a tracked validator's bit set, add to the
   in-call attesters set.
2. Record the (slot, validator) pair in seenAttestations.
3. Call unfulfilledAttesterDuties[attestedSlot].Remove(...) — but during
   the previous epoch's iteration that slot's duties weren't yet in the
   map, so Remove on the nil set was a silent no-op.

When epoch E+1's iteration ran, it populated unfulfilledAttesterDuties
with the new epoch's duties, rescanned the same blocks, and on the
second observation the seenAttestations dedup hit and skipped the
Remove via early-continue. The validator stayed unfulfilled and was
reported as missed one iteration later.

Move the unfulfilled-Remove above the dedup check so it always runs.
Dedup now only gates metric increments, which is its intended role.
Set.Remove and IsEmpty are nil-safe (delete on a nil map is a no-op),
so the unconditional call is safe regardless of whether the slot's
duties have been populated yet.

Verified on staging Caplin (Hoodi):
- Before: 50-65 false misses per epoch concentrated on slots 0/1/2
- After: 4 consecutive epoch boundaries with map[] (zero unfulfilled),
  100% success rate (5585/5585), 248 cross-epoch attestations correctly
  attributed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
processAttestations fetches AttesterDuties for [epoch-1, epoch, epoch+1]
and builds committeeLookup for all three. When a block in the current
epoch's scan window contains an attestation referencing a prev-epoch
slot, my code correctly identifies the tracked validator's bit but
then tries to compute the inclusion distance against a block far later
than the actual first inclusion (which was in prev-epoch blocks we
never fetched). The missed-slot adjustment loop further compounds this
by treating every slot outside the scan as "missed", under-counting
the real distance.

Example from the live run: validator 1083751 had a duty at slot
3030716 (epoch 94709) and actually attested via block 3030717. A
fresh container processing only epoch 94710 saw the cross-epoch
attestation in block 3030724 (a later inclusion) and reported
"attested slot 3030716 at slot 3030724, attestation distance is 3"
— while the chain truth is distance 0.

Fix: when the earliest possible inclusion slot is below
EpochLowestSlot(epoch), skip the distance/canonical/delayed metrics
for this attestation. seenAttestations is still updated so dedup
remains correct on subsequent observations. For long-running
containers the prev epoch's own iteration records the metric
correctly; for fresh starts the metric is genuinely unknown and we
choose silence over a misleading value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two service-layer fixes surfaced by the monitoring.go audit:

1. GetValidatorIndexes used a hand-rolled filter that included
   ValidatorStateActiveSlashed. The spec's get_unslashed_attesting_indices
   excludes slashed validators, and go-eth2-client already ships
   ValidatorState.IsAttesting() with the right semantics. Switch to it so
   slashed validators stop producing false "did not attest" reports until
   they exit.

2. GetBlock returned (nil, err) for missed slots and then checked
   `if resp == nil` after the err branch — dead code, since go-eth2-client
   returns (nil, *api.Error{StatusCode:404}) on a missed slot, never
   (nil, nil). Detect 404 explicitly and return (nil, nil) only in that
   case; other errors keep propagating. Callers' behavior is unchanged
   (ListEpochBlocks already treats both as missed), but the contract is
   now honest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
requestEpochBidTraces wrapped the parent context in WithTimeout once,
then ran every relay in an errgroup sharing that deadline. A single slow
relay starved the rest of their retry budget: with eight relays and a
4s budget, one 3s relay left 1s for the other seven combined.

Move WithTimeout inside each goroutine so the 4s applies per-relay. The
parent ctx still propagates cancellation on shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Findings from a top-down review of pkg/monitoring.go assumptions. Each
item is independent; they're bundled because they share the same audit.

- Validator-index cache TTL was effectively infinite. The check used
  time.Until(at) which returns a negative duration for past timestamps,
  so "< 8h" was always true. Switch to time.Since(at) and shorten the
  TTL to 30m so an exited or rotated validator falls out of cache
  within a bounded window.

- Empty validatorPubkeyFromIndex no longer panics. Mass exit, key
  rotation gaps, and beacon-node desync are all real ways to reach
  zero active validators temporarily; log a warning and continue
  instead of crashing the monitor.

- Skip the genesis epoch at the top of the per-epoch loop. epoch - 1
  appears in three load-bearing places (duty/committee window fetch
  and missedAttestationEpoch) that would underflow uint64 if a user
  passed --replay-epoch 0. The inner `if epoch > 0` guard around the
  seenAttestations prune cutoff is subsumed by the new outer guard.

- Sort the two unsorted map iterations (proposal/empty/vanilla per
  block, and the missed-proposal reporter) so Slack reports come out
  in ascending slot order.

- Extend the empty-block check beyond ExecutionPayload.Transactions
  to include BlobKZGCommitments and ExecutionRequests (deposits,
  withdrawals, consolidations). A new isBlockEmpty helper handles all
  three. A block that carries blobs or execution_requests isn't
  "empty" in the validator-economic sense even with zero transactions.

- Split the vanilla-block metric. totalVanillaBlocks now counts only
  confirmed hash mismatches; a new totalMissingBidTraces counter
  covers the case where no bid trace was found at all (which could
  be a relay-side failure rather than a real vanilla block).
  Dashboards summing totalVanillaBlocks for "any non-MEV-tracked
  block" should now sum both counters.

- Persist the last processed epoch in LocalCache. SubscribeToEpochs
  anchors at max(persisted, justified) on startup, and each iteration
  writes the just-finished epoch via SaveCache. A crash/restart no
  longer re-processes already-counted epochs, which previously spiked
  every cumulative counter.

- Drop the 250-validator chunking in ListProposerDuties. The endpoint
  returns at most SLOTS_PER_EPOCH duties regardless of input size and
  go-eth2-client filters indices client-side, so the loop was
  decorative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix five factual errors that would mislead future sessions: the
cache had no TTL (only an unused timestamp), golangci-lint runs as
action@v8 with no pinned tool version, the Go version line hid a
real go.mod/.tool-versions/CI mismatch, the committees endpoint
was omitted, and totalMissingBidTraces was missing from the
metrics table. Surface non-obvious behavior worth knowing before
touching the code: the --mev-relays file is JSON despite the
flag help text, the Caplin amount/index fixer only patches block
responses, slashed validators silently drop from monitoring, and
attestation dedup breaks if epochs aren't processed consecutively.
Also catch up the codebase structure with the new beaconchain
files and pkg/monitoring_test.go, and point at docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The release workflow built arm64 twice and skipped amd64, so amd64
binaries never shipped from tags. Tests only ran locally, so a
regression could merge unnoticed. The CLI carried a --beacon-node
flag that was wired but whose value nothing reads, a Slashings
struct and a pair of Monitor fields with zero references, and an
--mev-relays help text that described a one-per-line format while
the loader expects a JSON array. None of these were doing useful
work and several were actively misleading.

Bumps the build job's Go to 1.25.x to match go.mod and the lint
workflow. Adds a test job that the build now depends on. Removes
the dead flag, struct, and fields. Corrects the --mev-relays help
text. Updates CLAUDE.md to reflect the new CI shape, drop the
now-fixed gotchas, and correct the golangci-lint version claim
that I'd previously written wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pkg/monitoring.go shrinks from 669 to 232 lines and becomes a slim
orchestrator. Each issue class (per-epoch state fetch, attestation
detection, proposal detection) now owns its own file and tests so a
single concern can be reasoned about and exercised in isolation.

- epoch_context.go: EpochContext + BuildEpochContext bundle the
  validator-resolution, duty/committee/block/bid fetches into one
  call. Move ResolveValidatorKeys, ListProposerDuties,
  ListAttesterDuties, ListEpochBlocks, VALIDATOR_INDEX_INVALID.
- attestations.go: processAttestations, BuildCommitteeLookup,
  CommitteeInfo (verbatim) plus new PruneSeenAttestations and
  FinalizeMissedAttestations extracted from the old loop.
- proposals.go: isBlockEmpty (verbatim) plus new CheckProposal and
  FinalizeMissedProposals. CheckProposal returns bool so the
  orchestrator can preserve the existing proposer-mismatch path
  (do not delete the duty so it ends up reported as missed).
- monitoring.go now only holds the orchestrator loop,
  SubscribeToEpochs, LoadKeys, LoadMEVRelays.

Opportunistic fixes carried in the new code, not the old:
- Empty-block report swapped validatorIndex/pubkey argument order;
  now matches the (index, pubkey) convention used by every other
  validator report.
- Missed-proposal report gains the pubkey for parity with the
  missed-attestation report.

Tests split alongside the code. monitoring_test.go is gone:
- attestations_test.go inherits the 7 prior processAttestations /
  BuildCommitteeLookup tests and adds TestFinalizeMissedAttestations
  and TestPruneSeenAttestations.
- proposals_test.go adds TestIsBlockEmpty (table-driven),
  TestCheckProposal_* covering empty / missing-bid / vanilla /
  optimal / mismatch / empty+missing-bid fall-through, and
  TestFinalizeMissedProposals.
- test_helpers_test.go centralises counterValue, gaugeValue,
  histogramSampleCount, buildSingleValidatorAttestation.

processAttestations and the persistent seenAttestations /
unfulfilledAttesterDuties maps are unchanged byte-for-byte; the
refactor only wraps them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
http.Post returns (nil, err) on dial-level failures (DNS, connection
refused, transport context cancel). The previous code logged the error
but did not return, so the deferred resp.Body.Close() ran against a nil
response and panicked the goroutine. Reachable any time the configured
Slack URL is briefly unreachable.

Also bail after a json.Marshal failure instead of POSTing an empty body
(the marshal of an int8-keyed map is the only realistic trigger, but
sending "" to Slack on the error path is just noise).

Add reporting_test.go covering: transport failure (no panic), empty-URL
fast path (no request), happy path (one POST observed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous loop deferred file.Close() inside the for body, so every
opened pubkey file stayed open until LoadKeys returned. A multi-file
config could exhaust file descriptors on tight ulimits.

Extract a per-file helper (readPubkeysFile) so the defer runs at the
end of each iteration. Behaviour is otherwise identical: blank/whitespace
lines are skipped, scanner errors are surfaced.

Add LoadKeys / LoadMEVRelays tests covering merge with CLI keys, missing
files, malformed JSON, and a best-effort fd-leak guard via /proc/self/fd
(skipped on platforms without it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, any GetBlock error (transport blip, JSON quirk, etc.) was
silently treated as a missed slot. For a slot owned by a tracked
validator that meant a false missed-proposal report.

Add a bounded retry loop (3 attempts, 200ms→400ms→800ms backoff) before
falling back to the existing "treat as missed" behaviour. Ctx cancellation
short-circuits the backoff so shutdown stays prompt. Persistent failures
still log at ERROR so operators can alert on them.

Introduce a narrow blockFetcher interface so the retry logic is testable
without spinning up a fake beacon node. *beaconchain.BeaconChain satisfies
it implicitly. Tests cover: transient error recovery, persistent error
fallback, 404 short-circuit, ctx cancel during backoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously SaveCache wrote rawCache via a partial-write loop and renamed
the tmpfile without calling Sync or Close. A crash between Write and
Rename could leave torn JSON on disk; on next start LoadCache logged a
json.Unmarshal error and returned an empty cache, forcing the monitor
to re-resolve every validator index (slow on large key sets).

Replace the partial-write loop with a single Write (os.File.Write does
not short-write for in-memory buffers), then explicitly Sync + Close
before Rename. Defer Remove(tmpPath) covers every error path; once
Rename succeeds the path no longer exists and Remove is a harmless
no-op.

Add cache_test.go with seven cases: round-trip, multi-call merge,
forward-only LastEpoch invariant, corrupt-file graceful fallback,
missing-file first-run, tmpfile cleanup, and VALIDATOR_INDEX_INVALID
sentinel preservation. A withTempCachePath helper redirects
cacheFilePath so tests never touch the developer's real cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per spec attestation.data.slot < block.slot is invariant, so
earliestInclusionSlot (= data.slot + 1) ≤ block.slot. If a malformed
attestation surfaces with data.slot ≥ block.slot the uint64 subtraction
underflows to ~1e19 and is recorded as a "delayed attestation" sample,
spamming Slack and skewing the inclusion-distance histogram.

Add a defensive check that logs at WARN and skips the attestation.
Regression test asserts no metrics fire on a same-slot attestation.

Also add set_test.go covering the Set generic helper: constructor,
Add/Contains/Remove/IsEmpty, Elems iterator, String form, and the
nil-set contract (Remove no-ops, Add panics — matching Go's nil-map
semantics so refactors don't silently change behaviour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stakepeter and others added 29 commits May 14, 2026 07:51
Panel 4 (API Errors) was conflating two different signals: real
infrastructure errors (5xx, transport failures, beacon-side 4xx for
genuinely bad requests) AND the steady stream of 404s on
/eth/v2/beacon/blocks/{block_id} that the monitor uses as the
canonical missed-proposal detector. With even modest missed-slot
activity (Hoodi staging routinely produces several missed slots per
hour) the tile read a non-zero error share despite the underlying
infrastructure being healthy, defeating its stated intent of
"infrastructure health, not a validator metric".

Split the numerator: 5xx and transport errors count for every
endpoint, but 4xx only counts on endpoints OTHER than
/eth/v2/beacon/blocks/{block_id}. Denominator (total request volume)
is unchanged — the excluded 404s still appear in the Request Rate by
Endpoint timeseries inside the Beacon API row, just not as errors
here. 5xx / error responses on the block endpoint still count (those
DO signal real infrastructure trouble).

Updated the panel description to call out the exclusion.
…d count

Added 4 columns to panel 502 (Validator Clusters — top 50 by issue
score) and reordered the column set for readability:

  Container | Validators | Success % | Missed | Missed rate | Delayed >tol | Delayed >tol rate | Delayed | Delayed rate | Served

  - Missed rate     = Missed / (Served + Missed)
  - Delayed >tol rate = Delayed >tol / (Served + Missed)
  - Delayed         = count of canonical attestations included 2+ slots
                      later than optimal (spec distance ≥ 3, computed
                      from the canonical-distance histogram as
                      count − bucket{le=1.0} — the same expression the
                      issue-score formula already uses internally).
  - Delayed rate    = Delayed / (Served + Missed)

The column order groups Success % up front as the headline indicator,
then walks issue counts in descending severity (Missed → Delayed >tol
→ Delayed), each count immediately followed by its rate. Served sits
at the right as the least-actionable column.

Rate columns share thresholds (green / yellow @ 1% / red @ 5%) with
the API Errors stat. Count columns reuse the existing 1 / 10 threshold
band. Delayed = ≥2 slots late from optimal is the closest "any-delay"
definition the linear-1..32 histogram permits (the smallest bucket
le=1.0 conflates spec=1 with spec=2). Issue-score formula is unchanged.
The Attestation deficit (fleet) panel (id 10) was plotting three lines:
Missed, Delayed >tol, and a Distance loss line (Attestant's
1 − 1/avg_distance, computed as sum/(sum+count) over the canonical
distance histogram) pinned to a separate right y-axis.

Removed the Distance loss target, its byName field override, and the
matching description paragraph. The right axis goes with it, leaving a
two-series chart of just Missed and Delayed >tol on a single left axis.

The underlying canonical-distance histograms stay — the Distance
distribution row still renders them and the cluster table's "Delayed"
column still derives from count − bucket{le=1.0}.
…altime

Panel 12 (Attester effectiveness) was pinned to a fixed [7d] window on
every rate() call. The 7-day smoothing was originally added because
Attestant's docs caution that single-attestation values are too noisy
to act on as an SLO — but operators looking at this dashboard mid-
incident need to see what the fleet is doing NOW, not what the average
was over the previous week.

Switched all 6 rate() windows from [7d] to [$__rate_interval] so the
line auto-sizes per viewport pixel. Renamed the title to drop the "—
7d rolling" suffix and rewrote the description to explain the new
behaviour and the noise tradeoff for short ranges.
…lation

After switching panel 12 from [7d] to [\$__rate_interval], the line
oscillated wildly between 100% and 0%/NaN gaps. Root cause: the
underlying counters (ETH2_totalMissedAttestations, ETH2_totalServed-
Attestations, ETH2_canonicalAttestationDistances_*) only step at epoch
boundaries — every 32 slots × 12s ≈ 6.4 min. Any rate() window
narrower than ~13 minutes can span zero epoch transitions, in which
case rate() returns NaN (only 1 sample within window) or 0 (samples
present but counter unchanged). The formula then either:
  - clamps to 100% (both rates 0, 0/clamp_min(0,1e-9) = 0 → 1-0 = 1),
    visible as the high plateaus, or
  - propagates NaN, visible as gaps with spanNulls:false.

Pinned the window to [15m] in all 6 rate() calls — always spans ≥2
epoch boundaries on Hoodi cadence, so the rate is well-defined and the
line tracks recent fleet behaviour cleanly. Updated legendFormat from
"Effectiveness (7d)" to "Effectiveness (15m)" and explained the
choice in the panel description.
…anels

Audited every \$__rate_interval usage against the underlying counter's
emission cadence. The monitor's attestation counters step only at
epoch boundaries (32 slots × 12s ≈ 6.4 min), so any rate window
narrower than ~13 min oscillates between high spikes (at the
boundary) and NaN/zero gaps (between boundaries). Same root cause as
b7b997d (Attester effectiveness).

Pinned to [15m] — always spans ≥2 epoch boundaries:

  - Panel 10  (Attestation deficit fleet)         Missed / Delayed >tol
  - Panel 205 (Cross-Epoch Attestations rate)
  - Panel 203 (Duplicate Attestations Skipped rate)

Kept \$__rate_interval where the counter is request-cadence rather
than epoch-cadence:

  - Panel 401 (Request Rate by Endpoint) — ETH2_beaconAPIRequestsTotal
    increments per beacon-API request; many increments per minute, so
    \$__rate_interval is well-defined at any viewport width. The
    panel's existing description already documents this choice.

Out of scope: panels 601/602 (per-cluster 1m timeseries) use [1m]
which has the same root-cause problem against epoch-stepped counters;
their titles announce the "1m" cadence explicitly, so left alone for
a separate decision.
…tol / Delayed %

Surface the four primary attestation-quality KPIs as prominent stat
tiles in a new row directly under the existing top stat strip, so an
operator opening the dashboard immediately sees the fleet headline
numbers without having to sum the cluster table or pick a container:

  - Success %       — sum(served) / (sum(served)+sum(missed))
  - Missed %        — sum(missed) / (sum(served)+sum(missed))
  - Delayed >tol %  — sum(delayed_over_tol) / (sum(served)+sum(missed))
  - Delayed %       — (sum(canonical_count) − sum(bucket{le=1.0}))
                       / (sum(served)+sum(missed))

These are the fleet-wide counterparts of the rate columns in the
Validator Clusters table, using the same shared denominator.

Layout: 4 tiles at y=6, x=0/6/12/18 with w=6 each, h=4. The
Attestation deficit / Attester effectiveness charts and everything
below shift down by +4 rows; the panels array order matches the new
y-ordering.

Success % uses high-is-good thresholds (red below 99%, green ≥ 99.9%);
the three issue rates use high-is-bad thresholds (green / yellow @ 1%
/ red @ 5%) matching the API Errors band. All four use [\$__range]
for the rate windows (the rates cancel out, giving an exact ratio
over the dashboard time selection), consistent with the other stat
tiles.

The existing "Attester Eff." (different formula — multiplicative
availability×inclusion) and "Delayed Rate" (different denominator —
served only) tiles are kept; they overlap conceptually but are not
duplicates.
After 773324f split the API Errors numerator into two sum() terms
(5xx|error + filtered 4xx), the tile started rendering "No data" on
healthy fleets where neither selector matches anything. Root cause is
the Prometheus idiom that sum() over zero matching series returns the
empty vector, NOT zero — and vector addition propagates emptiness, so
empty + empty = empty, the division yields empty, and Grafana shows
"No data".

Wrapped each numerator term in (sum(...) or vector(0)) so an empty
sum() falls back to a labelless scalar 0. Addition is then always
well-defined and the tile renders 0.00% on a clean fleet, climbing
when a real 5xx or non-missed-block 4xx actually fires. The
denominator still uses clamp_min; in practice it's never empty because
the monitor is always making beacon-API requests.

The semantics of the exclusion are unchanged — missed-block 404s
still don't count as errors.
Deleted the collapsed "About this dashboard" row at y=0 (panel 800)
along with its nested markdown content panels (900-907): What this
dashboard is for, Quick glossary, How to read this dashboard,
Variables, Effectiveness explained, Severity bands, When effectiveness
drops, References.

Also stripped the dashboard-level description's pointer to the About
row, which is now stale.

The visible content (Global health, Clusters, Validators, collapsed
trend rows) is unchanged. The 1-row gap left at y=0 is invisible at
render time since nothing else lives there.
Removed panel 203 (timeseries inside the Beacon API row) — it tracked
the rate of (validator, slot) dedup-skip events, which is an internal
eth2-monitor counter rather than a validator-performance metric. The
Beacon API row keeps the four endpoint-rate / error-rate / duration
panels that actually inform infrastructure health.
The Distance distribution / Proposals / Beacon API rows were
collapsed:true with their child panels packed inside the row's nested
panels[] array (Grafana's collapsed-row storage format). On dashboard
load, the operator had to manually expand each row to see anything.

Flipped all three rows to collapsed:false, lifted every nested child
to the top-level panels[] array, and laid out their gridPos.y
sequentially below each row header:

  y=50  row 200  Distance distribution
  y=51  201, 202  Canonical / Raw distance distributions (h=8)
  y=59  204, 205  Missed Slots In Epoch / Cross-Epoch rate (h=8)
  y=67  row 300  Proposals (was y=51 collapsed)
  y=68  301, 302, 303  Served / Missed / Empty proposals (h=8)
  y=76  304-308  Last-event stat tiles (h=4)
  y=80  row 400  Beacon API (was y=52 collapsed)
  y=81  401, 402  Request rate / Error rate (h=8)
  y=89  403, 404  Request duration p50 / p99 (h=8)

Other rows (Global health, Clusters, Validators) were already
non-collapsed; no change to their content.
Replaced every short fixed window ([1m], [5m], [10m]) with [15m]
across query expressions. Total: 9 occurrences:

  - Panels 601, 602 (per-cluster missed / delayed-over-tolerance):
    3 [1m] each. Their titles and descriptions also said "1m" /
    "1 minute" — updated to "15m" / "15 minutes" to match.
  - Panel 402 (Error Rate by Endpoint + status_class): 1 [5m].
  - Panels 403, 404 (Request Duration p50 / p99): 1 [10m] each.

Same root cause as the earlier $__rate_interval audit: the eth2-monitor
attestation counters step at epoch boundaries (~6.4 min), so [1m] /
[5m] windows can span zero epoch transitions and yield NaN gaps or
zero-plateau oscillation. [10m] borderline cases get smoothed by the
upgrade. [15m] always spans ≥ 2 epoch boundaries on Hoodi / mainnet.

The beacon-API metrics (panels 402-404) are per-request so they
technically tolerate shorter windows, but unifying to [15m] keeps the
dashboard's smoothing behavior consistent and the operator's reading
of rate panels predictable. The only [$__rate_interval] usage left
(panel 401 Request Rate by Endpoint) is intentional — its description
already documents the auto-sizing choice for cold-start avoidance.
The dashboard carried several academic terms inherited from Attestant
/ Rated / BeaconScore methodology pages ("Attester effectiveness",
"RAVER-style proposer effectiveness", "Tier-3 indicator (Attestant
model)", "Attestant cause stakefish#1") and two opaque multiplicative
formulas. With the recently-added Success / Missed / Delayed >tol /
Delayed % stat row, the academic-jargon tiles are mostly redundant.

Changes:

  - Delete panel 2 "Attester Eff." stat tile — its
    (1 − missed/total) × (count/(sum+count)) formula multiplied
    availability by an inclusion-distance ratio; both factors now
    live as separate plain tiles in the new bottom stat row.
  - Delete panel 12 "Attester effectiveness" timeseries — same
    multiplicative formula in chart form. The Attestation deficit
    panel beside it already plots Missed % and Delayed >tol %
    directly, which is what an operator actually wants to track.
  - Top stat row: shift the 5 remaining tiles left to pack from x=0
    (Current Epoch / Proposer Eff / Delayed Rate / Distance p99 /
    API Errors at x=0/4/8/12/16).
  - Attestation deficit panel: widen w=12 → w=24 to fill the row
    now that the effectiveness chart is gone.
  - Rewrite Proposer Eff. (panel 6):
    title: "Proposer Eff. (24h)" → "Proposer Success % (24h)"
    formula: dropped the RAVER 0.75×empty subtraction; now plain
    served / (served+missed) where served = non_empty + empty
    description: rewritten without RAVER reference.
  - Description sweep on panels 3, 205, 501 to drop "Tier-3
    indicator (Attestant model)", "Attestant cause stakefish#1: generation
    delays", and "Per-validator effectiveness snapshot".

Zero remaining occurrences of effective/RAVER/BeaconScore/Attestant
in the dashboard JSON. histogram_quantile() in the API duration
panels stays — p50/p99 is universal Prometheus idiom, not jargon.
The 9 stat tiles under Global health already had graphMode:"area"
configured, but each target was instant:true range:false — so the
query returned a single scalar with no time series for Grafana to
plot behind the big number. Switched every target to instant:false
range:true so Prometheus returns a series across the dashboard's
selected time range; the lastNotNull reducer keeps using the latest
value as the big number, and the rest of the series fills in as the
soft area sparkline behind it.

Rate-using tiles also had their [\$__range] windows replaced with
[15m]. With range:true, [\$__range] at every step evaluates rate()
over the full dashboard time range — at any step in the middle of
the series, that window reaches backwards by hours/days and smears
the sparkline into a flat line. [15m] gives a proper trailing window
matching the rest of the rate-using panels in this dashboard. Panel
6 (Proposer Success %) keeps [24h] for its sparse-duties semantic;
panel 1 (Current Epoch) uses the gauge directly with no window.

The big-number semantics shift from "average over the dashboard time
range" to "last 15m" (or "last 24h" for Proposer Success %). For
at-a-glance fleet health that's more useful — long ranges previously
dragged everything toward neutrality. The dashboard's other
rate-using charts already use [15m], so the framing is now
consistent end-to-end.
The Proposer Success % tile was producing a single sparse signal that
spent most of its time reading "No data" (proposer duties are rare —
even a small fleet on a long tail of inactive validators rarely
satisfies the 5-duty floor at any given moment). Removed it.

The 3 remaining top-row stat tiles shift left by 4 columns each
(Delayed Rate / Distance p99 / API Errors at x=4/8/12) so the
Current Epoch + 3 others pack against the left edge. Right half
of the top row (x=16..23) is now empty.

Proposer-side metrics are still surfaced in the Proposals row
(panels 301-308) which separates served / missed / empty / vanilla
explicitly — more useful than a single bundled "% success" tile.
After removing Attester Eff. (b16e239) and Proposer Success %
(b053db2), the top stat row had only 4 tiles packed into the left
half (x=0..15) with x=16..23 empty, while the second row was full
width. Also Delayed Rate (panel 5) was a near-duplicate of
Delayed >tol % (panel 32) — same numerator, only the missed-rate
term distinguishes the denominators.

Dropped Delayed Rate (panel 5) and reorganised the remaining 7
tiles into two balanced rows that each fill the full 24-col grid:

  Row 1 (y=2, h=4): Success % | Missed % | Delayed >tol % | Delayed %
    health KPIs first — operator's incident-time glance
    w=6 each (4 × 6 = 24)

  Row 2 (y=6, h=4): Current Epoch | Distance p99 | API Errors
    monitor liveness + supporting context / infra
    w=8 each (3 × 8 = 24)

All retained tiles keep their existing queries, sparklines, units,
and threshold bands.
Added the healthy-baseline counterpart to the issue-side metrics that
currently dominate the Global health and Clusters sections. Two new
timeseries panels:

  - Panel 11 "Attestation served rate (15m)" — fleet-wide single line,
    sits at y=10 x=0 w=12 next to the Attestation deficit chart which
    shrinks from full-width to the right half (x=12 w=12). The two
    panels now mirror "throughput" + "failure rate" side by side.

  - Panel 605 "Attestation served rate (per cluster, 15m)" — one line
    per container, sits at y=26 x=0 w=8 as the leftmost panel in the
    Clusters trio. Panels 601 (% missed) and 602 (% delayed-over-tol)
    shift right to x=8/w=8 and x=16/w=8 respectively. The three panels
    now fill the 24-col grid evenly: served | missed | delayed-over-tol.

Both new panels use sum(rate(ETH2_totalServedAttestations{…}[15m])),
unit "ops", min 0. The fleet panel uses fixed green; per-cluster uses
palette-classic for multi-line distinction.

Hid the Served column in the Validator Clusters table (panel 502) by
adding "Served": true to the organize step's excludeByName. The
column's underlying value (Value #E) is still computed and fed into
Total / Success % / Missed rate / Delayed rate / Delayed >tol rate —
those rate columns continue to render correctly because exclude-by-name
runs AFTER the calculateField transforms.
Reordered the Validators table (panel 501) metric columns from
"Missed → Success % → Delayed >tol → Served" to
"Success % → Missed → Delayed >tol → Served" by updating the
organize transformation's indexByName map.

Identifier columns (Index, Container, Pubkey) keep their position at
the front. The new order matches the Cluster table's grouping
(headline health metric first, then severity-descending issue counts,
served count last as least-actionable context).
The view_mode template variable's two options ("Problematic only" = 0
default, "All" = -1) toggled the topk score threshold in the Cluster
and Validator tables. Operators never selected "All" in practice — a
healthy dashboard means the tables stay empty, and "All" produced a
wall of 99.99% rows that buried any actual issue. Removing the
toggle makes the dashboard always behave as the useful default.

Changes:
  - Replaced all 10 \$view_mode references (6 in panel 502, 4 in
    panel 501) with the literal 0. Both tables now always filter to
    rows with non-zero issue score (top 50 / top-\$top respectively).
  - Removed the view_mode entry from .templating.list — the "View"
    dropdown is gone from the top variable bar.
  - Updated both table descriptions to drop the View-toggle clause,
    explaining the always-on filter behaviour instead.
  - While in the Validators description, also dropped the stale
    "drilldown row below" wording — those state-timeline panels
    (603/604) were deleted in 6e08b71; the Index link now filters
    the dashboard rather than pinning a panel.
Mixed terminology in the dashboard UI — some places said "container"
(the underlying Prometheus label name), others said "cluster" (the
operator-facing concept). Renamed every display string to "cluster"
for consistency:

  - Title: "Clusters (by container)" → "Clusters"
  - Distance p99 description: "Per container, …" → "Per cluster, …"
  - Validator Clusters table description: "Per-cluster (container)
    rollup …; Click a Container cell …" → "Per-cluster rollup …;
    Click a Cluster cell …"
  - Per-cluster panel descriptions: "One line per scrape container"
    × 2 → "One line per scrape cluster"; "One line per container —"
    → "One line per cluster —"
  - Missed Slots In Epoch description: "per container" / "one
    container" → "per cluster" / "one cluster"
  - Variable description: dropped the "(Prometheus label: container)"
    implementation-detail leak
  - Both tables' column header: "Container" → "Cluster" (organize
    renameByName) plus the dependent byName field override matcher

The underlying Prometheus label name stays as "container" — all
queries, label selectors, aggregation keys, variable internal name,
URL params, legendFormat substitutions, and prometheus.yml scrape
config are untouched. This is a pure display-string change.
The rate-based Global health tiles answer "how bad per-unit-time" but
not "how many events happened over the selected time range". Added
three absolute-count stat tiles for the latter:

  - Total Served      = sum(increase(totalServedAttestations[\$__range]))
  - Total Missed      = sum(increase(totalMissedAttestations[\$__range]))
  - Total Delayed >tol = sum(increase(totalDelayed…[\$__range]))

New stat row at y=10, h=4, three tiles at x=0/8/16, w=8 each (full
24-col grid). Big-number-only (graphMode: "none") since totals are
headline figures, not trends — operators already have the rate-based
sparklines two rows above.

Thresholds: Total Served uses low-is-bad (red < 1, green ≥ 1 — zero
served means nothing's working); Total Missed and Total Delayed >tol
use high-is-bad (green / yellow @ 1 / red @ 10) to mirror the rate
tiles' colour band convention.

Shifted every panel at y≥10 down by +4 to make room — uniform shift
preserves relative spacing for the chart row (now y=14), Clusters row
(y=21), per-cluster timeseries (y=30), Validators row+table (y=39/40),
and all expanded inner rows below.
The 4 rate-based health stat tiles (Success % / Missed % / Delayed >tol
% / Delayed %) used lastNotNull as the reducer, so the big number
showed the most recent 15-minute value. Operators want the headline
number to be the average across the dashboard's selected time range —
"how did the fleet do overall in this window" — not "how is it doing
right now". The sparkline tail is still visible for the latter.

Switched calcs from "lastNotNull" to "mean" on panel ids 30/31/32/33.
The other Global-health tiles (Current Epoch — gauge; Distance p99 —
quantile; API Errors — rate ratio; Totals — single-point increases)
keep lastNotNull since their semantics work better as snapshots.
The Total Served / Total Missed / Total Delayed >tol stat tiles (panels
34/35/36) were rendered as bare big-numbers (graphMode: "none") with
instant queries. Switched all three to graphMode "area" with
instant:false range:true so Prometheus returns a series across the
dashboard time range and Grafana plots a soft area sparkline behind
the big number.

Query unchanged: sum(increase(ETH2_total*Attestations[\$__range])).
With range:true, at each step t the expression evaluates over the
trailing window [t − \$__range, t], so the series shows how the
"trailing-range total" evolves across the dashboard window. The
lastNotNull reducer keeps the big number = value at the right edge =
total over the currently-selected range (semantic unchanged).

A spike in the sparkline visually indicates a burst entered the
trailing window; the line returns to baseline once the burst exits.
…axis

Panels 201 (Canonical Distance Distribution) and 202 (Raw Distance
Distribution) plotted raw bucket counts on the y-axis. Switched them
to normalised fractions so each bar shows the share of attestations
that fell in that bucket, summing to 100% across all buckets.

Query change: divide cumulative bucket counts by the histogram total
(sum(increase(_count[\$__range]))) — Grafana's histogram panel
internally de-cumulates the cumulative fractions, so the rendered
bars show per-bucket shares.

Field config: unit "short" → "percentunit". Description updated to
say "share of attestations" instead of "sample count".

This makes the histogram shape comparable across time windows and
fleet sizes: the peak height (typically near 0–1 shifted slots, i.e.
spec distance 1–2) is now a percentage rather than an absolute count,
so the curve reads the same whether you're looking at one cluster of
a hundred validators or the whole fleet.
Added options.xAxisLabel = "Distance (shifted slots, 0 = optimal)"
to panels 201 (Canonical Distance Distribution) and 202 (Raw Distance
Distribution). The bucket boundaries already shown on the x-axis
(le=1..32) ARE the distances, but without an axis label the operator
had to read the description to figure that out. Now it's
self-explanatory.

Pairs with f7dc374 (percentage y-axis) — the histograms now read
"share of attestations" along Y and "distance from optimal" along X
without needing the description.
…able

Clicking a validator's Index in the Validators table sets the
\$validator_index variable, but until now nothing beneath the table
actually surfaced that validator's behaviour over time. Added three
timeseries panels in the Validators row, sitting directly below the
table at y=54, each w=8 (full 24-col grid):

  - Panel 700 "Served attestations (per validator, stacked, 15m)"
    Stacked area chart, rate(totalServed[15m]) by validator_index.
    Useful with the default "All" filter to see total fleet throughput
    split per validator, or after drilling down to compare a few
    selected validators.
  - Panel 701 "% missed (per validator, 15m)"
    Per-validator miss share over a 15-minute trailing window.
  - Panel 702 "% delayed-over-tolerance (per validator, 15m)"
    Per-validator delayed-over-tol share.

All three queries respect the dashboard filters (\$service /
\$container / \$validator_index), so the operator's typical workflow
— pick a cluster, click a validator's Index — narrows the three
panels to a single validator's trend.

Shifted Distance distribution / Proposals / Beacon API rows and their
expanded content down by +8 to make room.
… count

Panel 700 plotted the 15-minute rate of served attestations per
validator. Switched it to the raw cumulative counter so the chart
shows the actual accumulated attestation count over time — a
monotonically rising stacked area that visualises how each validator
contributes to the fleet total.

Changes:
  - Query: sum by (...)(rate(totalServed[15m])) → sum by (...)
    (totalServedAttestations) (no rate, raw counter value)
  - Unit: ops → short (event count, not events/sec)
  - Title: "Served attestations (per validator, stacked, 15m)" →
    "Served attestations (per validator, stacked, accumulated)"
  - Description: rewritten to clarify the cumulative semantics

% missed and % delayed-over-tolerance panels (701, 702) are unaffected
— they remain 15m rate-based since the per-second share is what
operators want to track for those.
…m legends

Flipped the order within the Validators row:

  - 3 per-validator diagrams (700 Served accumulated, 701 % missed,
    702 % delayed-over-tolerance) moved from y=54 to y=40 — now they
    sit directly below the row header
  - Validators table (501) moved from y=40 to y=48 — sits below the
    diagrams as the legend / lookup reference

Hid legends on all three diagrams (showLegend: false). The table
below already lists every validator with Index / Container / Pubkey /
Success % / Missed / Delayed >tol — that's a more compact and more
informative legend than a multi-line bottom-table in each panel.
Operators can hover a line for the tooltip, then cross-reference
against the table.

Section height (y=39..61) is unchanged so nothing below shifts.
Reconciles three sections with the supervisor work landed in 1efd600:
- Codebase Structure: add supervisor.go / supervisor_test.go; note
  monitoring.go's SubscribeToEpochs is wrapped in subscribeWithRetry.
- Architecture: rewrite "two goroutines" paragraph to introduce the
  outer monitoring.Supervise restart loop (cli/root.go:137) and the
  inner subscribeWithRetry wrapper (monitoring.go:180).
- Test Fixtures table: soften the empty_block row to match reality —
  the bundle is opportunistically captured, may be absent on a fresh
  clone, and scenario_empty_block_test.go t.Skip's accordingly.

Doc-only change; verified via three parallel audit passes that every
new factual claim (function names, file paths, line numbers, error
strings, metric labels, CLI flags) matches the code on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stakepeter
stakepeter merged commit 19197e7 into stakefish:master May 16, 2026
8 checks passed
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.

1 participant