Skip to main content

subc_daemon/
observability.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    sync::{
4        atomic::{AtomicU64, Ordering},
5        Arc, Mutex, MutexGuard,
6    },
7    time::Duration,
8};
9
10use serde_json::{json, Value};
11use tracing::debug;
12
13use crate::registry::ConnectionId;
14
15/// The only keys the route.open refusal counter may carry. A module's own
16/// error code on a rejected bind is counted under `module_rejected` and rides
17/// the log event as a separate field: if the module's string were the key, a
18/// module could grow this map for the daemon's lifetime and push terminal
19/// control sequences through `ck daemon` to an operator's screen. The
20/// `&'static str` increment signature plus the debug assertion keep the set
21/// closed at the call site, not just here.
22const ROUTE_OPEN_REFUSAL_COUNTER_CODES: &[&str] = &[
23    "module_warming",
24    ROUTE_OPEN_REFUSED_DECLARED_NOT_READY,
25    "target_unavailable",
26    "module_removed",
27    "module_no_protocol",
28    "unknown_module",
29    "module_reloading",
30    "op_not_allowed",
31    "bad_consumer_identity",
32    "capability_forbidden",
33    "admission_facts_not_permitted",
34    "admission_facts_target_not_allowed",
35    "route_limit",
36    "forwarding_error",
37    "module_timeout",
38    ROUTE_OPEN_REFUSED_BREAKER_OPEN,
39    "module_rejected",
40];
41
42/// Counter key for a `route.open` refused by the per-module bind-relay breaker
43/// before any relay was attempted.
44///
45/// The frame the caller receives carries `module_timeout`, because both SDKs
46/// already classify that as retryable with capped backoff and inventing a new
47/// wire code would need a change in each of them. The COUNTER is deliberately a
48/// different key: "this module burned the full bind budget" and "this module is
49/// being refused in microseconds because it already did that repeatedly" are
50/// the two states an operator most needs to tell apart, and they are
51/// indistinguishable from the client side, where both look like one retryable
52/// error that the next attempt may well satisfy.
53pub(crate) const ROUTE_OPEN_REFUSED_BREAKER_OPEN: &str = "module_timeout_breaker_open";
54
55/// Counter key for a registered module that declared itself not ready.
56///
57/// The caller still receives `module_warming`, but operators must be able to
58/// distinguish declared readiness from a supervised process that has not
59/// registered yet.
60pub(crate) const ROUTE_OPEN_REFUSED_DECLARED_NOT_READY: &str = "module_warming_declared_not_ready";
61
62/// Shared count of authenticated socket connections accepted by the daemon.
63#[derive(Debug, Clone, Default)]
64pub struct ConnectedClients {
65    count: Arc<AtomicU64>,
66}
67
68impl ConnectedClients {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn count(&self) -> u64 {
74        self.count.load(Ordering::SeqCst)
75    }
76
77    pub(crate) fn open(&self, connection_id: ConnectionId) -> ConnectedClientGuard {
78        let previous = self.count.fetch_add(1, Ordering::SeqCst);
79        let current = previous + 1;
80        // DEBUG, not INFO: this pair fired on every connect and disconnect and was
81        // measured at 43-74% of the daemon's own log (issue #114), burying the
82        // lines an operator opens the file for. The count itself is not lost: it
83        // is served live as `connected_clients` on server.describe (`ck daemon`),
84        // and a connection that opens a route still names its connection_id on the
85        // `route.open accepted` line, so per-connection forensics survive where
86        // they matter. Raise the filter (`CK_LOG=subc=debug`) to see every churn.
87        debug!(
88            connection_id = connection_id.get(),
89            connected_clients = current,
90            previous_connected_clients = previous,
91            "authenticated connection count changed"
92        );
93        ConnectedClientGuard {
94            clients: self.clone(),
95            connection_id,
96        }
97    }
98}
99
100pub(crate) struct ConnectedClientGuard {
101    clients: ConnectedClients,
102    connection_id: ConnectionId,
103}
104
105impl Drop for ConnectedClientGuard {
106    fn drop(&mut self) {
107        let previous = self.clients.count.fetch_sub(1, Ordering::SeqCst);
108        let current = previous.saturating_sub(1);
109        // DEBUG for the same reason as the open side above.
110        debug!(
111            connection_id = self.connection_id.get(),
112            connected_clients = current,
113            previous_connected_clients = previous,
114            "authenticated connection count changed"
115        );
116    }
117}
118
119/// Lock-free counters for route lifecycle drops and delivery failures.
120#[derive(Debug, Clone, Default)]
121pub struct DaemonCounters {
122    module_frames_dropped_no_route: Arc<AtomicU64>,
123    // Per-module maps and the rate window are daemon-lifetime diagnostics only:
124    // they deliberately reset on restart instead of becoming durable daemon state.
125    module_frames_dropped_no_route_by_module: Arc<Mutex<HashMap<String, u64>>>,
126    route_open_refused_by_code: Arc<Mutex<HashMap<String, u64>>>,
127    route_open_accepted_by_principal: Arc<Mutex<HashMap<String, u64>>>,
128    module_frames_dropped_no_route_window: Arc<Mutex<DropWindow>>,
129    module_requests_dropped_stale_route: Arc<AtomicU64>,
130    client_frames_dropped_stale_route: Arc<AtomicU64>,
131    client_egress_close_delivery_failed: Arc<AtomicU64>,
132    goodbye_relay_client_failed: Arc<AtomicU64>,
133    goodbye_relay_module_dropped: Arc<AtomicU64>,
134    goodbye_relay_module_dropped_by_module: Arc<Mutex<HashMap<String, u64>>>,
135    route_released_epoch_fenced: Arc<AtomicU64>,
136    route_release_stale_skipped: Arc<AtomicU64>,
137    drains_with_undeclared_gauge: Arc<AtomicU64>,
138}
139
140/// Ten one-minute buckets make sustained module-to-client route drops visible
141/// without retaining one record for every dropped frame.
142#[derive(Debug)]
143struct DropWindow {
144    started_at: tokio::time::Instant,
145    buckets: VecDeque<DropBucket>,
146}
147
148#[derive(Debug)]
149struct DropBucket {
150    minute: u64,
151    count: u64,
152}
153
154impl Default for DropWindow {
155    fn default() -> Self {
156        Self {
157            started_at: tokio::time::Instant::now(),
158            buckets: VecDeque::new(),
159        }
160    }
161}
162
163impl DropWindow {
164    const MINUTE: Duration = Duration::from_secs(60);
165    const BUCKETS: u64 = 10;
166
167    fn record(&mut self, now: tokio::time::Instant) {
168        let minute = self.minute_at(now);
169        self.prune_before(minute);
170        match self.buckets.back_mut() {
171            Some(bucket) if bucket.minute == minute => bucket.count += 1,
172            _ => self.buckets.push_back(DropBucket { minute, count: 1 }),
173        }
174    }
175
176    fn count_last_10m(&mut self, now: tokio::time::Instant) -> u64 {
177        let minute = self.minute_at(now);
178        self.prune_before(minute);
179        self.buckets.iter().map(|bucket| bucket.count).sum()
180    }
181
182    fn nonzero_minutes_last_10m(&mut self, now: tokio::time::Instant) -> u64 {
183        let minute = self.minute_at(now);
184        self.prune_before(minute);
185        self.buckets.len() as u64
186    }
187
188    fn minute_at(&self, now: tokio::time::Instant) -> u64 {
189        now.saturating_duration_since(self.started_at).as_secs() / Self::MINUTE.as_secs()
190    }
191
192    fn prune_before(&mut self, current_minute: u64) {
193        while self
194            .buckets
195            .front()
196            .is_some_and(|bucket| current_minute.saturating_sub(bucket.minute) >= Self::BUCKETS)
197        {
198            self.buckets.pop_front();
199        }
200    }
201}
202
203impl DaemonCounters {
204    pub fn new() -> Self {
205        Self::default()
206    }
207
208    /// Returns a JSON snapshot whose stable, additive schema keeps the
209    /// `server.describe` diagnostic endpoint backward-compatible.
210    pub fn snapshot(&self) -> Value {
211        let mut snapshot = serde_json::Map::new();
212        snapshot.insert(
213            "module_frames_dropped_no_route".into(),
214            self.module_frames_dropped_no_route
215                .load(Ordering::Relaxed)
216                .into(),
217        );
218        let mut drop_window = self
219            .module_frames_dropped_no_route_window
220            .lock()
221            .expect("drop-rate window mutex poisoned");
222        let now = tokio::time::Instant::now();
223        snapshot.insert(
224            "module_frames_dropped_no_route_last_10m".into(),
225            drop_window.count_last_10m(now).into(),
226        );
227        snapshot.insert(
228            "module_frames_dropped_no_route_nonzero_minutes_last_10m".into(),
229            drop_window.nonzero_minutes_last_10m(now).into(),
230        );
231        insert_nonempty_counts(
232            &mut snapshot,
233            "module_frames_dropped_no_route_by_module",
234            &self.module_frames_dropped_no_route_by_module,
235        );
236        insert_nonempty_counts(
237            &mut snapshot,
238            "route_open_refused_by_code",
239            &self.route_open_refused_by_code,
240        );
241        insert_nonempty_counts(
242            &mut snapshot,
243            "route_open_accepted_by_principal",
244            &self.route_open_accepted_by_principal,
245        );
246        snapshot.insert(
247            "module_requests_dropped_stale_route".into(),
248            self.module_requests_dropped_stale_route
249                .load(Ordering::Relaxed)
250                .into(),
251        );
252        snapshot.insert(
253            "client_frames_dropped_stale_route".into(),
254            self.client_frames_dropped_stale_route
255                .load(Ordering::Relaxed)
256                .into(),
257        );
258        snapshot.insert(
259            "client_egress_close_delivery_failed".into(),
260            self.client_egress_close_delivery_failed
261                .load(Ordering::Relaxed)
262                .into(),
263        );
264        snapshot.insert(
265            "goodbye_relay_client_failed".into(),
266            self.goodbye_relay_client_failed
267                .load(Ordering::Relaxed)
268                .into(),
269        );
270        snapshot.insert(
271            "goodbye_relay_module_dropped".into(),
272            self.goodbye_relay_module_dropped
273                .load(Ordering::Relaxed)
274                .into(),
275        );
276        insert_nonempty_counts(
277            &mut snapshot,
278            "goodbye_relay_module_dropped_by_module",
279            &self.goodbye_relay_module_dropped_by_module,
280        );
281        snapshot.insert(
282            "route_released_epoch_fenced".into(),
283            self.route_released_epoch_fenced
284                .load(Ordering::Relaxed)
285                .into(),
286        );
287        snapshot.insert(
288            "route_release_stale_skipped".into(),
289            self.route_release_stale_skipped
290                .load(Ordering::Relaxed)
291                .into(),
292        );
293        snapshot.insert(
294            "drains_with_undeclared_gauge".into(),
295            self.drains_with_undeclared_gauge
296                .load(Ordering::Relaxed)
297                .into(),
298        );
299        Value::Object(snapshot)
300    }
301
302    pub(crate) fn increment_module_frames_dropped_no_route(&self, module_id: Option<&str>) {
303        self.module_frames_dropped_no_route
304            .fetch_add(1, Ordering::Relaxed);
305        if let Some(module_id) = module_id {
306            increment_keyed_count(&self.module_frames_dropped_no_route_by_module, module_id);
307        }
308        self.module_frames_dropped_no_route_window
309            .lock()
310            .expect("drop-rate window mutex poisoned")
311            .record(tokio::time::Instant::now());
312    }
313
314    pub(crate) fn increment_route_open_refused(&self, code: &'static str) {
315        debug_assert!(ROUTE_OPEN_REFUSAL_COUNTER_CODES.contains(&code));
316        increment_keyed_count(&self.route_open_refused_by_code, code);
317    }
318
319    /// Count an accepted route.open by the principal the daemon stamped.
320    ///
321    /// THE KEY SPACE IS CLOSED BY CONSTRUCTION, unlike the refusal counter which
322    /// needs an explicit allowlist: a principal is `direct` or
323    /// `reserved:<module_id>`, and a module id was already refused at HELLO
324    /// unless it is a single path component free of control characters. So an
325    /// untrusted string cannot expand this map without first passing module-id
326    /// validation, and the bound is the number of modules rather than the number
327    /// of distinct strings a caller can invent.
328    pub(crate) fn increment_route_open_accepted(&self, principal: &str) {
329        increment_keyed_count(&self.route_open_accepted_by_principal, principal);
330    }
331
332    pub(crate) fn increment_module_requests_dropped_stale_route(&self) {
333        self.module_requests_dropped_stale_route
334            .fetch_add(1, Ordering::Relaxed);
335    }
336
337    pub(crate) fn increment_client_frames_dropped_stale_route(&self) {
338        self.client_frames_dropped_stale_route
339            .fetch_add(1, Ordering::Relaxed);
340    }
341
342    pub(crate) fn increment_client_egress_close_delivery_failed(&self) {
343        self.client_egress_close_delivery_failed
344            .fetch_add(1, Ordering::Relaxed);
345    }
346
347    pub(crate) fn increment_goodbye_relay_client_failed(&self) {
348        self.goodbye_relay_client_failed
349            .fetch_add(1, Ordering::Relaxed);
350    }
351
352    pub(crate) fn increment_goodbye_relay_module_dropped(&self, module_id: Option<&str>) {
353        self.goodbye_relay_module_dropped
354            .fetch_add(1, Ordering::Relaxed);
355        if let Some(module_id) = module_id {
356            increment_keyed_count(&self.goodbye_relay_module_dropped_by_module, module_id);
357        }
358    }
359
360    pub(crate) fn increment_route_released_epoch_fenced(&self) {
361        self.route_released_epoch_fenced
362            .fetch_add(1, Ordering::Relaxed);
363    }
364
365    pub(crate) fn increment_route_release_stale_skipped(&self) {
366        self.route_release_stale_skipped
367            .fetch_add(1, Ordering::Relaxed);
368    }
369
370    pub(crate) fn increment_drains_with_undeclared_gauge(&self) {
371        self.drains_with_undeclared_gauge
372            .fetch_add(1, Ordering::Relaxed);
373    }
374}
375
376fn increment_keyed_count(counts: &Mutex<HashMap<String, u64>>, key: &str) {
377    *counts
378        .lock()
379        .expect("keyed counter mutex poisoned")
380        .entry(key.to_string())
381        .or_default() += 1;
382}
383
384fn insert_nonempty_counts(
385    snapshot: &mut serde_json::Map<String, Value>,
386    key: &str,
387    counts: &Mutex<HashMap<String, u64>>,
388) {
389    let counts: MutexGuard<'_, HashMap<String, u64>> =
390        counts.lock().expect("keyed counter mutex poisoned");
391    if !counts.is_empty() {
392        snapshot.insert(key.to_string(), json!(&*counts));
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn counter_snapshot_includes_zero_rate_and_omits_empty_module_maps() {
402        let counters = DaemonCounters::new();
403        let snapshot = counters.snapshot();
404
405        assert_eq!(snapshot["module_frames_dropped_no_route_last_10m"], 0);
406        assert_eq!(
407            snapshot["module_frames_dropped_no_route_nonzero_minutes_last_10m"],
408            0
409        );
410        assert!(snapshot
411            .get("module_frames_dropped_no_route_by_module")
412            .is_none());
413        assert!(snapshot
414            .get("goodbye_relay_module_dropped_by_module")
415            .is_none());
416    }
417
418    #[tokio::test(start_paused = true)]
419    async fn module_frame_drops_are_attributed_to_the_emitting_module() {
420        let counters = DaemonCounters::new();
421        counters.increment_module_frames_dropped_no_route(Some("alpha"));
422        counters.increment_module_frames_dropped_no_route(Some("alpha"));
423
424        let snapshot = counters.snapshot();
425        assert_eq!(snapshot["module_frames_dropped_no_route"], 2);
426        assert_eq!(
427            snapshot["module_frames_dropped_no_route_by_module"],
428            json!({ "alpha": 2 })
429        );
430        assert_eq!(snapshot["module_frames_dropped_no_route_last_10m"], 2);
431    }
432
433    #[tokio::test(start_paused = true)]
434    async fn frame_drop_rate_ages_out_after_ten_minute_buckets() {
435        let counters = DaemonCounters::new();
436        counters.increment_module_frames_dropped_no_route(Some("alpha"));
437
438        tokio::time::advance(Duration::from_secs(9 * 60)).await;
439        assert_eq!(
440            counters.snapshot()["module_frames_dropped_no_route_last_10m"],
441            1
442        );
443
444        tokio::time::advance(Duration::from_secs(60)).await;
445        assert_eq!(
446            counters.snapshot()["module_frames_dropped_no_route_last_10m"],
447            0
448        );
449    }
450
451    #[tokio::test(start_paused = true)]
452    async fn frame_drop_window_counts_only_nonzero_minutes() {
453        let counters = DaemonCounters::new();
454        for minute in 0..10 {
455            if minute > 0 {
456                tokio::time::advance(Duration::from_secs(60)).await;
457            }
458            if minute != 4 {
459                counters.increment_module_frames_dropped_no_route(Some("alpha"));
460            }
461        }
462
463        assert_eq!(
464            counters.snapshot()["module_frames_dropped_no_route_nonzero_minutes_last_10m"],
465            9
466        );
467    }
468
469    #[test]
470    fn goodbye_relay_drops_are_attributed_to_the_target_module() {
471        let counters = DaemonCounters::new();
472        counters.increment_goodbye_relay_module_dropped(Some("alpha"));
473
474        assert_eq!(
475            counters.snapshot()["goodbye_relay_module_dropped_by_module"],
476            json!({ "alpha": 1 })
477        );
478    }
479}