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,
127 Miss,
129}
130
131#[cfg(feature = "tracing")]
132impl ReadOutcome {
133 fn as_str(self) -> &'static str {
134 match self {
135 Self::L1Hit => "l1_hit",
136 Self::L1Stale => "l1_stale",
137 Self::L2Hit => "l2_hit",
138 Self::Miss => "miss",
139 }
140 }
141}
142
143#[derive(Debug)]
150pub(crate) struct CacheCounters {
151 l1_hits: AtomicU64,
152 l2_hits: AtomicU64,
153 misses: AtomicU64,
154 l1_enabled: bool,
156}
157
158impl CacheCounters {
159 pub(crate) fn new(l1_enabled: bool) -> Self {
160 Self {
161 l1_hits: AtomicU64::new(0),
162 l2_hits: AtomicU64::new(0),
163 misses: AtomicU64::new(0),
164 l1_enabled,
165 }
166 }
167
168 pub(crate) fn record(&self, outcome: ReadOutcome, full_key: &str) {
170 let counter = match outcome {
171 ReadOutcome::L1Hit | ReadOutcome::L1Stale => &self.l1_hits,
172 ReadOutcome::L2Hit => &self.l2_hits,
173 ReadOutcome::Miss => &self.misses,
174 };
175 counter.fetch_add(1, Ordering::Relaxed);
176 trace_read(full_key, outcome);
177 }
178
179 pub(crate) fn snapshot(&self) -> L1Stats {
181 L1Stats {
182 l1_hits: self.l1_hits.load(Ordering::Relaxed),
183 l2_hits: self.l2_hits.load(Ordering::Relaxed),
184 misses: self.misses.load(Ordering::Relaxed),
185 l1_enabled: self.l1_enabled,
186 }
187 }
188
189 pub(crate) fn provider(self: &Arc<Self>) -> MetricsProvider {
197 let counters = Arc::downgrade(self);
198 Arc::new(move || counters.upgrade().map(|c| c.snapshot()))
199 }
200}
201
202#[cfg(feature = "tracing")]
213const TARGET: &str = "cachekit";
214
215fn trace_read(full_key: &str, outcome: ReadOutcome) {
216 #[cfg(feature = "tracing")]
217 tracing::debug!(
218 target: TARGET,
219 op = "get",
220 key_hash = %key_hash(full_key),
221 outcome = outcome.as_str(),
222 );
223 #[cfg(not(feature = "tracing"))]
224 let _ = (full_key, outcome);
225}
226
227pub(crate) fn trace_write(full_key: &str, ttl: Duration) {
229 #[cfg(feature = "tracing")]
230 tracing::debug!(
231 target: TARGET,
232 op = "set",
233 key_hash = %key_hash(full_key),
234 ttl_secs = ttl.as_secs(),
235 );
236 #[cfg(not(feature = "tracing"))]
237 let _ = (full_key, ttl);
238}
239
240pub(crate) fn trace_delete(full_key: &str, existed: bool) {
242 #[cfg(feature = "tracing")]
243 tracing::debug!(
244 target: TARGET,
245 op = "delete",
246 key_hash = %key_hash(full_key),
247 existed,
248 );
249 #[cfg(not(feature = "tracing"))]
250 let _ = (full_key, existed);
251}
252
253#[cfg(test)]
254#[allow(clippy::panic)] mod tests {
256 use super::*;
257
258 fn header<'a>(headers: &'a [(&str, String)], name: &str) -> &'a str {
259 headers
260 .iter()
261 .find(|h| h.0 == name)
262 .map(|h| h.1.as_str())
263 .unwrap_or_else(|| panic!("header {name} missing from {headers:?}"))
264 }
265
266 fn provider(l1_hits: u64, l2_hits: u64, misses: u64, l1_enabled: bool) -> MetricsProvider {
267 Arc::new(move || {
268 Some(L1Stats {
269 l1_hits,
270 l2_hits,
271 misses,
272 l1_enabled,
273 })
274 })
275 }
276
277 #[test]
278 fn disabled_when_no_provider() {
279 let headers = metrics_headers(None);
280 assert_eq!(headers.len(), 1);
281 assert_eq!(headers[0], ("X-CacheKit-L1-Status", "disabled".to_string()));
282 }
283
284 #[test]
285 fn disabled_when_l1_not_enabled() {
286 let headers = metrics_headers(Some(&provider(0, 0, 0, false)));
287 assert_eq!(headers[0].1, "disabled");
288 }
289
290 #[test]
293 fn status_is_a_server_accepted_value_when_l1_enabled() {
294 let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
295 let status = header(&headers, "X-CacheKit-L1-Status");
296 assert_eq!(status, "miss");
297 assert!(["hit", "miss", "disabled"].contains(&status));
298 }
299
300 #[test]
301 fn correct_hit_rate_calculation() {
302 let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
303 assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.300"); assert_eq!(header(&headers, "X-CacheKit-L1-Hits"), "3");
305 assert_eq!(header(&headers, "X-CacheKit-L2-Hits"), "2");
306 assert_eq!(header(&headers, "X-CacheKit-Misses"), "5");
307 }
308
309 #[test]
310 fn zero_division_guard() {
311 let headers = metrics_headers(Some(&provider(0, 0, 0, true)));
312 assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.000");
313 assert_eq!(L1Stats::default().l1_hit_rate(), 0.0);
314 }
315
316 #[test]
317 fn disabled_when_provider_panics() {
318 #[allow(clippy::panic)]
319 let provider: MetricsProvider = Arc::new(|| panic!("boom"));
320 let headers = metrics_headers(Some(&provider));
321 assert_eq!(headers[0].1, "disabled");
322 }
323
324 #[test]
325 fn counters_record_and_snapshot() {
326 let counters = Arc::new(CacheCounters::new(true));
327 counters.record(ReadOutcome::L1Hit, "k");
328 counters.record(ReadOutcome::L1Stale, "k");
329 counters.record(ReadOutcome::L2Hit, "k");
330 counters.record(ReadOutcome::Miss, "k");
331 assert_eq!(
332 counters.snapshot(),
333 L1Stats {
334 l1_hits: 2,
335 l2_hits: 1,
336 misses: 1,
337 l1_enabled: true,
338 }
339 );
340 let provider = counters.provider();
342 counters.record(ReadOutcome::Miss, "k");
343 assert_eq!(provider().map(|s| s.misses), Some(2));
344 drop(counters);
346 assert_eq!(provider(), None);
347 assert_eq!(metrics_headers(Some(&provider))[0].1, "disabled");
348 }
349
350 #[test]
353 fn key_hash_is_py_blake2b16_hex() {
354 assert_eq!(
355 key_hash("ns:app:func:m.f:args:abc:v1"),
356 "bcb35ae6f64fa65b2770ab3af631b1ce" );
358 assert_eq!(key_hash("").len(), 32);
359 }
360}