Skip to main content

mediagit_compression/
metrics.rs

1// MediaGit - Git for Media Files
2// Copyright (C) 2025 MediaGit Contributors
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published
6// by the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU Affero General Public License for more details.
13
14//! Compression metrics and statistics
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20
21/// Compression algorithm identifier (copy to avoid circular dependency)
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[repr(u8)]
24pub enum CompressionAlgorithm {
25    /// No compression (raw data)
26    None = 0,
27    /// Zlib compression (Git-compatible)
28    Zlib = 1,
29    /// Zstd compression
30    Zstd = 2,
31    /// Brotli compression
32    Brotli = 3,
33}
34
35/// Compression level configuration
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum CompressionLevel {
38    /// Fast compression, larger output
39    Fast,
40    /// Default balance
41    Default,
42    /// Best compression, slower
43    Best,
44}
45
46/// Compression metrics for tracking performance and effectiveness
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CompressionMetrics {
49    // Size metrics
50    /// Original data size in bytes
51    pub original_size: usize,
52    /// Compressed data size in bytes
53    pub compressed_size: usize,
54    /// Compression ratio (original/compressed)
55    pub compression_ratio: f64,
56    /// Space saved in bytes
57    pub space_saved: usize,
58    /// Space saved as percentage
59    pub space_saved_percent: f64,
60
61    // Performance metrics
62    /// Compression duration
63    pub compression_time: Duration,
64    /// Decompression duration (optional)
65    pub decompression_time: Option<Duration>,
66    /// Throughput in MB/s
67    pub throughput_mbps: f64,
68
69    // Algorithm info
70    /// Compression algorithm used
71    pub algorithm: CompressionAlgorithm,
72    /// Compression level used
73    pub level: CompressionLevel,
74
75    // Accumulated metrics
76    /// Total number of compression operations
77    pub total_operations: u64,
78    /// Total bytes processed
79    pub total_bytes_processed: u64,
80    /// Average compression ratio across all operations
81    pub avg_compression_ratio: f64,
82}
83
84impl CompressionMetrics {
85    /// Create new metrics instance
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Create metrics from size information (backward compatibility)
91    pub fn from_sizes(original_size: usize, compressed_size: usize) -> Self {
92        let compression_ratio = if original_size == 0 {
93            1.0
94        } else {
95            original_size as f64 / compressed_size as f64
96        };
97
98        let space_saved = original_size.saturating_sub(compressed_size);
99        let space_saved_percent = if original_size == 0 {
100            0.0
101        } else {
102            (space_saved as f64 / original_size as f64) * 100.0
103        };
104
105        CompressionMetrics {
106            original_size,
107            compressed_size,
108            compression_ratio,
109            space_saved,
110            space_saved_percent,
111            compression_time: Duration::from_secs(0),
112            decompression_time: None,
113            throughput_mbps: 0.0,
114            algorithm: CompressionAlgorithm::None,
115            level: CompressionLevel::Default,
116            total_operations: 1,
117            total_bytes_processed: original_size as u64,
118            avg_compression_ratio: compression_ratio,
119        }
120    }
121
122    /// Record a compression operation
123    pub fn record_compression(
124        &mut self,
125        original: &[u8],
126        compressed: &[u8],
127        duration: Duration,
128        algorithm: CompressionAlgorithm,
129        level: CompressionLevel,
130    ) {
131        self.original_size = original.len();
132        self.compressed_size = compressed.len();
133
134        // Calculate compression ratio (original/compressed, higher is better)
135        self.compression_ratio = if compressed.is_empty() {
136            f64::INFINITY
137        } else {
138            original.len() as f64 / compressed.len() as f64
139        };
140
141        self.space_saved = original.len().saturating_sub(compressed.len());
142        self.space_saved_percent = if original.is_empty() {
143            0.0
144        } else {
145            (self.space_saved as f64 / original.len() as f64) * 100.0
146        };
147
148        self.compression_time = duration;
149        self.algorithm = algorithm;
150        self.level = level;
151
152        // Calculate throughput (MB/s)
153        let mb = original.len() as f64 / 1_048_576.0;
154        let seconds = duration.as_secs_f64();
155        self.throughput_mbps = if seconds > 0.0 { mb / seconds } else { 0.0 };
156
157        // Update accumulated metrics
158        self.total_operations += 1;
159        self.total_bytes_processed += original.len() as u64;
160        self.update_avg_ratio();
161    }
162
163    /// Record a decompression operation
164    pub fn record_decompression(&mut self, duration: Duration) {
165        self.decompression_time = Some(duration);
166    }
167
168    /// Update average compression ratio
169    fn update_avg_ratio(&mut self) {
170        if self.total_operations == 0 {
171            self.avg_compression_ratio = 1.0;
172        } else {
173            // Weighted average: current avg + (new_ratio - avg) / total_ops
174            let weight = 1.0 / self.total_operations as f64;
175            self.avg_compression_ratio =
176                self.avg_compression_ratio * (1.0 - weight) + self.compression_ratio * weight;
177        }
178    }
179
180    /// Export metrics in Prometheus format
181    pub fn to_prometheus_metrics(&self) -> String {
182        format!(
183            "# HELP mediagit_compression_ratio Compression ratio (original/compressed)\n\
184             # TYPE mediagit_compression_ratio gauge\n\
185             mediagit_compression_ratio{{algorithm=\"{:?}\",level=\"{:?}\"}} {}\n\
186             \n\
187             # HELP mediagit_compression_throughput_mbps Compression throughput in MB/s\n\
188             # TYPE mediagit_compression_throughput_mbps gauge\n\
189             mediagit_compression_throughput_mbps{{algorithm=\"{:?}\",level=\"{:?}\"}} {}\n\
190             \n\
191             # HELP mediagit_compression_space_saved_bytes Space saved in bytes\n\
192             # TYPE mediagit_compression_space_saved_bytes counter\n\
193             mediagit_compression_space_saved_bytes{{algorithm=\"{:?}\",level=\"{:?}\"}} {}\n\
194             \n\
195             # HELP mediagit_compression_total_operations Total compression operations\n\
196             # TYPE mediagit_compression_total_operations counter\n\
197             mediagit_compression_total_operations{{algorithm=\"{:?}\",level=\"{:?}\"}} {}\n\
198             \n\
199             # HELP mediagit_compression_total_bytes_processed Total bytes processed\n\
200             # TYPE mediagit_compression_total_bytes_processed counter\n\
201             mediagit_compression_total_bytes_processed{{algorithm=\"{:?}\",level=\"{:?}\"}} {}\n\
202             \n\
203             # HELP mediagit_compression_avg_ratio Average compression ratio\n\
204             # TYPE mediagit_compression_avg_ratio gauge\n\
205             mediagit_compression_avg_ratio{{algorithm=\"{:?}\",level=\"{:?}\"}} {}\n",
206            self.algorithm,
207            self.level,
208            self.compression_ratio,
209            self.algorithm,
210            self.level,
211            self.throughput_mbps,
212            self.algorithm,
213            self.level,
214            self.space_saved,
215            self.algorithm,
216            self.level,
217            self.total_operations,
218            self.algorithm,
219            self.level,
220            self.total_bytes_processed,
221            self.algorithm,
222            self.level,
223            self.avg_compression_ratio
224        )
225    }
226
227    /// Export metrics as JSON
228    pub fn to_json(&self) -> serde_json::Value {
229        serde_json::json!({
230            "size_metrics": {
231                "original_size": self.original_size,
232                "compressed_size": self.compressed_size,
233                "compression_ratio": self.compression_ratio,
234                "space_saved": self.space_saved,
235                "space_saved_percent": self.space_saved_percent
236            },
237            "performance_metrics": {
238                "compression_time_ms": self.compression_time.as_millis(),
239                "decompression_time_ms": self.decompression_time.map(|d| d.as_millis()),
240                "throughput_mbps": self.throughput_mbps
241            },
242            "algorithm": {
243                "name": format!("{:?}", self.algorithm),
244                "level": format!("{:?}", self.level)
245            },
246            "accumulated_metrics": {
247                "total_operations": self.total_operations,
248                "total_bytes_processed": self.total_bytes_processed,
249                "avg_compression_ratio": self.avg_compression_ratio
250            }
251        })
252    }
253
254    /// Generate human-readable summary
255    pub fn summary(&self) -> String {
256        format!(
257            "Compressed {} → {} bytes ({:.1}% reduction, {:.2}x ratio) in {:.2}ms ({:.1} MB/s)",
258            self.original_size,
259            self.compressed_size,
260            self.space_saved_percent,
261            self.compression_ratio,
262            self.compression_time.as_millis(),
263            self.throughput_mbps
264        )
265    }
266
267    /// Calculate compression ratio (compressed / original) - backward compatibility
268    pub fn compression_ratio_legacy(&self) -> f64 {
269        if self.original_size == 0 {
270            1.0
271        } else {
272            self.compressed_size as f64 / self.original_size as f64
273        }
274    }
275
276    /// Calculate bytes saved - backward compatibility
277    pub fn bytes_saved(&self) -> i64 {
278        self.space_saved as i64
279    }
280
281    /// Calculate savings percentage - backward compatibility
282    pub fn savings_percentage(&self) -> f64 {
283        self.space_saved_percent
284    }
285
286    /// Calculate compression throughput (bytes/second) - backward compatibility
287    pub fn compression_throughput(&self) -> Option<f64> {
288        let secs = self.compression_time.as_secs_f64();
289        if secs > 0.0 {
290            Some(self.original_size as f64 / secs)
291        } else {
292            None
293        }
294    }
295
296    /// Calculate decompression throughput (bytes/second) - backward compatibility
297    pub fn decompression_throughput(&self) -> Option<f64> {
298        self.decompression_time.map(|duration| {
299            let secs = duration.as_secs_f64();
300            if secs > 0.0 {
301                self.compressed_size as f64 / secs
302            } else {
303                0.0
304            }
305        })
306    }
307
308    /// Format metrics as a readable string - backward compatibility
309    pub fn format_summary(&self) -> String {
310        self.summary()
311    }
312}
313
314impl Default for CompressionMetrics {
315    fn default() -> Self {
316        CompressionMetrics {
317            original_size: 0,
318            compressed_size: 0,
319            compression_ratio: 1.0,
320            space_saved: 0,
321            space_saved_percent: 0.0,
322            compression_time: Duration::from_secs(0),
323            decompression_time: None,
324            throughput_mbps: 0.0,
325            algorithm: CompressionAlgorithm::None,
326            level: CompressionLevel::Default,
327            total_operations: 0,
328            total_bytes_processed: 0,
329            avg_compression_ratio: 1.0,
330        }
331    }
332}
333
334/// Timer for measuring compression operations
335pub struct CompressionTimer {
336    start: Instant,
337}
338
339impl CompressionTimer {
340    /// Create and start a new timer
341    pub fn start() -> Self {
342        CompressionTimer {
343            start: Instant::now(),
344        }
345    }
346
347    /// Stop timer and return elapsed duration
348    pub fn stop(self) -> Duration {
349        self.start.elapsed()
350    }
351}
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_record_compression() {
360        let mut metrics = CompressionMetrics::new();
361        let original = vec![0u8; 1000];
362        let compressed = vec![0u8; 400];
363
364        metrics.record_compression(
365            &original,
366            &compressed,
367            Duration::from_millis(50),
368            CompressionAlgorithm::Zstd,
369            CompressionLevel::Default,
370        );
371
372        assert_eq!(metrics.original_size, 1000);
373        assert_eq!(metrics.compressed_size, 400);
374        assert_eq!(metrics.space_saved, 600);
375        assert_eq!(metrics.space_saved_percent, 60.0);
376        assert!((metrics.compression_ratio - 2.5).abs() < 0.01); // 1000/400 = 2.5
377        assert_eq!(metrics.total_operations, 1);
378        assert_eq!(metrics.total_bytes_processed, 1000);
379    }
380
381    #[test]
382    fn test_prometheus_export() {
383        let mut metrics = CompressionMetrics::new();
384        let original = vec![0u8; 1000];
385        let compressed = vec![0u8; 500];
386
387        metrics.record_compression(
388            &original,
389            &compressed,
390            Duration::from_millis(10),
391            CompressionAlgorithm::Zstd,
392            CompressionLevel::Best,
393        );
394
395        let prometheus = metrics.to_prometheus_metrics();
396
397        assert!(prometheus.contains("mediagit_compression_ratio"));
398        assert!(prometheus.contains("mediagit_compression_throughput_mbps"));
399        assert!(prometheus.contains("mediagit_compression_space_saved_bytes"));
400        assert!(prometheus.contains("algorithm=\"Zstd\""));
401        assert!(prometheus.contains("level=\"Best\""));
402    }
403
404    #[test]
405    fn test_json_export() {
406        let mut metrics = CompressionMetrics::new();
407        let original = vec![0u8; 2000];
408        let compressed = vec![0u8; 800];
409
410        metrics.record_compression(
411            &original,
412            &compressed,
413            Duration::from_millis(20),
414            CompressionAlgorithm::Brotli,
415            CompressionLevel::Fast,
416        );
417
418        let json = metrics.to_json();
419
420        assert_eq!(json["size_metrics"]["original_size"], 2000);
421        assert_eq!(json["size_metrics"]["compressed_size"], 800);
422        assert_eq!(json["size_metrics"]["space_saved"], 1200);
423        assert_eq!(json["algorithm"]["name"], "Brotli");
424        assert_eq!(json["algorithm"]["level"], "Fast");
425        assert!(json["performance_metrics"]["throughput_mbps"].is_number());
426    }
427
428    #[test]
429    fn test_summary_output() {
430        let mut metrics = CompressionMetrics::new();
431        let original = vec![0u8; 1000];
432        let compressed = vec![0u8; 250];
433
434        metrics.record_compression(
435            &original,
436            &compressed,
437            Duration::from_millis(5),
438            CompressionAlgorithm::Zstd,
439            CompressionLevel::Default,
440        );
441
442        let summary = metrics.summary();
443
444        assert!(summary.contains("1000"));
445        assert!(summary.contains("250"));
446        assert!(summary.contains("75.0%")); // reduction
447        assert!(summary.contains("4.00x")); // ratio
448    }
449
450    #[test]
451    fn test_accumulated_metrics() {
452        let mut metrics = CompressionMetrics::new();
453
454        // First operation
455        metrics.record_compression(
456            &vec![0u8; 1000],
457            &vec![0u8; 500],
458            Duration::from_millis(10),
459            CompressionAlgorithm::Zstd,
460            CompressionLevel::Default,
461        );
462
463        // Second operation
464        metrics.record_compression(
465            &vec![0u8; 2000],
466            &vec![0u8; 800],
467            Duration::from_millis(20),
468            CompressionAlgorithm::Zstd,
469            CompressionLevel::Default,
470        );
471
472        assert_eq!(metrics.total_operations, 2);
473        assert_eq!(metrics.total_bytes_processed, 3000);
474        assert!(metrics.avg_compression_ratio > 2.0);
475    }
476
477    #[test]
478    fn test_throughput_calculation() {
479        let mut metrics = CompressionMetrics::new();
480        let data = vec![0u8; 1_048_576]; // 1 MB
481        let compressed = vec![0u8; 524_288]; // 0.5 MB
482
483        metrics.record_compression(
484            &data,
485            &compressed,
486            Duration::from_secs(1),
487            CompressionAlgorithm::Zstd,
488            CompressionLevel::Fast,
489        );
490
491        // 1 MB in 1 second = 1.0 MB/s
492        assert!((metrics.throughput_mbps - 1.0).abs() < 0.01);
493    }
494
495    #[test]
496    fn test_decompression_recording() {
497        let mut metrics = CompressionMetrics::new();
498
499        metrics.record_compression(
500            &vec![0u8; 1000],
501            &vec![0u8; 500],
502            Duration::from_millis(10),
503            CompressionAlgorithm::Zstd,
504            CompressionLevel::Default,
505        );
506
507        metrics.record_decompression(Duration::from_millis(5));
508
509        assert!(metrics.decompression_time.is_some());
510        assert_eq!(
511            metrics.decompression_time.unwrap(),
512            Duration::from_millis(5)
513        );
514    }
515
516    #[test]
517    fn test_backward_compatibility() {
518        let metrics = CompressionMetrics::from_sizes(1000, 500);
519
520        assert_eq!(metrics.original_size, 1000);
521        assert_eq!(metrics.compressed_size, 500);
522        assert_eq!(metrics.bytes_saved(), 500);
523        assert_eq!(metrics.savings_percentage(), 50.0);
524        assert!((metrics.compression_ratio_legacy() - 0.5).abs() < 0.01);
525    }
526
527    #[test]
528    fn test_zero_size_handling() {
529        let metrics = CompressionMetrics::from_sizes(0, 0);
530
531        assert_eq!(metrics.compression_ratio, 1.0);
532        assert_eq!(metrics.space_saved_percent, 0.0);
533    }
534
535    #[test]
536    fn test_timer() {
537        let timer = CompressionTimer::start();
538        std::thread::sleep(Duration::from_millis(10));
539        let elapsed = timer.stop();
540
541        assert!(elapsed >= Duration::from_millis(10));
542    }
543
544    #[test]
545    fn test_metrics_overhead() {
546        // Ensure metrics collection overhead is minimal
547        let iterations = 1000;
548        let start = Instant::now();
549
550        for _ in 0..iterations {
551            let mut metrics = CompressionMetrics::new();
552            metrics.record_compression(
553                &[0u8; 100],
554                &[0u8; 50],
555                Duration::from_micros(1),
556                CompressionAlgorithm::Zstd,
557                CompressionLevel::Fast,
558            );
559            let _ = metrics.to_prometheus_metrics();
560            let _ = metrics.to_json();
561        }
562
563        let elapsed = start.elapsed();
564        let per_op = elapsed.as_micros() / iterations;
565
566        // Should be under 100 microseconds per operation
567        assert!(
568            per_op < 100,
569            "Metrics overhead too high: {}μs per operation",
570            per_op
571        );
572    }
573}
574
575/// Global metrics aggregator for compression statistics
576///
577/// Provides system-wide statistics across all compression operations,
578/// with support for per-algorithm and per-level breakdowns.
579#[derive(Debug, Clone)]
580pub struct MetricsAggregator {
581    /// Per-algorithm statistics
582    per_algorithm: Arc<Mutex<HashMap<CompressionAlgorithm, AggregatedStats>>>,
583    /// Per-level statistics
584    per_level: Arc<Mutex<HashMap<CompressionLevel, AggregatedStats>>>,
585    /// Overall global statistics
586    global: Arc<Mutex<AggregatedStats>>,
587}
588
589/// Aggregated statistics for a group of compressions
590#[derive(Debug, Clone, Default, Serialize, Deserialize)]
591pub struct AggregatedStats {
592    /// Total number of operations
593    pub operations: usize,
594    /// Total bytes processed
595    pub total_bytes_processed: usize,
596    /// Total bytes compressed
597    pub total_bytes_compressed: usize,
598    /// Total compression time
599    pub total_time_ms: u64,
600    /// Minimum compression ratio seen
601    pub min_ratio: f64,
602    /// Maximum compression ratio seen
603    pub max_ratio: f64,
604    /// Average compression ratio
605    pub avg_ratio: f64,
606}
607
608impl AggregatedStats {
609    /// Record a new compression operation
610    pub fn record(&mut self, metrics: &CompressionMetrics) {
611        self.operations += 1;
612        self.total_bytes_processed += metrics.original_size;
613        self.total_bytes_compressed += metrics.compressed_size;
614        self.total_time_ms += metrics.compression_time.as_millis() as u64;
615
616        // Update min/max
617        if self.operations == 1 {
618            self.min_ratio = metrics.compression_ratio;
619            self.max_ratio = metrics.compression_ratio;
620        } else {
621            self.min_ratio = self.min_ratio.min(metrics.compression_ratio);
622            self.max_ratio = self.max_ratio.max(metrics.compression_ratio);
623        }
624
625        // Update rolling average
626        let weight = 1.0 / self.operations as f64;
627        self.avg_ratio = self.avg_ratio * (1.0 - weight) + metrics.compression_ratio * weight;
628    }
629
630    /// Get overall compression ratio
631    pub fn compression_ratio(&self) -> f64 {
632        if self.total_bytes_processed == 0 {
633            1.0
634        } else {
635            self.total_bytes_compressed as f64 / self.total_bytes_processed as f64
636        }
637    }
638
639    /// Get average throughput (MB/s)
640    pub fn avg_throughput_mbps(&self) -> f64 {
641        if self.total_time_ms == 0 {
642            0.0
643        } else {
644            let mb = self.total_bytes_processed as f64 / 1_048_576.0;
645            let seconds = self.total_time_ms as f64 / 1000.0;
646            mb / seconds
647        }
648    }
649
650    /// Get total space saved
651    pub fn total_space_saved(&self) -> i64 {
652        self.total_bytes_processed as i64 - self.total_bytes_compressed as i64
653    }
654
655    /// Get space saved percentage
656    pub fn space_saved_percent(&self) -> f64 {
657        if self.total_bytes_processed == 0 {
658            0.0
659        } else {
660            self.total_space_saved() as f64 / self.total_bytes_processed as f64 * 100.0
661        }
662    }
663}
664
665impl MetricsAggregator {
666    /// Create a new metrics aggregator
667    pub fn new() -> Self {
668        MetricsAggregator {
669            per_algorithm: Arc::new(Mutex::new(HashMap::new())),
670            per_level: Arc::new(Mutex::new(HashMap::new())),
671            global: Arc::new(Mutex::new(AggregatedStats::default())),
672        }
673    }
674
675    /// Record a compression operation
676    pub fn record(&self, metrics: &CompressionMetrics) {
677        // Update global stats
678        if let Ok(mut global) = self.global.lock() {
679            global.record(metrics);
680        }
681
682        // Update per-algorithm stats
683        if let Ok(mut per_algo) = self.per_algorithm.lock() {
684            per_algo
685                .entry(metrics.algorithm)
686                .or_insert_with(AggregatedStats::default)
687                .record(metrics);
688        }
689
690        // Update per-level stats
691        if let Ok(mut per_level) = self.per_level.lock() {
692            per_level
693                .entry(metrics.level)
694                .or_insert_with(AggregatedStats::default)
695                .record(metrics);
696        }
697    }
698
699    /// Get global statistics
700    pub fn global_stats(&self) -> AggregatedStats {
701        self.global
702            .lock()
703            .ok()
704            .map(|s| s.clone())
705            .unwrap_or_default()
706    }
707
708    /// Get statistics for a specific algorithm
709    pub fn algorithm_stats(&self, algorithm: CompressionAlgorithm) -> Option<AggregatedStats> {
710        self.per_algorithm.lock().ok()?.get(&algorithm).cloned()
711    }
712
713    /// Get statistics for a specific level
714    pub fn level_stats(&self, level: CompressionLevel) -> Option<AggregatedStats> {
715        self.per_level.lock().ok()?.get(&level).cloned()
716    }
717
718    /// Get all algorithm statistics
719    pub fn all_algorithm_stats(&self) -> HashMap<CompressionAlgorithm, AggregatedStats> {
720        self.per_algorithm
721            .lock()
722            .ok()
723            .map(|s| s.clone())
724            .unwrap_or_default()
725    }
726
727    /// Get all level statistics
728    pub fn all_level_stats(&self) -> HashMap<CompressionLevel, AggregatedStats> {
729        self.per_level
730            .lock()
731            .ok()
732            .map(|s| s.clone())
733            .unwrap_or_default()
734    }
735
736    /// Reset all statistics
737    pub fn reset(&self) {
738        if let Ok(mut global) = self.global.lock() {
739            *global = AggregatedStats::default();
740        }
741        if let Ok(mut per_algo) = self.per_algorithm.lock() {
742            per_algo.clear();
743        }
744        if let Ok(mut per_level) = self.per_level.lock() {
745            per_level.clear();
746        }
747    }
748
749    /// Export metrics in Prometheus format
750    pub fn to_prometheus_metrics(&self) -> String {
751        let mut output = String::new();
752
753        // Global metrics
754        let global = self.global_stats();
755        output.push_str("# HELP mediagit_compression_global_ratio Global compression ratio\n");
756        output.push_str("# TYPE mediagit_compression_global_ratio gauge\n");
757        output.push_str(&format!(
758            "mediagit_compression_global_ratio {}\n\n",
759            global.avg_ratio
760        ));
761
762        output.push_str("# HELP mediagit_compression_global_throughput Global throughput (MB/s)\n");
763        output.push_str("# TYPE mediagit_compression_global_throughput gauge\n");
764        output.push_str(&format!(
765            "mediagit_compression_global_throughput {}\n\n",
766            global.avg_throughput_mbps()
767        ));
768
769        output.push_str("# HELP mediagit_compression_global_operations Total operations\n");
770        output.push_str("# TYPE mediagit_compression_global_operations counter\n");
771        output.push_str(&format!(
772            "mediagit_compression_global_operations {}\n\n",
773            global.operations
774        ));
775
776        // Per-algorithm metrics
777        output.push_str(
778            "# HELP mediagit_compression_algorithm_ratio Compression ratio by algorithm\n",
779        );
780        output.push_str("# TYPE mediagit_compression_algorithm_ratio gauge\n");
781        for (algo, stats) in self.all_algorithm_stats() {
782            output.push_str(&format!(
783                "mediagit_compression_algorithm_ratio{{algorithm=\"{:?}\"}} {}\n",
784                algo, stats.avg_ratio
785            ));
786        }
787        output.push('\n');
788
789        // Per-level metrics
790        output.push_str("# HELP mediagit_compression_level_ratio Compression ratio by level\n");
791        output.push_str("# TYPE mediagit_compression_level_ratio gauge\n");
792        for (level, stats) in self.all_level_stats() {
793            output.push_str(&format!(
794                "mediagit_compression_level_ratio{{level=\"{:?}\"}} {}\n",
795                level, stats.avg_ratio
796            ));
797        }
798
799        output
800    }
801
802    /// Export metrics as JSON
803    pub fn to_json(&self) -> serde_json::Value {
804        serde_json::json!({
805            "global": self.global_stats(),
806            "by_algorithm": self.all_algorithm_stats(),
807            "by_level": self.all_level_stats(),
808        })
809    }
810}
811
812impl Default for MetricsAggregator {
813    fn default() -> Self {
814        Self::new()
815    }
816}
817
818#[cfg(test)]
819#[allow(clippy::unwrap_used)]
820mod aggregator_tests {
821    use super::*;
822
823    #[test]
824    fn test_aggregator_basic() {
825        let aggregator = MetricsAggregator::new();
826
827        let mut metrics1 = CompressionMetrics::default();
828        metrics1.record_compression(
829            &vec![0u8; 1000],
830            &vec![0u8; 500],
831            Duration::from_millis(10),
832            CompressionAlgorithm::Zstd,
833            CompressionLevel::Default,
834        );
835
836        aggregator.record(&metrics1);
837
838        let global = aggregator.global_stats();
839        assert_eq!(global.operations, 1);
840        assert_eq!(global.total_bytes_processed, 1000);
841        assert_eq!(global.total_bytes_compressed, 500);
842    }
843
844    #[test]
845    fn test_aggregator_multiple_operations() {
846        let aggregator = MetricsAggregator::new();
847
848        for _ in 0..10 {
849            let mut metrics = CompressionMetrics::default();
850            metrics.record_compression(
851                &vec![0u8; 1000],
852                &vec![0u8; 500],
853                Duration::from_millis(10),
854                CompressionAlgorithm::Zstd,
855                CompressionLevel::Default,
856            );
857            aggregator.record(&metrics);
858        }
859
860        let global = aggregator.global_stats();
861        assert_eq!(global.operations, 10);
862        assert_eq!(global.total_bytes_processed, 10000);
863        assert_eq!(global.total_bytes_compressed, 5000);
864        assert!((global.avg_ratio - 2.0).abs() < 0.01);
865    }
866
867    #[test]
868    fn test_aggregator_per_algorithm() {
869        let aggregator = MetricsAggregator::new();
870
871        // Record Zstd compression
872        let mut metrics_zstd = CompressionMetrics::default();
873        metrics_zstd.record_compression(
874            &vec![0u8; 1000],
875            &vec![0u8; 500],
876            Duration::from_millis(10),
877            CompressionAlgorithm::Zstd,
878            CompressionLevel::Default,
879        );
880        aggregator.record(&metrics_zstd);
881
882        // Record Brotli compression
883        let mut metrics_brotli = CompressionMetrics::default();
884        metrics_brotli.record_compression(
885            &vec![0u8; 1000],
886            &vec![0u8; 400],
887            Duration::from_millis(20),
888            CompressionAlgorithm::Brotli,
889            CompressionLevel::Best,
890        );
891        aggregator.record(&metrics_brotli);
892
893        // Check per-algorithm stats
894        let zstd_stats = aggregator
895            .algorithm_stats(CompressionAlgorithm::Zstd)
896            .unwrap();
897        assert_eq!(zstd_stats.operations, 1);
898        assert_eq!(zstd_stats.total_bytes_compressed, 500);
899
900        let brotli_stats = aggregator
901            .algorithm_stats(CompressionAlgorithm::Brotli)
902            .unwrap();
903        assert_eq!(brotli_stats.operations, 1);
904        assert_eq!(brotli_stats.total_bytes_compressed, 400);
905
906        // Global stats should include both
907        let global = aggregator.global_stats();
908        assert_eq!(global.operations, 2);
909    }
910
911    #[test]
912    fn test_aggregator_prometheus_export() {
913        let aggregator = MetricsAggregator::new();
914
915        let mut metrics = CompressionMetrics::default();
916        metrics.record_compression(
917            &vec![0u8; 1000],
918            &vec![0u8; 500],
919            Duration::from_millis(10),
920            CompressionAlgorithm::Zstd,
921            CompressionLevel::Default,
922        );
923        aggregator.record(&metrics);
924
925        let prometheus = aggregator.to_prometheus_metrics();
926
927        assert!(prometheus.contains("mediagit_compression_global_ratio"));
928        assert!(prometheus.contains("mediagit_compression_algorithm_ratio"));
929        assert!(prometheus.contains("algorithm=\"Zstd\""));
930    }
931
932    #[test]
933    fn test_aggregator_reset() {
934        let aggregator = MetricsAggregator::new();
935
936        let mut metrics = CompressionMetrics::default();
937        metrics.record_compression(
938            &vec![0u8; 1000],
939            &vec![0u8; 500],
940            Duration::from_millis(10),
941            CompressionAlgorithm::Zstd,
942            CompressionLevel::Default,
943        );
944        aggregator.record(&metrics);
945
946        assert_eq!(aggregator.global_stats().operations, 1);
947
948        aggregator.reset();
949
950        assert_eq!(aggregator.global_stats().operations, 0);
951    }
952}