metrics.rs (19518B)
1 use std::collections::BTreeMap; 2 3 use crate::{ 4 adapters::ui_data_store::BlockMetricRow, 5 app::{PeerDirection, PeerInfo}, 6 domain::{Amount, Block, Transaction}, 7 }; 8 9 use super::{ 10 PEER_STALE_AFTER_MS, now_ms, 11 types::{ 12 MempoolCounts, MetricsChart, MetricsLeaderboards, MetricsPoint, MetricsResponse, 13 MetricsValueKind, MineProofLeaderboardEntry, NetworkHealthLocalState, 14 NetworkHealthResponse, 15 }, 16 ui::proof_bits, 17 }; 18 19 pub(super) fn network_health( 20 local: NetworkHealthLocalState, 21 peers: &[PeerInfo], 22 mempool: MempoolCounts, 23 ) -> NetworkHealthResponse { 24 network_health_at(local, peers, mempool, now_ms()) 25 } 26 27 pub(super) fn metrics_response( 28 enabled: bool, 29 preparing: bool, 30 rows: Vec<BlockMetricRow>, 31 leaderboards: MetricsLeaderboards, 32 chain_storage_bytes: &BTreeMap<String, u64>, 33 top_mine_proofs: Vec<MineProofLeaderboardEntry>, 34 ) -> MetricsResponse { 35 let latest = rows.last().cloned(); 36 MetricsResponse { 37 enabled, 38 preparing, 39 latest, 40 leaderboards, 41 top_mine_proofs, 42 charts: vec![ 43 metrics_chart( 44 "block-time", 45 "Block interval", 46 "s", 47 MetricsValueKind::Seconds, 48 &rows, 49 |row| { 50 row.block_time_ms 51 .filter(|_| row.height > 1) 52 .map(|ms| ms as f64 / 1_000.0) 53 }, 54 ), 55 metrics_chart( 56 "difficulty", 57 "Mining difficulty", 58 "bits", 59 MetricsValueKind::Number, 60 &rows, 61 |row| Some(row.mine_difficulty_bits as f64), 62 ), 63 metrics_chart( 64 "supply", 65 "Circulating supply", 66 "IUNA", 67 MetricsValueKind::Iuna, 68 &rows, 69 |row| Some(micro_iuna_as_iuna(row.circulating_supply)), 70 ), 71 metrics_chart( 72 "chain-storage-bytes", 73 "Compact chain size", 74 "bytes", 75 MetricsValueKind::Bytes, 76 &rows, 77 |row| { 78 chain_storage_bytes 79 .get(&row.block_hash) 80 .map(|bytes| *bytes as f64) 81 }, 82 ), 83 metrics_chart( 84 "total-utxos", 85 "UTXO set size", 86 "UTXOs", 87 MetricsValueKind::Number, 88 &rows, 89 |row| Some(row.utxo_count as f64), 90 ), 91 metrics_chart( 92 "transactions", 93 "Transactions per block", 94 "tx", 95 MetricsValueKind::Number, 96 &rows, 97 |row| Some(row.transaction_count as f64), 98 ), 99 metrics_chart( 100 "burn-count", 101 "Burns per block", 102 "burns", 103 MetricsValueKind::Number, 104 &rows, 105 |row| Some(row.burn_count as f64), 106 ), 107 metrics_chart( 108 "burn-amount", 109 "Burned per block", 110 "IUNA", 111 MetricsValueKind::Iuna, 112 &rows, 113 |row| Some(micro_iuna_as_iuna(row.burned_amount)), 114 ), 115 metrics_chart( 116 "total-burn", 117 "Cumulative burned", 118 "IUNA", 119 MetricsValueKind::Iuna, 120 &rows, 121 |row| Some(micro_iuna_as_iuna(row.total_burned_amount)), 122 ), 123 metrics_chart( 124 "fees", 125 "Fees per block", 126 "IUNA", 127 MetricsValueKind::Iuna, 128 &rows, 129 |row| Some(micro_iuna_as_iuna(row.fees_amount)), 130 ), 131 metrics_chart( 132 "mine-actions", 133 "Mine actions per block", 134 "mine", 135 MetricsValueKind::Number, 136 &rows, 137 |row| Some(row.mine_count as f64), 138 ), 139 metrics_chart( 140 "vdf-rounds", 141 "VDF work per block", 142 "rounds", 143 MetricsValueKind::Number, 144 &rows, 145 |row| (row.vdf_rounds > 0).then_some(row.vdf_rounds as f64), 146 ), 147 ], 148 } 149 } 150 151 pub(super) fn top_mine_proofs(blocks: &[Block], limit: usize) -> Vec<MineProofLeaderboardEntry> { 152 let mut proofs = blocks 153 .iter() 154 .flat_map(|block| { 155 block.transactions.iter().filter_map(|transaction| { 156 let Transaction::Mine { 157 recipient, 158 difficulty_bits, 159 signature, 160 .. 161 } = transaction 162 else { 163 return None; 164 }; 165 Some(MineProofLeaderboardEntry { 166 height: block.height, 167 address: recipient.clone(), 168 proof_bits: proof_bits(signature), 169 difficulty_bits: *difficulty_bits, 170 proof_hash: signature.clone(), 171 }) 172 }) 173 }) 174 .collect::<Vec<_>>(); 175 proofs.sort_by(|left, right| { 176 right 177 .proof_bits 178 .cmp(&left.proof_bits) 179 .then_with(|| left.height.cmp(&right.height)) 180 .then_with(|| left.proof_hash.cmp(&right.proof_hash)) 181 }); 182 proofs.truncate(limit); 183 proofs 184 } 185 186 fn metrics_chart( 187 id: &'static str, 188 title: &'static str, 189 unit: &'static str, 190 value_kind: MetricsValueKind, 191 rows: &[BlockMetricRow], 192 value: impl Fn(&BlockMetricRow) -> Option<f64>, 193 ) -> MetricsChart { 194 MetricsChart { 195 id, 196 title, 197 unit, 198 value_kind, 199 points: rows 200 .iter() 201 .filter_map(|row| { 202 value(row).map(|value| MetricsPoint { 203 height: row.height, 204 value, 205 }) 206 }) 207 .collect(), 208 } 209 } 210 211 fn micro_iuna_as_iuna(amount: Amount) -> f64 { 212 amount as f64 / 1_000_000.0 213 } 214 215 pub(super) fn network_health_at( 216 local: NetworkHealthLocalState, 217 peers: &[PeerInfo], 218 mempool: MempoolCounts, 219 now_ms: u64, 220 ) -> NetworkHealthResponse { 221 let local_height = local.height; 222 let last_block_age_ms = local 223 .tip_timestamp_ms 224 .map(|tip_timestamp_ms| now_ms.saturating_sub(tip_timestamp_ms)); 225 let remote_best_height = peers.iter().filter_map(|peer| peer.last_known_height).max(); 226 let best_known_height = remote_best_height 227 .unwrap_or(local_height) 228 .max(local.sync_target_height.unwrap_or(local_height)) 229 .max(local_height); 230 let healthy_heights = peers 231 .iter() 232 .filter(|peer| peer.last_error.is_none()) 233 .filter_map(|peer| peer.last_known_height) 234 .collect::<Vec<_>>(); 235 let shared_height = healthy_heights 236 .iter() 237 .copied() 238 .min() 239 .unwrap_or(local_height) 240 .min(local_height); 241 let outbound_peers = peers 242 .iter() 243 .filter(|peer| peer.direction != PeerDirection::Inbound) 244 .count(); 245 let inbound_peers = peers 246 .iter() 247 .filter(|peer| peer.direction == PeerDirection::Inbound) 248 .count(); 249 let healthy_peers = peers 250 .iter() 251 .filter(|peer| peer.last_error.is_none() && peer.last_known_height.is_some()) 252 .count(); 253 let failed_peers = peers 254 .iter() 255 .filter(|peer| peer.last_error.is_some()) 256 .count(); 257 let stale_peers = peers 258 .iter() 259 .filter(|peer| { 260 peer.last_success_ms.is_some_and(|last_success| { 261 now_ms.saturating_sub(last_success) > PEER_STALE_AFTER_MS 262 }) 263 }) 264 .count(); 265 let banned_peers = peers 266 .iter() 267 .filter(|peer| peer.is_banned_at(now_ms)) 268 .count(); 269 let network_time_offset_ms = median_peer_clock_offset(peers, now_ms); 270 let bad_clock_peers = peers 271 .iter() 272 .filter(|peer| { 273 peer.last_clock_observed_ms.is_some_and(|observed_ms| { 274 now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS 275 }) 276 }) 277 .filter(|peer| peer.last_clock_offset_accepted == Some(false)) 278 .count(); 279 let lag_blocks = best_known_height.saturating_sub(local_height); 280 let last_error = peers.iter().rev().find_map(|peer| { 281 peer.last_error 282 .as_ref() 283 .map(|error| format!("{}: {error}", peer.address)) 284 }); 285 let rejected_chain_payloads = local 286 .rejected_blocks 287 .saturating_add(local.rejected_block_batches) 288 .saturating_add(local.rejected_snapshots); 289 let actively_syncing = local 290 .sync_target_height 291 .is_some_and(|target_height| target_height > local_height); 292 293 let state = if actively_syncing { 294 "syncing" 295 } else if peers.is_empty() { 296 "isolated" 297 } else if banned_peers > 0 && healthy_peers == 0 { 298 "banned" 299 } else if lag_blocks > 0 { 300 "syncing" 301 } else if failed_peers > 0 && healthy_peers == 0 { 302 "peer errors" 303 } else if stale_peers > 0 && healthy_peers == stale_peers { 304 "stale" 305 } else if remote_best_height.is_some_and(|height| local_height > height) { 306 "ahead of peers" 307 } else { 308 "healthy" 309 } 310 .to_string(); 311 312 NetworkHealthResponse { 313 ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > stale_peers, 314 state, 315 local_height, 316 local_tip_hash: local.tip_hash, 317 finalized_height: local.finalized_height, 318 finalized_hash: local.finalized_hash, 319 last_block_age_ms, 320 best_known_height, 321 sync_start_height: local.sync_start_height, 322 sync_validated_height: local.sync_validated_height, 323 sync_target_height: local.sync_target_height, 324 shared_height, 325 lag_blocks, 326 outbound_peers, 327 inbound_peers, 328 healthy_peers, 329 failed_peers, 330 stale_peers, 331 banned_peers, 332 pending_transactions: local.pending_transactions, 333 pending_plain_transactions: mempool.plain_transactions, 334 pending_v2_transactions: mempool.v2_transactions, 335 last_finalizer_mode: local.last_finalizer_mode, 336 last_finalizer_rank: local.last_finalizer_rank, 337 last_block_finalizer: local.last_block_finalizer, 338 current_leader: local.current_leader, 339 wallet_is_current_leader: local.wallet_is_current_leader, 340 last_auto_finalization_status: local.last_auto_finalization_status, 341 vdf_rounds: local.vdf_rounds, 342 vdf_target_block_ms: local.vdf_target_block_ms, 343 rejected_blocks: local.rejected_blocks, 344 rejected_block_batches: local.rejected_block_batches, 345 rejected_snapshots: local.rejected_snapshots, 346 rejected_chain_payloads, 347 last_chain_payload_error: local.last_chain_payload_error, 348 network_time_offset_ms, 349 bad_clock_peers, 350 last_error, 351 } 352 } 353 354 fn median_peer_clock_offset(peers: &[PeerInfo], now_ms: u64) -> Option<i64> { 355 let mut offsets = peers 356 .iter() 357 .filter(|peer| peer.last_error.is_none()) 358 .filter(|peer| !peer.is_banned_at(now_ms)) 359 .filter(|peer| peer.last_clock_offset_accepted == Some(true)) 360 .filter(|peer| { 361 peer.last_clock_observed_ms.is_some_and(|observed_ms| { 362 now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS 363 }) 364 }) 365 .filter_map(|peer| peer.last_clock_offset_ms) 366 .collect::<Vec<_>>(); 367 if offsets.is_empty() { 368 return None; 369 } 370 offsets.sort_unstable(); 371 Some(offsets[offsets.len() / 2]) 372 } 373 374 #[cfg(test)] 375 mod tests { 376 use std::collections::BTreeMap; 377 378 use crate::{ 379 adapters::ui_data_store::BlockMetricRow, 380 domain::{Block, BurnBundleSection, FinalizerMode, Transaction}, 381 }; 382 383 use super::{ 384 MempoolCounts, MetricsLeaderboards, NetworkHealthLocalState, metrics_response, 385 network_health_at, top_mine_proofs, 386 }; 387 388 #[test] 389 fn stale_metrics_remain_visible_while_the_latest_tip_is_preparing() { 390 let row = BlockMetricRow { 391 height: 7, 392 block_hash: "cached-tip".to_string(), 393 timestamp_ms: 1_000, 394 block_time_ms: Some(100), 395 mine_difficulty_bits: 10, 396 circulating_supply: 100, 397 known_wallet_addresses: 2, 398 utxo_count: 4, 399 transaction_count: 1, 400 transfer_count: 0, 401 burn_count: 1, 402 mine_count: 0, 403 burned_amount: 1, 404 total_burned_amount: 3, 405 fees_amount: 1, 406 reward_amount: 1, 407 vdf_rounds: 10, 408 finalizer_rank: 0, 409 }; 410 411 let chain_storage_bytes = BTreeMap::from([("cached-tip".to_string(), 12_345)]); 412 let response = metrics_response( 413 true, 414 true, 415 vec![row.clone()], 416 MetricsLeaderboards::default(), 417 &chain_storage_bytes, 418 Vec::new(), 419 ); 420 421 assert!(response.preparing); 422 assert_eq!(response.latest, Some(row)); 423 assert!(response.charts.iter().any(|chart| !chart.points.is_empty())); 424 assert_eq!( 425 response 426 .charts 427 .iter() 428 .find(|chart| chart.id == "chain-storage-bytes") 429 .and_then(|chart| chart.points.first()) 430 .map(|point| point.value), 431 Some(12_345.0) 432 ); 433 assert_eq!( 434 response 435 .charts 436 .iter() 437 .find(|chart| chart.id == "total-utxos") 438 .and_then(|chart| chart.points.first()) 439 .map(|point| point.value), 440 Some(4.0) 441 ); 442 assert_eq!( 443 response 444 .charts 445 .iter() 446 .map(|chart| chart.id) 447 .collect::<Vec<_>>(), 448 vec![ 449 "block-time", 450 "difficulty", 451 "supply", 452 "chain-storage-bytes", 453 "total-utxos", 454 "transactions", 455 "burn-count", 456 "burn-amount", 457 "total-burn", 458 "fees", 459 "mine-actions", 460 "vdf-rounds", 461 ] 462 ); 463 } 464 465 #[test] 466 fn mine_proofs_are_ranked_by_achieved_bits() { 467 let mine = |recipient: &str, signature: &str| Transaction::Mine { 468 recipient: recipient.to_string(), 469 anchor: "anchor".to_string(), 470 salt: 0, 471 nonce: 0, 472 difficulty_bits: 4, 473 proof_header: None, 474 signature: signature.to_string(), 475 }; 476 let block = |height, transactions| Block { 477 height, 478 prev_hash: "0".repeat(64), 479 timestamp_ms: 0, 480 miner: "finalizer".to_string(), 481 reward_address: None, 482 reward_address_signature: None, 483 finalizer_mode: FinalizerMode::Ticket, 484 finalizer_rank: 0, 485 reward: 0, 486 vdf_rounds: 1, 487 vdf_output: String::new(), 488 leader_proof: None, 489 burn_bundle_section: BurnBundleSection::default(), 490 transactions, 491 transactions_v2: Vec::new(), 492 hash: format!("block-{height}"), 493 }; 494 495 let proofs = top_mine_proofs( 496 &[ 497 block( 498 7, 499 vec![mine("four-bits", "0fff"), mine("late-twelve-bits", "000f")], 500 ), 501 block( 502 3, 503 vec![ 504 mine("early-twelve-bits", "000a"), 505 mine("eight-bits", "00ff"), 506 ], 507 ), 508 ], 509 3, 510 ); 511 512 assert_eq!(proofs.len(), 3); 513 assert_eq!(proofs[0].address, "early-twelve-bits"); 514 assert_eq!(proofs[0].proof_bits, 12); 515 assert_eq!(proofs[0].height, 3); 516 assert_eq!(proofs[1].address, "late-twelve-bits"); 517 assert_eq!(proofs[1].proof_bits, 12); 518 assert_eq!(proofs[1].height, 7); 519 assert_eq!(proofs[2].address, "eight-bits"); 520 assert_eq!(proofs[2].proof_bits, 8); 521 } 522 523 #[test] 524 fn network_health_exposes_operator_chain_and_rejection_context() { 525 let local = NetworkHealthLocalState { 526 height: 42, 527 tip_hash: "tip-hash".to_string(), 528 finalized_height: Some(40), 529 finalized_hash: Some("finalized-hash".to_string()), 530 tip_timestamp_ms: Some(1_000), 531 sync_start_height: Some(42), 532 sync_validated_height: Some(47), 533 sync_target_height: Some(60), 534 pending_transactions: 3, 535 last_finalizer_mode: Some("ticket".to_string()), 536 last_finalizer_rank: Some(1), 537 last_block_finalizer: Some("last-finalizer".to_string()), 538 current_leader: Some("next-leader".to_string()), 539 wallet_is_current_leader: true, 540 last_auto_finalization_status: Some("waiting for VDF".to_string()), 541 vdf_rounds: 67_000_000, 542 vdf_target_block_ms: 120_000, 543 rejected_blocks: 2, 544 rejected_block_batches: 3, 545 rejected_snapshots: 5, 546 last_chain_payload_error: Some("snapshot: invalid block".to_string()), 547 }; 548 549 let health = network_health_at( 550 local, 551 &[], 552 MempoolCounts { 553 plain_transactions: 3, 554 v2_transactions: 0, 555 }, 556 2_500, 557 ); 558 559 assert_eq!(health.local_height, 42); 560 assert_eq!(health.best_known_height, 60); 561 assert_eq!(health.sync_start_height, Some(42)); 562 assert_eq!(health.sync_validated_height, Some(47)); 563 assert_eq!(health.sync_target_height, Some(60)); 564 assert_eq!(health.state, "syncing"); 565 assert_eq!(health.local_tip_hash, "tip-hash"); 566 assert_eq!(health.finalized_height, Some(40)); 567 assert_eq!(health.finalized_hash.as_deref(), Some("finalized-hash")); 568 assert_eq!(health.last_block_age_ms, Some(1_500)); 569 assert_eq!(health.last_finalizer_mode.as_deref(), Some("ticket")); 570 assert_eq!(health.last_finalizer_rank, Some(1)); 571 assert_eq!( 572 health.last_block_finalizer.as_deref(), 573 Some("last-finalizer") 574 ); 575 assert_eq!(health.current_leader.as_deref(), Some("next-leader")); 576 assert!(health.wallet_is_current_leader); 577 assert_eq!( 578 health.last_auto_finalization_status.as_deref(), 579 Some("waiting for VDF") 580 ); 581 assert_eq!(health.vdf_rounds, 67_000_000); 582 assert_eq!(health.vdf_target_block_ms, 120_000); 583 assert_eq!(health.rejected_blocks, 2); 584 assert_eq!(health.rejected_block_batches, 3); 585 assert_eq!(health.rejected_snapshots, 5); 586 assert_eq!(health.rejected_chain_payloads, 10); 587 assert_eq!( 588 health.last_chain_payload_error.as_deref(), 589 Some("snapshot: invalid block") 590 ); 591 } 592 }