From b7b4c3733797be23147ac836bda64b039cf734a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:00:15 -0300 Subject: [PATCH 1/2] feat: add fork choice counter metrics Add Group 3 metrics from the leanMetrics specification: - lean_attestations_valid_total: Count of valid attestations with source label (block/gossip) - lean_attestations_invalid_total: Count of invalid attestations with source label (block/gossip) - lean_fork_choice_reorgs_total: Count of fork choice reorganizations Attestation counting is added to on_gossip_attestation (gossip source) and on_block (block source). Reorg detection is added to update_head by checking if the new head is not a direct child of the previous head. --- crates/blockchain/src/metrics.rs | 45 +++++++++++++++++++++++ crates/blockchain/src/store.rs | 63 ++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index a9ffbd1..e151780 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -84,3 +84,48 @@ pub fn set_node_start_time() { .as_secs(); LEAN_NODE_START_TIME_SECONDS.set(timestamp as i64); } + +/// Increment the valid attestations counter. +pub fn inc_attestations_valid(source: &str) { + static LEAN_ATTESTATIONS_VALID_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + prometheus::register_int_counter_vec!( + "lean_attestations_valid_total", + "Count of valid attestations", + &["source"] + ) + .unwrap() + }); + LEAN_ATTESTATIONS_VALID_TOTAL + .with_label_values(&[source]) + .inc(); +} + +/// Increment the invalid attestations counter. +pub fn inc_attestations_invalid(source: &str) { + static LEAN_ATTESTATIONS_INVALID_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + prometheus::register_int_counter_vec!( + "lean_attestations_invalid_total", + "Count of invalid attestations", + &["source"] + ) + .unwrap() + }); + LEAN_ATTESTATIONS_INVALID_TOTAL + .with_label_values(&[source]) + .inc(); +} + +/// Increment the fork choice reorgs counter. +pub fn inc_fork_choice_reorgs() { + static LEAN_FORK_CHOICE_REORGS_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + prometheus::register_int_counter!( + "lean_fork_choice_reorgs_total", + "Count of fork choice reorganizations" + ) + .unwrap() + }); + LEAN_FORK_CHOICE_REORGS_TOTAL.inc(); +} diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index d921d5f..180354a 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -16,7 +16,7 @@ use ethlambda_types::{ }; use tracing::{info, trace, warn}; -use crate::SECONDS_PER_SLOT; +use crate::{SECONDS_PER_SLOT, metrics}; const JUSTIFICATION_LOOKBACK_SLOTS: u64 = 3; @@ -175,13 +175,20 @@ impl Store { } pub fn update_head(&mut self) { - let head = ethlambda_fork_choice::compute_lmd_ghost_head( + let old_head = self.head; + let new_head = ethlambda_fork_choice::compute_lmd_ghost_head( self.latest_justified.root, &self.blocks, &self.latest_known_attestations, 0, ); - self.head = head; + + if is_reorg(old_head, new_head, &self.blocks) { + metrics::inc_fork_choice_reorgs(); + info!(%old_head, %new_head, "Fork choice reorg detected"); + } + + self.head = new_head; } pub fn update_safe_target(&mut self) { @@ -308,7 +315,10 @@ impl Store { validator_id, data: signed_attestation.message, }; - self.validate_attestation(&attestation)?; + if let Err(err) = self.validate_attestation(&attestation) { + metrics::inc_attestations_invalid("gossip"); + return Err(err); + } let target = attestation.data.target; let target_state = self .states @@ -339,6 +349,7 @@ impl Store { let signature = ValidatorSignature::from_bytes(&signed_attestation.signature) .map_err(|_| StoreError::SignatureDecodingFailed)?; self.gossip_signatures.insert(signature_key, signature); + metrics::inc_attestations_valid("gossip"); } Ok(()) } @@ -501,6 +512,9 @@ impl Store { // TODO: validate attestations before processing if let Err(err) = self.on_attestation(attestation, true) { warn!(%slot, %validator_id, %err, "Invalid attestation in block"); + metrics::inc_attestations_invalid("block"); + } else { + metrics::inc_attestations_valid("block"); } } } @@ -716,6 +730,47 @@ impl Store { } } +/// Check if a head change represents a reorg. +/// +/// A reorg occurs when the chains diverge - i.e., when walking back from the higher +/// slot head to the lower slot head's slot, we don't arrive at the lower slot head. +fn is_reorg(old_head: H256, new_head: H256, blocks: &HashMap) -> bool { + if new_head == old_head { + return false; + } + + let Some(old_head_block) = blocks.get(&old_head) else { + return false; + }; + + let Some(new_head_block) = blocks.get(&new_head) else { + return false; + }; + + let old_slot = old_head_block.slot; + let new_slot = new_head_block.slot; + + // Determine which head has the higher slot and walk back from it + let (mut current_root, target_slot, target_root) = if new_slot >= old_slot { + (new_head, old_slot, old_head) + } else { + (old_head, new_slot, new_head) + }; + + // Walk back through the chain until we reach the target slot + while let Some(current_block) = blocks.get(¤t_root) { + if current_block.slot <= target_slot { + // We've reached the target slot - check if we're at the target block + return current_root != target_root; + } + current_root = current_block.parent_root; + } + + // Couldn't walk back far enough (missing blocks in chain) + // Conservative: assume no reorg if we can't determine + false +} + /// Errors that can occur during Store operations. #[derive(Debug, thiserror::Error)] pub enum StoreError { From 85563265712ca641a4c32c4525428ac2dc256d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:12:21 -0300 Subject: [PATCH 2/2] docs: update metrics checklist --- docs/metrics.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/metrics.md b/docs/metrics.md index 5bfbd1e..7613d8e 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -35,10 +35,10 @@ The exposed metrics follow [the leanMetrics specification](https://github.com/le | `lean_current_slot` | Gauge | Current slot of the lean chain | On scrape | | | ✅(*) | | `lean_safe_target_slot` | Gauge | Safe target slot | On safe target update | | | ✅ | |`lean_fork_choice_block_processing_time_seconds`| Histogram | Time taken to process block | On fork choice process block | | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | □ | -|`lean_attestations_valid_total`| Counter | Total number of valid attestations | On validate attestation | source=block,gossip | | □ | -|`lean_attestations_invalid_total`| Counter | Total number of invalid attestations | On validate attestation | source=block,gossip | | □ | +|`lean_attestations_valid_total`| Counter | Total number of valid attestations | On validate attestation | source=block,gossip | | ✅ | +|`lean_attestations_invalid_total`| Counter | Total number of invalid attestations | On validate attestation | source=block,gossip | | ✅ | |`lean_attestation_validation_time_seconds`| Histogram | Time taken to validate attestation | On validate attestation | | 0.005, 0.01, 0.025, 0.05, 0.1, 1 | □ | -| `lean_fork_choice_reorgs_total` | Counter | Total number of fork choice reorgs | On fork choice reorg | | | □ | +| `lean_fork_choice_reorgs_total` | Counter | Total number of fork choice reorgs | On fork choice reorg | | | ✅ | | `lean_fork_choice_reorg_depth` | Histogram | Depth of fork choice reorgs (in blocks) | On fork choice reorg | | 1, 2, 3, 5, 7, 10, 20, 30, 50, 100 | □ | ## State Transition Metrics