Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/core/src/tracker/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
57 changes: 56 additions & 1 deletion crates/core/src/tracker/reporters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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}"
);
}
}
187 changes: 176 additions & 11 deletions crates/p2p/src/p2p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
use std::{
pin::Pin,
task::{Context, Poll},
time::Duration,
};

use futures::{Stream, StreamExt, stream::FusedStream};
Expand Down Expand Up @@ -282,6 +283,7 @@ impl<B: NetworkBehaviour> Node<B> {
{
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),
Expand Down Expand Up @@ -323,6 +325,8 @@ impl<B: NetworkBehaviour> Node<B> {
{
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),
Expand Down Expand Up @@ -608,17 +612,18 @@ impl<B: NetworkBehaviour> Node<B> {
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
Expand Down Expand Up @@ -697,6 +702,58 @@ impl<B: NetworkBehaviour> FusedStream for Node<B> {
}
}

/// 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<Duration, ping::Failure>,
) {
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
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 14 additions & 1 deletion crates/p2p/src/relay/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ use super::{
event::{RelayDialError, RelayDialType, RelayManagerEvent},
};
use crate::{
metrics::P2P_METRICS,
name::peer_name,
p2p_context::P2PContext,
peer::{MutablePeer, Peer},
};
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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!(
Expand Down
Loading
Loading