1use prometheus::{
17 Error as PrometheusError, GaugeVec, Histogram, HistogramOpts, IntCounter, IntGauge,
18 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_count: IntGaugeVec,
32 pub vm_created_total: IntCounter,
34 pub vm_destroyed_total: IntCounter,
36
37 pub vm_cpu_percent: GaugeVec,
40 pub vm_memory_bytes: GaugeVec,
42
43 pub exec_total: IntCounter,
46 pub exec_duration: Histogram,
48 pub exec_errors_total: IntCounter,
50
51 pub image_pull_total: IntCounter,
54 pub image_pull_duration: Histogram,
56 pub image_build_total: IntCounter,
58 pub rootfs_cache_hits: IntCounter,
60 pub rootfs_cache_misses: IntCounter,
62
63 pub warm_pool_size: IntGauge,
66 pub warm_pool_capacity: IntGauge,
68 pub warm_pool_hits: IntCounter,
70 pub warm_pool_misses: IntCounter,
72}
73
74impl RuntimeMetrics {
75 pub fn new() -> Self {
77 Self::try_new().expect("static RuntimeMetrics descriptors should be valid")
78 }
79
80 pub fn try_new() -> Result<Self, PrometheusError> {
82 let registry = Registry::new();
83 Self::try_with_registry(registry)
84 }
85
86 pub fn with_registry(registry: Registry) -> Self {
88 Self::try_with_registry(registry)
89 .expect("static RuntimeMetrics descriptors should not conflict")
90 }
91
92 pub fn try_with_registry(registry: Registry) -> Result<Self, PrometheusError> {
94 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 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 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 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 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 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 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 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}