Skip to main content

oxios_kernel/
metrics.rs

1//! Metrics — Prometheus-compatible counters, gauges, and histograms.
2//!
3//! This module provides in-process metrics without external dependencies.
4//! Exposed via GET /api/metrics in Prometheus text format.
5
6#![allow(missing_docs)]
7
8use parking_lot::{Mutex, RwLock};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11/// Thread-safe metrics registry.
12#[derive(Default)]
13pub struct MetricsRegistry {
14    counters: RwLock<Vec<Counter>>,
15    gauges: RwLock<Vec<Gauge>>,
16    histograms: RwLock<Vec<Histogram>>,
17    /// Counters with dynamic label values (RFC-024).
18    ///
19    /// Each (name, label_key, label_value) triple is a unique time series.
20    /// `LabeledCounterHandle` stores the index into this vec, and increment
21    /// is a single `fetch_add` on the stored atomic. O(1) per inc, O(n) only
22    /// at registration.
23    labeled_counters: RwLock<Vec<LabeledCounter>>,
24}
25
26struct LabeledCounter {
27    name: String,
28    help: String,
29    label_key: &'static str,
30    label_value: String,
31    value: AtomicU64,
32}
33
34impl MetricsRegistry {
35    /// Create a new metrics registry.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Register a new counter and return a handle.
41    pub fn counter(
42        &self,
43        name: &'static str,
44        help: &'static str,
45        labels: &[(&'static str, &'static str)],
46    ) -> CounterHandle {
47        let mut counters = self.counters.write();
48        let id = counters.len();
49        counters.push(Counter {
50            name: name.into(),
51            help: help.into(),
52            labels: labels.into(),
53            value: AtomicU64::new(0),
54        });
55        CounterHandle { id }
56    }
57
58    /// Register a new gauge and return a handle.
59    pub fn gauge(&self, name: &'static str, help: &'static str, initial: f64) -> GaugeHandle {
60        let mut gauges = self.gauges.write();
61        let id = gauges.len();
62        gauges.push(Gauge {
63            name: name.into(),
64            help: help.into(),
65            value: Mutex::new(initial),
66        });
67        GaugeHandle { id }
68    }
69
70    /// Register a new histogram and return a handle.
71    pub fn histogram(
72        &self,
73        name: &'static str,
74        help: &'static str,
75        buckets: Vec<f64>,
76    ) -> HistogramHandle {
77        let mut histograms = self.histograms.write();
78        let id = histograms.len();
79        let counts: Vec<usize> = vec![0; buckets.len() + 1];
80        histograms.push(Histogram {
81            name: name.into(),
82            help: help.into(),
83            buckets: buckets.clone(),
84            counts: RwLock::new(counts),
85            sum: Mutex::new(0.0),
86            count: Mutex::new(0u64),
87        });
88        HistogramHandle { id, buckets }
89    }
90    /// Register a labeled counter (RFC-024) — one time series per
91    /// (name, label_key, label_value) triple. O(1) increment, registration
92    /// is O(n) over existing labeled counters with the same name (linear
93    /// scan; metric name sets are small at boot).
94    pub fn labeled_counter(
95        &self,
96        name: &'static str,
97        help: &'static str,
98        label_key: &'static str,
99        label_value: &str,
100    ) -> LabeledCounterHandle {
101        let mut labeled = self.labeled_counters.write();
102        // Reuse the existing series if registered with the same triple.
103        for (i, lc) in labeled.iter().enumerate() {
104            if lc.name == name && lc.label_key == label_key && lc.label_value == label_value {
105                return LabeledCounterHandle { id: i };
106            }
107        }
108        let id = labeled.len();
109        labeled.push(LabeledCounter {
110            name: name.into(),
111            help: help.into(),
112            label_key,
113            label_value: label_value.into(),
114            value: AtomicU64::new(0),
115        });
116        LabeledCounterHandle { id }
117    }
118
119    /// Export all metrics in Prometheus text format.
120    pub fn export(&self) -> String {
121        let mut out = String::new();
122
123        // Counters
124        {
125            let counters = self.counters.read();
126            for c in counters.iter() {
127                out.push_str(&format!("# HELP {} {}\n", c.name, c.help));
128                out.push_str(&format!("# TYPE {} counter\n", c.name));
129                let value = c.value.load(Ordering::Relaxed);
130                let labels_str = if c.labels.is_empty() {
131                    String::new()
132                } else {
133                    format!(
134                        "{{{}}}",
135                        c.labels
136                            .iter()
137                            .map(|(k, v)| format!("{k}=\"{v}\""))
138                            .collect::<Vec<_>>()
139                            .join(",")
140                    )
141                };
142                out.push_str(&format!("{}{} {}\n", c.name, labels_str, value));
143            }
144        }
145
146        // Gauges
147        {
148            let gauges = self.gauges.read();
149            for g in gauges.iter() {
150                out.push_str(&format!("# HELP {} {}\n", g.name, g.help));
151                out.push_str(&format!("# TYPE {} gauge\n", g.name));
152                let value = *g.value.lock();
153                out.push_str(&format!("{} {}\n", g.name, value));
154            }
155        }
156
157        // Histograms
158        {
159            let histograms = self.histograms.read();
160            for h in histograms.iter() {
161                out.push_str(&format!("# HELP {} {}\n", h.name, h.help));
162                out.push_str(&format!("# TYPE {} histogram\n", h.name));
163                let sum = *h.sum.lock();
164                let count = *h.count.lock();
165                let bucket_values = h.buckets.clone();
166                let counts = h.counts.read();
167                let mut cumulative = 0usize;
168                for (i, _) in bucket_values.iter().enumerate() {
169                    cumulative += counts[i];
170                    let boundary = bucket_values[i];
171                    out.push_str(&format!(
172                        "{}{{le=\"{}\"}} {}\n",
173                        h.name, boundary, cumulative
174                    ));
175                }
176                // +Inf bucket
177                out.push_str(&format!("{}{{le=\"+Inf\"}} {}\n", h.name, cumulative));
178                out.push_str(&format!("{}_sum {} \n", h.name, sum));
179                out.push_str(&format!("{}_count {} \n", h.name, count));
180            }
181        }
182
183        // Labeled counters (RFC-024). One series per registered
184        // (name, label_key, label_value) triple. HELP/TYPE lines are emitted
185        // per-series (Prometheus accepts repeated HELP/TYPE per series).
186        {
187            let labeled = self.labeled_counters.read();
188            let mut seen_help: std::collections::HashSet<String> = std::collections::HashSet::new();
189            for lc in labeled.iter() {
190                if seen_help.insert(lc.name.clone()) {
191                    out.push_str(&format!("# HELP {} {}\n", lc.name, lc.help));
192                    out.push_str(&format!("# TYPE {} counter\n", lc.name));
193                }
194                out.push_str(&format!(
195                    "{}{{{}=\"{}\"}} {}\n",
196                    lc.name,
197                    lc.label_key,
198                    lc.label_value,
199                    lc.value.load(Ordering::Relaxed)
200                ));
201            }
202        }
203
204        out
205    }
206}
207
208/// Global metrics registry.
209static REGISTRY: std::sync::OnceLock<MetricsRegistry> = std::sync::OnceLock::new();
210
211/// Get the global metrics registry.
212pub fn registry() -> &'static MetricsRegistry {
213    REGISTRY.get_or_init(MetricsRegistry::new)
214}
215
216#[derive(Clone)]
217pub struct CounterHandle {
218    id: usize,
219}
220
221impl CounterHandle {
222    /// Increment the counter by 1.
223    pub fn inc(&self) {
224        let r = registry();
225        let counters = r.counters.read();
226        if let Some(c) = counters.get(self.id) {
227            c.value.fetch_add(1, Ordering::Relaxed);
228        }
229    }
230
231    /// Increment the counter by `n`.
232    pub fn inc_by(&self, n: u64) {
233        if n == 0 {
234            return;
235        }
236        let r = registry();
237        let counters = r.counters.read();
238        if let Some(c) = counters.get(self.id) {
239            c.value.fetch_add(n, Ordering::Relaxed);
240        }
241    }
242}
243
244/// Handle to a labeled counter (RFC-024). Each handle refers to one
245/// (name, label_key, label_value) time series.
246#[derive(Clone)]
247pub struct LabeledCounterHandle {
248    id: usize,
249}
250
251impl LabeledCounterHandle {
252    /// Increment the counter by 1.
253    pub fn inc(&self) {
254        let r = registry();
255        let labeled = r.labeled_counters.read();
256        if let Some(c) = labeled.get(self.id) {
257            c.value.fetch_add(1, Ordering::Relaxed);
258        }
259    }
260
261    /// Increment the counter by `n`.
262    pub fn inc_by(&self, n: u64) {
263        if n == 0 {
264            return;
265        }
266        let r = registry();
267        let labeled = r.labeled_counters.read();
268        if let Some(c) = labeled.get(self.id) {
269            c.value.fetch_add(n, Ordering::Relaxed);
270        }
271    }
272}
273
274#[derive(Clone)]
275pub struct GaugeHandle {
276    id: usize,
277}
278
279impl GaugeHandle {
280    /// Set the gauge to a specific value.
281    pub fn set(&self, v: f64) {
282        let r = registry();
283        let gauges = r.gauges.read();
284        if let Some(g) = gauges.get(self.id) {
285            *g.value.lock() = v;
286        }
287    }
288
289    /// Increment the gauge by 1.
290    pub fn inc(&self) {
291        let r = registry();
292        let gauges = r.gauges.read();
293        if let Some(g) = gauges.get(self.id) {
294            let mut val = g.value.lock();
295            *val += 1.0;
296        }
297    }
298
299    /// Decrement the gauge by 1.
300    pub fn dec(&self) {
301        let r = registry();
302        let gauges = r.gauges.read();
303        if let Some(g) = gauges.get(self.id) {
304            let mut val = g.value.lock();
305            *val -= 1.0;
306        }
307    }
308}
309
310#[derive(Clone)]
311pub struct HistogramHandle {
312    id: usize,
313    buckets: Vec<f64>,
314}
315
316impl HistogramHandle {
317    /// Observe a value, adding it to the histogram.
318    pub fn observe(&self, value: f64) {
319        let r = registry();
320        let histograms = r.histograms.read();
321        if let Some(h) = histograms.get(self.id) {
322            {
323                let mut sum = h.sum.lock();
324                *sum += value;
325            }
326            {
327                let mut count = h.count.lock();
328                *count += 1;
329            }
330            {
331                let mut counts = h.counts.write();
332                for (i, boundary) in self.buckets.iter().enumerate() {
333                    if value <= *boundary {
334                        counts[i] += 1;
335                    }
336                }
337                // +Inf bucket
338                counts[self.buckets.len()] += 1;
339            }
340        }
341    }
342}
343
344struct Counter {
345    name: String,
346    help: String,
347    labels: Vec<(&'static str, &'static str)>,
348    value: AtomicU64,
349}
350
351struct Gauge {
352    name: String,
353    help: String,
354    value: Mutex<f64>,
355}
356
357struct Histogram {
358    name: String,
359    help: String,
360    buckets: Vec<f64>,
361    counts: RwLock<Vec<usize>>,
362    sum: Mutex<f64>,
363    count: Mutex<u64>,
364}
365
366/// Metrics handles initialized at startup.
367#[derive(Clone)]
368pub struct MetricsHandles {
369    pub agents_forked: CounterHandle,
370    pub agents_completed: CounterHandle,
371    pub agents_failed: CounterHandle,
372    /// RFC-027 retry metrics.
373    pub retry_attempted: CounterHandle,
374    pub retry_improved: CounterHandle,
375    pub retry_unchanged: CounterHandle,
376    pub retry_degraded: CounterHandle,
377    pub orch_duration: HistogramHandle,
378    pub messages: CounterHandle,
379    /// LLM circuit breaker state: 0=closed, 1=open, 2=half_open.
380    pub llm_circuit_breaker_state: GaugeHandle,
381    /// Tool execution metrics.
382    pub tool_calls: CounterHandle,
383    pub tool_errors: CounterHandle,
384    pub tool_duration: HistogramHandle,
385    /// LLM call metrics.
386    pub llm_calls: CounterHandle,
387    pub llm_errors: CounterHandle,
388    pub audit_lagged_events: CounterHandle,
389
390    // ── RFC-024: web↔daemon reliability metrics ──
391    // Labeled counters use one handle per (name, label_key, label_value)
392    // triple. Wire them up at the increment sites in oxios-gateway and the
393    // web routes — see RFC-024 §11.
394    /// Outgoing messages by outcome (delivered | dropped | resynced | timed_out).
395    pub gateway_messages_delivered: LabeledCounterHandle,
396    pub gateway_messages_dropped: LabeledCounterHandle,
397    pub gateway_messages_resynced: LabeledCounterHandle,
398    pub gateway_messages_timed_out: LabeledCounterHandle,
399
400    /// Replay requests by outcome (replay | resync).
401    pub gateway_replay_replay: LabeledCounterHandle,
402    pub gateway_replay_resync: LabeledCounterHandle,
403
404    /// `send_and_wait` duration histogram.
405    pub gateway_response_duration: HistogramHandle,
406
407    /// SSE client connections by action (open | close). The original
408    /// RFC-024 §11 metric (`sse_reconnects_total{reason}`) required
409    /// client-side observability (proxy/NAT/UA-specific reasons) the
410    /// server cannot see. We expose server-side lifecycle instead.
411    pub sse_connections_open: LabeledCounterHandle,
412    pub sse_connections_close: LabeledCounterHandle,
413
414    /// WS client connections by action (open | close | keepalive_timeout).
415    pub ws_connections_open: LabeledCounterHandle,
416    pub ws_connections_close: LabeledCounterHandle,
417    pub ws_connections_keepalive_timeout: LabeledCounterHandle,
418
419    /// Atomic web-dist swaps (RFC-024 SP3).
420    pub web_dist_swaps: CounterHandle,
421
422    /// 0 = warming up, 1 = ready (RFC-024 SP4). Updated from
423    /// `ReadinessGate` when a subsystem changes state.
424    pub readiness_state: GaugeHandle,
425}
426
427impl MetricsHandles {
428    /// Increment agents_forked counter.
429    pub fn inc_agents_forked(&self) {
430        self.agents_forked.inc();
431    }
432
433    /// Increment agents_completed counter.
434    pub fn inc_agents_completed(&self) {
435        self.agents_completed.inc();
436    }
437
438    /// Increment agents_failed counter.
439    pub fn inc_agents_failed(&self) {
440        self.agents_failed.inc();
441    }
442
443    /// Increment messages counter.
444    pub fn inc_messages(&self) {
445        self.messages.inc();
446    }
447
448    /// Observe orchestration duration.
449    pub fn observe_orch_duration(&self, value: f64) {
450        self.orch_duration.observe(value);
451    }
452}
453/// Global lazy metric handles.
454static METRICS: std::sync::OnceLock<MetricsHandles> = std::sync::OnceLock::new();
455
456/// Get or create the metrics handles.
457pub fn get_metrics() -> &'static MetricsHandles {
458    METRICS.get_or_init(|| {
459        let r = registry();
460        MetricsHandles {
461            agents_forked: r.counter("oxios_agents_forked_total", "Total agents forked", &[]),
462            agents_completed: r.counter(
463                "oxios_agents_completed_total",
464                "Total agents completed",
465                &[],
466            ),
467            agents_failed: r.counter("oxios_agents_failed_total", "Total agents failed", &[]),
468            retry_attempted: r.counter(
469                "oxios_retry_attempted_total",
470                "Review retries attempted",
471                &[],
472            ),
473            retry_improved: r.counter(
474                "oxios_retry_improved_total",
475                "Retries that improved score",
476                &[],
477            ),
478            retry_unchanged: r.counter(
479                "oxios_retry_unchanged_total",
480                "Retries with same score",
481                &[],
482            ),
483            retry_degraded: r.counter(
484                "oxios_retry_degraded_total",
485                "Retries that degraded score",
486                &[],
487            ),
488            orch_duration: r.histogram(
489                "oxios_orchestration_duration_seconds",
490                "Orchestration duration",
491                vec![0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
492            ),
493            messages: r.counter("oxios_messages_processed_total", "Messages processed", &[]),
494            llm_circuit_breaker_state: r.gauge(
495                "oxios_llm_circuit_breaker_state",
496                "LLM circuit breaker state: 0=closed, 1=open, 2=half_open",
497                0.0,
498            ),
499            tool_calls: r.counter("oxios_tool_calls_total", "Tool calls", &[]),
500            tool_errors: r.counter("oxios_tool_errors_total", "Tool errors", &[]),
501            tool_duration: r.histogram(
502                "oxios_tool_duration_seconds",
503                "Tool call duration",
504                vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
505            ),
506            llm_calls: r.counter("oxios_llm_calls_total", "LLM API calls", &[]),
507            llm_errors: r.counter("oxios_llm_errors_total", "LLM API errors", &[]),
508            audit_lagged_events: r.counter(
509                "oxios_audit_lagged_events_total",
510                "Audit events dropped due to broadcast subscriber lag",
511                &[],
512            ),
513
514            // ── RFC-024: web↔daemon reliability metrics ──
515            // One handle per (name, label_value) variant. The registry's
516            // labeled_counter dedup ensures each variant registers exactly
517            // once even if both `get_metrics()` and `register_builtin_metrics`
518            // are called at startup.
519            gateway_messages_delivered: r.labeled_counter(
520                "oxios_gateway_messages_total",
521                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
522                "result",
523                "delivered",
524            ),
525            gateway_messages_dropped: r.labeled_counter(
526                "oxios_gateway_messages_total",
527                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
528                "result",
529                "dropped",
530            ),
531            gateway_messages_resynced: r.labeled_counter(
532                "oxios_gateway_messages_total",
533                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
534                "result",
535                "resynced",
536            ),
537            gateway_messages_timed_out: r.labeled_counter(
538                "oxios_gateway_messages_total",
539                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
540                "result",
541                "timed_out",
542            ),
543            gateway_replay_replay: r.labeled_counter(
544                "oxios_gateway_replay_requests_total",
545                "Replay requests (outcome=replay|resync)",
546                "outcome",
547                "replay",
548            ),
549            gateway_replay_resync: r.labeled_counter(
550                "oxios_gateway_replay_requests_total",
551                "Replay requests (outcome=replay|resync)",
552                "outcome",
553                "resync",
554            ),
555            gateway_response_duration: r.histogram(
556                "oxios_gateway_response_duration_seconds",
557                "send_and_wait duration in seconds",
558                vec![0.05, 0.25, 1.0, 5.0, 30.0, 60.0, 120.0],
559            ),
560            sse_connections_open: r.labeled_counter(
561                "oxios_sse_connections_total",
562                "SSE client connections (action=open|close)",
563                "action",
564                "open",
565            ),
566            sse_connections_close: r.labeled_counter(
567                "oxios_sse_connections_total",
568                "SSE client connections (action=open|close)",
569                "action",
570                "close",
571            ),
572            ws_connections_open: r.labeled_counter(
573                "oxios_ws_connections_total",
574                "WS client connections (action=open|close|keepalive_timeout)",
575                "action",
576                "open",
577            ),
578            ws_connections_close: r.labeled_counter(
579                "oxios_ws_connections_total",
580                "WS client connections (action=open|close|keepalive_timeout)",
581                "action",
582                "close",
583            ),
584            ws_connections_keepalive_timeout: r.labeled_counter(
585                "oxios_ws_connections_total",
586                "WS client connections (action=open|close|keepalive_timeout)",
587                "action",
588                "keepalive_timeout",
589            ),
590            web_dist_swaps: r.counter(
591                "oxios_web_dist_swaps_total",
592                "Atomic web-dist swaps (RFC-024 SP3)",
593                &[],
594            ),
595            readiness_state: r.gauge(
596                "oxios_readiness_state",
597                "0 = warming up, 1 = ready (RFC-024 SP4)",
598                0.0,
599            ),
600        }
601    })
602}
603
604/// Register all built-in metrics. Call once at startup.
605pub fn register_builtin_metrics() {
606    let r = registry();
607
608    // Agent metrics
609    r.counter("oxios_agents_forked_total", "Total agents forked", &[]);
610    r.gauge("oxios_agents_running", "Currently running agents", 0.0);
611    r.counter(
612        "oxios_agents_completed_total",
613        "Total agents completed",
614        &[],
615    );
616    r.counter("oxios_agents_failed_total", "Total agents failed", &[]);
617
618    // Message metrics
619    r.counter(
620        "oxios_messages_processed_total",
621        "User messages processed",
622        &[],
623    );
624    r.histogram(
625        "oxios_orchestration_duration_seconds",
626        "Full orchestration duration",
627        vec![0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
628    );
629
630    r.histogram(
631        "oxios_phase_duration_seconds",
632        "Phase duration",
633        vec![0.01, 0.05, 0.1, 0.5, 1.0, 2.5, 5.0, 10.0],
634    );
635
636    // LLM metrics
637    r.counter("oxios_llm_calls_total", "LLM API calls", &[]);
638    r.histogram(
639        "oxios_llm_duration_seconds",
640        "LLM call duration",
641        vec![0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
642    );
643    r.counter("oxios_llm_errors_total", "LLM API errors", &[]);
644    // Audit pipeline metric (state-area F4): events silently dropped when
645    // the audit trail subscriber falls behind the broadcast bus.
646    r.counter(
647        "oxios_audit_lagged_events_total",
648        "Audit events dropped due to broadcast subscriber lag",
649        &[],
650    );
651
652    // Tool metrics
653    r.counter("oxios_tool_calls_total", "Tool calls", &[]);
654    r.histogram(
655        "oxios_tool_duration_seconds",
656        "Tool call duration",
657        vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
658    );
659    r.counter("oxios_tool_errors_total", "Tool errors", &[]);
660
661    // Memory metrics
662    r.gauge("oxios_memory_entries_total", "Total memory entries", 0.0);
663    r.counter("oxios_memory_recall_total", "Memory recall operations", &[]);
664
665    // Container metrics
666    r.counter("oxios_exec_total", "Exec calls", &[]);
667    r.histogram(
668        "oxios_exec_duration_seconds",
669        "Exec duration",
670        vec![0.1, 0.5, 1.0, 5.0, 10.0, 30.0],
671    );
672
673    // Session metrics
674    r.gauge("oxios_active_sessions", "Active sessions", 0.0);
675
676    // Initialize get_metrics() handles
677    let _ = get_metrics();
678}