1use prometheus::{
17 Error as PrometheusError, GaugeVec, Histogram, HistogramOpts, HistogramVec, IntCounter,
18 IntGauge, IntGaugeVec, Opts, Registry,
19};
20
21#[derive(Clone)]
23pub struct RuntimeMetrics {
24 pub registry: Registry,
26
27 pub vm_boot_duration: Histogram,
30 pub vm_boot_phase_duration: HistogramVec,
36 pub vm_count: IntGaugeVec,
38 pub vm_created_total: IntCounter,
40 pub vm_destroyed_total: IntCounter,
42
43 pub vm_cpu_percent: GaugeVec,
46 pub vm_memory_bytes: GaugeVec,
48
49 pub exec_total: IntCounter,
52 pub exec_duration: Histogram,
54 pub exec_errors_total: IntCounter,
56
57 pub image_pull_total: IntCounter,
60 pub image_pull_duration: Histogram,
62 pub image_build_total: IntCounter,
64 pub rootfs_cache_hits: IntCounter,
66 pub rootfs_cache_misses: IntCounter,
68
69 pub warm_pool_size: IntGauge,
72 pub warm_pool_capacity: IntGauge,
74 pub warm_pool_hits: IntCounter,
76 pub warm_pool_misses: IntCounter,
78 pub warm_pool_boots_inflight: IntGauge,
80 pub warm_pool_boot_failures_total: IntCounter,
82 pub warm_pool_initial_fill_duration: Histogram,
88}
89
90impl RuntimeMetrics {
91 pub fn new() -> Self {
93 Self::try_new().expect("static RuntimeMetrics descriptors should be valid")
94 }
95
96 pub fn try_new() -> Result<Self, PrometheusError> {
98 let registry = Registry::new();
99 Self::try_with_registry(registry)
100 }
101
102 pub fn with_registry(registry: Registry) -> Self {
104 Self::try_with_registry(registry)
105 .expect("static RuntimeMetrics descriptors should not conflict")
106 }
107
108 pub fn try_with_registry(registry: Registry) -> Result<Self, PrometheusError> {
110 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 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 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 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 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 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 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 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 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
308pub(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 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}