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, HistogramVec, IntCounter,
18    IntGauge, 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    /// VM boot duration split by a stable lifecycle phase.
31    ///
32    /// The `phase` label is intentionally bounded to runtime-owned values such
33    /// as `layout`, `prepare`, `launch`, and `readiness`; callers must not use
34    /// box IDs, image references, or other unbounded values as labels.
35    pub vm_boot_phase_duration: HistogramVec,
36    /// Number of VMs by state (created, ready, busy, compacting, stopped).
37    pub vm_count: IntGaugeVec,
38    /// Total VMs created since process start.
39    pub vm_created_total: IntCounter,
40    /// Total VMs destroyed since process start.
41    pub vm_destroyed_total: IntCounter,
42
43    // -- VM resources --
44    /// VM CPU usage percentage (per VM, labeled by box_id).
45    pub vm_cpu_percent: GaugeVec,
46    /// VM memory usage in bytes (per VM, labeled by box_id).
47    pub vm_memory_bytes: GaugeVec,
48
49    // -- Exec operations --
50    /// Total exec commands executed.
51    pub exec_total: IntCounter,
52    /// Exec command duration in seconds.
53    pub exec_duration: Histogram,
54    /// Exec commands that failed (non-zero exit or error).
55    pub exec_errors_total: IntCounter,
56
57    // -- Image operations --
58    /// Total image pulls.
59    pub image_pull_total: IntCounter,
60    /// Image pull duration in seconds.
61    pub image_pull_duration: Histogram,
62    /// Total image builds.
63    pub image_build_total: IntCounter,
64    /// Rootfs cache hits.
65    pub rootfs_cache_hits: IntCounter,
66    /// Rootfs cache misses.
67    pub rootfs_cache_misses: IntCounter,
68
69    // -- Warm pool --
70    /// Current warm pool size (idle VMs).
71    pub warm_pool_size: IntGauge,
72    /// Warm pool capacity (max_size).
73    pub warm_pool_capacity: IntGauge,
74    /// Total VMs allocated from warm pool.
75    pub warm_pool_hits: IntCounter,
76    /// Total VMs created fresh (warm pool miss).
77    pub warm_pool_misses: IntCounter,
78    /// Number of VM boots currently in flight across warm pools.
79    pub warm_pool_boots_inflight: IntGauge,
80    /// Total warm-pool VM boot failures, including failed replenishment attempts.
81    pub warm_pool_boot_failures_total: IntCounter,
82    /// Time spent on the initial warm-pool fill before the pool is published.
83    ///
84    /// For lazy pools this is the time until the first ready VM (the remaining
85    /// idle target is filled asynchronously); eager pools report the complete
86    /// configured initial fill.
87    pub warm_pool_initial_fill_duration: Histogram,
88}
89
90impl RuntimeMetrics {
91    /// Create and register all metrics with a new registry.
92    pub fn new() -> Self {
93        Self::try_new().expect("static RuntimeMetrics descriptors should be valid")
94    }
95
96    /// Try to create and register all metrics with a new registry.
97    pub fn try_new() -> Result<Self, PrometheusError> {
98        let registry = Registry::new();
99        Self::try_with_registry(registry)
100    }
101
102    /// Create and register all metrics with an existing registry.
103    pub fn with_registry(registry: Registry) -> Self {
104        Self::try_with_registry(registry)
105            .expect("static RuntimeMetrics descriptors should not conflict")
106    }
107
108    /// Try to create and register all metrics with an existing registry.
109    pub fn try_with_registry(registry: Registry) -> Result<Self, PrometheusError> {
110        // VM lifecycle
111        let vm_boot_duration = Histogram::with_opts(
112            HistogramOpts::new(
113                "a3s_box_vm_boot_duration_seconds",
114                "VM boot duration in seconds",
115            )
116            .buckets(vec![0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.0, 5.0, 10.0]),
117        )?;
118
119        let vm_boot_phase_duration = HistogramVec::new(
120            HistogramOpts::new(
121                "a3s_box_vm_boot_phase_duration_seconds",
122                "VM boot duration in seconds by lifecycle phase",
123            )
124            .buckets(vec![
125                0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0,
126            ]),
127            &["phase"],
128        )?;
129
130        let vm_count = IntGaugeVec::new(
131            Opts::new("a3s_box_vm_count", "Number of VMs by state"),
132            &["state"],
133        )?;
134
135        let vm_created_total = IntCounter::new("a3s_box_vm_created_total", "Total VMs created")?;
136
137        let vm_destroyed_total =
138            IntCounter::new("a3s_box_vm_destroyed_total", "Total VMs destroyed")?;
139
140        // VM resources
141        let vm_cpu_percent = GaugeVec::new(
142            Opts::new("a3s_box_vm_cpu_percent", "VM CPU usage percentage"),
143            &["box_id"],
144        )?;
145
146        let vm_memory_bytes = GaugeVec::new(
147            Opts::new("a3s_box_vm_memory_bytes", "VM memory usage in bytes"),
148            &["box_id"],
149        )?;
150
151        // Exec operations
152        let exec_total = IntCounter::new("a3s_box_exec_total", "Total exec commands executed")?;
153
154        let exec_duration = Histogram::with_opts(
155            HistogramOpts::new(
156                "a3s_box_exec_duration_seconds",
157                "Exec command duration in seconds",
158            )
159            .buckets(vec![0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0]),
160        )?;
161
162        let exec_errors_total =
163            IntCounter::new("a3s_box_exec_errors_total", "Total failed exec commands")?;
164
165        // Image operations
166        let image_pull_total = IntCounter::new("a3s_box_image_pull_total", "Total image pulls")?;
167
168        let image_pull_duration = Histogram::with_opts(
169            HistogramOpts::new(
170                "a3s_box_image_pull_duration_seconds",
171                "Image pull duration in seconds",
172            )
173            .buckets(vec![0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0]),
174        )?;
175
176        let image_build_total = IntCounter::new("a3s_box_image_build_total", "Total image builds")?;
177
178        let rootfs_cache_hits =
179            IntCounter::new("a3s_box_rootfs_cache_hits_total", "Rootfs cache hits")?;
180
181        let rootfs_cache_misses =
182            IntCounter::new("a3s_box_rootfs_cache_misses_total", "Rootfs cache misses")?;
183
184        // Warm pool
185        let warm_pool_size = IntGauge::new(
186            "a3s_box_warm_pool_size",
187            "Current warm pool size (idle VMs)",
188        )?;
189
190        let warm_pool_capacity =
191            IntGauge::new("a3s_box_warm_pool_capacity", "Warm pool max capacity")?;
192
193        let warm_pool_hits = IntCounter::new(
194            "a3s_box_warm_pool_hits_total",
195            "VMs allocated from warm pool",
196        )?;
197
198        let warm_pool_misses = IntCounter::new(
199            "a3s_box_warm_pool_misses_total",
200            "VMs created fresh (warm pool miss)",
201        )?;
202
203        let warm_pool_boots_inflight = IntGauge::new(
204            "a3s_box_warm_pool_boots_inflight",
205            "VM boots currently in flight across warm pools",
206        )?;
207
208        let warm_pool_boot_failures_total = IntCounter::new(
209            "a3s_box_warm_pool_boot_failures_total",
210            "Warm-pool VM boot failures",
211        )?;
212
213        let warm_pool_initial_fill_duration = Histogram::with_opts(
214            HistogramOpts::new(
215                "a3s_box_warm_pool_initial_fill_duration_seconds",
216                "Warm-pool initial fill duration in seconds",
217            )
218            .buckets(vec![
219                0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0,
220            ]),
221        )?;
222
223        // Register all metrics
224        registry.register(Box::new(vm_boot_duration.clone()))?;
225        registry.register(Box::new(vm_boot_phase_duration.clone()))?;
226        registry.register(Box::new(vm_count.clone()))?;
227        registry.register(Box::new(vm_created_total.clone()))?;
228        registry.register(Box::new(vm_destroyed_total.clone()))?;
229        registry.register(Box::new(vm_cpu_percent.clone()))?;
230        registry.register(Box::new(vm_memory_bytes.clone()))?;
231        registry.register(Box::new(exec_total.clone()))?;
232        registry.register(Box::new(exec_duration.clone()))?;
233        registry.register(Box::new(exec_errors_total.clone()))?;
234        registry.register(Box::new(image_pull_total.clone()))?;
235        registry.register(Box::new(image_pull_duration.clone()))?;
236        registry.register(Box::new(image_build_total.clone()))?;
237        registry.register(Box::new(rootfs_cache_hits.clone()))?;
238        registry.register(Box::new(rootfs_cache_misses.clone()))?;
239        registry.register(Box::new(warm_pool_size.clone()))?;
240        registry.register(Box::new(warm_pool_capacity.clone()))?;
241        registry.register(Box::new(warm_pool_hits.clone()))?;
242        registry.register(Box::new(warm_pool_misses.clone()))?;
243        registry.register(Box::new(warm_pool_boots_inflight.clone()))?;
244        registry.register(Box::new(warm_pool_boot_failures_total.clone()))?;
245        registry.register(Box::new(warm_pool_initial_fill_duration.clone()))?;
246
247        Ok(Self {
248            registry,
249            vm_boot_duration,
250            vm_boot_phase_duration,
251            vm_count,
252            vm_created_total,
253            vm_destroyed_total,
254            vm_cpu_percent,
255            vm_memory_bytes,
256            exec_total,
257            exec_duration,
258            exec_errors_total,
259            image_pull_total,
260            image_pull_duration,
261            image_build_total,
262            rootfs_cache_hits,
263            rootfs_cache_misses,
264            warm_pool_size,
265            warm_pool_capacity,
266            warm_pool_hits,
267            warm_pool_misses,
268            warm_pool_boots_inflight,
269            warm_pool_boot_failures_total,
270            warm_pool_initial_fill_duration,
271        })
272    }
273
274    /// Record one bounded VM boot phase in seconds.
275    pub fn record_vm_boot_phase(&self, phase: &str, duration_secs: f64) {
276        self.vm_boot_phase_duration
277            .with_label_values(&[phase])
278            .observe(duration_secs);
279    }
280
281    /// Remove resource gauges for a VM that no longer exists.
282    ///
283    /// The gauges are labelled by box ID for point-in-time inspection. Keeping
284    /// every historical ID in a long-lived daemon would make the registry grow
285    /// without bound, so lifecycle teardown must delete both label sets.
286    pub fn remove_vm_resource_metrics(&self, box_id: &str) {
287        if let Err(error) = self.vm_cpu_percent.remove_label_values(&[box_id]) {
288            tracing::debug!(%box_id, %error, "VM CPU metric labels were already absent");
289        }
290        if let Err(error) = self.vm_memory_bytes.remove_label_values(&[box_id]) {
291            tracing::debug!(%box_id, %error, "VM memory metric labels were already absent");
292        }
293    }
294
295    /// Encode all metrics in Prometheus text exposition format.
296    pub fn encode(&self) -> String {
297        use prometheus::Encoder;
298        let encoder = prometheus::TextEncoder::new();
299        let metric_families = self.registry.gather();
300        let mut buffer = Vec::new();
301        encoder
302            .encode(&metric_families, &mut buffer)
303            .expect("encode");
304        String::from_utf8(buffer).expect("utf8")
305    }
306}
307
308/// Drop-based timer for recording a boot phase even when the phase returns an
309/// error. The metrics handle is cloned so the timer never borrows a
310/// [`VmManager`] across an await point.
311pub(crate) struct BootPhaseTimer {
312    metrics: Option<RuntimeMetrics>,
313    phase: &'static str,
314    started: std::time::Instant,
315}
316
317impl BootPhaseTimer {
318    pub(crate) fn new(metrics: Option<RuntimeMetrics>, phase: &'static str) -> Self {
319        Self {
320            metrics,
321            phase,
322            started: std::time::Instant::now(),
323        }
324    }
325}
326
327impl Drop for BootPhaseTimer {
328    fn drop(&mut self) {
329        if let Some(metrics) = &self.metrics {
330            metrics.record_vm_boot_phase(self.phase, self.started.elapsed().as_secs_f64());
331        }
332    }
333}
334
335impl Default for RuntimeMetrics {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341impl a3s_box_core::traits::MetricsCollector for RuntimeMetrics {
342    fn record_vm_boot(&self, duration_secs: f64) {
343        self.vm_boot_duration.observe(duration_secs);
344    }
345
346    fn inc_vm_state(&self, state: &str) {
347        self.vm_count.with_label_values(&[state]).inc();
348    }
349
350    fn dec_vm_state(&self, state: &str) {
351        self.vm_count.with_label_values(&[state]).dec();
352    }
353
354    fn inc_vm_created(&self) {
355        self.vm_created_total.inc();
356    }
357
358    fn inc_vm_destroyed(&self) {
359        self.vm_destroyed_total.inc();
360    }
361
362    fn record_exec(&self, duration_secs: f64, success: bool) {
363        self.exec_total.inc();
364        self.exec_duration.observe(duration_secs);
365        if !success {
366            self.exec_errors_total.inc();
367        }
368    }
369
370    fn inc_cache_hit(&self) {
371        self.rootfs_cache_hits.inc();
372    }
373
374    fn inc_cache_miss(&self) {
375        self.rootfs_cache_misses.inc();
376    }
377}
378
379impl std::fmt::Debug for RuntimeMetrics {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        f.debug_struct("RuntimeMetrics").finish()
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use a3s_box_core::traits::MetricsCollector;
389
390    #[test]
391    fn test_metrics_creation() {
392        let m = RuntimeMetrics::new();
393        assert_eq!(m.vm_created_total.get(), 0);
394        assert_eq!(m.vm_destroyed_total.get(), 0);
395        assert_eq!(m.exec_total.get(), 0);
396    }
397
398    #[test]
399    fn test_vm_boot_duration_observe() {
400        let m = RuntimeMetrics::new();
401        m.vm_boot_duration.observe(0.195);
402        m.vm_boot_duration.observe(0.210);
403        assert_eq!(m.vm_boot_duration.get_sample_count(), 2);
404    }
405
406    #[test]
407    fn test_vm_boot_phase_duration_observe_and_encode() {
408        let m = RuntimeMetrics::new();
409        m.record_vm_boot_phase("layout", 0.012);
410        m.record_vm_boot_phase("layout", 0.018);
411        m.record_vm_boot_phase("readiness", 0.125);
412
413        assert_eq!(
414            m.vm_boot_phase_duration
415                .with_label_values(&["layout"])
416                .get_sample_count(),
417            2
418        );
419        assert_eq!(
420            m.vm_boot_phase_duration
421                .with_label_values(&["readiness"])
422                .get_sample_count(),
423            1
424        );
425        let output = m.encode();
426        assert!(output.contains("a3s_box_vm_boot_phase_duration_seconds"));
427        assert!(output.contains("phase=\"layout\""));
428    }
429
430    #[test]
431    fn test_boot_phase_timer_records_on_drop() {
432        let m = RuntimeMetrics::new();
433        {
434            let _timer = BootPhaseTimer::new(Some(m.clone()), "launch");
435        }
436        assert_eq!(
437            m.vm_boot_phase_duration
438                .with_label_values(&["launch"])
439                .get_sample_count(),
440            1
441        );
442    }
443
444    #[test]
445    fn test_vm_count_by_state() {
446        let m = RuntimeMetrics::new();
447        m.vm_count.with_label_values(&["ready"]).set(3);
448        m.vm_count.with_label_values(&["busy"]).set(1);
449        assert_eq!(m.vm_count.with_label_values(&["ready"]).get(), 3);
450        assert_eq!(m.vm_count.with_label_values(&["busy"]).get(), 1);
451        assert_eq!(m.vm_count.with_label_values(&["stopped"]).get(), 0);
452    }
453
454    #[test]
455    fn test_vm_created_destroyed_counters() {
456        let m = RuntimeMetrics::new();
457        m.vm_created_total.inc();
458        m.vm_created_total.inc();
459        m.vm_destroyed_total.inc();
460        assert_eq!(m.vm_created_total.get(), 2);
461        assert_eq!(m.vm_destroyed_total.get(), 1);
462    }
463
464    #[test]
465    fn test_vm_resource_gauges() {
466        let m = RuntimeMetrics::new();
467        m.vm_cpu_percent.with_label_values(&["box-123"]).set(45.5);
468        m.vm_memory_bytes
469            .with_label_values(&["box-123"])
470            .set(256.0 * 1024.0 * 1024.0);
471        assert_eq!(m.vm_cpu_percent.with_label_values(&["box-123"]).get(), 45.5);
472    }
473
474    #[test]
475    fn test_remove_vm_resource_metrics_drops_dynamic_labels() {
476        let m = RuntimeMetrics::new();
477        m.vm_cpu_percent
478            .with_label_values(&["box-ephemeral"])
479            .set(1.0);
480        m.vm_memory_bytes
481            .with_label_values(&["box-ephemeral"])
482            .set(2.0);
483
484        m.remove_vm_resource_metrics("box-ephemeral");
485
486        let encoded = m.encode();
487        assert!(!encoded.contains("box-ephemeral"));
488    }
489
490    #[test]
491    fn test_exec_metrics() {
492        let m = RuntimeMetrics::new();
493        m.exec_total.inc();
494        m.exec_duration.observe(0.05);
495        m.exec_errors_total.inc();
496        assert_eq!(m.exec_total.get(), 1);
497        assert_eq!(m.exec_errors_total.get(), 1);
498        assert_eq!(m.exec_duration.get_sample_count(), 1);
499    }
500
501    #[test]
502    fn test_image_metrics() {
503        let m = RuntimeMetrics::new();
504        m.image_pull_total.inc();
505        m.image_pull_duration.observe(3.5);
506        m.image_build_total.inc();
507        m.rootfs_cache_hits.inc();
508        m.rootfs_cache_misses.inc();
509        m.rootfs_cache_misses.inc();
510        assert_eq!(m.image_pull_total.get(), 1);
511        assert_eq!(m.rootfs_cache_hits.get(), 1);
512        assert_eq!(m.rootfs_cache_misses.get(), 2);
513    }
514
515    #[test]
516    fn test_warm_pool_metrics() {
517        let m = RuntimeMetrics::new();
518        m.warm_pool_capacity.set(10);
519        m.warm_pool_size.set(5);
520        m.warm_pool_hits.inc();
521        m.warm_pool_misses.inc();
522        m.warm_pool_boots_inflight.inc();
523        m.warm_pool_boot_failures_total.inc();
524        m.warm_pool_initial_fill_duration.observe(1.25);
525        assert_eq!(m.warm_pool_capacity.get(), 10);
526        assert_eq!(m.warm_pool_size.get(), 5);
527        assert_eq!(m.warm_pool_hits.get(), 1);
528        assert_eq!(m.warm_pool_misses.get(), 1);
529        assert_eq!(m.warm_pool_boots_inflight.get(), 1);
530        assert_eq!(m.warm_pool_boot_failures_total.get(), 1);
531        assert_eq!(m.warm_pool_initial_fill_duration.get_sample_count(), 1);
532    }
533
534    #[test]
535    fn test_encode_prometheus_format() {
536        let m = RuntimeMetrics::new();
537        m.vm_created_total.inc();
538        m.exec_total.inc();
539        let output = m.encode();
540        assert!(output.contains("a3s_box_vm_created_total 1"));
541        assert!(output.contains("a3s_box_exec_total 1"));
542        assert!(output.contains("# HELP"));
543        assert!(output.contains("# TYPE"));
544    }
545
546    #[test]
547    fn test_metrics_clone() {
548        let m = RuntimeMetrics::new();
549        m.vm_created_total.inc();
550        let m2 = m.clone();
551        // Cloned metrics share the same underlying counters
552        assert_eq!(m2.vm_created_total.get(), 1);
553        m.vm_created_total.inc();
554        assert_eq!(m2.vm_created_total.get(), 2);
555    }
556
557    #[test]
558    fn test_metrics_default() {
559        let m = RuntimeMetrics::default();
560        assert_eq!(m.vm_created_total.get(), 0);
561    }
562
563    #[test]
564    fn test_try_with_registry_reports_duplicate_registration() {
565        let registry = Registry::new();
566        let _first = RuntimeMetrics::try_with_registry(registry.clone()).unwrap();
567        let second = RuntimeMetrics::try_with_registry(registry);
568        assert!(second.is_err());
569    }
570
571    #[test]
572    fn test_metrics_collector_trait_updates_registered_metrics() {
573        let m = RuntimeMetrics::new();
574
575        MetricsCollector::record_vm_boot(&m, 0.25);
576        MetricsCollector::inc_vm_state(&m, "ready");
577        MetricsCollector::inc_vm_created(&m);
578        MetricsCollector::inc_vm_destroyed(&m);
579        MetricsCollector::record_exec(&m, 0.05, false);
580        MetricsCollector::inc_cache_hit(&m);
581        MetricsCollector::inc_cache_miss(&m);
582        MetricsCollector::dec_vm_state(&m, "ready");
583
584        assert_eq!(m.vm_boot_duration.get_sample_count(), 1);
585        assert_eq!(m.vm_count.with_label_values(&["ready"]).get(), 0);
586        assert_eq!(m.vm_created_total.get(), 1);
587        assert_eq!(m.vm_destroyed_total.get(), 1);
588        assert_eq!(m.exec_total.get(), 1);
589        assert_eq!(m.exec_errors_total.get(), 1);
590        assert_eq!(m.exec_duration.get_sample_count(), 1);
591        assert_eq!(m.rootfs_cache_hits.get(), 1);
592        assert_eq!(m.rootfs_cache_misses.get(), 1);
593    }
594
595    #[test]
596    fn test_metrics_collector_trait_does_not_count_successful_exec_as_error() {
597        let m = RuntimeMetrics::new();
598
599        MetricsCollector::record_exec(&m, 0.01, true);
600
601        assert_eq!(m.exec_total.get(), 1);
602        assert_eq!(m.exec_errors_total.get(), 0);
603        assert_eq!(m.exec_duration.get_sample_count(), 1);
604    }
605
606    #[test]
607    fn test_runtime_metrics_debug_is_stable_and_compact() {
608        let m = RuntimeMetrics::new();
609
610        assert_eq!(format!("{m:?}"), "RuntimeMetrics");
611    }
612}