Skip to content

Commit 9ec4b44

Browse files
test(node): assert the gauges from hand built reports
`ProbeReport` gains `From<Vec<ProviderHealth>>`, so the gauge tests no longer open sockets and drive another crate's probe just to reach this crate's publication. They now cover several chains in one report, and pin the label of the one chain whose key is not its variant name. Log lines carry the chain's config key rather than its variant name, and a healthy or uncheckable provider logs at info, so the documented line per provider appears at the level operators run. Also drops metric labels from the truncation rationale: no metric carries a fingerprint.
1 parent 42fd5f2 commit 9ec4b44

6 files changed

Lines changed: 80 additions & 68 deletions

File tree

crates/e2e-tests/tests/foreign_chain_probe.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ async fn foreign_chain_probe__should_publish_provider_health_on_startup() {
4242
setup_evm_mock(&server, MockAuthExpectation::None);
4343
let url = server.url("/");
4444

45+
// when cluster probing is ran as part of start up
4546
let (cluster, _running) = common::must_setup_cluster(
4647
common::FOREIGN_CHAIN_PROBE_PORT_SEED,
4748
|c: &mut e2e_tests::MpcClusterConfig| {
@@ -61,7 +62,7 @@ async fn foreign_chain_probe__should_publish_provider_health_on_startup() {
6162
)
6263
.await;
6364

64-
// when — the probe runs detached at startup, so both gauges settle on their own.
65+
// then
6566
common::wait_metric_on_nodes(
6667
&cluster,
6768
&[0, 1],
@@ -72,7 +73,6 @@ async fn foreign_chain_probe__should_publish_provider_health_on_startup() {
7273
.await
7374
.expect("both nodes should report one configured Base provider");
7475

75-
// then
7676
common::wait_metric_on_nodes(
7777
&cluster,
7878
&[0],
@@ -83,13 +83,13 @@ async fn foreign_chain_probe__should_publish_provider_health_on_startup() {
8383
.await
8484
.expect("the provider serving the expected network should be healthy");
8585

86-
let healthy = cluster
87-
.get_metric_all_nodes(metrics::FOREIGN_CHAIN_RPC_PROVIDERS_HEALTHY)
88-
.await
89-
.expect("failed to scrape metrics");
90-
assert_eq!(
91-
healthy[1],
92-
Some(0),
93-
"the provider serving another network should not be healthy"
94-
);
86+
common::wait_metric_on_nodes(
87+
&cluster,
88+
&[1],
89+
metrics::FOREIGN_CHAIN_RPC_PROVIDERS_HEALTHY,
90+
|value| value == 0,
91+
CLUSTER_WAIT_TIMEOUT,
92+
)
93+
.await
94+
.expect("the provider serving another network should not be healthy");
9595
}

crates/foreign-chain-health-check/src/probe.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ pub struct ProbeReport {
7070
rows: Vec<ProviderHealth>,
7171
}
7272

73+
impl From<Vec<ProviderHealth>> for ProbeReport {
74+
fn from(rows: Vec<ProviderHealth>) -> Self {
75+
Self { rows }
76+
}
77+
}
78+
7379
impl ProbeReport {
7480
pub fn rows(&self) -> &[ProviderHealth] {
7581
&self.rows
@@ -142,8 +148,10 @@ pub async fn probe_all_providers(config: &ForeignChainsConfig) -> ProbeReport {
142148
}
143149
});
144150

145-
let report_rows = futures::future::join_all(probe_attempts).await.concat();
146-
ProbeReport { rows: report_rows }
151+
futures::future::join_all(probe_attempts)
152+
.await
153+
.concat()
154+
.into()
147155
}
148156

149157
async fn probe_evm<Chain>(chain: ForeignChain, config: &ForeignChainConfig) -> Vec<ProviderHealth>

crates/foreign-chain-inspector/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ impl NetworkFingerprint {
5555
pub const MAX_CHARS: usize = 96;
5656
const CUT_SHORT_MARKER: &str = "_TRUNCATED";
5757

58-
/// Text longer than [`Self::MAX_CHARS`] is cut short, a very long string answered by
59-
/// faulty providers does not reach logs and metric labels in full length.
58+
/// Text longer than [`Self::MAX_CHARS`] is cut short, so a very long string answered by a
59+
/// faulty provider does not reach the logs in full length.
6060
pub fn new(fingerprint: impl Into<String>) -> Self {
6161
const KEPT_CHARS: usize =
6262
NetworkFingerprint::MAX_CHARS - NetworkFingerprint::CUT_SHORT_MARKER.len();

crates/near-mpc-contract-interface/src/types/foreign_chain.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1839,7 +1839,6 @@ mod tests {
18391839
#[rstest]
18401840
#[case::hyper_evm(ForeignChain::HyperEvm, "hyper_evm")]
18411841
#[case::abstract_chain(ForeignChain::Abstract, "abstract")]
1842-
#[case::sui(ForeignChain::Sui, "sui")]
18431842
fn label__should_name_the_chain_as_its_config_key(
18441843
#[case] chain: ForeignChain,
18451844
#[case] expected: &str,

crates/node/src/foreign_chain_probe.rs

Lines changed: 56 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use foreign_chain_health_check::probe::{
88
};
99
use mpc_node_config::ForeignChainsConfig;
1010
use near_mpc_contract_interface::types as dtos;
11-
use tracing::{debug, info, warn};
11+
use tracing::{info, warn};
1212

1313
use crate::metrics;
1414

@@ -46,18 +46,18 @@ fn log_report(report: &ProbeReport) {
4646
let rows = report.rows();
4747
for row in rows {
4848
match &row.status {
49-
ProviderStatus::Healthy => debug!(
50-
chain = ?row.chain,
49+
ProviderStatus::Healthy => info!(
50+
chain = %row.chain.label(),
5151
provider = %row.provider,
5252
"foreign-chain RPC provider serves the expected network",
5353
),
54-
ProviderStatus::ProbeNotImplemented => debug!(
55-
chain = ?row.chain,
54+
ProviderStatus::ProbeNotImplemented => info!(
55+
chain = %row.chain.label(),
5656
provider = %row.provider,
57-
"foreign-chain RPC provider cannot be probed",
57+
"foreign-chain RPC provider cannot be checked",
5858
),
5959
unhealthy => warn!(
60-
chain = ?row.chain,
60+
chain = %row.chain.label(),
6161
provider = %row.provider,
6262
status = ?unhealthy,
6363
"foreign-chain RPC provider is unhealthy",
@@ -102,28 +102,7 @@ fn publish_metrics(report: &ProbeReport) {
102102
#[expect(non_snake_case)]
103103
mod tests {
104104
use super::*;
105-
use mpc_node_config::{AuthConfig, ForeignChainConfig, ForeignChainProviderConfig};
106-
use near_mpc_bounded_collections::NonEmptyBTreeMap;
107105
use prometheus::core::Collector as _;
108-
use std::num::NonZeroU64;
109-
110-
const CLOSED_PORT_URL: &str = "http://127.0.0.1:9";
111-
const ANY_FINGERPRINT: &str = "any-fingerprint";
112-
113-
fn chain_config(expected: &str, rpc_url: &str) -> ForeignChainConfig {
114-
ForeignChainConfig {
115-
timeout_sec: NonZeroU64::new(1).unwrap(),
116-
max_retries: NonZeroU64::new(1).unwrap(),
117-
expected_network_fingerprint: Some(expected.to_string()),
118-
providers: NonEmptyBTreeMap::new(
119-
"only".to_string().into(),
120-
ForeignChainProviderConfig {
121-
rpc_url: rpc_url.to_string(),
122-
auth: AuthConfig::None,
123-
},
124-
),
125-
}
126-
}
127106

128107
/// The chains a gauge holds a series for.
129108
fn labelled_chains(gauge: &prometheus::IntGaugeVec) -> Vec<String> {
@@ -136,10 +115,21 @@ mod tests {
136115
.collect()
137116
}
138117

139-
fn row(chain: dtos::ForeignChain, status: ProviderStatus) -> ProviderHealth {
118+
fn counts(chain: &str) -> (i64, i64) {
119+
(
120+
metrics::FOREIGN_CHAIN_RPC_PROVIDERS_CONFIGURED
121+
.with_label_values(&[chain])
122+
.get(),
123+
metrics::FOREIGN_CHAIN_RPC_PROVIDERS_HEALTHY
124+
.with_label_values(&[chain])
125+
.get(),
126+
)
127+
}
128+
129+
fn row(chain: dtos::ForeignChain, provider: &str, status: ProviderStatus) -> ProviderHealth {
140130
ProviderHealth {
141131
chain,
142-
provider: dtos::ProviderId("only".to_string()),
132+
provider: dtos::ProviderId(provider.to_string()),
143133
status,
144134
}
145135
}
@@ -149,10 +139,11 @@ mod tests {
149139
fn summarize__should_count_only_the_providers_a_probe_covers() {
150140
// Given
151141
let rows = [
152-
row(dtos::ForeignChain::Base, ProviderStatus::Healthy),
153-
row(dtos::ForeignChain::Bnb, ProviderStatus::Unreachable),
142+
row(dtos::ForeignChain::Base, "only", ProviderStatus::Healthy),
143+
row(dtos::ForeignChain::Bnb, "only", ProviderStatus::Unreachable),
154144
row(
155145
dtos::ForeignChain::Solana,
146+
"only",
156147
ProviderStatus::ProbeNotImplemented,
157148
),
158149
];
@@ -169,6 +160,7 @@ mod tests {
169160
// Given
170161
let rows = [row(
171162
dtos::ForeignChain::Solana,
163+
"only",
172164
ProviderStatus::ProbeNotImplemented,
173165
)];
174166

@@ -179,35 +171,48 @@ mod tests {
179171
assert_eq!(summary.probed, 0);
180172
}
181173

182-
#[tokio::test]
183-
async fn run_startup_probe__should_publish_the_provider_counts_per_chain() {
174+
/// `HyperEvm` is labelled `hyper_evm`, so the series is keyed by the config key rather than the
175+
/// variant name.
176+
#[test]
177+
fn publish_metrics__should_count_the_providers_of_each_chain() {
184178
// Given
185-
let foreign_chains = ForeignChainsConfig {
186-
aptos: Some(chain_config(ANY_FINGERPRINT, CLOSED_PORT_URL)),
187-
..Default::default()
188-
};
179+
let report = ProbeReport::from(vec![
180+
row(
181+
dtos::ForeignChain::HyperEvm,
182+
"alchemy",
183+
ProviderStatus::Healthy,
184+
),
185+
row(
186+
dtos::ForeignChain::HyperEvm,
187+
"quicknode",
188+
ProviderStatus::Unreachable,
189+
),
190+
row(dtos::ForeignChain::Aptos, "only", ProviderStatus::TimedOut),
191+
]);
189192

190193
// When
191-
run_startup_probe(foreign_chains).await;
194+
publish_metrics(&report);
192195

193-
// Then — unreachable, so configured counts it and healthy does not.
194-
let counts = |gauge: &prometheus::IntGaugeVec| gauge.with_label_values(&["aptos"]).get();
195-
assert_eq!(counts(&metrics::FOREIGN_CHAIN_RPC_PROVIDERS_CONFIGURED), 1);
196-
assert_eq!(counts(&metrics::FOREIGN_CHAIN_RPC_PROVIDERS_HEALTHY), 0);
196+
// Then
197+
assert_eq!(counts("hyper_evm"), (2, 1));
198+
assert_eq!(counts("aptos"), (1, 0));
197199
}
198200

199201
/// A `0` healthy for a chain no probe covers would read as every provider failing.
200-
#[tokio::test]
201-
async fn run_startup_probe__should_publish_no_counts_for_an_unprobeable_chain() {
202+
#[test]
203+
fn publish_metrics__should_publish_no_counts_for_an_unprobeable_chain() {
202204
// Given
203-
let foreign_chains = ForeignChainsConfig {
204-
bnb: Some(chain_config(ANY_FINGERPRINT, CLOSED_PORT_URL)),
205-
solana: Some(chain_config(ANY_FINGERPRINT, CLOSED_PORT_URL)),
206-
..Default::default()
207-
};
205+
let report = ProbeReport::from(vec![
206+
row(dtos::ForeignChain::Bnb, "only", ProviderStatus::Healthy),
207+
row(
208+
dtos::ForeignChain::Solana,
209+
"only",
210+
ProviderStatus::ProbeNotImplemented,
211+
),
212+
]);
208213

209214
// When
210-
run_startup_probe(foreign_chains).await;
215+
publish_metrics(&report);
211216

212217
// Then
213218
let chains = labelled_chains(&metrics::FOREIGN_CHAIN_RPC_PROVIDERS_CONFIGURED);

docs/foreign-chain-transactions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ Every chain with an inspector is probed, each by the RPC below. `solana` and `et
562562

563563
The reported and the configured value are normalized before they are compared, because the same fingerprint has several legal spellings. Starknet's is the chain id felt in lowercase `0x` hex without leading zeros, which providers and operators alike are free to pad and upper-case. The EVM chain id is compared in decimal, the form it is published and configured in, while `eth_chainId` answers a `0x` hex quantity. Bitcoin's genesis hash is compared in lowercase hex, with the leading zeros kept, since they are digits of the hash. Aptos answers its chain id as a number, so only the configured value needs normalizing, and Sui's base58 digest has a single spelling with nothing to normalize.
564564

565-
An answer that is no fingerprint at all is reported as the wrong network, carrying the text the provider sent, so the report says what was actually claimed. An answer longer than any real fingerprint is cut short and ends in `_TRUNCATED`, because it is repeated into logs and metric labels.
565+
An answer that is no fingerprint at all is reported as the wrong network, carrying the text the provider sent, so the report says what was actually claimed. An answer longer than any real fingerprint is cut short and ends in `_TRUNCATED`, because it is repeated into the logs.
566566

567567
#### Why drop-and-log on local-config mismatch, not hard-crash
568568

0 commit comments

Comments
 (0)