diff --git a/crates/core/src/tracker/metrics.rs b/crates/core/src/tracker/metrics.rs index 17081c6c..da013a7c 100644 --- a/crates/core/src/tracker/metrics.rs +++ b/crates/core/src/tracker/metrics.rs @@ -15,6 +15,10 @@ pub struct TrackerMetrics { #[metrics(labels = ["duty", "peer"])] pub participation_success_total: LabeledFamily<(String, String), Counter, 2>, + /// Total number of successful participations by peer and duty type. + #[metrics(labels = ["duty", "peer"])] + pub participation_total: LabeledFamily<(String, String), Counter, 2>, + /// Total number of missed participations by peer and duty type. #[metrics(labels = ["duty", "peer"])] pub participation_missed_total: LabeledFamily<(String, String), Counter, 2>, diff --git a/crates/core/src/tracker/reporters.rs b/crates/core/src/tracker/reporters.rs index 48b323ca..7adc5216 100644 --- a/crates/core/src/tracker/reporters.rs +++ b/crates/core/src/tracker/reporters.rs @@ -186,6 +186,7 @@ impl MetricsParticipationReporter { for peer in &peers { let labels = (dt_str.clone(), peer.name.clone()); TRACKER_METRICS.participation_success_total[&labels].inc_by(0); + TRACKER_METRICS.participation_total[&labels].inc_by(0); TRACKER_METRICS.participation_missed_total[&labels].inc_by(0); TRACKER_METRICS.participation_expected_total[&labels].inc_by(0); } @@ -225,6 +226,7 @@ impl MetricsParticipationReporter { let labels = (dt_str.clone(), peer.name.clone()); TRACKER_METRICS.participation_success_total[&labels].inc_by(part as u64); + TRACKER_METRICS.participation_total[&labels].inc_by(part as u64); TRACKER_METRICS.participation_expected_total[&labels].inc_by(expected_per_peer as u64); TRACKER_METRICS.participation_missed_total[&labels] .inc_by(expected_per_peer.saturating_sub(part) as u64); @@ -327,7 +329,10 @@ pub fn report_par_sigs(duty: &Duty, parsigs: &ParSigsByMsg) { mod tests { use super::*; use crate::{ - tracker::reason::{REASON_BUG_AGGREGATION_ERROR, REASON_UNKNOWN}, + tracker::{ + metrics::TrackerMetrics, + reason::{REASON_BUG_AGGREGATION_ERROR, REASON_UNKNOWN}, + }, types::SlotNumber, }; @@ -424,4 +429,54 @@ mod tests { }), )); } + + #[test] + fn participation_total_mirrors_participation_success_total() { + // The metric registry is process-global, so use a peer name no other + // test reports on. + let peer_name = "participation-legacy-peer".to_string(); + let mut reporter = MetricsParticipationReporter::new(vec![PeerInfo { + name: peer_name.clone(), + share_idx: 1, + }]); + + let duty = Duty::new_attester_duty(SlotNumber::new(1)); + let labels = (duty.duty_type.to_string(), peer_name); + let legacy = || { + TRACKER_METRICS + .participation_total + .get(&labels) + .map(|c| c.get()) + }; + let success = || { + TRACKER_METRICS + .participation_success_total + .get(&labels) + .map(|c| c.get()) + }; + + assert_eq!(legacy(), Some(0), "series must exist before the first duty"); + + reporter.report(&duty, false, &HashMap::from([(1, 3)]), &HashMap::new(), 4); + + assert_eq!(success(), Some(3)); + assert_eq!(legacy(), success()); + + // Registered standalone because `MetricsCollection::collect()` sees the + // globals of this crate twice in the test binary. + let metrics = TrackerMetrics::default(); + metrics.participation_total[&labels].inc_by(3); + let mut registry = vise::Registry::empty(); + registry.register_metrics(&metrics); + let mut buffer = String::new(); + registry + .encode(&mut buffer, vise::Format::OpenMetricsForPrometheus) + .expect("encode registry"); + assert!( + buffer.contains( + r#"core_tracker_participation_total{duty="attester",peer="participation-legacy-peer"} 3"# + ), + "exported metric name/labels changed:\n{buffer}" + ); + } } diff --git a/crates/p2p/src/p2p.rs b/crates/p2p/src/p2p.rs index 4cf88307..b0eba0c7 100644 --- a/crates/p2p/src/p2p.rs +++ b/crates/p2p/src/p2p.rs @@ -89,6 +89,7 @@ use std::{ pin::Pin, task::{Context, Poll}, + time::Duration, }; use futures::{Stream, StreamExt, stream::FusedStream}; @@ -282,6 +283,7 @@ impl Node { { let keypair = utils::keypair_from_secret_key(key)?; Self::bind_local_peer_id(&p2p_context, keypair.public().to_peer_id())?; + init_ping_metrics(&p2p_context); let mut node = match node_type { NodeType::TCP => Self::build_tcp_client(keypair, p2p_context, behaviour_fn), @@ -323,6 +325,8 @@ impl Node { { let keypair = utils::keypair_from_secret_key(key)?; Self::bind_local_peer_id(&p2p_context, keypair.public().to_peer_id())?; + // No-op for a relay server, which tracks no cluster peers. + init_ping_metrics(&p2p_context); let mut node = match node_type { NodeType::TCP => Self::build_tcp_server(keypair, p2p_context, bandwidth, behaviour_fn), @@ -608,17 +612,18 @@ impl Node { SwarmEvent::Behaviour(PlutoBehaviourEvent::Ping(ping::Event { peer, result, .. })) => { - let peer_label = peer_name(peer); - match result { - Ok(duration) => { - P2P_METRICS.ping_latency_secs[&peer_label].observe(duration.as_secs_f64()); - P2P_METRICS.ping_success[&peer_label].set(1); - } - Err(_) => { - P2P_METRICS.ping_error_total[&peer_label].inc(); - P2P_METRICS.ping_success[&peer_label].set(0); - } - } + record_ping_metrics(&self.p2p_context, peer, result); + } + + // libp2p drops the ping handler with the connection, so an orderly + // disconnect produces no ping failure and `ping_success` would sit + // at 1 indefinitely. + SwarmEvent::ConnectionClosed { + peer_id, + num_established: 0, + .. + } => { + clear_ping_success(&self.p2p_context, peer_id); } // AutoNAT reachability status @@ -697,6 +702,58 @@ impl FusedStream for Node { } } +/// Records the outcome of a ping, gated to known cluster peers. +/// +/// Charon pings an explicit cluster allowlist (`p2p.NewPingService`), while +/// libp2p's ping behaviour pings every connected peer — relays included — so +/// the allowlist is applied here instead. +fn record_ping_metrics( + ctx: &P2PContext, + peer: &PeerId, + result: &std::result::Result, +) { + if !ctx.is_known_peer(peer) { + return; + } + + let peer_label = peer_name(peer); + match result { + Ok(duration) => { + P2P_METRICS.ping_latency_secs[&peer_label].observe(duration.as_secs_f64()); + P2P_METRICS.ping_success[&peer_label].set(1); + } + Err(_) => { + P2P_METRICS.ping_error_total[&peer_label].inc(); + P2P_METRICS.ping_success[&peer_label].set(0); + } + } +} + +/// Publishes `p2p_ping_success{peer}=0` for every known cluster peer except +/// this node, so a peer that never connects reads as a zero line rather than +/// no series at all. Charon gets this for free by starting a ping loop per +/// cluster peer regardless of reachability. +fn init_ping_metrics(ctx: &P2PContext) { + let local = ctx.local_peer_id(); + for peer in ctx.known_peers() { + // Charon's ping service skips self. + if Some(*peer) == local { + continue; + } + P2P_METRICS.ping_success[&peer_name(peer)].set(0); + } +} + +/// Marks a known cluster peer as unreachable on `p2p_ping_success`. Gated on +/// the cluster allowlist for the same reason as [`record_ping_metrics`]. +fn clear_ping_success(ctx: &P2PContext, peer: &PeerId) { + if !ctx.is_known_peer(peer) { + return; + } + + P2P_METRICS.ping_success[&peer_name(peer)].set(0); +} + /// Stores identify-reported listen addresses for a peer, gated to known cluster /// peers only. Addresses from unknown peers are dropped (and not cloned), since /// the only consumers of `peer_addresses` look up known peers exclusively — so @@ -712,6 +769,8 @@ fn store_identify_addrs(ctx: &P2PContext, peer_id: &PeerId, addrs: &[Multiaddr]) #[cfg(test)] mod tests { + use vise::{Counter, Gauge}; + use super::*; fn random_peer_id() -> PeerId { @@ -752,6 +811,112 @@ mod tests { assert!(ctx.peer_store_lock().peer_addresses(&unknown).is_none()); } + #[test] + fn ping_metrics_recorded_for_known_peer() { + let known = random_peer_id(); + let ctx = P2PContext::new([known]); + let label = peer_name(&known); + + record_ping_metrics(&ctx, &known, &Ok(Duration::from_millis(20))); + + assert_eq!( + P2P_METRICS.ping_success.get(&label).map(Gauge::get), + Some(1) + ); + assert!( + P2P_METRICS.ping_latency_secs.contains(&label), + "latency must be observed for a known cluster peer" + ); + + record_ping_metrics(&ctx, &known, &Err(ping::Failure::Timeout)); + + assert_eq!( + P2P_METRICS.ping_success.get(&label).map(Gauge::get), + Some(0) + ); + assert_eq!( + P2P_METRICS.ping_error_total.get(&label).map(Counter::get), + Some(1) + ); + } + + #[test] + fn ping_metrics_skipped_for_relay_or_unknown_peer() { + let known = random_peer_id(); + let relay = random_peer_id(); + let ctx = P2PContext::new([known]); + let label = peer_name(&relay); + + record_ping_metrics(&ctx, &relay, &Ok(Duration::from_millis(20))); + record_ping_metrics(&ctx, &relay, &Err(ping::Failure::Timeout)); + + // `contains` rather than indexing, so the assertions don't create the + // very series they check for. + assert!(!P2P_METRICS.ping_success.contains(&label)); + assert!(!P2P_METRICS.ping_latency_secs.contains(&label)); + assert!( + !P2P_METRICS.ping_error_total.contains(&label), + "the error path must be gated too, not just success/latency" + ); + } + + #[test] + fn ping_success_cleared_when_last_connection_closes() { + let known = random_peer_id(); + let ctx = P2PContext::new([known]); + let label = peer_name(&known); + + record_ping_metrics(&ctx, &known, &Ok(Duration::from_millis(20))); + assert_eq!( + P2P_METRICS.ping_success.get(&label).map(Gauge::get), + Some(1) + ); + + clear_ping_success(&ctx, &known); + + assert_eq!( + P2P_METRICS.ping_success.get(&label).map(Gauge::get), + Some(0) + ); + } + + #[test] + fn ping_success_not_cleared_for_relay_or_unknown_peer() { + let known = random_peer_id(); + let relay = random_peer_id(); + let ctx = P2PContext::new([known]); + let label = peer_name(&relay); + + clear_ping_success(&ctx, &relay); + + assert!( + !P2P_METRICS.ping_success.contains(&label), + "clearing must not create a series for a non-cluster peer" + ); + } + + #[test] + fn ping_success_seeded_for_known_peers_except_self() { + let local = random_peer_id(); + let peer = random_peer_id(); + let ctx = P2PContext::new([local, peer]); + ctx.set_local_peer_id(local); + + init_ping_metrics(&ctx); + + assert_eq!( + P2P_METRICS + .ping_success + .get(&peer_name(&peer)) + .map(Gauge::get), + Some(0) + ); + assert!( + !P2P_METRICS.ping_success.contains(&peer_name(&local)), + "Charon's ping service skips self, so no self series" + ); + } + #[test] fn identify_addrs_capped_for_known_peer() { let known = random_peer_id(); diff --git a/crates/p2p/src/relay/manager.rs b/crates/p2p/src/relay/manager.rs index b7a2ad04..7a6527d8 100644 --- a/crates/p2p/src/relay/manager.rs +++ b/crates/p2p/src/relay/manager.rs @@ -26,6 +26,8 @@ use super::{ event::{RelayDialError, RelayDialType, RelayManagerEvent}, }; use crate::{ + metrics::P2P_METRICS, + name::peer_name, p2p_context::P2PContext, peer::{MutablePeer, Peer}, }; @@ -195,7 +197,8 @@ impl RelayManager { self.set_relay_state(relay.id, RelayConnectionState::Dialing); } - /// Updates the connection state for a relay, logging the transition and + /// Updates the connection state for a relay, logging the transition, + /// reporting reservation availability on `p2p_relay_connections`, and /// maintaining the `established_at` watchdog timestamp. fn set_relay_state(&mut self, relay_id: PeerId, next: RelayConnectionState) { let prev = self.connection_states.insert(relay_id, next); @@ -207,6 +210,7 @@ impl RelayManager { "Relay connection state transition" ); } + Self::report_relay_connection(relay_id, matches!(next, RelayConnectionState::Reserved)); match next { // Entering or refreshing the no-reservation-yet state: start (or // restart, on demote from Reserved) the stuck-Established timer. @@ -223,6 +227,13 @@ impl RelayManager { } } + /// Reports whether a relay *reservation* is currently held on + /// `p2p_relay_connections` — not how many transport connections exist, + /// matching Charon's `relay.go`. + fn report_relay_connection(relay_id: PeerId, reserved: bool) { + P2P_METRICS.relay_connections[&peer_name(&relay_id)].set(i64::from(reserved)); + } + /// Polls every active dial state once, queuing a `ToSwarm::Dial` event for /// any whose backoff has elapsed. Wakers for the remaining (pending) ones /// are registered via the underlying `Sleep` futures. @@ -714,6 +725,8 @@ impl RelayManager { "Relay closed but addresses no longer tracked; cannot redial" ); self.connection_states.remove(&relay_id); + // The only path that drops relay state without `set_relay_state`. + Self::report_relay_connection(relay_id, false); return; }; tracing::debug!( diff --git a/crates/p2p/src/relay/manager/tests.rs b/crates/p2p/src/relay/manager/tests.rs index 3d9cfb33..58d883a7 100644 --- a/crates/p2p/src/relay/manager/tests.rs +++ b/crates/p2p/src/relay/manager/tests.rs @@ -863,6 +863,80 @@ async fn poll_fires_swept_peer_dial_within_the_same_watchdog_pass() { ); } +// ---- relay_connections metric -------------------------------------- + +/// Current `p2p_relay_connections` value for a relay, or `None` if it has no +/// series yet. Uses `get` rather than indexing so it doesn't create one. +fn relay_connections(relay_id: PeerId) -> Option { + P2P_METRICS + .relay_connections + .get(&peer_name(&relay_id)) + .map(vise::Gauge::get) +} + +#[tokio::test] +async fn relay_connections_tracks_reservation_lifecycle() { + let mut mgr = manager(); + let relay_id = PeerId::random(); + let circuit = addr(&format!( + "/ip4/10.0.0.1/tcp/9000/p2p/{relay_id}/p2p-circuit" + )); + + assert_eq!( + relay_connections(relay_id), + None, + "no series before the relay is known" + ); + + // Dialing. + mgr.queue_relay_update(relay_peer(relay_id, vec![addr("/ip4/10.0.0.1/tcp/9000")])); + assert_eq!(relay_connections(relay_id), Some(0)); + + mgr.on_connection_established(relay_id); + assert_eq!( + relay_connections(relay_id), + Some(0), + "a transport connection alone is not a reservation" + ); + + // Reservation confirmed. + mgr.on_new_listen_addr(&circuit); + assert_eq!(relay_connections(relay_id), Some(1)); + + // Reservation lost without a ConnectionClosed: libp2p owns refreshes. + mgr.on_expired_listen_addr(&circuit); + assert_eq!( + mgr.connection_states.get(&relay_id), + Some(&RelayConnectionState::Established), + "precondition: demoted without losing the transport connection" + ); + assert_eq!(relay_connections(relay_id), Some(0)); + + // Transport drops → redial campaign. + mgr.on_connection_closed(relay_id); + assert_eq!(relay_connections(relay_id), Some(0)); + + // Reconnect and re-reserve. + mgr.on_connection_established(relay_id); + mgr.on_new_listen_addr(&circuit); + assert_eq!(relay_connections(relay_id), Some(1)); +} + +#[tokio::test] +async fn relay_connections_cleared_when_relay_state_is_dropped() { + // `redial_relay` drops the state without `set_relay_state` when the relay's + // addresses are no longer tracked. + let mut mgr = manager(); + let relay_id = PeerId::random(); + mgr.set_relay_state(relay_id, RelayConnectionState::Reserved); + assert_eq!(relay_connections(relay_id), Some(1)); + + mgr.on_connection_closed(relay_id); + + assert!(!mgr.connection_states.contains_key(&relay_id)); + assert_eq!(relay_connections(relay_id), Some(0)); +} + #[tokio::test] async fn sweep_is_noop_without_reserved_relays() { let target = PeerId::random(); diff --git a/crates/p2p/tests/common/mod.rs b/crates/p2p/tests/common/mod.rs new file mode 100644 index 00000000..d24ae946 --- /dev/null +++ b/crates/p2p/tests/common/mod.rs @@ -0,0 +1,78 @@ +//! Shared fixtures for the `pluto-p2p` integration tests. + +use std::time::Duration; + +use futures::StreamExt as _; +use k256::SecretKey; +use libp2p::{Multiaddr, PeerId, relay, swarm::SwarmEvent}; +use pluto_p2p::{ + config::P2PConfig, + p2p::{Node, NodeType}, + p2p_context::P2PContext, + peer::peer_id_from_key, +}; +use tokio::{task::JoinHandle, time::timeout}; + +/// How long any single step of an integration test may take before it is +/// treated as hung. +pub const TEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Starts an in-process relay server on loopback TCP and drives it in the +/// background. Returns its peer id, listen address, and swarm task handle — +/// abort the handle to stop the relay. +pub async fn spawn_relay_server(key: SecretKey) -> (PeerId, Multiaddr, JoinHandle<()>) { + let peer_id = peer_id_from_key(key.public_key()).expect("relay peer id"); + + let mut node = Node::new_server( + P2PConfig::default(), + key, + NodeType::TCP, + false, + // Relay servers don't track cluster peers - they serve all connections. + P2PContext::default(), + None, + |builder, keypair| { + builder.with_inner(relay::Behaviour::new( + keypair.public().to_peer_id(), + relay::Config { + // Room for a circuit to carry a real payload; the default + // is 128 KiB. + max_circuit_bytes: 32 << 20, + // Keep the defaults: an exhaustive literal drops the + // per-peer and per-IP rate limiters. + ..relay::Config::default() + }, + )) + }, + ) + .expect("build relay server node"); + + node.listen_on( + "/ip4/127.0.0.1/tcp/0" + .parse::() + .expect("parse relay listen multiaddr"), + ) + .expect("relay listen_on"); + + let addr = timeout(TEST_TIMEOUT, async { + loop { + if let SwarmEvent::NewListenAddr { address, .. } = node.select_next_some().await { + return address; + } + } + }) + .await + .expect("timed out waiting for the relay listen address"); + + // Without a reachable advertised address, reservations are rejected + // client-side with `NoAddressesInReservation`. + node.add_external_address(addr.clone()); + + let handle = tokio::spawn(async move { + loop { + node.select_next_some().await; + } + }); + + (peer_id, addr, handle) +} diff --git a/crates/p2p/tests/relay_circuit.rs b/crates/p2p/tests/relay_circuit.rs index 396b396c..6eb08517 100644 --- a/crates/p2p/tests/relay_circuit.rs +++ b/crates/p2p/tests/relay_circuit.rs @@ -14,10 +14,10 @@ //! and relay client paths over real sockets — the relay reservation and circuit //! hop, not just a direct dial. -use std::time::Duration; +mod common; use futures::StreamExt as _; -use libp2p::{Multiaddr, PeerId, multiaddr::Protocol, relay, swarm::SwarmEvent}; +use libp2p::{PeerId, multiaddr::Protocol, relay, swarm::SwarmEvent}; use pluto_p2p::{ config::P2PConfig, p2p::{Node, NodeType}, @@ -28,71 +28,18 @@ use pluto_p2p::{ use pluto_testutil::random::generate_insecure_k1_key; use tokio::time::timeout; -const TEST_TIMEOUT: Duration = Duration::from_secs(30); +use common::{TEST_TIMEOUT, spawn_relay_server}; #[tokio::test] async fn two_nodes_connect_through_relay_circuit() { - let relay_key = generate_insecure_k1_key(1); let listener_key = generate_insecure_k1_key(2); let dialer_key = generate_insecure_k1_key(3); - let relay_peer = peer_id_from_key(relay_key.public_key()).expect("relay peer id"); let listener_peer = peer_id_from_key(listener_key.public_key()).expect("listener peer id"); let dialer_peer = peer_id_from_key(dialer_key.public_key()).expect("dialer peer id"); - // --- Relay server node. --- - let relay_config = relay::Config { - max_reservations: 16, - max_reservations_per_peer: 4, - reservation_duration: Duration::from_secs(3600), - reservation_rate_limiters: vec![], - max_circuits: 16, - max_circuits_per_peer: 4, - max_circuit_duration: Duration::from_secs(120), - max_circuit_bytes: 32 * 1024 * 1024, - circuit_src_rate_limiters: vec![], - }; - let mut relay_node = Node::new_server( - P2PConfig::default(), - relay_key, - NodeType::TCP, - false, - P2PContext::default(), - None, - move |builder, keypair| { - let behaviour = relay::Behaviour::new(keypair.public().to_peer_id(), relay_config); - builder.with_inner(behaviour) - }, - ) - .expect("build relay server node"); - - let relay_listen = "/ip4/127.0.0.1/tcp/0" - .parse::() - .expect("parse relay listen multiaddr"); - relay_node.listen_on(relay_listen).expect("relay listen_on"); - - // Wait for the relay's concrete TCP address, then keep the relay driven in - // the background so it can service reservations and circuits. - let relay_addr = timeout(TEST_TIMEOUT, async { - loop { - let event = relay_node.select_next_some().await; - if let SwarmEvent::NewListenAddr { address, .. } = event { - return address; - } - } - }) - .await - .expect("timed out waiting for the relay listen address"); - - // The relay must advertise a reachable address, otherwise reservations are - // rejected client-side with `NoAddressesInReservation`. - relay_node.add_external_address(relay_addr.clone()); - - let relay_handle = tokio::spawn(async move { - loop { - relay_node.select_next_some().await; - } - }); + let (relay_peer, relay_addr, relay_handle) = + spawn_relay_server(generate_insecure_k1_key(1)).await; // Full relay address including its peer id, plus the circuit suffix. let relay_with_id = relay_addr.with(Protocol::P2p(relay_peer)); diff --git a/crates/p2p/tests/relay_metrics.rs b/crates/p2p/tests/relay_metrics.rs new file mode 100644 index 00000000..9fe8bcb0 --- /dev/null +++ b/crates/p2p/tests/relay_metrics.rs @@ -0,0 +1,115 @@ +//! End-to-end check of relay connectivity metrics on the production path: a +//! real [`Node`] whose [`RelayManager`] reserves a circuit on an in-process +//! relay server over loopback TCP. The `relay::manager` unit tests drive the +//! `FromSwarm` handlers directly; this one exercises the full swarm plumbing, +//! where both metric bugs showed up. + +mod common; + +use futures::StreamExt as _; +use libp2p::{ + PeerId, ping, relay, + swarm::{NetworkBehaviour, SwarmEvent}, +}; +use pluto_p2p::{ + behaviours::pluto::PlutoBehaviourEvent, + config::P2PConfig, + metrics::P2P_METRICS, + name::peer_name, + p2p::{Node, NodeType}, + p2p_context::P2PContext, + peer::{AddrInfo, MutablePeer, Peer}, + relay::{RelayManager, RelayManagerEvent}, +}; +use pluto_testutil::random::generate_insecure_k1_key; +use tokio::time::timeout; +use vise::Gauge; + +use common::{TEST_TIMEOUT, spawn_relay_server}; + +/// Mirrors the app wiring: relay client transport plus the [`RelayManager`]. +/// Ping lives in the outer `PlutoBehaviour`, as it does in the app. +#[derive(NetworkBehaviour)] +struct ClientBehaviour { + relay: relay::client::Behaviour, + relay_manager: RelayManager, +} + +#[tokio::test] +async fn relay_reservation_sets_relay_connections_and_emits_no_ping_metrics() { + let (relay_peer, relay_addr, relay_handle) = + spawn_relay_server(generate_insecure_k1_key(11)).await; + let relay_label = peer_name(&relay_peer); + + let relay_mutable = MutablePeer::new(Peer::new_relay_peer(&AddrInfo { + id: relay_peer, + addrs: vec![relay_addr], + })); + + // The relay is deliberately absent from the known-peer set: the app builds + // `P2PContext` from cluster peers only. + let mut client: Node = Node::new( + P2PConfig::default(), + generate_insecure_k1_key(12), + NodeType::TCP, + false, + P2PContext::new(Vec::::new()), + move |builder, _keypair, relay_client| { + let p2p_context = builder.p2p_context(); + builder.with_inner(ClientBehaviour { + relay: relay_client, + relay_manager: RelayManager::new(vec![relay_mutable], p2p_context), + }) + }, + ) + .expect("build relay client node"); + + // Drive the client until it holds a reservation *and* has pinged the relay. + // `Node::handle_event` records metrics before yielding an event, so both + // writes have run by the time these are observed. + let mut reserved = false; + let mut pinged = false; + timeout(TEST_TIMEOUT, async { + while !(reserved && pinged) { + match client.select_next_some().await { + SwarmEvent::Behaviour(PlutoBehaviourEvent::Inner( + ClientBehaviourEvent::RelayManager(RelayManagerEvent::RelayReserved(peer)), + )) if peer == relay_peer => reserved = true, + SwarmEvent::Behaviour(PlutoBehaviourEvent::Ping(ping::Event { peer, .. })) + if peer == relay_peer => + { + pinged = true; + } + _ => {} + } + } + }) + .await + .expect("timed out waiting for the relay reservation and a ping of the relay"); + + assert_eq!( + P2P_METRICS + .relay_connections + .get(&relay_label) + .map(Gauge::get), + Some(1), + "a held reservation must show up as p2p_relay_connections{{peer}}=1" + ); + + // `contains` rather than indexing, so the assertions don't create the very + // series they check for. + assert!( + !P2P_METRICS.ping_success.contains(&relay_label), + "the relay must not appear in p2p_ping_success" + ); + assert!( + !P2P_METRICS.ping_latency_secs.contains(&relay_label), + "the relay must not appear in p2p_ping_latency_secs" + ); + assert!( + !P2P_METRICS.ping_error_total.contains(&relay_label), + "the relay must not appear in p2p_ping_error_total" + ); + + relay_handle.abort(); +}