Skip to main content

a3s_box_runtime/
prom.rs

1//! Prometheus metrics for the A3S Box runtime.
2//!
3//! Provides pre-registered metrics for VM lifecycle, exec operations,
4//! image management, and warm pool monitoring.
5//!
6//! # Usage
7//!
8//! ```rust,no_run
9//! use a3s_box_runtime::prom::RuntimeMetrics;
10//!
11//! let metrics = RuntimeMetrics::new();
12//! metrics.vm_boot_duration.observe(0.195); // 195ms boot
13//! metrics.vm_count.with_label_values(&["ready"]).inc();
14//! ```
15
16use prometheus::{
17    Error as PrometheusError, GaugeVec, Histogram, HistogramOpts, IntCounter, IntGauge,
18    IntGaugeVec, Opts, Registry,
19};
20
21/// Pre-registered Prometheus metrics for the Box runtime.
22#[derive(Clone)]
23pub struct RuntimeMetrics {
24    /// Prometheus registry holding all metrics.
25    pub registry: Registry,
26
27    // -- VM lifecycle --
28    /// VM boot duration in seconds.
29    pub vm_boot_duration: Histogram,
30    /// Number of VMs by state (created, ready, busy, compacting, stopped).
31    pub vm_count: IntGaugeVec,
32    /// Total VMs created since process start.
33    pub vm_created_total: IntCounter,
34    /// Total VMs destroyed since process start.
35    pub vm_destroyed_total: IntCounter,
36
37    // -- VM resources --
38    /// VM CPU usage percentage (per VM, labeled by box_id).
39    pub vm_cpu_percent: GaugeVec,
40    /// VM memory usage in bytes (per VM, labeled by box_id).
41    pub vm_memory_bytes: GaugeVec,
42
43    // -- Exec operations --
44    /// Total exec commands executed.
45    pub exec_total: IntCounter,
46    /// Exec command duration in seconds.
47    pub exec_duration: Histogram,
48    /// Exec commands that failed (non-zero exit or error).
49    pub exec_errors_total: IntCounter,
50
51    // -- Image operations --
52    /// Total image pulls.
53    pub image_pull_total: IntCounter,
54    /// Image pull duration in seconds.
55    pub image_pull_duration: Histogram,
56    /// Total image builds.
57    pub image_build_total: IntCounter,
58    /// Rootfs cache hits.
59    pub rootfs_cache_hits: IntCounter,
60    /// Rootfs cache misses.
61    pub rootfs_cache_misses: IntCounter,
62
63    // -- Warm pool --
64    /// Current warm pool size (idle VMs).
65    pub warm_pool_size: IntGauge,
66    /// Warm pool capacity (max_size).
67    pub warm_pool_capacity: IntGauge,
68    /// Total VMs allocated from warm pool.
69    pub warm_pool_hits: IntCounter,
70    /// Total VMs created fresh (warm pool miss).
71    pub warm_pool_misses: IntCounter,
72}
73
74impl RuntimeMetrics {
75    /// Create and register all metrics with a new registry.
76    pub fn new() -> Self {
77        Self::try_new().expect("static RuntimeMetrics descriptors should be valid")
78    }
79
80    /// Try to create and register all metrics with a new registry.
81    pub fn try_new() -> Result<Self, PrometheusError> {
82        let registry = Registry::new();
83        Self::try_with_registry(registry)
84    }
85
86    /// Create and register all metrics with an existing registry.
87    pub fn with_registry(registry: Registry) -> Self {
88        Self::try_with_registry(registry)
89            .expect("static RuntimeMetrics descriptors should not conflict")
90    }
91
92    /// Try to create and register all metrics with an existing registry.
93    pub fn try_with_registry(registry: Registry) -> Result<Self, PrometheusError> {
94        // VM lifecycle
95        let vm_boot_duration = Histogram::with_opts(
96            HistogramOpts::new(
97                "a3s_box_vm_boot_duration_seconds",
98                "VM boot duration in seconds",
99            )
100            .buckets(vec![0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.0, 5.0, 10.0]),
101        )?;
102
103        let vm_count = IntGaugeVec::new(
104            Opts::new("a3s_box_vm_count", "Number of VMs by state"),
105            &["state"],
106        )?;
107
108        let vm_created_total = IntCounter::new("a3s_box_vm_created_total", "Total VMs created")?;
109
110        let vm_destroyed_total =
111            IntCounter::new("a3s_box_vm_destroyed_total", "Total VMs destroyed")?;
112
113        // VM resources
114        let vm_cpu_percent = GaugeVec::new(
115            Opts::new("a3s_box_vm_cpu_percent", "VM CPU usage percentage"),
116            &["box_id"],
117        )?;
118
119        let vm_memory_bytes = GaugeVec::new(
120            Opts::new("a3s_box_vm_memory_bytes", "VM memory usage in bytes"),
121            &["box_id"],
122        )?;
123
124        // Exec operations
125        let exec_total = IntCounter::new("a3s_box_exec_total", "Total exec commands executed")?;
126
127        let exec_duration = Histogram::with_opts(
128            HistogramOpts::new(
129                "a3s_box_exec_duration_seconds",
130                "Exec command duration in seconds",
131            )
132            .buckets(vec![0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0]),
133        )?;
134
135        let exec_errors_total =
136            IntCounter::new("a3s_box_exec_errors_total", "Total failed exec commands")?;
137
138        // Image operations
139        let image_pull_total = IntCounter::new("a3s_box_image_pull_total", "Total image pulls")?;
140
141        let image_pull_duration = Histogram::with_opts(
142            HistogramOpts::new(
143                "a3s_box_image_pull_duration_seconds",
144                "Image pull duration in seconds",
145            )
146            .buckets(vec![0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0]),
147        )?;
148
149        let image_build_total = IntCounter::new("a3s_box_image_build_total", "Total image builds")?;
150
151        let rootfs_cache_hits =
152            IntCounter::new("a3s_box_rootfs_cache_hits_total", "Rootfs cache hits")?;
153
154        let rootfs_cache_misses =
155            IntCounter::new("a3s_box_rootfs_cache_misses_total", "Rootfs cache misses")?;
156
157        // Warm pool
158        let warm_pool_size = IntGauge::new(
159            "a3s_box_warm_pool_size",
160            "Current warm pool size (idle VMs)",
161        )?;
162
163        let warm_pool_capacity =
164            IntGauge::new("a3s_box_warm_pool_capacity", "Warm pool max capacity")?;
165
166        let warm_pool_hits = IntCounter::new(
167            "a3s_box_warm_pool_hits_total",
168            "VMs allocated from warm pool",
169        )?;
170
171        let warm_pool_misses = IntCounter::new(
172            "a3s_box_warm_pool_misses_total",
173            "VMs created fresh (warm pool miss)",
174        )?;
175
176        // Register all metrics
177        registry.register(Box::new(vm_boot_duration.clone()))?;
178        registry.register(Box::new(vm_count.clone()))?;
179        registry.register(Box::new(vm_created_total.clone()))?;
180        registry.register(Box::new(vm_destroyed_total.clone()))?;
181        registry.register(Box::new(vm_cpu_percent.clone()))?;
182        registry.register(Box::new(vm_memory_bytes.clone()))?;
183        registry.register(Box::new(exec_total.clone()))?;
184        registry.register(Box::new(exec_duration.clone()))?;
185        registry.register(Box::new(exec_errors_total.clone()))?;
186        registry.register(Box::new(image_pull_total.clone()))?;
187        registry.register(Box::new(image_pull_duration.clone()))?;
188        registry.register(Box::new(image_build_total.clone()))?;
189        registry.register(Box::new(rootfs_cache_hits.clone()))?;
190        registry.register(Box::new(rootfs_cache_misses.clone()))?;
191        registry.register(Box::new(warm_pool_size.clone()))?;
192        registry.register(Box::new(warm_pool_capacity.clone()))?;
193        registry.register(Box::new(warm_pool_hits.clone()))?;
194        registry.register(Box::new(warm_pool_misses.clone()))?;
195
196        Ok(Self {
197            registry,
198            vm_boot_duration,
199            vm_count,
200            vm_created_total,
201            vm_destroyed_total,
202            vm_cpu_percent,
203            vm_memory_bytes,
204            exec_total,
205            exec_duration,
206            exec_errors_total,
207            image_pull_total,
208            image_pull_duration,
209            image_build_total,
210            rootfs_cache_hits,
211            rootfs_cache_misses,
212            warm_pool_size,
213            warm_pool_capacity,
214            warm_pool_hits,
215            warm_pool_misses,
216        })
217    }
218
219    /// Encode all metrics in Prometheus text exposition format.
220    pub fn encode(&self) -> String {
221        use prometheus::Encoder;
222        let encoder = prometheus::TextEncoder::new();
223        let metric_families = self.registry.gather();
224        let mut buffer = Vec::new();
225        encoder
226            .encode(&metric_families, &mut buffer)
227            .expect("encode");
228        String::from_utf8(buffer).expect("utf8")
229    }
230}
231
232impl Default for RuntimeMetrics {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238impl a3s_box_core::traits::MetricsCollector for RuntimeMetrics {
239    fn record_vm_boot(&self, duration_secs: f64) {
240        self.vm_boot_duration.observe(duration_secs);
241    }
242
243    fn inc_vm_state(&self, state: &str) {
244        self.vm_count.with_label_values(&[state]).inc();
245    }
246
247    fn dec_vm_state(&self, state: &str) {
248        self.vm_count.with_label_values(&[state]).dec();
249    }
250
251    fn inc_vm_created(&self) {
252        self.vm_created_total.inc();
253    }
254
255    fn inc_vm_destroyed(&self) {
256        self.vm_destroyed_total.inc();
257    }
258
259    fn record_exec(&self, duration_secs: f64, success: bool) {
260        self.exec_total.inc();
261        self.exec_duration.observe(duration_secs);
262        if !success {
263            self.exec_errors_total.inc();
264        }
265    }
266
267    fn inc_cache_hit(&self) {
268        self.rootfs_cache_hits.inc();
269    }
270
271    fn inc_cache_miss(&self) {
272        self.rootfs_cache_misses.inc();
273    }
274}
275
276impl std::fmt::Debug for RuntimeMetrics {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.debug_struct("RuntimeMetrics").finish()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use a3s_box_core::traits::MetricsCollector;
286
287    #[test]
288    fn test_metrics_creation() {
289        let m = RuntimeMetrics::new();
290        assert_eq!(m.vm_created_total.get(), 0);
291        assert_eq!(m.vm_destroyed_total.get(), 0);
292        assert_eq!(m.exec_total.get(), 0);
293    }
294
295    #[test]
296    fn test_vm_boot_duration_observe() {
297        let m = RuntimeMetrics::new();
298        m.vm_boot_duration.observe(0.195);
299        m.vm_boot_duration.observe(0.210);
300        assert_eq!(m.vm_boot_duration.get_sample_count(), 2);
301    }
302
303    #[test]
304    fn test_vm_count_by_state() {
305        let m = RuntimeMetrics::new();
306        m.vm_count.with_label_values(&["ready"]).set(3);
307        m.vm_count.with_label_values(&["busy"]).set(1);
308        assert_eq!(m.vm_count.with_label_values(&["ready"]).get(), 3);
309        assert_eq!(m.vm_count.with_label_values(&["busy"]).get(), 1);
310        assert_eq!(m.vm_count.with_label_values(&["stopped"]).get(), 0);
311    }
312
313    #[test]
314    fn test_vm_created_destroyed_counters() {
315        let m = RuntimeMetrics::new();
316        m.vm_created_total.inc();
317        m.vm_created_total.inc();
318        m.vm_destroyed_total.inc();
319        assert_eq!(m.vm_created_total.get(), 2);
320        assert_eq!(m.vm_destroyed_total.get(), 1);
321    }
322
323    #[test]
324    fn test_vm_resource_gauges() {
325        let m = RuntimeMetrics::new();
326        m.vm_cpu_percent.with_label_values(&["box-123"]).set(45.5);
327        m.vm_memory_bytes
328            .with_label_values(&["box-123"])
329            .set(256.0 * 1024.0 * 1024.0);
330        assert_eq!(m.vm_cpu_percent.with_label_values(&["box-123"]).get(), 45.5);
331    }
332
333    #[test]
334    fn test_exec_metrics() {
335        let m = RuntimeMetrics::new();
336        m.exec_total.inc();
337        m.exec_duration.observe(0.05);
338        m.exec_errors_total.inc();
339        assert_eq!(m.exec_total.get(), 1);
340        assert_eq!(m.exec_errors_total.get(), 1);
341        assert_eq!(m.exec_duration.get_sample_count(), 1);
342    }
343
344    #[test]
345    fn test_image_metrics() {
346        let m = RuntimeMetrics::new();
347        m.image_pull_total.inc();
348        m.image_pull_duration.observe(3.5);
349        m.image_build_total.inc();
350        m.rootfs_cache_hits.inc();
351        m.rootfs_cache_misses.inc();
352        m.rootfs_cache_misses.inc();
353        assert_eq!(m.image_pull_total.get(), 1);
354        assert_eq!(m.rootfs_cache_hits.get(), 1);
355        assert_eq!(m.rootfs_cache_misses.get(), 2);
356    }
357
358    #[test]
359    fn test_warm_pool_metrics() {
360        let m = RuntimeMetrics::new();
361        m.warm_pool_capacity.set(10);
362        m.warm_pool_size.set(5);
363        m.warm_pool_hits.inc();
364        m.warm_pool_misses.inc();
365        assert_eq!(m.warm_pool_capacity.get(), 10);
366        assert_eq!(m.warm_pool_size.get(), 5);
367        assert_eq!(m.warm_pool_hits.get(), 1);
368        assert_eq!(m.warm_pool_misses.get(), 1);
369    }
370
371    #[test]
372    fn test_encode_prometheus_format() {
373        let m = RuntimeMetrics::new();
374        m.vm_created_total.inc();
375        m.exec_total.inc();
376        let output = m.encode();
377        assert!(output.contains("a3s_box_vm_created_total 1"));
378        assert!(output.contains("a3s_box_exec_total 1"));
379        assert!(output.contains("# HELP"));
380        assert!(output.contains("# TYPE"));
381    }
382
383    #[test]
384    fn test_metrics_clone() {
385        let m = RuntimeMetrics::new();
386        m.vm_created_total.inc();
387        let m2 = m.clone();
388        // Cloned metrics share the same underlying counters
389        assert_eq!(m2.vm_created_total.get(), 1);
390        m.vm_created_total.inc();
391        assert_eq!(m2.vm_created_total.get(), 2);
392    }
393
394    #[test]
395    fn test_metrics_default() {
396        let m = RuntimeMetrics::default();
397        assert_eq!(m.vm_created_total.get(), 0);
398    }
399
400    #[test]
401    fn test_try_with_registry_reports_duplicate_registration() {
402        let registry = Registry::new();
403        let _first = RuntimeMetrics::try_with_registry(registry.clone()).unwrap();
404        let second = RuntimeMetrics::try_with_registry(registry);
405        assert!(second.is_err());
406    }
407
408    #[test]
409    fn test_metrics_collector_trait_updates_registered_metrics() {
410        let m = RuntimeMetrics::new();
411
412        MetricsCollector::record_vm_boot(&m, 0.25);
413        MetricsCollector::inc_vm_state(&m, "ready");
414        MetricsCollector::inc_vm_created(&m);
415        MetricsCollector::inc_vm_destroyed(&m);
416        MetricsCollector::record_exec(&m, 0.05, false);
417        MetricsCollector::inc_cache_hit(&m);
418        MetricsCollector::inc_cache_miss(&m);
419        MetricsCollector::dec_vm_state(&m, "ready");
420
421        assert_eq!(m.vm_boot_duration.get_sample_count(), 1);
422        assert_eq!(m.vm_count.with_label_values(&["ready"]).get(), 0);
423        assert_eq!(m.vm_created_total.get(), 1);
424        assert_eq!(m.vm_destroyed_total.get(), 1);
425        assert_eq!(m.exec_total.get(), 1);
426        assert_eq!(m.exec_errors_total.get(), 1);
427        assert_eq!(m.exec_duration.get_sample_count(), 1);
428        assert_eq!(m.rootfs_cache_hits.get(), 1);
429        assert_eq!(m.rootfs_cache_misses.get(), 1);
430    }
431
432    #[test]
433    fn test_metrics_collector_trait_does_not_count_successful_exec_as_error() {
434        let m = RuntimeMetrics::new();
435
436        MetricsCollector::record_exec(&m, 0.01, true);
437
438        assert_eq!(m.exec_total.get(), 1);
439        assert_eq!(m.exec_errors_total.get(), 0);
440        assert_eq!(m.exec_duration.get_sample_count(), 1);
441    }
442
443    #[test]
444    fn test_runtime_metrics_debug_is_stable_and_compact() {
445        let m = RuntimeMetrics::new();
446
447        assert_eq!(format!("{m:?}"), "RuntimeMetrics");
448    }
449}