Skip to main content

clawft_kernel/
metrics.rs

1//! Kernel metrics collection with lock-free counters, gauges, and histograms.
2//!
3//! [`MetricsRegistry`] provides a lock-free metrics subsystem. Counters and
4//! gauges use atomic operations on the hot path. Histograms use fixed bucket
5//! boundaries with atomic counters -- O(buckets) per record.
6
7use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
8
9use dashmap::DashMap;
10use serde::{Deserialize, Serialize};
11
12/// Default histogram bucket boundaries (in milliseconds for latency metrics).
13pub const DEFAULT_BUCKETS: &[f64] = &[1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0];
14
15// ── Built-in metric names ───────────────────────────────────────
16
17/// Counter: total messages sent via A2ARouter.
18pub const METRIC_MESSAGES_SENT: &str = "kernel.messages_sent";
19/// Counter: total messages successfully delivered.
20pub const METRIC_MESSAGES_DELIVERED: &str = "kernel.messages_delivered";
21/// Counter: total messages dropped (dead-lettered).
22pub const METRIC_MESSAGES_DROPPED: &str = "kernel.messages_dropped";
23/// Counter: total agent spawns.
24pub const METRIC_AGENT_SPAWNS: &str = "kernel.agent_spawns";
25/// Counter: total agent crashes.
26pub const METRIC_AGENT_CRASHES: &str = "kernel.agent_crashes";
27/// Counter: total tool executions.
28pub const METRIC_TOOL_EXECUTIONS: &str = "kernel.tool_executions";
29/// Gauge: currently active agents.
30pub const METRIC_ACTIVE_AGENTS: &str = "kernel.active_agents";
31/// Gauge: currently active services.
32pub const METRIC_ACTIVE_SERVICES: &str = "kernel.active_services";
33/// Gauge: exochain length.
34pub const METRIC_CHAIN_LENGTH: &str = "kernel.chain_length";
35/// Histogram: IPC latency in milliseconds.
36pub const METRIC_IPC_LATENCY_MS: &str = "kernel.ipc_latency_ms";
37/// Histogram: tool execution time in milliseconds.
38pub const METRIC_TOOL_EXECUTION_MS: &str = "kernel.tool_execution_ms";
39/// Histogram: governance evaluation time in milliseconds.
40pub const METRIC_GOVERNANCE_EVAL_MS: &str = "kernel.governance_evaluation_ms";
41
42/// A fixed-bucket histogram for recording distributions.
43///
44/// Each bucket tracks a count of values <= the bucket boundary.
45/// The `sum` and `count` fields track totals for mean calculation.
46pub struct Histogram {
47    /// Bucket boundaries and their counts. Each bucket counts values
48    /// that are <= the boundary (cumulative).
49    buckets: Vec<(f64, AtomicU64)>,
50    /// Sum of all recorded values (stored as u64 bits of f64).
51    sum_bits: AtomicU64,
52    /// Total number of recorded values.
53    count: AtomicU64,
54}
55
56impl Histogram {
57    /// Create a histogram with the given bucket boundaries.
58    ///
59    /// Boundaries are sorted ascending. An implicit +Inf bucket is
60    /// always present.
61    pub fn new(boundaries: &[f64]) -> Self {
62        let mut sorted: Vec<f64> = boundaries.to_vec();
63        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
64        sorted.dedup();
65
66        let buckets = sorted.into_iter().map(|b| (b, AtomicU64::new(0))).collect();
67
68        Self {
69            buckets,
70            sum_bits: AtomicU64::new(0),
71            count: AtomicU64::new(0),
72        }
73    }
74
75    /// Create a histogram with default bucket boundaries.
76    pub fn with_defaults() -> Self {
77        Self::new(DEFAULT_BUCKETS)
78    }
79
80    /// Record a value in the histogram.
81    pub fn record(&self, value: f64) {
82        // Increment the count for each bucket whose boundary >= value.
83        // This gives us cumulative counts.
84        for (bound, count) in &self.buckets {
85            if value <= *bound {
86                count.fetch_add(1, Ordering::Relaxed);
87            }
88        }
89
90        // Add to sum (atomic f64 addition via CAS loop)
91        loop {
92            let old_bits = self.sum_bits.load(Ordering::Relaxed);
93            let old_val = f64::from_bits(old_bits);
94            let new_val = old_val + value;
95            let new_bits = new_val.to_bits();
96            if self
97                .sum_bits
98                .compare_exchange_weak(old_bits, new_bits, Ordering::Relaxed, Ordering::Relaxed)
99                .is_ok()
100            {
101                break;
102            }
103        }
104
105        self.count.fetch_add(1, Ordering::Relaxed);
106    }
107
108    /// Get the total count of recorded values.
109    pub fn total_count(&self) -> u64 {
110        self.count.load(Ordering::Relaxed)
111    }
112
113    /// Get the sum of all recorded values.
114    pub fn sum(&self) -> f64 {
115        f64::from_bits(self.sum_bits.load(Ordering::Relaxed))
116    }
117
118    /// Get a snapshot of bucket boundaries and their cumulative counts.
119    pub fn bucket_snapshot(&self) -> Vec<(f64, u64)> {
120        self.buckets
121            .iter()
122            .map(|(bound, count)| (*bound, count.load(Ordering::Relaxed)))
123            .collect()
124    }
125
126    /// Estimate a percentile value from the histogram buckets.
127    ///
128    /// Uses linear interpolation between bucket boundaries.
129    /// Returns `None` if no values have been recorded.
130    pub fn percentile(&self, p: f64) -> Option<f64> {
131        let total = self.total_count();
132        if total == 0 {
133            return None;
134        }
135
136        let target = (p * total as f64).ceil() as u64;
137        let buckets = self.bucket_snapshot();
138
139        // Find the first bucket whose cumulative count >= target
140        for (i, (bound, cumulative)) in buckets.iter().enumerate() {
141            if *cumulative >= target {
142                if i == 0 {
143                    return Some(*bound);
144                }
145                // Linear interpolation between previous and current bucket
146                let prev_bound = buckets[i - 1].0;
147                let prev_count = buckets[i - 1].1;
148                let remaining = target.saturating_sub(prev_count) as f64;
149                let bucket_count = cumulative.saturating_sub(prev_count) as f64;
150                if bucket_count <= 0.0 {
151                    return Some(*bound);
152                }
153                let fraction = remaining / bucket_count;
154                return Some(prev_bound + fraction * (bound - prev_bound));
155            }
156        }
157
158        // Value exceeds all bucket boundaries
159        buckets.last().map(|(b, _)| *b)
160    }
161}
162
163/// A snapshot of a single metric.
164#[non_exhaustive]
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub enum MetricSnapshot {
167    /// A counter metric (monotonically increasing).
168    Counter {
169        /// Metric name.
170        name: String,
171        /// Current value.
172        value: u64,
173    },
174    /// A gauge metric (can go up or down).
175    Gauge {
176        /// Metric name.
177        name: String,
178        /// Current value.
179        value: i64,
180    },
181    /// A histogram metric (distribution).
182    Histogram {
183        /// Metric name.
184        name: String,
185        /// Bucket boundaries and cumulative counts.
186        buckets: Vec<(f64, u64)>,
187        /// Sum of all values.
188        sum: f64,
189        /// Total count of values.
190        count: u64,
191    },
192}
193
194/// Registry of all kernel metrics. Lock-free on the hot path.
195///
196/// Counters use `AtomicU64`, gauges use `AtomicI64`, and histograms
197/// use fixed-bucket atomic counters. `DashMap` provides concurrent
198/// access to the registry without global locks.
199pub struct MetricsRegistry {
200    counters: DashMap<String, AtomicU64>,
201    gauges: DashMap<String, AtomicI64>,
202    histograms: DashMap<String, Histogram>,
203}
204
205impl MetricsRegistry {
206    /// Create a new empty metrics registry.
207    pub fn new() -> Self {
208        Self {
209            counters: DashMap::new(),
210            gauges: DashMap::new(),
211            histograms: DashMap::new(),
212        }
213    }
214
215    /// Create a registry pre-populated with built-in kernel metrics.
216    pub fn with_builtins() -> Self {
217        let registry = Self::new();
218
219        // Counters
220        for name in [
221            METRIC_MESSAGES_SENT,
222            METRIC_MESSAGES_DELIVERED,
223            METRIC_MESSAGES_DROPPED,
224            METRIC_AGENT_SPAWNS,
225            METRIC_AGENT_CRASHES,
226            METRIC_TOOL_EXECUTIONS,
227        ] {
228            registry
229                .counters
230                .insert(name.to_string(), AtomicU64::new(0));
231        }
232
233        // Gauges
234        for name in [
235            METRIC_ACTIVE_AGENTS,
236            METRIC_ACTIVE_SERVICES,
237            METRIC_CHAIN_LENGTH,
238        ] {
239            registry.gauges.insert(name.to_string(), AtomicI64::new(0));
240        }
241
242        // Histograms
243        for name in [
244            METRIC_IPC_LATENCY_MS,
245            METRIC_TOOL_EXECUTION_MS,
246            METRIC_GOVERNANCE_EVAL_MS,
247        ] {
248            registry
249                .histograms
250                .insert(name.to_string(), Histogram::with_defaults());
251        }
252
253        registry
254    }
255
256    // ── Counter operations ──────────────────────────────────────
257
258    /// Increment a counter by 1.
259    pub fn counter_inc(&self, name: &str) {
260        self.counter_add(name, 1);
261    }
262
263    /// Add a value to a counter.
264    pub fn counter_add(&self, name: &str, value: u64) {
265        match self.counters.get(name) {
266            Some(counter) => {
267                counter.fetch_add(value, Ordering::Relaxed);
268            }
269            None => {
270                self.counters
271                    .insert(name.to_string(), AtomicU64::new(value));
272            }
273        }
274    }
275
276    /// Get the current value of a counter.
277    pub fn counter_get(&self, name: &str) -> u64 {
278        self.counters
279            .get(name)
280            .map(|c| c.load(Ordering::Relaxed))
281            .unwrap_or(0)
282    }
283
284    // ── Gauge operations ────────────────────────────────────────
285
286    /// Set a gauge to a specific value.
287    pub fn gauge_set(&self, name: &str, value: i64) {
288        match self.gauges.get(name) {
289            Some(gauge) => {
290                gauge.store(value, Ordering::Relaxed);
291            }
292            None => {
293                self.gauges.insert(name.to_string(), AtomicI64::new(value));
294            }
295        }
296    }
297
298    /// Increment a gauge by a value.
299    pub fn gauge_inc(&self, name: &str, delta: i64) {
300        match self.gauges.get(name) {
301            Some(gauge) => {
302                gauge.fetch_add(delta, Ordering::Relaxed);
303            }
304            None => {
305                self.gauges.insert(name.to_string(), AtomicI64::new(delta));
306            }
307        }
308    }
309
310    /// Decrement a gauge by a value.
311    pub fn gauge_dec(&self, name: &str, delta: i64) {
312        self.gauge_inc(name, -delta);
313    }
314
315    /// Get the current value of a gauge.
316    pub fn gauge_get(&self, name: &str) -> i64 {
317        self.gauges
318            .get(name)
319            .map(|g| g.load(Ordering::Relaxed))
320            .unwrap_or(0)
321    }
322
323    // ── Histogram operations ────────────────────────────────────
324
325    /// Record a value in a histogram.
326    ///
327    /// If the histogram does not exist, it is created with default buckets.
328    pub fn histogram_record(&self, name: &str, value: f64) {
329        match self.histograms.get(name) {
330            Some(hist) => {
331                hist.record(value);
332            }
333            None => {
334                let hist = Histogram::with_defaults();
335                hist.record(value);
336                self.histograms.insert(name.to_string(), hist);
337            }
338        }
339    }
340
341    /// Register a histogram with custom bucket boundaries.
342    pub fn histogram_register(&self, name: &str, boundaries: &[f64]) {
343        if !self.histograms.contains_key(name) {
344            self.histograms
345                .insert(name.to_string(), Histogram::new(boundaries));
346        }
347    }
348
349    /// Get a percentile estimate from a histogram.
350    pub fn histogram_percentile(&self, name: &str, p: f64) -> Option<f64> {
351        self.histograms.get(name).and_then(|h| h.percentile(p))
352    }
353
354    /// Get histogram bucket snapshot.
355    pub fn histogram_buckets(&self, name: &str) -> Option<Vec<(f64, u64)>> {
356        self.histograms.get(name).map(|h| h.bucket_snapshot())
357    }
358
359    // ── Snapshot ────────────────────────────────────────────────
360
361    /// Take a snapshot of all metrics.
362    pub fn snapshot_all(&self) -> Vec<MetricSnapshot> {
363        let mut snapshots = Vec::new();
364
365        for entry in self.counters.iter() {
366            snapshots.push(MetricSnapshot::Counter {
367                name: entry.key().clone(),
368                value: entry.value().load(Ordering::Relaxed),
369            });
370        }
371
372        for entry in self.gauges.iter() {
373            snapshots.push(MetricSnapshot::Gauge {
374                name: entry.key().clone(),
375                value: entry.value().load(Ordering::Relaxed),
376            });
377        }
378
379        for entry in self.histograms.iter() {
380            let hist = entry.value();
381            snapshots.push(MetricSnapshot::Histogram {
382                name: entry.key().clone(),
383                buckets: hist.bucket_snapshot(),
384                sum: hist.sum(),
385                count: hist.total_count(),
386            });
387        }
388
389        snapshots
390    }
391
392    /// List all metric names.
393    pub fn list_names(&self) -> Vec<String> {
394        let mut names: Vec<String> = Vec::new();
395        for e in self.counters.iter() {
396            names.push(e.key().clone());
397        }
398        for e in self.gauges.iter() {
399            names.push(e.key().clone());
400        }
401        for e in self.histograms.iter() {
402            names.push(e.key().clone());
403        }
404        names
405    }
406}
407
408impl Default for MetricsRegistry {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn counter_increment_and_get() {
420        let registry = MetricsRegistry::new();
421        registry.counter_inc("test.counter");
422        registry.counter_inc("test.counter");
423        assert_eq!(registry.counter_get("test.counter"), 2);
424    }
425
426    #[test]
427    fn counter_add() {
428        let registry = MetricsRegistry::new();
429        registry.counter_add("test.counter", 10);
430        registry.counter_add("test.counter", 5);
431        assert_eq!(registry.counter_get("test.counter"), 15);
432    }
433
434    #[test]
435    fn counter_get_nonexistent_returns_zero() {
436        let registry = MetricsRegistry::new();
437        assert_eq!(registry.counter_get("nope"), 0);
438    }
439
440    #[test]
441    fn gauge_set_and_get() {
442        let registry = MetricsRegistry::new();
443        registry.gauge_set("test.gauge", 42);
444        assert_eq!(registry.gauge_get("test.gauge"), 42);
445    }
446
447    #[test]
448    fn gauge_increment_decrement() {
449        let registry = MetricsRegistry::new();
450        registry.gauge_set("test.gauge", 10);
451        registry.gauge_inc("test.gauge", 5);
452        assert_eq!(registry.gauge_get("test.gauge"), 15);
453        registry.gauge_dec("test.gauge", 3);
454        assert_eq!(registry.gauge_get("test.gauge"), 12);
455    }
456
457    #[test]
458    fn gauge_negative() {
459        let registry = MetricsRegistry::new();
460        registry.gauge_set("test.gauge", -5);
461        assert_eq!(registry.gauge_get("test.gauge"), -5);
462    }
463
464    #[test]
465    fn histogram_record_and_count() {
466        let registry = MetricsRegistry::new();
467        registry.histogram_record("test.hist", 5.0);
468        registry.histogram_record("test.hist", 15.0);
469        registry.histogram_record("test.hist", 150.0);
470
471        let buckets = registry.histogram_buckets("test.hist").unwrap();
472        // 5.0 should be in bucket 5.0 and above
473        // 15.0 should be in bucket 25.0 and above
474        // 150.0 should be in bucket 250.0 and above
475
476        // Bucket 5.0: count of values <= 5 = 1
477        let b5 = buckets
478            .iter()
479            .find(|(b, _)| (*b - 5.0).abs() < f64::EPSILON);
480        assert_eq!(b5.unwrap().1, 1);
481
482        // Bucket 25.0: count of values <= 25 = 2
483        let b25 = buckets
484            .iter()
485            .find(|(b, _)| (*b - 25.0).abs() < f64::EPSILON);
486        assert_eq!(b25.unwrap().1, 2);
487
488        // Bucket 250.0: count of values <= 250 = 3
489        let b250 = buckets
490            .iter()
491            .find(|(b, _)| (*b - 250.0).abs() < f64::EPSILON);
492        assert_eq!(b250.unwrap().1, 3);
493    }
494
495    #[test]
496    fn histogram_sum_and_count() {
497        let hist = Histogram::with_defaults();
498        hist.record(10.0);
499        hist.record(20.0);
500        hist.record(30.0);
501
502        assert_eq!(hist.total_count(), 3);
503        assert!((hist.sum() - 60.0).abs() < f64::EPSILON);
504    }
505
506    #[test]
507    fn histogram_percentile_p50() {
508        let hist = Histogram::new(&[10.0, 20.0, 30.0, 40.0, 50.0]);
509        for v in [5.0, 15.0, 25.0, 35.0, 45.0] {
510            hist.record(v);
511        }
512
513        let p50 = hist.percentile(0.50).unwrap();
514        // 5 values total, p50 = 3rd value, should be in 20-30 range
515        assert!(p50 >= 20.0 && p50 <= 30.0, "p50 = {p50}");
516    }
517
518    #[test]
519    fn histogram_percentile_p95() {
520        let hist = Histogram::new(&[10.0, 50.0, 100.0, 500.0, 1000.0]);
521        for _ in 0..95 {
522            hist.record(5.0);
523        }
524        for _ in 0..5 {
525            hist.record(800.0);
526        }
527
528        let p95 = hist.percentile(0.95).unwrap();
529        // 95% of values are <= 10.0
530        assert!(p95 <= 10.0, "p95 = {p95}");
531    }
532
533    #[test]
534    fn histogram_percentile_p99() {
535        let hist = Histogram::new(&[10.0, 50.0, 100.0, 500.0, 1000.0]);
536        for _ in 0..99 {
537            hist.record(5.0);
538        }
539        hist.record(800.0);
540
541        let p99 = hist.percentile(0.99).unwrap();
542        assert!(p99 <= 10.0, "p99 = {p99}");
543    }
544
545    #[test]
546    fn histogram_percentile_empty() {
547        let hist = Histogram::with_defaults();
548        assert!(hist.percentile(0.50).is_none());
549    }
550
551    #[test]
552    fn registry_with_builtins() {
553        let registry = MetricsRegistry::with_builtins();
554        assert_eq!(registry.counter_get(METRIC_MESSAGES_SENT), 0);
555        assert_eq!(registry.gauge_get(METRIC_ACTIVE_AGENTS), 0);
556        assert!(registry.histogram_buckets(METRIC_IPC_LATENCY_MS).is_some());
557    }
558
559    #[test]
560    fn snapshot_all() {
561        let registry = MetricsRegistry::new();
562        registry.counter_inc("c1");
563        registry.gauge_set("g1", 42);
564        registry.histogram_record("h1", 10.0);
565
566        let snap = registry.snapshot_all();
567        assert_eq!(snap.len(), 3);
568
569        let has_counter = snap
570            .iter()
571            .any(|s| matches!(s, MetricSnapshot::Counter { name, .. } if name == "c1"));
572        let has_gauge = snap.iter().any(
573            |s| matches!(s, MetricSnapshot::Gauge { name, value } if name == "g1" && *value == 42),
574        );
575        let has_hist = snap
576            .iter()
577            .any(|s| matches!(s, MetricSnapshot::Histogram { name, .. } if name == "h1"));
578
579        assert!(has_counter);
580        assert!(has_gauge);
581        assert!(has_hist);
582    }
583
584    #[test]
585    fn list_names() {
586        let registry = MetricsRegistry::new();
587        registry.counter_inc("alpha");
588        registry.gauge_set("beta", 1);
589        registry.histogram_record("gamma", 1.0);
590
591        let names = registry.list_names();
592        assert_eq!(names.len(), 3);
593        assert!(names.contains(&"alpha".to_string()));
594        assert!(names.contains(&"beta".to_string()));
595        assert!(names.contains(&"gamma".to_string()));
596    }
597
598    #[test]
599    fn counter_concurrent_atomicity() {
600        let registry = MetricsRegistry::new();
601        // Simulate concurrent increments
602        for _ in 0..1000 {
603            registry.counter_inc("concurrent");
604        }
605        assert_eq!(registry.counter_get("concurrent"), 1000);
606    }
607
608    #[test]
609    fn histogram_custom_buckets() {
610        let registry = MetricsRegistry::new();
611        registry.histogram_register("custom", &[1.0, 2.0, 5.0]);
612        registry.histogram_record("custom", 1.5);
613
614        let buckets = registry.histogram_buckets("custom").unwrap();
615        assert_eq!(buckets.len(), 3);
616        // 1.5 is > 1.0 but <= 2.0
617        assert_eq!(buckets[0].1, 0); // <= 1.0
618        assert_eq!(buckets[1].1, 1); // <= 2.0
619        assert_eq!(buckets[2].1, 1); // <= 5.0
620    }
621
622    #[test]
623    fn metric_snapshot_serde() {
624        let snap = MetricSnapshot::Counter {
625            name: "test".into(),
626            value: 42,
627        };
628        let json = serde_json::to_string(&snap).unwrap();
629        let restored: MetricSnapshot = serde_json::from_str(&json).unwrap();
630        assert!(matches!(
631            restored,
632            MetricSnapshot::Counter { value: 42, .. }
633        ));
634    }
635
636    #[test]
637    fn default_registry() {
638        let registry = MetricsRegistry::default();
639        assert!(registry.list_names().is_empty());
640    }
641}