Skip to main content

codlet_core/
metrics.rs

1//! Structured, redacted observability hooks (RFC-024).
2//!
3//! [`MetricsObserver`] is an optional, no-op-by-default trait for counters and
4//! outcome tracking. Implementations must never include plaintext secrets,
5//! lookup keys, subject IDs, or IP addresses in metric labels (RFC-024 §redaction).
6//!
7//! The recommended metric names follow a `codlet_<noun>_<verb>_total` pattern
8//! (RFC-024 §metrics). High-cardinality labels (code IDs, user IDs, raw
9//! scopes) must not be used as metric dimensions.
10//!
11//! ## Usage
12//!
13//! ```rust
14//! use codlet_core::metrics::{MetricsObserver, NoopMetrics, Outcome};
15//!
16//! struct MyMetrics;
17//! impl MetricsObserver for MyMetrics {
18//!     fn increment(&self, counter: &'static str, outcome: Option<Outcome>) {
19//!         // forward to your metrics backend (prometheus, statsd, …)
20//!         let _ = (counter, outcome);
21//!     }
22//! }
23//! ```
24
25/// Outcome label for metrics that distinguish result categories.
26///
27/// Uses stable string values so metric dimensions don't change between
28/// codlet versions.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum Outcome {
31    /// Code issue, claim won, session issue, form-token Proceed.
32    Success,
33    /// Claim lost, session missing/expired/revoked.
34    Miss,
35    /// Rate-limit threshold exceeded.
36    RateLimited,
37    /// Invalid input, wrong binding, expired token.
38    Invalid,
39    /// Replay detected on form-token or idempotency path.
40    Replay,
41    /// Transient store or key error.
42    Error,
43}
44
45impl Outcome {
46    /// Stable string label for use in metric dimensions.
47    #[must_use]
48    pub const fn label(self) -> &'static str {
49        match self {
50            Outcome::Success => "success",
51            Outcome::Miss => "miss",
52            Outcome::RateLimited => "rate_limited",
53            Outcome::Invalid => "invalid",
54            Outcome::Replay => "replay",
55            Outcome::Error => "error",
56        }
57    }
58}
59
60/// Recommended counter names (RFC-024 §metrics).
61///
62/// Use these constants as the `counter` argument to
63/// [`MetricsObserver::increment`] so metric names stay consistent across
64/// adapters and host integrations.
65pub mod counter {
66    /// A one-time code was successfully issued.
67    pub const CODE_ISSUED: &str = "codlet_code_issue_total";
68    /// A code redemption was attempted (normalised and looked up).
69    pub const CODE_REDEEM_ATTEMPT: &str = "codlet_code_redeem_attempt_total";
70    /// The atomic claim succeeded (exactly one winner).
71    pub const CODE_CLAIM_WON: &str = "codlet_code_claim_won_total";
72    /// The atomic claim was lost to a concurrent caller.
73    pub const CODE_CLAIM_LOST: &str = "codlet_code_claim_lost_total";
74    /// A form-token consume call completed (use `outcome` to distinguish).
75    pub const FORM_TOKEN_CONSUME: &str = "codlet_form_token_consume_total";
76    /// A session was successfully issued.
77    pub const SESSION_ISSUED: &str = "codlet_session_issue_total";
78    /// A session validation attempt completed.
79    pub const SESSION_VALIDATE: &str = "codlet_session_validate_total";
80    /// A rate-limit check blocked an operation.
81    pub const RATE_LIMIT_BLOCKED: &str = "codlet_rate_limit_block_total";
82}
83
84/// Optional observability sink for metrics and counters (RFC-024).
85///
86/// All implementations must be no-op by default (see [`NoopMetrics`]).
87/// Implementations must not include high-cardinality or sensitive values in
88/// metric labels — no code IDs, subject IDs, IP addresses, lookup keys, or
89/// raw scopes.
90pub trait MetricsObserver {
91    /// Increment `counter` by 1, optionally tagging with `outcome`.
92    ///
93    /// Counter names should come from the [`counter`] module constants.
94    /// This method is called in hot paths; it must not block.
95    fn increment(&self, counter: &'static str, outcome: Option<Outcome>);
96}
97
98/// A no-op metrics observer. Compiles to nothing.
99#[derive(Debug, Default, Clone, Copy)]
100pub struct NoopMetrics;
101
102impl MetricsObserver for NoopMetrics {
103    #[inline]
104    fn increment(&self, _counter: &'static str, _outcome: Option<Outcome>) {}
105}
106
107/// A metrics observer that records increments in a `Vec` for inspection
108/// in tests. Available under the `test-utils` feature.
109#[cfg(any(test, feature = "test-utils"))]
110#[derive(Debug, Default)]
111pub struct CapturingMetrics {
112    records: std::sync::Mutex<Vec<(&'static str, Option<Outcome>)>>,
113}
114
115#[cfg(any(test, feature = "test-utils"))]
116impl CapturingMetrics {
117    /// Construct an empty capturing observer.
118    #[must_use]
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Return all captured `(counter, outcome)` pairs.
124    pub fn drain(&self) -> Vec<(&'static str, Option<Outcome>)> {
125        self.records.lock().unwrap().drain(..).collect()
126    }
127
128    /// Count how many times `counter` was incremented.
129    pub fn count(&self, counter: &'static str) -> usize {
130        self.records
131            .lock()
132            .unwrap()
133            .iter()
134            .filter(|(c, _)| *c == counter)
135            .count()
136    }
137}
138
139#[cfg(any(test, feature = "test-utils"))]
140impl MetricsObserver for CapturingMetrics {
141    fn increment(&self, counter: &'static str, outcome: Option<Outcome>) {
142        self.records.lock().unwrap().push((counter, outcome));
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn noop_metrics_is_zero_cost() {
152        let m = NoopMetrics;
153        // Should compile to nothing; just verify it doesn't panic.
154        for _ in 0..1000 {
155            m.increment(counter::CODE_ISSUED, None);
156            m.increment(counter::SESSION_VALIDATE, Some(Outcome::Miss));
157        }
158    }
159
160    #[test]
161    fn outcome_labels_are_stable() {
162        assert_eq!(Outcome::Success.label(), "success");
163        assert_eq!(Outcome::Miss.label(), "miss");
164        assert_eq!(Outcome::RateLimited.label(), "rate_limited");
165        assert_eq!(Outcome::Invalid.label(), "invalid");
166        assert_eq!(Outcome::Replay.label(), "replay");
167        assert_eq!(Outcome::Error.label(), "error");
168    }
169
170    #[test]
171    fn capturing_metrics_records_and_drains() {
172        let m = CapturingMetrics::new();
173        m.increment(counter::CODE_ISSUED, None);
174        m.increment(counter::CODE_CLAIM_WON, Some(Outcome::Success));
175        m.increment(counter::SESSION_VALIDATE, Some(Outcome::Miss));
176        assert_eq!(m.count(counter::CODE_ISSUED), 1);
177        assert_eq!(m.count(counter::SESSION_VALIDATE), 1);
178        let all = m.drain();
179        assert_eq!(all.len(), 3);
180        assert!(m.drain().is_empty());
181    }
182
183    #[test]
184    fn metric_names_contain_no_secret_vocabulary() {
185        // Guard: counter names must not contain words that suggest they carry
186        // sensitive data. Metric label names are logged and exported.
187        let forbidden = [
188            "secret",
189            "key",
190            "hmac",
191            "pepper",
192            "code_value",
193            "subject_id",
194        ];
195        for (name, _) in [
196            (counter::CODE_ISSUED, ()),
197            (counter::CODE_REDEEM_ATTEMPT, ()),
198            (counter::CODE_CLAIM_WON, ()),
199            (counter::CODE_CLAIM_LOST, ()),
200            (counter::FORM_TOKEN_CONSUME, ()),
201            (counter::SESSION_ISSUED, ()),
202            (counter::SESSION_VALIDATE, ()),
203            (counter::RATE_LIMIT_BLOCKED, ()),
204        ] {
205            for word in forbidden {
206                assert!(
207                    !name.contains(word),
208                    "counter {name:?} contains sensitive word {word:?}"
209                );
210            }
211        }
212    }
213}