From 4cc1f6b6303324c40f900f1b6ada0fc3222bd797 Mon Sep 17 00:00:00 2001 From: obchain Date: Sun, 26 Apr 2026 16:37:07 +0530 Subject: [PATCH] feat(cli): block-listener INFO heartbeat (closes #333) `charon listen` logged received blocks at DEBUG only, so under the default `RUST_LOG=info` filter the post-startup terminal stayed silent for minutes at a time and operators routinely assumed the bot had hung. The block listener was in fact ticking; the only proof was scraping `/metrics`. Add a low-frequency INFO heartbeat keyed off the chain block number. Default cadence is 50 blocks (~150s on BSC's 3s block time); operators tune via `[bot] heartbeat_blocks = N`, and a value of `0` disables it entirely (e.g. JSON-log pipelines that prefer to derive liveness from the metrics surface). Block-number-keyed (vs internal counter) so cadence stays deterministic across restarts, which matters for log-grep based liveness checks. Backfill heads are skipped so a reconnect storm replaying many synthesised heads cannot fire N heartbeat lines. The existing per-block DEBUG line stays for high-resolution debugging. --- crates/charon-cli/src/main.rs | 27 +++++++++++++++++++++ crates/charon-core/src/config.rs | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 9061128..1f44366 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -1019,6 +1019,15 @@ async fn run_listen(config: &Config, borrowers: Vec
, execute: bool) -> // bytes. let signer_key = config.bot.signer_key.clone(); + // Operator heartbeat cadence (#333). Default 50 blocks ≈ 150s + // on BSC. The block listener used to log only at DEBUG, so a + // bot running cleanly under default `RUST_LOG=info` produced no + // post-startup output for minutes at a time and operators + // routinely assumed it had hung. `heartbeat_blocks = 0` + // disables the heartbeat entirely (e.g. JSON-log pipelines that + // prefer to derive liveness from the metrics surface). + let heartbeat_blocks = config.bot.heartbeat_blocks; + // The first real (non-backfill) block on the Venus chain seeds // the scanner with the operator-supplied borrower list. // Subsequent scans pull from the scheduler-selected bucket @@ -1045,6 +1054,24 @@ async fn run_listen(config: &Config, borrowers: Vec
, execute: bool) -> backfill, "cli drained event" ); + // Operator-visible heartbeat. Keyed off the + // chain block number so the cadence is + // deterministic across restarts (versus an + // internal counter that resets to 0 on every + // boot). Skip backfill heads so a reconnect + // storm does not produce a heartbeat per + // replayed block. + if !backfill + && heartbeat_blocks != 0 + && number % heartbeat_blocks == 0 + { + tracing::info!( + chain = %chain, + block = number, + cadence_blocks = heartbeat_blocks, + "block listener heartbeat" + ); + } if backfill { // Skip backfill — the next real head will // snapshot the final state of the missed diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index f42298e..27e9a30 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -281,6 +281,15 @@ pub struct BotConfig { /// COLD (Healthy) bucket scan cadence. Default every 100 blocks. #[serde(default = "default_cold_scan_blocks")] pub cold_scan_blocks: u64, + /// Cadence (in blocks) for the operator-visible INFO heartbeat + /// emitted while listening (#333). Under the default + /// `RUST_LOG=info` filter the post-startup terminal is otherwise + /// silent for minutes at a time, and operators routinely assume + /// the bot has hung. `50` ≈ 150s on BSC's 3s block time. Set to + /// `0` to disable the heartbeat entirely (the existing DEBUG + /// per-block line still fires when `RUST_LOG=debug`). + #[serde(default = "default_heartbeat_blocks")] + pub heartbeat_blocks: u64, /// Hot-wallet signer key, fed in via `${CHARON_SIGNER_KEY}` env /// substitution in `config/default.toml`. Held in a /// [`SecretString`] so the raw hex never reaches `Debug` output or @@ -327,6 +336,7 @@ impl fmt::Debug for BotConfig { .field("hot_scan_blocks", &self.hot_scan_blocks) .field("warm_scan_blocks", &self.warm_scan_blocks) .field("cold_scan_blocks", &self.cold_scan_blocks) + .field("heartbeat_blocks", &self.heartbeat_blocks) .field( "signer_key", &if self.signer_key.is_some() { @@ -355,6 +365,9 @@ fn default_warm_scan_blocks() -> u64 { fn default_cold_scan_blocks() -> u64 { 100 } +fn default_heartbeat_blocks() -> u64 { + 50 +} /// RPC endpoints for a single chain. **The URLs typically embed API keys; /// `Debug` prints `` rather than the URL.** @@ -1053,6 +1066,7 @@ mod private_rpc_tests { hot_scan_blocks: 1, warm_scan_blocks: 10, cold_scan_blocks: 100, + heartbeat_blocks: 50, signer_key: None, profile_tag: None, }, @@ -1307,6 +1321,7 @@ mod fork_profile_tests { hot_scan_blocks: 1, warm_scan_blocks: 10, cold_scan_blocks: 100, + heartbeat_blocks: 50, signer_key: None, profile_tag: Some("fork".into()), }, @@ -1455,6 +1470,32 @@ mod fork_profile_tests { let w: Wrapper = toml::from_str("").expect("parse empty"); assert!(w.chainlink_max_age_secs.is_empty()); } + + #[test] + fn heartbeat_blocks_defaults_to_50_when_unset() { + // BotConfig requires several non-default fields; populate + // only what serde demands and let `heartbeat_blocks` flow + // through `default_heartbeat_blocks()`. + let toml_src = r#" + min_profit_usd_1e6 = 5000000 + max_gas_wei = "5000000000" + scan_interval_ms = 1000 + "#; + let bot: BotConfig = toml::from_str(toml_src).expect("parse"); + assert_eq!(bot.heartbeat_blocks, 50); + } + + #[test] + fn heartbeat_blocks_round_trips_explicit_override() { + let toml_src = r#" + min_profit_usd_1e6 = 5000000 + max_gas_wei = "5000000000" + scan_interval_ms = 1000 + heartbeat_blocks = 0 + "#; + let bot: BotConfig = toml::from_str(toml_src).expect("parse"); + assert_eq!(bot.heartbeat_blocks, 0, "explicit 0 disables heartbeat"); + } } #[cfg(test)]