Skip to main content

cachekit/
metrics.rs

1use std::sync::Arc;
2
3/// Snapshot of L1/L2 cache hit statistics.
4pub struct L1Stats {
5    /// Number of requests served from the L1 in-process cache.
6    pub l1_hits: u64,
7    /// Number of requests served from the L2 backend.
8    pub l2_hits: u64,
9    /// Number of cache misses.
10    pub misses: u64,
11    /// Whether the L1 cache is currently enabled.
12    pub l1_enabled: bool,
13}
14
15/// Thread-safe closure that produces an optional [`L1Stats`] snapshot.
16pub type MetricsProvider = Arc<dyn Fn() -> Option<L1Stats> + Send + Sync>;
17
18/// Build `X-CacheKit-*` HTTP headers from the current L1 stats provider.
19pub fn metrics_headers(provider: Option<&MetricsProvider>) -> Vec<(&'static str, String)> {
20    let disabled = vec![("X-CacheKit-L1-Status", "disabled".to_string())];
21
22    let provider = match provider {
23        Some(p) => p,
24        None => return disabled,
25    };
26
27    let stats = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (provider)())) {
28        Ok(Some(s)) => s,
29        _ => return disabled,
30    };
31
32    if !stats.l1_enabled {
33        return disabled;
34    }
35
36    let total = stats.l1_hits + stats.l2_hits + stats.misses;
37    let hit_rate = if total > 0 {
38        stats.l1_hits as f64 / total as f64
39    } else {
40        0.0
41    };
42
43    vec![
44        ("X-CacheKit-L1-Status", "enabled".to_string()),
45        ("X-CacheKit-L1-Hits", stats.l1_hits.to_string()),
46        ("X-CacheKit-L2-Hits", stats.l2_hits.to_string()),
47        ("X-CacheKit-Misses", stats.misses.to_string()),
48        ("X-CacheKit-L1-Hit-Rate", format!("{hit_rate:.3}")),
49    ]
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn disabled_when_no_provider() {
58        let headers = metrics_headers(None);
59        assert_eq!(headers.len(), 1);
60        assert_eq!(headers[0], ("X-CacheKit-L1-Status", "disabled".to_string()));
61    }
62
63    #[test]
64    fn disabled_when_l1_not_enabled() {
65        let provider: MetricsProvider = Arc::new(|| {
66            Some(L1Stats {
67                l1_hits: 0,
68                l2_hits: 0,
69                misses: 0,
70                l1_enabled: false,
71            })
72        });
73        let headers = metrics_headers(Some(&provider));
74        assert_eq!(headers[0].1, "disabled");
75    }
76
77    #[test]
78    fn correct_hit_rate_calculation() {
79        let provider: MetricsProvider = Arc::new(|| {
80            Some(L1Stats {
81                l1_hits: 3,
82                l2_hits: 2,
83                misses: 5,
84                l1_enabled: true,
85            })
86        });
87        let headers = metrics_headers(Some(&provider));
88        let rate = headers
89            .iter()
90            .find(|h| h.0 == "X-CacheKit-L1-Hit-Rate")
91            .unwrap();
92        assert_eq!(rate.1, "0.300"); // 3 / (3+2+5)
93    }
94
95    #[test]
96    fn zero_division_guard() {
97        let provider: MetricsProvider = Arc::new(|| {
98            Some(L1Stats {
99                l1_hits: 0,
100                l2_hits: 0,
101                misses: 0,
102                l1_enabled: true,
103            })
104        });
105        let headers = metrics_headers(Some(&provider));
106        let rate = headers
107            .iter()
108            .find(|h| h.0 == "X-CacheKit-L1-Hit-Rate")
109            .unwrap();
110        assert_eq!(rate.1, "0.000");
111    }
112
113    #[test]
114    fn disabled_when_provider_panics() {
115        #[allow(clippy::panic)]
116        let provider: MetricsProvider = Arc::new(|| panic!("boom"));
117        let headers = metrics_headers(Some(&provider));
118        assert_eq!(headers[0].1, "disabled");
119    }
120}