Skip to main content

zebra_network/
connection_metrics.rs

1//! Bounded metrics for Zebra's peer connection lifecycle.
2//!
3//! Keeping these metrics at the crate root prevents address-book and peer-set code
4//! from reaching through peer implementation details.
5
6use std::io::ErrorKind;
7
8use zebra_chain::{
9    parameters::{Network, NetworkKind},
10    serialization::SerializationError,
11};
12
13use crate::{
14    peer::{ConnectedAddr, HandshakeError},
15    BoxError, PeerSocketAddr,
16};
17
18const CONNECTION_ATTEMPTS_TOTAL: &str = "zcash.net.connection.attempts.total";
19const CONNECTION_OUTCOMES_TOTAL: &str = "zcash.net.connection.outcomes.total";
20const REMOTE_VERSION_MESSAGES_TOTAL: &str = "zcash.net.peer.version.messages.total";
21const REMOTE_VERSION_OUTCOMES_TOTAL: &str = "zcash.net.peer.version.outcomes.total";
22
23#[derive(Copy, Clone, Debug, Eq, PartialEq)]
24pub(crate) enum ConnectionDirection {
25    Inbound,
26    Outbound,
27}
28
29impl ConnectionDirection {
30    fn label(self) -> &'static str {
31        match self {
32            Self::Inbound => "inbound",
33            Self::Outbound => "outbound",
34        }
35    }
36}
37
38#[derive(Copy, Clone, Debug, Eq, PartialEq)]
39struct ConnectionErrorLabels {
40    stage: &'static str,
41    outcome: &'static str,
42}
43
44#[derive(Copy, Clone, Debug, Eq, PartialEq)]
45struct RemoteVersionMetricLabels {
46    network: &'static str,
47    direction: &'static str,
48    address_family: &'static str,
49    implementation: &'static str,
50}
51
52/// Records exactly one terminal outcome after Zebra decodes a remote `Version` message.
53///
54/// Dropping this guard before an explicit outcome records a cancellation. This makes
55/// the metric complete when the outer connection timeout cancels an in-progress handshake.
56#[must_use = "dropping the guard records a canceled remote Version outcome"]
57pub(crate) struct RemoteVersionOutcomeGuard {
58    labels: Option<RemoteVersionMetricLabels>,
59    outcome_recorded: bool,
60}
61
62impl RemoteVersionOutcomeGuard {
63    /// Records a decoded remote `Version` message and starts tracking its terminal outcome.
64    pub(crate) fn new(network: &Network, connected_addr: &ConnectedAddr, user_agent: &str) -> Self {
65        let labels =
66            connection_labels(connected_addr).map(|(direction, addr)| RemoteVersionMetricLabels {
67                network: network_kind_label(network),
68                direction: direction.label(),
69                address_family: address_family_label(addr),
70                implementation: implementation_label(user_agent),
71            });
72
73        if let Some(labels) = labels {
74            metrics::counter!(
75                REMOTE_VERSION_MESSAGES_TOTAL,
76                "network" => labels.network,
77                "direction" => labels.direction,
78                "address_family" => labels.address_family,
79                "implementation" => labels.implementation,
80            )
81            .increment(1);
82        }
83
84        Self {
85            labels,
86            outcome_recorded: false,
87        }
88    }
89
90    /// Records a handshake error and returns it for propagation to the caller.
91    pub(crate) fn record_error(&mut self, error: HandshakeError) -> HandshakeError {
92        self.record_outcome(handshake_error_outcome(&error));
93        error
94    }
95
96    /// Records a successful handshake without emitting a cancellation when this guard is dropped.
97    pub(crate) fn record_success(mut self) {
98        self.record_outcome("success");
99    }
100
101    fn record_outcome(&mut self, outcome: &'static str) {
102        if std::mem::replace(&mut self.outcome_recorded, true) {
103            return;
104        }
105
106        let Some(labels) = self.labels else {
107            return;
108        };
109
110        metrics::counter!(
111            REMOTE_VERSION_OUTCOMES_TOTAL,
112            "network" => labels.network,
113            "direction" => labels.direction,
114            "address_family" => labels.address_family,
115            "implementation" => labels.implementation,
116            "outcome" => outcome,
117        )
118        .increment(1);
119    }
120}
121
122impl Drop for RemoteVersionOutcomeGuard {
123    fn drop(&mut self) {
124        self.record_outcome("canceled");
125    }
126}
127
128/// Returns a bounded metrics label for `network`.
129pub(crate) fn network_kind_label(network: &Network) -> &'static str {
130    match network.kind() {
131        NetworkKind::Mainnet => "mainnet",
132        NetworkKind::Testnet => "testnet",
133        NetworkKind::Regtest => "regtest",
134    }
135}
136
137/// Records that Zebra started a TCP connection attempt.
138pub(crate) fn record_connection_attempt_started(
139    network: &Network,
140    direction: ConnectionDirection,
141    addr: PeerSocketAddr,
142) {
143    metrics::counter!(
144        CONNECTION_ATTEMPTS_TOTAL,
145        "network" => network_kind_label(network),
146        "direction" => direction.label(),
147        "address_family" => address_family_label(addr),
148    )
149    .increment(1);
150}
151
152/// Records the terminal result of a TCP connection and Zcash handshake attempt.
153pub(crate) fn record_connection_attempt_finished(
154    network: &Network,
155    direction: ConnectionDirection,
156    addr: PeerSocketAddr,
157    error: Option<&BoxError>,
158) {
159    let labels = error.map_or(
160        ConnectionErrorLabels {
161            stage: "handshake",
162            outcome: "success",
163        },
164        |error| classify_connection_error(direction, error),
165    );
166
167    metrics::counter!(
168        CONNECTION_OUTCOMES_TOTAL,
169        "network" => network_kind_label(network),
170        "direction" => direction.label(),
171        "address_family" => address_family_label(addr),
172        "stage" => labels.stage,
173        "outcome" => labels.outcome,
174    )
175    .increment(1);
176}
177
178/// Records an inbound TCP connection that Zebra rejected before its Zcash handshake.
179pub(crate) fn record_inbound_connection_rejected(
180    network: &Network,
181    addr: PeerSocketAddr,
182    outcome: &'static str,
183) {
184    metrics::counter!(
185        CONNECTION_OUTCOMES_TOTAL,
186        "network" => network_kind_label(network),
187        "direction" => ConnectionDirection::Inbound.label(),
188        "address_family" => address_family_label(addr),
189        "stage" => "admission",
190        "outcome" => outcome,
191    )
192    .increment(1);
193}
194
195fn classify_connection_error(
196    direction: ConnectionDirection,
197    error: &BoxError,
198) -> ConnectionErrorLabels {
199    if error.is::<tower::timeout::error::Elapsed>() {
200        return ConnectionErrorLabels {
201            stage: match direction {
202                ConnectionDirection::Inbound => "handshake",
203                ConnectionDirection::Outbound => "tcp_or_handshake",
204            },
205            outcome: "timeout",
206        };
207    }
208
209    if error.is::<tokio::time::error::Elapsed>() {
210        return ConnectionErrorLabels {
211            stage: "handshake",
212            outcome: "timeout",
213        };
214    }
215
216    if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
217        return classify_io_error("tcp_connect", io_error);
218    }
219
220    let Some(handshake_error) = error.downcast_ref::<HandshakeError>() else {
221        return ConnectionErrorLabels {
222            stage: "unknown",
223            outcome: "other",
224        };
225    };
226
227    let stage = match handshake_error {
228        HandshakeError::RemoteNonceReuse
229        | HandshakeError::ObsoleteVersion(_)
230        | HandshakeError::MissingRequiredServices { .. } => "version",
231        _ => "handshake",
232    };
233
234    ConnectionErrorLabels {
235        stage,
236        outcome: handshake_error_outcome(handshake_error),
237    }
238}
239
240fn handshake_error_outcome(error: &HandshakeError) -> &'static str {
241    match error {
242        HandshakeError::UnexpectedMessage(_) => "unexpected_message",
243        HandshakeError::RemoteNonceReuse => "self_connection",
244        HandshakeError::LocalDuplicateNonce => "local_nonce_collision",
245        HandshakeError::ConnectionClosed => "connection_closed",
246        HandshakeError::Io(io_error) => io_error_outcome(io_error),
247        HandshakeError::Serialization(SerializationError::Io(io_error)) => {
248            io_error_outcome(io_error)
249        }
250        HandshakeError::Serialization(_) => "protocol_parse_error",
251        HandshakeError::ObsoleteVersion(_) => "obsolete_version",
252        HandshakeError::MissingRequiredServices { .. } => "missing_required_services",
253        HandshakeError::Timeout => "timeout",
254    }
255}
256
257fn classify_io_error(stage: &'static str, error: &std::io::Error) -> ConnectionErrorLabels {
258    ConnectionErrorLabels {
259        stage,
260        outcome: io_error_outcome(error),
261    }
262}
263
264fn io_error_outcome(error: &std::io::Error) -> &'static str {
265    match error.kind() {
266        ErrorKind::ConnectionRefused => "connection_refused",
267        ErrorKind::ConnectionReset => "connection_reset",
268        ErrorKind::ConnectionAborted => "connection_aborted",
269        ErrorKind::NotConnected => "not_connected",
270        ErrorKind::AddrInUse => "address_in_use",
271        ErrorKind::AddrNotAvailable => "address_not_available",
272        ErrorKind::BrokenPipe => "broken_pipe",
273        ErrorKind::TimedOut => "timeout",
274        ErrorKind::UnexpectedEof => "unexpected_eof",
275        ErrorKind::NetworkUnreachable => "network_unreachable",
276        ErrorKind::HostUnreachable => "host_unreachable",
277        _ => "io_error",
278    }
279}
280
281fn connection_labels(
282    connected_addr: &ConnectedAddr,
283) -> Option<(ConnectionDirection, PeerSocketAddr)> {
284    match connected_addr {
285        ConnectedAddr::OutboundDirect { addr } => Some((ConnectionDirection::Outbound, *addr)),
286        ConnectedAddr::InboundDirect { addr } => Some((ConnectionDirection::Inbound, *addr)),
287        ConnectedAddr::OutboundProxy {
288            transient_local_addr,
289            ..
290        } => Some((
291            ConnectionDirection::Outbound,
292            (*transient_local_addr).into(),
293        )),
294        ConnectedAddr::InboundProxy { transient_addr } => {
295            Some((ConnectionDirection::Inbound, (*transient_addr).into()))
296        }
297        ConnectedAddr::Isolated => None,
298    }
299}
300
301fn address_family_label(addr: PeerSocketAddr) -> &'static str {
302    if addr.is_ipv4() {
303        "ipv4"
304    } else {
305        "ipv6"
306    }
307}
308
309fn implementation_label(user_agent: &str) -> &'static str {
310    if user_agent.starts_with("/Zakura:") {
311        "zakura"
312    } else if user_agent.starts_with("/Zebra:") {
313        "zebra"
314    } else if user_agent.starts_with("/MagicBean:") {
315        "legacy_zcashd"
316    } else {
317        "other"
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use std::{
324        future, io,
325        sync::{Arc, Mutex},
326        time::Duration,
327    };
328
329    use super::*;
330    use metrics::{
331        Counter, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit,
332    };
333    use tower::{service_fn, ServiceExt};
334
335    #[derive(Default)]
336    struct CounterRegistrationRecorder {
337        keys: Mutex<Vec<(String, Vec<(String, String)>)>>,
338    }
339
340    impl CounterRegistrationRecorder {
341        fn label_values(&self, metric_name: &str, label_name: &str) -> Vec<String> {
342            self.keys
343                .lock()
344                .expect("the test recorder mutex must not be poisoned")
345                .iter()
346                .filter(|(name, _)| name == metric_name)
347                .filter_map(|(_, labels)| {
348                    labels
349                        .iter()
350                        .find(|(key, _)| key == label_name)
351                        .map(|(_, value)| value.clone())
352                })
353                .collect()
354        }
355    }
356
357    impl Recorder for CounterRegistrationRecorder {
358        fn describe_counter(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {
359        }
360
361        fn describe_gauge(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
362
363        fn describe_histogram(
364            &self,
365            _key: KeyName,
366            _unit: Option<Unit>,
367            _description: SharedString,
368        ) {
369        }
370
371        fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter {
372            let labels = key
373                .labels()
374                .map(|label| (label.key().to_owned(), label.value().to_owned()))
375                .collect();
376
377            self.keys
378                .lock()
379                .expect("the test recorder mutex must not be poisoned")
380                .push((key.name().to_owned(), labels));
381
382            Counter::noop()
383        }
384
385        fn register_gauge(&self, _key: &Key, _metadata: &Metadata<'_>) -> Gauge {
386            Gauge::noop()
387        }
388
389        fn register_histogram(&self, _key: &Key, _metadata: &Metadata<'_>) -> Histogram {
390            Histogram::noop()
391        }
392    }
393
394    #[test]
395    fn user_agent_implementation_labels_are_bounded() {
396        assert_eq!(implementation_label("/Zakura:1.2.3/"), "zakura");
397        assert_eq!(implementation_label("/Zebra:5.1.0/"), "zebra");
398        assert_eq!(implementation_label("/MagicBean:6.2.0/"), "legacy_zcashd");
399        assert_eq!(implementation_label("/another-node:1.0/"), "other");
400        assert_eq!(implementation_label(""), "other");
401    }
402
403    #[test]
404    fn serialization_io_errors_keep_their_transport_meaning() {
405        let error: BoxError = HandshakeError::Serialization(SerializationError::Io(Arc::new(
406            io::Error::new(ErrorKind::ConnectionReset, "reset by test peer"),
407        )))
408        .into();
409
410        assert_eq!(
411            classify_connection_error(ConnectionDirection::Inbound, &error),
412            ConnectionErrorLabels {
413                stage: "handshake",
414                outcome: "connection_reset",
415            }
416        );
417    }
418
419    #[test]
420    fn tcp_errors_are_distinct_from_handshake_errors() {
421        let error: BoxError =
422            io::Error::new(ErrorKind::ConnectionRefused, "test listener is closed").into();
423
424        assert_eq!(
425            classify_connection_error(ConnectionDirection::Outbound, &error),
426            ConnectionErrorLabels {
427                stage: "tcp_connect",
428                outcome: "connection_refused",
429            }
430        );
431    }
432
433    #[tokio::test]
434    async fn outbound_outer_timeout_keeps_its_combined_stage() {
435        let service = tower::timeout::Timeout::new(
436            service_fn(|()| future::pending::<Result<(), BoxError>>()),
437            Duration::ZERO,
438        );
439        let error = service
440            .oneshot(())
441            .await
442            .expect_err("the pending test service must time out");
443
444        assert_eq!(
445            classify_connection_error(ConnectionDirection::Outbound, &error),
446            ConnectionErrorLabels {
447                stage: "tcp_or_handshake",
448                outcome: "timeout",
449            }
450        );
451    }
452
453    #[tokio::test]
454    async fn inbound_outer_timeout_is_a_handshake_timeout() {
455        let service = tower::timeout::Timeout::new(
456            service_fn(|()| future::pending::<Result<(), BoxError>>()),
457            Duration::ZERO,
458        );
459        let error = service
460            .oneshot(())
461            .await
462            .expect_err("the pending test service must time out");
463
464        assert_eq!(
465            classify_connection_error(ConnectionDirection::Inbound, &error),
466            ConnectionErrorLabels {
467                stage: "handshake",
468                outcome: "timeout",
469            }
470        );
471    }
472
473    #[tokio::test]
474    async fn tokio_timeout_is_a_handshake_timeout() {
475        let error: BoxError = tokio::time::timeout(Duration::ZERO, future::pending::<()>())
476            .await
477            .expect_err("the pending test future must time out")
478            .into();
479
480        assert_eq!(
481            classify_connection_error(ConnectionDirection::Inbound, &error),
482            ConnectionErrorLabels {
483                stage: "handshake",
484                outcome: "timeout",
485            }
486        );
487    }
488
489    #[test]
490    fn remote_version_outcomes_are_cancellation_safe() {
491        let recorder = CounterRegistrationRecorder::default();
492        let connected_addr = ConnectedAddr::InboundDirect {
493            addr: "127.0.0.1:8233"
494                .parse()
495                .expect("the test peer address must be valid"),
496        };
497
498        metrics::with_local_recorder(&recorder, || {
499            drop(RemoteVersionOutcomeGuard::new(
500                &Network::Mainnet,
501                &connected_addr,
502                "/Zakura:1.2.3/",
503            ));
504
505            RemoteVersionOutcomeGuard::new(&Network::Mainnet, &connected_addr, "/Zebra:3.0.0/")
506                .record_success();
507        });
508
509        assert_eq!(
510            recorder.label_values(REMOTE_VERSION_MESSAGES_TOTAL, "implementation"),
511            ["zakura", "zebra"]
512        );
513        assert_eq!(
514            recorder.label_values(REMOTE_VERSION_OUTCOMES_TOTAL, "outcome"),
515            ["canceled", "success"]
516        );
517    }
518}