1use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::Duration;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct L1Stats {
26 pub l1_hits: u64,
28 pub l2_hits: u64,
30 pub misses: u64,
32 pub l1_enabled: bool,
34}
35
36impl L1Stats {
37 pub fn l1_hit_rate(&self) -> f64 {
39 let total = self.l1_hits + self.l2_hits + self.misses;
40 if total == 0 {
41 0.0
42 } else {
43 self.l1_hits as f64 / total as f64
44 }
45 }
46}
47
48pub type MetricsProvider = Arc<dyn Fn() -> Option<L1Stats> + Send + Sync>;
50
51pub fn metrics_headers(provider: Option<&MetricsProvider>) -> Vec<(&'static str, String)> {
53 let disabled = vec![("X-CacheKit-L1-Status", "disabled".to_string())];
54
55 let provider = match provider {
56 Some(p) => p,
57 None => return disabled,
58 };
59
60 let stats = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (provider)())) {
61 Ok(Some(s)) => s,
62 _ => return disabled,
63 };
64
65 if !stats.l1_enabled {
66 return disabled;
67 }
68
69 vec![
70 ("X-CacheKit-L1-Status", "miss".to_string()),
76 ("X-CacheKit-L1-Hits", stats.l1_hits.to_string()),
77 ("X-CacheKit-L2-Hits", stats.l2_hits.to_string()),
78 ("X-CacheKit-Misses", stats.misses.to_string()),
79 (
80 "X-CacheKit-L1-Hit-Rate",
81 format!("{:.3}", stats.l1_hit_rate()),
82 ),
83 ]
84}
85
86pub fn key_hash(key: &str) -> String {
99 use blake2::{digest::consts::U16, Blake2b, Digest};
100
101 let mut hasher = Blake2b::<U16>::new();
102 hasher.update(key.as_bytes());
103 hex::encode(hasher.finalize())
104}
105
106#[cfg_attr(
114 not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))),
115 allow(dead_code)
116)]
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub(crate) enum ReadOutcome {
119 L1Hit,
121 L1Stale,
124 L2Hit,
126 Miss,
128}
129
130#[cfg(feature = "tracing")]
131impl ReadOutcome {
132 fn as_str(self) -> &'static str {
133 match self {
134 Self::L1Hit => "l1_hit",
135 Self::L1Stale => "l1_stale",
136 Self::L2Hit => "l2_hit",
137 Self::Miss => "miss",
138 }
139 }
140}
141
142#[derive(Debug)]
149pub(crate) struct CacheCounters {
150 l1_hits: AtomicU64,
151 l2_hits: AtomicU64,
152 misses: AtomicU64,
153 l1_enabled: bool,
155}
156
157impl CacheCounters {
158 pub(crate) fn new(l1_enabled: bool) -> Self {
159 Self {
160 l1_hits: AtomicU64::new(0),
161 l2_hits: AtomicU64::new(0),
162 misses: AtomicU64::new(0),
163 l1_enabled,
164 }
165 }
166
167 pub(crate) fn record(&self, outcome: ReadOutcome, full_key: &str) {
169 let counter = match outcome {
170 ReadOutcome::L1Hit | ReadOutcome::L1Stale => &self.l1_hits,
171 ReadOutcome::L2Hit => &self.l2_hits,
172 ReadOutcome::Miss => &self.misses,
173 };
174 counter.fetch_add(1, Ordering::Relaxed);
175 trace_read(full_key, outcome);
176 }
177
178 pub(crate) fn snapshot(&self) -> L1Stats {
180 L1Stats {
181 l1_hits: self.l1_hits.load(Ordering::Relaxed),
182 l2_hits: self.l2_hits.load(Ordering::Relaxed),
183 misses: self.misses.load(Ordering::Relaxed),
184 l1_enabled: self.l1_enabled,
185 }
186 }
187
188 pub(crate) fn provider(self: &Arc<Self>) -> MetricsProvider {
196 let counters = Arc::downgrade(self);
197 Arc::new(move || counters.upgrade().map(|c| c.snapshot()))
198 }
199}
200
201#[cfg(feature = "tracing")]
212const TARGET: &str = "cachekit";
213
214fn trace_read(full_key: &str, outcome: ReadOutcome) {
215 #[cfg(feature = "tracing")]
216 tracing::debug!(
217 target: TARGET,
218 op = "get",
219 key_hash = %key_hash(full_key),
220 outcome = outcome.as_str(),
221 );
222 #[cfg(not(feature = "tracing"))]
223 let _ = (full_key, outcome);
224}
225
226pub(crate) fn trace_write(full_key: &str, ttl: Duration) {
228 #[cfg(feature = "tracing")]
229 tracing::debug!(
230 target: TARGET,
231 op = "set",
232 key_hash = %key_hash(full_key),
233 ttl_secs = ttl.as_secs(),
234 );
235 #[cfg(not(feature = "tracing"))]
236 let _ = (full_key, ttl);
237}
238
239pub(crate) fn trace_delete(full_key: &str, existed: bool) {
241 #[cfg(feature = "tracing")]
242 tracing::debug!(
243 target: TARGET,
244 op = "delete",
245 key_hash = %key_hash(full_key),
246 existed,
247 );
248 #[cfg(not(feature = "tracing"))]
249 let _ = (full_key, existed);
250}
251
252#[cfg(test)]
253#[allow(clippy::panic)] mod tests {
255 use super::*;
256
257 fn header<'a>(headers: &'a [(&str, String)], name: &str) -> &'a str {
258 headers
259 .iter()
260 .find(|h| h.0 == name)
261 .map(|h| h.1.as_str())
262 .unwrap_or_else(|| panic!("header {name} missing from {headers:?}"))
263 }
264
265 fn provider(l1_hits: u64, l2_hits: u64, misses: u64, l1_enabled: bool) -> MetricsProvider {
266 Arc::new(move || {
267 Some(L1Stats {
268 l1_hits,
269 l2_hits,
270 misses,
271 l1_enabled,
272 })
273 })
274 }
275
276 #[test]
277 fn disabled_when_no_provider() {
278 let headers = metrics_headers(None);
279 assert_eq!(headers.len(), 1);
280 assert_eq!(headers[0], ("X-CacheKit-L1-Status", "disabled".to_string()));
281 }
282
283 #[test]
284 fn disabled_when_l1_not_enabled() {
285 let headers = metrics_headers(Some(&provider(0, 0, 0, false)));
286 assert_eq!(headers[0].1, "disabled");
287 }
288
289 #[test]
292 fn status_is_a_server_accepted_value_when_l1_enabled() {
293 let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
294 let status = header(&headers, "X-CacheKit-L1-Status");
295 assert_eq!(status, "miss");
296 assert!(["hit", "miss", "disabled"].contains(&status));
297 }
298
299 #[test]
300 fn correct_hit_rate_calculation() {
301 let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
302 assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.300"); assert_eq!(header(&headers, "X-CacheKit-L1-Hits"), "3");
304 assert_eq!(header(&headers, "X-CacheKit-L2-Hits"), "2");
305 assert_eq!(header(&headers, "X-CacheKit-Misses"), "5");
306 }
307
308 #[test]
309 fn zero_division_guard() {
310 let headers = metrics_headers(Some(&provider(0, 0, 0, true)));
311 assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.000");
312 assert_eq!(L1Stats::default().l1_hit_rate(), 0.0);
313 }
314
315 #[test]
316 fn disabled_when_provider_panics() {
317 #[allow(clippy::panic)]
318 let provider: MetricsProvider = Arc::new(|| panic!("boom"));
319 let headers = metrics_headers(Some(&provider));
320 assert_eq!(headers[0].1, "disabled");
321 }
322
323 #[test]
324 fn counters_record_and_snapshot() {
325 let counters = Arc::new(CacheCounters::new(true));
326 counters.record(ReadOutcome::L1Hit, "k");
327 counters.record(ReadOutcome::L1Stale, "k");
328 counters.record(ReadOutcome::L2Hit, "k");
329 counters.record(ReadOutcome::Miss, "k");
330 assert_eq!(
331 counters.snapshot(),
332 L1Stats {
333 l1_hits: 2,
334 l2_hits: 1,
335 misses: 1,
336 l1_enabled: true,
337 }
338 );
339 let provider = counters.provider();
341 counters.record(ReadOutcome::Miss, "k");
342 assert_eq!(provider().map(|s| s.misses), Some(2));
343 drop(counters);
345 assert_eq!(provider(), None);
346 assert_eq!(metrics_headers(Some(&provider))[0].1, "disabled");
347 }
348
349 #[test]
352 fn key_hash_is_py_blake2b16_hex() {
353 assert_eq!(
354 key_hash("ns:app:func:m.f:args:abc:v1"),
355 "bcb35ae6f64fa65b2770ab3af631b1ce" );
357 assert_eq!(key_hash("").len(), 32);
358 }
359}