Skip to main content

cachekit/
metrics.rs

1//! Telemetry: live hit/miss counters, the `X-CacheKit-*` SaaS telemetry
2//! headers built from them, and — behind the `tracing` feature — structured
3//! events for cache operations.
4//!
5//! Counters are owned by a [`CacheKit`](crate::CacheKit) and shared by its
6//! clones; read a snapshot with [`CacheKit::stats`](crate::CacheKit::stats).
7//! At build time the client hands a
8//! [`MetricsProvider`](crate::metrics::MetricsProvider) over those counters to
9//! the backend ([`Backend::attach_metrics`](crate::backend::Backend::attach_metrics)),
10//! so the cachekit.io backends' telemetry headers report real numbers with no
11//! user plumbing. A provider given to the backend's own builder takes
12//! precedence.
13
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::Duration;
17
18/// Snapshot of cache hit statistics for one [`CacheKit`](crate::CacheKit)
19/// and all of its clones.
20///
21/// Counts cover the value read path — `get`, `interop_get`, their SWR
22/// variants, and the `SecureCache` equivalents. `exists` and reads that end
23/// in a backend error are not counted.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct L1Stats {
26    /// Number of reads served from the L1 in-process cache.
27    pub l1_hits: u64,
28    /// Number of reads served from the L2 backend.
29    pub l2_hits: u64,
30    /// Number of reads that found nothing in either layer.
31    pub misses: u64,
32    /// Whether the L1 cache is enabled on this client.
33    pub l1_enabled: bool,
34}
35
36impl L1Stats {
37    /// Fraction of reads served from L1, in `[0.0, 1.0]` (`0.0` before any read).
38    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
48/// Thread-safe closure that produces an optional [`L1Stats`] snapshot.
49pub type MetricsProvider = Arc<dyn Fn() -> Option<L1Stats> + Send + Sync>;
50
51/// Names of the `X-CacheKit-*` telemetry headers, in the order
52/// [`metrics_headers`] emits them: L1 status, L1 hits, L2 hits, misses, L1 hit
53/// rate.
54pub(crate) const METRICS_HEADER_NAMES: [&str; 5] = [
55    "X-CacheKit-L1-Status",
56    "X-CacheKit-L1-Hits",
57    "X-CacheKit-L2-Hits",
58    "X-CacheKit-Misses",
59    "X-CacheKit-L1-Hit-Rate",
60];
61
62/// Build `X-CacheKit-*` HTTP headers from the current L1 stats provider.
63pub fn metrics_headers(provider: Option<&MetricsProvider>) -> Vec<(&'static str, String)> {
64    metrics_headers_named(provider, &METRICS_HEADER_NAMES)
65}
66
67/// [`metrics_headers`] with the names supplied in [`METRICS_HEADER_NAMES`]
68/// order, so the native CachekitIO backend can pass prebuilt `HeaderName`s
69/// instead of having reqwest parse each name on every request.
70pub(crate) fn metrics_headers_named<N: Clone>(
71    provider: Option<&MetricsProvider>,
72    names: &[N; 5],
73) -> Vec<(N, String)> {
74    let [status, l1_hits, l2_hits, misses, hit_rate] = names.clone();
75    let disabled = vec![(status.clone(), "disabled".to_string())];
76
77    let provider = match provider {
78        Some(p) => p,
79        None => return disabled,
80    };
81
82    let stats = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (provider)())) {
83        Ok(Some(s)) => s,
84        _ => return disabled,
85    };
86
87    if !stats.l1_enabled {
88        return disabled;
89    }
90
91    vec![
92        // The server accepts exactly `hit`, `miss`, or `disabled` and answers
93        // 400 to an SDK key sending anything else (spec/saas-api.md, "Optional
94        // Metrics Headers"). Like the Python and TypeScript SDKs we report the
95        // aggregate `miss` whenever L1 is on — per-request classification is
96        // not something an L2 request can know about itself.
97        (status, "miss".to_string()),
98        (l1_hits, stats.l1_hits.to_string()),
99        (l2_hits, stats.l2_hits.to_string()),
100        (misses, stats.misses.to_string()),
101        (hit_rate, format!("{:.3}", stats.l1_hit_rate())),
102    ]
103}
104
105// ── Key hashing ──────────────────────────────────────────────────────────────
106
107/// Blake2b-128 digest of a storage key as 32 lowercase hex characters.
108///
109/// Cache keys routinely embed user identifiers, so `tracing` events carry this
110/// digest instead of the key (CWE-532). It is a **correlator, not a
111/// redaction**: it is unkeyed and deterministic, so the same key hashes
112/// identically in every process and matches the File backend's on-disk
113/// filename (and cachekit-py's `hashlib.blake2b(key, digest_size=16)`) — and
114/// for that same reason a low-entropy key such as `user:42` can be recovered
115/// from its digest by enumeration. Treat `cachekit=debug` output with the
116/// care you give the keys themselves.
117pub fn key_hash(key: &str) -> String {
118    use blake2::{digest::consts::U16, Blake2b, Digest};
119
120    let mut hasher = Blake2b::<U16>::new();
121    hasher.update(key.as_bytes());
122    hex::encode(hasher.finalize())
123}
124
125// ── Live counters ────────────────────────────────────────────────────────────
126
127/// How a value read was served. Counted by [`CacheCounters`] and, with the
128/// `tracing` feature, emitted as the `outcome` field of the read event.
129///
130/// The L1 variants are only constructed by the L1 read paths (`L1Stale` by
131/// the native SWR path), so builds without them would flag dead variants.
132#[cfg_attr(
133    not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))),
134    allow(dead_code)
135)]
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub(crate) enum ReadOutcome {
138    /// Served from the in-process L1 cache, within its freshness window.
139    L1Hit,
140    /// Served from L1 past the SWR freshness threshold: still an L1 hit for
141    /// the counters, but the caller is about to refresh it in the background.
142    L1Stale,
143    /// Served from the L2 backend (and backfilled into L1 when enabled, unless
144    /// the server marked the read stale or its freshness exhausted).
145    L2Hit,
146    /// Neither layer had the key.
147    Miss,
148}
149
150#[cfg(feature = "tracing")]
151impl ReadOutcome {
152    fn as_str(self) -> &'static str {
153        match self {
154            Self::L1Hit => "l1_hit",
155            Self::L1Stale => "l1_stale",
156            Self::L2Hit => "l2_hit",
157            Self::Miss => "miss",
158        }
159    }
160}
161
162/// Live counters behind [`L1Stats`], owned by a client and shared with its
163/// clones and its backend's [`MetricsProvider`].
164///
165/// Relaxed atomics: each counter is an independent monotonic tally and a
166/// snapshot may observe one counter a step ahead of another — fine for
167/// telemetry, and the cost of a stronger ordering on the read hot path is not.
168#[derive(Debug)]
169pub(crate) struct CacheCounters {
170    l1_hits: AtomicU64,
171    l2_hits: AtomicU64,
172    misses: AtomicU64,
173    /// Fixed at build time: whether the owning client has an L1.
174    l1_enabled: bool,
175}
176
177impl CacheCounters {
178    pub(crate) fn new(l1_enabled: bool) -> Self {
179        Self {
180            l1_hits: AtomicU64::new(0),
181            l2_hits: AtomicU64::new(0),
182            misses: AtomicU64::new(0),
183            l1_enabled,
184        }
185    }
186
187    /// Count one read outcome and emit its `tracing` event (feature `tracing`).
188    pub(crate) fn record(&self, outcome: ReadOutcome, full_key: &str) {
189        let counter = match outcome {
190            ReadOutcome::L1Hit | ReadOutcome::L1Stale => &self.l1_hits,
191            ReadOutcome::L2Hit => &self.l2_hits,
192            ReadOutcome::Miss => &self.misses,
193        };
194        counter.fetch_add(1, Ordering::Relaxed);
195        trace_read(full_key, outcome);
196    }
197
198    /// Current totals.
199    pub(crate) fn snapshot(&self) -> L1Stats {
200        L1Stats {
201            l1_hits: self.l1_hits.load(Ordering::Relaxed),
202            l2_hits: self.l2_hits.load(Ordering::Relaxed),
203            misses: self.misses.load(Ordering::Relaxed),
204            l1_enabled: self.l1_enabled,
205        }
206    }
207
208    /// A [`MetricsProvider`] over these counters, for
209    /// [`Backend::attach_metrics`](crate::backend::Backend::attach_metrics).
210    ///
211    /// Holds the counters weakly: a backend can outlive the client that was
212    /// built over it, and once every clone of that client is gone the provider
213    /// reports `None` — the headers fall back to `disabled` — rather than the
214    /// frozen numbers of a dead client.
215    pub(crate) fn provider(self: &Arc<Self>) -> MetricsProvider {
216        let counters = Arc::downgrade(self);
217        Arc::new(move || counters.upgrade().map(|c| c.snapshot()))
218    }
219}
220
221// ── tracing events ───────────────────────────────────────────────────────────
222//
223// One `debug` event per completed cache operation on the `cachekit` target
224// (`RUST_LOG=cachekit=debug`), one per failed fill-lock call, and one per
225// `#[cachekit]` result the macro could not store. Fields: `op`
226// (`get` | `set` | `delete` | `lock` | `unlock`), `key_hash` (see `key_hash`),
227// and per op `outcome` / `ttl_secs` / `existed` / `error_kind`.
228// Field expressions only run when a subscriber is enabled for the callsite,
229// so the hash is never computed for an uninterested process. Without the
230// feature these are empty functions the optimizer removes.
231
232/// Target for cache-operation events.
233#[cfg(feature = "tracing")]
234const TARGET: &str = "cachekit";
235
236fn trace_read(full_key: &str, outcome: ReadOutcome) {
237    #[cfg(feature = "tracing")]
238    tracing::debug!(
239        target: TARGET,
240        op = "get",
241        key_hash = %key_hash(full_key),
242        outcome = outcome.as_str(),
243    );
244    #[cfg(not(feature = "tracing"))]
245    let _ = (full_key, outcome);
246}
247
248/// Emit the event for a completed write (`set` and SWR refresh commits).
249pub(crate) fn trace_write(full_key: &str, ttl: Duration) {
250    #[cfg(feature = "tracing")]
251    tracing::debug!(
252        target: TARGET,
253        op = "set",
254        key_hash = %key_hash(full_key),
255        ttl_secs = crate::backend::ttl_wire_secs(ttl),
256    );
257    #[cfg(not(feature = "tracing"))]
258    let _ = (full_key, ttl);
259}
260
261/// Emit the event for a completed delete.
262pub(crate) fn trace_delete(full_key: &str, existed: bool) {
263    #[cfg(feature = "tracing")]
264    tracing::debug!(
265        target: TARGET,
266        op = "delete",
267        key_hash = %key_hash(full_key),
268        existed,
269    );
270    #[cfg(not(feature = "tracing"))]
271    let _ = (full_key, existed);
272}
273
274/// Emit the event for a failed fill-lock call (`op` is `lock` or `unlock`).
275/// The call bypasses the circuit breaker, so this is the only signal of a
276/// failing lock endpoint: refreshes stand down, cold misses fill without the
277/// lease, and an unreleased lease is left to expire. It carries the error's
278/// kind, never its message: a transport error names the request URL, and the
279/// URL holds the key (CWE-532).
280#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
281pub(crate) fn trace_lock_error(full_key: &str, op: &'static str, err: &crate::BackendError) {
282    #[cfg(feature = "tracing")]
283    tracing::debug!(
284        target: TARGET,
285        op,
286        key_hash = %key_hash(full_key),
287        error_kind = %err.kind,
288    );
289    #[cfg(not(feature = "tracing"))]
290    let _ = (full_key, op, err);
291}
292
293/// Emit the event for a `#[cachekit]` result the macro computed but could not
294/// store: a cold-miss fill, or an SWR refresh whose commit failed. Macro
295/// plumbing. A cold-miss call still returns its result; a failed refresh
296/// commit is dropped and the stale value keeps being served. Either way this
297/// is the only signal of a failing store — otherwise it shows only as repeat
298/// misses or refreshes.
299/// The macro stores only on an un-namespaced client, so `key` is the storage
300/// key. Like the lock-error event, it carries the error's kind, never
301/// its message: a transport error names the request URL, and an invalid-key
302/// error the key (CWE-532).
303#[doc(hidden)]
304pub fn __trace_store_error(key: &str, err: &crate::CachekitError) {
305    #[cfg(feature = "tracing")]
306    {
307        use crate::CachekitError as E;
308        let error_kind: &dyn std::fmt::Display = match err {
309            E::Backend(be) => &be.kind,
310            E::Serialization(_) => &"serialization",
311            E::Encryption(_) => &"encryption",
312            E::Config(_) => &"config",
313            E::PayloadTooLarge { .. } => &"payload-too-large",
314            E::InvalidKey(_) => &"invalid-key",
315        };
316        tracing::debug!(
317            target: TARGET,
318            op = "set",
319            key_hash = %key_hash(key),
320            error_kind = %error_kind,
321        );
322    }
323    #[cfg(not(feature = "tracing"))]
324    let _ = (key, err);
325}
326
327#[cfg(test)]
328#[allow(clippy::panic)] // test-only: a missing header on a fixture should panic loudly
329mod tests {
330    use super::*;
331
332    fn header<'a>(headers: &'a [(&str, String)], name: &str) -> &'a str {
333        headers
334            .iter()
335            .find(|h| h.0 == name)
336            .map(|h| h.1.as_str())
337            .unwrap_or_else(|| panic!("header {name} missing from {headers:?}"))
338    }
339
340    fn provider(l1_hits: u64, l2_hits: u64, misses: u64, l1_enabled: bool) -> MetricsProvider {
341        Arc::new(move || {
342            Some(L1Stats {
343                l1_hits,
344                l2_hits,
345                misses,
346                l1_enabled,
347            })
348        })
349    }
350
351    #[test]
352    fn disabled_when_no_provider() {
353        let headers = metrics_headers(None);
354        assert_eq!(headers.len(), 1);
355        assert_eq!(headers[0], ("X-CacheKit-L1-Status", "disabled".to_string()));
356    }
357
358    #[test]
359    fn disabled_when_l1_not_enabled() {
360        let headers = metrics_headers(Some(&provider(0, 0, 0, false)));
361        assert_eq!(headers[0].1, "disabled");
362    }
363
364    /// The server rejects any status outside `hit|miss|disabled` with a 400
365    /// for SDK keys — this pins the value the SDK sends when L1 is on.
366    #[test]
367    fn status_is_a_server_accepted_value_when_l1_enabled() {
368        let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
369        let status = header(&headers, "X-CacheKit-L1-Status");
370        assert_eq!(status, "miss");
371        assert!(["hit", "miss", "disabled"].contains(&status));
372    }
373
374    #[test]
375    fn correct_hit_rate_calculation() {
376        let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
377        assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.300"); // 3 / (3+2+5)
378        assert_eq!(header(&headers, "X-CacheKit-L1-Hits"), "3");
379        assert_eq!(header(&headers, "X-CacheKit-L2-Hits"), "2");
380        assert_eq!(header(&headers, "X-CacheKit-Misses"), "5");
381    }
382
383    #[test]
384    fn zero_division_guard() {
385        let headers = metrics_headers(Some(&provider(0, 0, 0, true)));
386        assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.000");
387        assert_eq!(L1Stats::default().l1_hit_rate(), 0.0);
388    }
389
390    #[test]
391    fn disabled_when_provider_panics() {
392        #[allow(clippy::panic)]
393        let provider: MetricsProvider = Arc::new(|| panic!("boom"));
394        let headers = metrics_headers(Some(&provider));
395        assert_eq!(headers[0].1, "disabled");
396    }
397
398    #[test]
399    fn counters_record_and_snapshot() {
400        let counters = Arc::new(CacheCounters::new(true));
401        counters.record(ReadOutcome::L1Hit, "k");
402        counters.record(ReadOutcome::L1Stale, "k");
403        counters.record(ReadOutcome::L2Hit, "k");
404        counters.record(ReadOutcome::Miss, "k");
405        assert_eq!(
406            counters.snapshot(),
407            L1Stats {
408                l1_hits: 2,
409                l2_hits: 1,
410                misses: 1,
411                l1_enabled: true,
412            }
413        );
414        // The provider is a live view, not a copy...
415        let provider = counters.provider();
416        counters.record(ReadOutcome::Miss, "k");
417        assert_eq!(provider().map(|s| s.misses), Some(2));
418        // ...and a weak one: a dead client reports nothing, not stale numbers.
419        drop(counters);
420        assert_eq!(provider(), None);
421        assert_eq!(metrics_headers(Some(&provider))[0].1, "disabled");
422    }
423
424    /// Same digest as the File backend filename and cachekit-py's
425    /// `hashlib.blake2b(key, digest_size=16).hexdigest()`.
426    #[test]
427    fn key_hash_is_py_blake2b16_hex() {
428        assert_eq!(
429            key_hash("ns:app:func:m.f:args:abc:v1"),
430            "bcb35ae6f64fa65b2770ab3af631b1ce" // pragma: allowlist secret
431        );
432        assert_eq!(key_hash("").len(), 32);
433    }
434}