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    /// Web surface scoped restarts (RFC-030).
422    pub supervisor_restarts: CounterHandle,
423
424    /// 0 = warming up, 1 = ready (RFC-024 SP4). Updated from
425    /// `ReadinessGate` when a subsystem changes state.
426    pub readiness_state: GaugeHandle,
427}
428
429impl MetricsHandles {
430    /// Increment agents_forked counter.
431    pub fn inc_agents_forked(&self) {
432        self.agents_forked.inc();
433    }
434
435    /// Increment agents_completed counter.
436    pub fn inc_agents_completed(&self) {
437        self.agents_completed.inc();
438    }
439
440    /// Increment agents_failed counter.
441    pub fn inc_agents_failed(&self) {
442        self.agents_failed.inc();
443    }
444
445    /// Increment messages counter.
446    pub fn inc_messages(&self) {
447        self.messages.inc();
448    }
449    /// Increment the supervisor scoped-restart counter (RFC-030).
450    pub fn inc_supervisor_restart(&self) {
451        self.supervisor_restarts.inc();
452    }
453
454    /// Observe orchestration duration.
455    pub fn observe_orch_duration(&self, value: f64) {
456        self.orch_duration.observe(value);
457    }
458}
459/// Global lazy metric handles.
460static METRICS: std::sync::OnceLock<MetricsHandles> = std::sync::OnceLock::new();
461
462/// Get or create the metrics handles.
463pub fn get_metrics() -> &'static MetricsHandles {
464    METRICS.get_or_init(|| {
465        let r = registry();
466        MetricsHandles {
467            agents_forked: r.counter("oxios_agents_forked_total", "Total agents forked", &[]),
468            agents_completed: r.counter(
469                "oxios_agents_completed_total",
470                "Total agents completed",
471                &[],
472            ),
473            agents_failed: r.counter("oxios_agents_failed_total", "Total agents failed", &[]),
474            retry_attempted: r.counter(
475                "oxios_retry_attempted_total",
476                "Review retries attempted",
477                &[],
478            ),
479            retry_improved: r.counter(
480                "oxios_retry_improved_total",
481                "Retries that improved score",
482                &[],
483            ),
484            retry_unchanged: r.counter(
485                "oxios_retry_unchanged_total",
486                "Retries with same score",
487                &[],
488            ),
489            retry_degraded: r.counter(
490                "oxios_retry_degraded_total",
491                "Retries that degraded score",
492                &[],
493            ),
494            orch_duration: r.histogram(
495                "oxios_orchestration_duration_seconds",
496                "Orchestration duration",
497                vec![0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
498            ),
499            messages: r.counter("oxios_messages_processed_total", "Messages processed", &[]),
500            llm_circuit_breaker_state: r.gauge(
501                "oxios_llm_circuit_breaker_state",
502                "LLM circuit breaker state: 0=closed, 1=open, 2=half_open",
503                0.0,
504            ),
505            tool_calls: r.counter("oxios_tool_calls_total", "Tool calls", &[]),
506            tool_errors: r.counter("oxios_tool_errors_total", "Tool errors", &[]),
507            tool_duration: r.histogram(
508                "oxios_tool_duration_seconds",
509                "Tool call duration",
510                vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
511            ),
512            llm_calls: r.counter("oxios_llm_calls_total", "LLM API calls", &[]),
513            llm_errors: r.counter("oxios_llm_errors_total", "LLM API errors", &[]),
514            audit_lagged_events: r.counter(
515                "oxios_audit_lagged_events_total",
516                "Audit events dropped due to broadcast subscriber lag",
517                &[],
518            ),
519
520            // ── RFC-024: web↔daemon reliability metrics ──
521            // One handle per (name, label_value) variant. The registry's
522            // labeled_counter dedup ensures each variant registers exactly
523            // once even if both `get_metrics()` and `register_builtin_metrics`
524            // are called at startup.
525            gateway_messages_delivered: r.labeled_counter(
526                "oxios_gateway_messages_total",
527                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
528                "result",
529                "delivered",
530            ),
531            gateway_messages_dropped: r.labeled_counter(
532                "oxios_gateway_messages_total",
533                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
534                "result",
535                "dropped",
536            ),
537            gateway_messages_resynced: r.labeled_counter(
538                "oxios_gateway_messages_total",
539                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
540                "result",
541                "resynced",
542            ),
543            gateway_messages_timed_out: r.labeled_counter(
544                "oxios_gateway_messages_total",
545                "Outgoing messages (result=delivered|dropped|resynced|timed_out)",
546                "result",
547                "timed_out",
548            ),
549            gateway_replay_replay: r.labeled_counter(
550                "oxios_gateway_replay_requests_total",
551                "Replay requests (outcome=replay|resync)",
552                "outcome",
553                "replay",
554            ),
555            gateway_replay_resync: r.labeled_counter(
556                "oxios_gateway_replay_requests_total",
557                "Replay requests (outcome=replay|resync)",
558                "outcome",
559                "resync",
560            ),
561            gateway_response_duration: r.histogram(
562                "oxios_gateway_response_duration_seconds",
563                "send_and_wait duration in seconds",
564                vec![0.05, 0.25, 1.0, 5.0, 30.0, 60.0, 120.0],
565            ),
566            sse_connections_open: r.labeled_counter(
567                "oxios_sse_connections_total",
568                "SSE client connections (action=open|close)",
569                "action",
570                "open",
571            ),
572            sse_connections_close: r.labeled_counter(
573                "oxios_sse_connections_total",
574                "SSE client connections (action=open|close)",
575                "action",
576                "close",
577            ),
578            ws_connections_open: r.labeled_counter(
579                "oxios_ws_connections_total",
580                "WS client connections (action=open|close|keepalive_timeout)",
581                "action",
582                "open",
583            ),
584            ws_connections_close: r.labeled_counter(
585                "oxios_ws_connections_total",
586                "WS client connections (action=open|close|keepalive_timeout)",
587                "action",
588                "close",
589            ),
590            ws_connections_keepalive_timeout: r.labeled_counter(
591                "oxios_ws_connections_total",
592                "WS client connections (action=open|close|keepalive_timeout)",
593                "action",
594                "keepalive_timeout",
595            ),
596            web_dist_swaps: r.counter(
597                "oxios_web_dist_swaps_total",
598                "Atomic web-dist swaps (RFC-024 SP3)",
599                &[],
600            ),
601            supervisor_restarts: r.counter(
602                "oxios_supervisor_restarts_total",
603                "Web surface scoped restarts (RFC-030)",
604                &[],
605            ),
606            readiness_state: r.gauge(
607                "oxios_readiness_state",
608                "0 = warming up, 1 = ready (RFC-024 SP4)",
609                0.0,
610            ),
611        }
612    })
613}
614
615/// Register all built-in metrics. Call once at startup.
616pub fn register_builtin_metrics() {
617    let r = registry();
618
619    // Agent metrics
620    r.counter("oxios_agents_forked_total", "Total agents forked", &[]);
621    r.gauge("oxios_agents_running", "Currently running agents", 0.0);
622    r.counter(
623        "oxios_agents_completed_total",
624        "Total agents completed",
625        &[],
626    );
627    r.counter("oxios_agents_failed_total", "Total agents failed", &[]);
628
629    // Message metrics
630    r.counter(
631        "oxios_messages_processed_total",
632        "User messages processed",
633        &[],
634    );
635    r.histogram(
636        "oxios_orchestration_duration_seconds",
637        "Full orchestration duration",
638        vec![0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
639    );
640
641    r.histogram(
642        "oxios_phase_duration_seconds",
643        "Phase duration",
644        vec![0.01, 0.05, 0.1, 0.5, 1.0, 2.5, 5.0, 10.0],
645    );
646
647    // LLM metrics
648    r.counter("oxios_llm_calls_total", "LLM API calls", &[]);
649    r.histogram(
650        "oxios_llm_duration_seconds",
651        "LLM call duration",
652        vec![0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
653    );
654    r.counter("oxios_llm_errors_total", "LLM API errors", &[]);
655    // Audit pipeline metric (state-area F4): events silently dropped when
656    // the audit trail subscriber falls behind the broadcast bus.
657    r.counter(
658        "oxios_audit_lagged_events_total",
659        "Audit events dropped due to broadcast subscriber lag",
660        &[],
661    );
662
663    // Tool metrics
664    r.counter("oxios_tool_calls_total", "Tool calls", &[]);
665    r.histogram(
666        "oxios_tool_duration_seconds",
667        "Tool call duration",
668        vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
669    );
670    r.counter("oxios_tool_errors_total", "Tool errors", &[]);
671
672    // Memory metrics
673    r.gauge("oxios_memory_entries_total", "Total memory entries", 0.0);
674    r.counter("oxios_memory_recall_total", "Memory recall operations", &[]);
675
676    // Container metrics
677    r.counter("oxios_exec_total", "Exec calls", &[]);
678    r.histogram(
679        "oxios_exec_duration_seconds",
680        "Exec duration",
681        vec![0.1, 0.5, 1.0, 5.0, 10.0, 30.0],
682    );
683
684    // Session metrics
685    r.gauge("oxios_active_sessions", "Active sessions", 0.0);
686
687    // Initialize get_metrics() handles
688    let _ = get_metrics();
689}