Skip to content

feat(metrics): add gossip arrival-time histograms and position counters - #566

Merged
MegaRedHand merged 5 commits into
mainfrom
feat/gossip-arrival-metrics
Aug 4, 2026
Merged

feat(metrics): add gossip arrival-time histograms and position counters#566
MegaRedHand merged 5 commits into
mainfrom
feat/gossip-arrival-metrics

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Motivation

We expose plenty of production timing (lean_block_building_time_seconds, lean_pq_sig_*) but nothing about reception timing. When finality degrades the first question is usually "are votes arriving late, or not arriving at all?", and until now that could only be answered out-of-band with tooling/event-monitor, which stamps arrivals on a collector clock over SSE. These metrics answer it from inside each node, on the node's own clock, with no external collector.

Not in the leanMetrics spec yet, so they live under ## Custom Metrics (non-leanMetrics) in docs/metrics.md. Upstreaming the rows is a follow-up.

Metrics

Name Type Labels
lean_gossip_block_arrival_delay_seconds Histogram
lean_gossip_attestation_arrival_delay_seconds Histogram
lean_gossip_aggregation_arrival_delay_seconds Histogram
lean_gossip_block_arrival_total Counter position=before,inside,after
lean_gossip_attestation_arrival_total Counter position=before,inside,after
lean_gossip_aggregation_arrival_total Counter position=inside,after

Buckets, shared: 0.05, 0.1, 0.2, 0.4, 0.8, 1.2, 1.6, 2.4, 4, 8, 16, interval-aligned (0.8 = one interval, 4 = one slot).

Semantics

Arrival is stamped as the first statement of each network-message handler, before verification. Both metrics derive from one signed delta; the histogram observes its absolute value, the counter classifies it.

Kind Anchor Delta range
block interval 0 of block.slot unbounded
attestation interval 1 of data.slot unbounded
aggregate latest interval-2 boundary at or before arrival [0, 4s)

The interval, not the slot, is what counts as inside. An attestation that lands in its own slot's aggregation interval missed the production interval it was due in, so it reads after. inside is half-open: a delta of exactly one interval is after.

Why aggregates anchor differently. aggregation.rs shows an aggregate published at interval 2 of slot N can legitimately carry data.slot < N (stale-group catch-up). Anchoring those to data.slot would fill the histogram with large values that are not a health problem. Anchoring to the latest boundary bounds the delay to one slot and makes before structurally unreachable, so the aggregate counter does not export that series; a sweep unit test guards the invariant instead.

Known trade-off. Because the histogram observes the absolute delta, an arrival 300 ms early shares a bucket with one 300 ms late. The counter's position label is what separates them, and it now does so at interval rather than slot resolution.

Drive-by fix: ChainEvent::BlockGossip was firing for sync blocks

new_block is shared by the gossip topic and req/resp. Telling them apart needs a BlockSource discriminator, which also fixes a pre-existing mislabel: events.rs documents BlockGossip as "A block seen on gossip, before import", yet it fired for req/resp sync-fetched blocks too. Now gated. tooling/event-monitor sees fewer, more accurate block_gossip events; no CONTRACT.md change needed.

Sync arrivals are excluded from the block metrics entirely, since backfill delivers blocks many slots after they were due and would swamp the histogram.

Graphing

Four panels, following the conventions in leanMetrics/dashboards/; natural home is beside the existing lean_gossip_*_size_bytes panels. Dashboard JSON belongs in that repo, so it is not in this PR.

  1. Delay percentileshistogram_quantile(0.5/0.95/0.99, sum by (le, job) (rate(..._bucket[$__rate_interval]))) plus a vector(0.8) threshold line (same trick lean_tick_interval_duration_seconds uses). Above the line, the typical message misses its interval.
  2. Distribution heatmapsum by (le) (rate(..._bucket[$__rate_interval])). The in-Prometheus analogue of event-monitor's beeswarm; exposes a bimodal profile that percentiles average away.
  3. On-time fractionrate(...{position="inside"}) / rate(...), unit percentunit.
  4. Position breakdownsum by (position) (rate(..._total[$__rate_interval])), stacked. The only panel that recovers the sign the histogram discards: rising before means clock skew, rising after means propagation or CPU.

Reading caveats: percentiles between bucket bounds are interpolated, not measured; a +Inf p99 is a real answer (>1% over 16 s late), not a plotting glitch; and aggregate p99 is not comparable to the other two on one axis since it is bounded below 4 s by construction.

Design notes

  • No new module. SlotPosition, the geometry helpers and the entry points all live in metrics.rs; SlotInterval gained one method, to_ms_since_genesis(slot), the inverse of the existing from_ms_since_genesis. That keeps interval numbering encapsulated (no public index(), no explicit discriminants) and lets a round-trip test pin the two inverse matches together.
  • genesis_time is read per message from store.config() rather than cached; it is a Metadata read served from the RocksDB block cache.
  • Arrivals are recorded even for messages the store later rejects as invalid. These are network metrics, and rejection is already tracked by lean_attestations_invalid_total.

Testing

  • 5 new unit tests: 4 in metrics.rs covering the geometry helpers against pre-computed values at every interval boundary (including the half-open inside edge, the aggregate wrap, and a pre-genesis arrival), plus the SlotInterval round-trip in lib.rs.
  • cargo clippy --workspace --all-targets -- -D warnings clean.
  • Full workspace unit suite green.

Network health had plenty of block-production timing but nothing about
reception timing, so "are votes arriving late, or not arriving at all?" could
only be answered out-of-band with tooling/event-monitor's collector-side clock.

Adds three histograms recording the absolute distance between a gossip
message's arrival and the start of the interval it was due in, plus three
counters splitting arrivals into before/inside/after that interval. The
interval is what counts as inside, not the slot: an attestation landing in its
own slot's aggregation interval missed the production interval it was due in,
so it reads as after.

Aggregates anchor to the most recent aggregation-interval boundary instead of
their own data.slot, since a stale-group catch-up aggregate legitimately
carries an older slot and would otherwise fill the histogram with large values
that are not a health problem. That bounds their delay to one slot and makes
before unreachable, so the aggregate counter does not export that series.

Blocks are sampled only when received on gossip; req/resp sync backfill
delivers them many slots after they were due and would swamp the histogram.
Threading a BlockSource through new_block to tell the two apart also fixes
ChainEvent::BlockGossip, which events.rs already documents as gossip-only yet
until now also fired for sync-fetched blocks.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Devnet verification

4-node all-ethlambda local devnet on this branch, one aggregator, attestation_committee_count: 1, run to slot 43. Chain healthy throughout: head=43 justified=39 finalized=36 on all four nodes, 3 peers each, no panics. The only log warnings were ordinary local noise (QUIC handshake timeouts at startup, the macOS Docker quinn_udp GSO EINVAL that self-disables and continues, peer churn), none related to this change.

All six metrics populate

Mean delay per node, at slot 43:

Node block attestation aggregation
ethlambda_0 (aggregator) 460 ms (n=30) 101 ms (n=121) n=0
ethlambda_1 340 ms (n=29) 25 ms (n=115) 15 ms (n=14)
ethlambda_2 436 ms (n=29) 66 ms (n=123) 427 ms (n=18)
ethlambda_3 530 ms (n=30) 97 ms (n=124) 342 ms (n=18)

The n=0 on the aggregator is the behaviour you want to see: it produces aggregates, so none arrive to it over gossip, while the three non-aggregators each received 14-18. Block means of 340-530 ms are consistent with publication landing a few hundred ms into interval 0 once building completes.

Invariants checked atomically per scrape

On every node, for every kind:

  • before + inside + after == histogram_count — matched everywhere, so no observation is counted in one metric but not the other.
  • bucket(le="0.8") == inside — matched everywhere. This is the structural cross-check that position_from_delta and the absolute-value observation agree: inside is exactly delta ∈ [0, one interval), and since nothing arrived early on a single-clock devnet, |delta| <= 0.8 coincides with inside.

Label seeding behaves as designed

Before any gossip arrived, all series already existed at zero, and the aggregation counter exports only two:

lean_gossip_aggregation_arrival_total{position="after"} 0
lean_gossip_aggregation_arrival_total{position="inside"} 0
lean_gossip_attestation_arrival_total{position="after"} 0
lean_gossip_attestation_arrival_total{position="before"} 0
lean_gossip_attestation_arrival_total{position="inside"} 0
lean_gossip_block_arrival_total{position="after"} 0
lean_gossip_block_arrival_total{position="before"} 0
lean_gossip_block_arrival_total{position="inside"} 0

There is no lean_gossip_aggregation_arrival_total{position="before"} series at all, confirming the unreachable label is never exported rather than sitting at a permanent zero.

Bucket distribution is sane

ethlambda_3 blocks, cumulative:

le=0.05  2     le=0.8   17    le=4    20
le=0.1   9     le=1.2   18    le=8    21
le=0.2   10    le=1.6   18    le=16   21
le=0.4   16    le=2.4   18    +Inf    21

Resolution sits where it is needed: the sub-interval range 0.05-0.4 separates most of the mass, the 0.8 bound marks the inside/after cutoff, and the tail stays sparse.

Caveat on what this run did not exercise

position="before" stayed at 0 for blocks and attestations throughout. All four nodes share one host clock, so nothing can arrive early; that path is covered by unit tests, not by this run. Exercising it for real needs clock skew between hosts, i.e. a multi-server devnet.

Test suite

Full suite green: 223 workspace unit tests (5 new), plus 122 forkchoice / 74 stf / 119 ssz / 3 signature spec tests and 8 test_driver_e2e. make lint clean.

…trics

An aggregator never receives its own aggregate back over gossip, so it
reported an empty aggregate arrival profile: the one node whose aggregation
timing matters most was the one node with no data.

Observes locally produced aggregates in the same series from
Handler<AggregateProduced>, which already exists for exactly this asymmetry
(it emits ChainEvent::Aggregate for the same reason). The timestamp is taken
at handler entry but observed after the stale-session guard, so a late
worker's discarded output does not inflate the count.

Sharing one series with received aggregates rather than splitting by a source
label costs little in practice. Delivery of a local aggregate is held to the
interval-2 boundary upstream, so it lands near zero unless proving overran the
interval, and a late aggregate is late for every node at once. Measured on a
4-node devnet the two populations are near-identical: of the sole aggregator's
24 own aggregates 67% fell in the lowest bucket, against 70% of a receiver's
23 gossip-received ones, with a matching overrun tail on both. Both are
dominated by production time, not propagation.
@MegaRedHand

Copy link
Copy Markdown
Collaborator Author

Follow-up: an aggregator now counts its own aggregates

An aggregator never receives its own aggregate back over gossip, so lean_gossip_aggregation_arrival_* sat at zero on the one node whose aggregation timing matters most. Fixed in 742bb53 by observing locally produced aggregates from Handler<AggregateProduced>, which already existed for this exact asymmetry (it emits ChainEvent::Aggregate for the same reason). The timestamp is taken at handler entry but observed after the stale-session guard, so a late worker's discarded output does not inflate the count.

Own and received aggregates share one unlabelled series. I initially expected that to be a meaningful compromise, on the reasoning that a local aggregate's delivery is held to the interval-2 boundary and so measures proving overrun, whereas a received one measures propagation. A second devnet run showed that expectation was wrong, so the docs and code comment now say what is actually observed.

ethlambda_0 is the sole aggregator, so every one of its samples is its own output, and ethlambda_3 receives all of its from gossip. Cumulative buckets on a healthy finalizing chain (head tracking current exactly, fin=48):

bucket ethlambda_0 (24 own) ethlambda_3 (23 received)
≤ 0.05 16 (67%) 16 (70%)
0.05-1.6 2 3
1.6-4.0 6 4

Near-identical, in both mode and tail. The reason is that a late aggregate is late for every node simultaneously, and on a single host propagation is sub-50 ms, so both populations are dominated by production time rather than propagation. Splitting them by a source label would have bought very little.

The mechanism half of the prediction did hold: two thirds land in the lowest bucket, consistent with boundary-pinned delivery, with a tail where proving overran the interval. So the operator guidance is to read a rising tail as aggregation cost and cross-check lean_pq_sig_aggregated_signatures_building_time_seconds and lean_committee_signatures_aggregation_time_seconds, not as a slow network. That is now written into docs/metrics.md and the metric help strings.

Devnet caveats worth recording

The first two restart attempts produced numbers I would have misread as real:

  • Restarting with --cleanData alone keeps the old genesis_time. With empty DBs and a genesis ~20 min in the past, all four nodes sync-gate at head 0 and limp through catch-up. That run showed fin=0 and aggregate means of 1.7-3.6 s, and put 6 of the aggregator's 7 samples in after — which would have "confirmed" a completely wrong picture.
  • --generateGenesis deletes the data/ tree without recreating the per-node directories, so the Docker Desktop bind mount has nothing to materialize and every node dies with IO error: ... while open a file for lock: /data/LOCK: No such file or directory. Pre-creating local-devnet/data/<node>/ with a marker file fixes it.

Only the third run, with fresh genesis and pre-created data dirs, gave a chain healthy enough to measure.

cargo clippy --workspace --all-targets -- -D warnings clean; blockchain lib suite green (70 passed).

Comment thread crates/blockchain/src/lib.rs Outdated
@MegaRedHand
MegaRedHand marked this pull request as ready for review August 4, 2026 19:12
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: Solid PR adding well-designed gossip arrival metrics with correct timing logic and proper separation between gossip and sync sources. No critical issues found.

Minor Suggestions:

  1. Arithmetic safety documentation (crates/blockchain/src/metrics.rs, lines 640-646):
    The interval_delta_ms function casts u64 to i64 which is safe for realistic timestamps (year 292 million+) but consider adding a debug assertion or saturating cast for defensive programming:

    // Consider:
    let expected_ms = genesis_ms.saturating_add(interval.to_ms_since_genesis(anchor_slot));
    arrival_ms as i64 - expected_ms as i64

    (Not critical given Ethereum's timeframe constraints.)

  2. Metric observation order (crates/blockchain/src/lib.rs, lines 1407-1410):
    In Handler<AggregateProduced>, arrival_ms is captured before the session validation check but only observed after. This is correct—avoids measuring stale aggregates—but ensure this ordering is


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/blockchain/src/lib.rs:116-125, crates/blockchain/src/metrics.rs:622-629, and the pre-validation call sites at crates/blockchain/src/lib.rs:1393-1400 and 1408-1414 introduce unchecked arithmetic on untrusted gossip slots. slot * MILLISECONDS_PER_SLOT and the later u64 -> i64 casts happen before block/attestation validation, so a peer can send an absurdly large slot/data.slot and make debug builds panic on overflow, while release builds wrap and emit nonsense latency samples. This is in the network-facing path, so I’d treat it as a real bug. Use checked_mul/checked_add/checked_sub in the metric helpers and drop the sample on overflow, or only observe after the slot has passed existing sanity checks.

Other than that, the BlockSource split looks clean: it keeps sync backfill out of block_gossip and out of the new block-arrival metrics without changing the actual import path.

I couldn’t run a full cargo check in this environment because Cargo needs to populate its git cache for leansig, and the provided cache paths are read-only here.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds gossip arrival-delay histograms and interval-relative position counters, while distinguishing gossip blocks from synchronization responses.

  • Adds shared arrival-time geometry and Prometheus metrics for blocks, attestations, and aggregates.
  • Records arrivals at blockchain-handler entry before message validation.
  • Extends the P2P-to-blockchain protocol with BlockSource and excludes sync-fetched blocks from gossip events and metrics.
  • Documents the custom metric names, labels, anchors, and dashboard-query guidance.

Confidence Score: 5/5

The PR appears safe to merge; no concrete blocking or independently actionable non-blocking defects were identified.

The source discriminator is propagated through both gossip and synchronization call paths, and the new metric calculations and documented interval semantics are internally consistent.

Important Files Changed

Filename Overview
crates/blockchain/src/lib.rs Records message arrival times at handler entry, gates block gossip events and metrics by source, and includes locally produced aggregates in the aggregate timing series.
crates/blockchain/src/metrics.rs Defines the new histograms, counters, interval geometry, position classification, and startup label seeding.
crates/net/api/src/lib.rs Adds the BlockSource discriminator to the typed block-delivery protocol.
crates/net/p2p/src/gossipsub/handler.rs Marks decoded gossipsub blocks as gossip-originated when forwarding them to the blockchain actor.
crates/net/p2p/src/req_resp/handlers.rs Marks blocks returned by root and range synchronization requests as sync-originated.
docs/metrics.md Documents the new custom metrics, interval-relative semantics, aggregation anchor, and graphing guidance.

Sequence Diagram

sequenceDiagram
  participant Peer
  participant P2P as P2P actor
  participant BC as Blockchain actor
  participant Metrics as Prometheus metrics
  participant Import as Validation/import

  Peer->>P2P: Gossip message
  P2P->>P2P: Decompress and SSZ-decode
  P2P->>BC: NewBlock(..., Gossip) / attestation / aggregate
  BC->>BC: Capture handler-entry time
  BC->>Metrics: Observe delay and position
  BC->>Import: Validate and process message

  Peer->>P2P: Req/resp sync block
  P2P->>BC: NewBlock(..., Sync)
  BC->>Import: Process without gossip metric/event
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/gossip-arr..." | Re-trigger Greptile

@MegaRedHand
MegaRedHand merged commit 9ed607f into main Aug 4, 2026
9 of 10 checks passed
@MegaRedHand
MegaRedHand deleted the feat/gossip-arrival-metrics branch August 4, 2026 20:45
MegaRedHand added a commit that referenced this pull request Aug 4, 2026
#566 added `lean_gossip_*_arrival_delay_seconds` and `lean_gossip_*_arrival_total`,
which answer "are votes arriving late, or not arriving at all?" from inside each
node. Nothing graphed them, so answering it still meant an out-of-band
event-monitor run against a collector clock.

Adds a "Gossip Arrival Timing" row to `client-dashboard.json`: a 3x4 grid, one
column per message kind (block / attestation / aggregate), one row per view.

- delay p99/p50 per node, with a dashed line at one interval (0.8s): above it the
  typical message misses the interval it was due in
- delay distribution heatmap, which exposes the bimodal profile that percentiles
  average away
- arrival position stacked by `position`, the only view that recovers the sign the
  absolute-value histogram discards (rising `before` is clock skew, rising `after`
  is propagation or CPU). The aggregate panel omits `before`, unreachable by
  construction since aggregates anchor to the latest aggregation-interval boundary
  rather than their own data slot
- on-time fraction per node, so one late node separates from a fleet-wide drop

All 15 queries were run against the central Prometheus before committing.

Also adds a receiver-side timing block to the node-health checklist, whose item 5
covered only the node's own duties, and corrects where dashboards get deployed:
the JSONs live in the host dir bind-mounted at `/var/lib/grafana/dashboards`, not
in `<GRAFANA_PROV_DIR>/dashboards`, which holds only the provider yaml. A JSON
dropped in the provisioning tree is silently ignored, which reads as a working
copy that never appears. Recorded as `GRAFANA_DASHBOARDS_DIR`.
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