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}