Skip to main content

wickra_exchange_core/
observability.rs

1//! Health introspection and secret redaction.
2//!
3//! A long-running connection needs a readable health surface — is it connected,
4//! how stale is the last message, what is the clock offset and rate budget — and
5//! it must never leak secret material into logs. [`Health`] is the status
6//! snapshot; [`redact`] removes known secrets from a string before it is logged.
7
8use serde::{Deserialize, Serialize};
9
10/// A snapshot of a connection's health.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12pub struct Health {
13    /// Whether the stream is currently connected.
14    pub connected: bool,
15    /// Timestamp (ms since the Unix epoch) of the last received message.
16    pub last_message_ms: Option<i64>,
17    /// The current server-clock offset in milliseconds.
18    pub clock_offset_ms: i64,
19    /// Weight charged against the rate budget in the current window.
20    pub rate_budget_used: u32,
21    /// How many times the stream has reconnected.
22    pub reconnects: u64,
23}
24
25impl Health {
26    /// Milliseconds since the last message at `now_ms`, or `None` if no message
27    /// has been received yet.
28    #[must_use]
29    pub fn staleness_ms(&self, now_ms: i64) -> Option<i64> {
30        self.last_message_ms.map(|last| now_ms - last)
31    }
32
33    /// Whether the connection is healthy: connected and a message arrived within
34    /// `max_staleness_ms`.
35    #[must_use]
36    pub fn is_healthy(&self, now_ms: i64, max_staleness_ms: i64) -> bool {
37        self.connected
38            && self
39                .staleness_ms(now_ms)
40                .is_some_and(|s| s <= max_staleness_ms)
41    }
42}
43
44/// Replace every occurrence of `secret` in `text` with `"<redacted>"`. An empty
45/// secret is ignored (it would otherwise match everywhere), so this is safe to
46/// call unconditionally before logging.
47#[must_use]
48pub fn redact(text: &str, secret: &str) -> String {
49    if secret.is_empty() {
50        return text.to_string();
51    }
52    text.replace(secret, "<redacted>")
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn staleness_and_health() {
61        let mut health = Health {
62            connected: true,
63            last_message_ms: Some(1_000),
64            ..Health::default()
65        };
66        assert_eq!(health.staleness_ms(1_500), Some(500));
67        assert!(health.is_healthy(1_500, 1_000));
68        assert!(!health.is_healthy(5_000, 1_000)); // too stale
69
70        health.connected = false;
71        assert!(!health.is_healthy(1_100, 1_000)); // disconnected
72    }
73
74    #[test]
75    fn health_with_no_messages_is_never_healthy() {
76        let health = Health {
77            connected: true,
78            last_message_ms: None,
79            ..Health::default()
80        };
81        assert_eq!(health.staleness_ms(1_000), None);
82        assert!(!health.is_healthy(1_000, 10_000));
83    }
84
85    #[test]
86    fn redaction_removes_secrets_and_ignores_empty() {
87        let line = "auth header X-KEY=SECRET123 sent";
88        assert_eq!(
89            redact(line, "SECRET123"),
90            "auth header X-KEY=<redacted> sent"
91        );
92        // Multiple occurrences.
93        assert_eq!(
94            redact("aSECRETbSECRETc", "SECRET"),
95            "a<redacted>b<redacted>c"
96        );
97        // Empty secret is a no-op (does not redact everything).
98        assert_eq!(redact(line, ""), line);
99    }
100}