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