#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Outcome {
Success,
Miss,
RateLimited,
Invalid,
Replay,
Error,
}
impl Outcome {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Outcome::Success => "success",
Outcome::Miss => "miss",
Outcome::RateLimited => "rate_limited",
Outcome::Invalid => "invalid",
Outcome::Replay => "replay",
Outcome::Error => "error",
}
}
}
pub mod counter {
pub const CODE_ISSUED: &str = "codlet_code_issue_total";
pub const CODE_REDEEM_ATTEMPT: &str = "codlet_code_redeem_attempt_total";
pub const CODE_CLAIM_WON: &str = "codlet_code_claim_won_total";
pub const CODE_CLAIM_LOST: &str = "codlet_code_claim_lost_total";
pub const FORM_TOKEN_CONSUME: &str = "codlet_form_token_consume_total";
pub const SESSION_ISSUED: &str = "codlet_session_issue_total";
pub const SESSION_VALIDATE: &str = "codlet_session_validate_total";
pub const RATE_LIMIT_BLOCKED: &str = "codlet_rate_limit_block_total";
}
pub trait MetricsObserver {
fn increment(&self, counter: &'static str, outcome: Option<Outcome>);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopMetrics;
impl MetricsObserver for NoopMetrics {
#[inline]
fn increment(&self, _counter: &'static str, _outcome: Option<Outcome>) {}
}
#[cfg(any(test, feature = "test-utils"))]
#[derive(Debug, Default)]
pub struct CapturingMetrics {
records: std::sync::Mutex<Vec<(&'static str, Option<Outcome>)>>,
}
#[cfg(any(test, feature = "test-utils"))]
impl CapturingMetrics {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn drain(&self) -> Vec<(&'static str, Option<Outcome>)> {
self.records.lock().unwrap().drain(..).collect()
}
pub fn count(&self, counter: &'static str) -> usize {
self.records
.lock()
.unwrap()
.iter()
.filter(|(c, _)| *c == counter)
.count()
}
}
#[cfg(any(test, feature = "test-utils"))]
impl MetricsObserver for CapturingMetrics {
fn increment(&self, counter: &'static str, outcome: Option<Outcome>) {
self.records.lock().unwrap().push((counter, outcome));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noop_metrics_is_zero_cost() {
let m = NoopMetrics;
for _ in 0..1000 {
m.increment(counter::CODE_ISSUED, None);
m.increment(counter::SESSION_VALIDATE, Some(Outcome::Miss));
}
}
#[test]
fn outcome_labels_are_stable() {
assert_eq!(Outcome::Success.label(), "success");
assert_eq!(Outcome::Miss.label(), "miss");
assert_eq!(Outcome::RateLimited.label(), "rate_limited");
assert_eq!(Outcome::Invalid.label(), "invalid");
assert_eq!(Outcome::Replay.label(), "replay");
assert_eq!(Outcome::Error.label(), "error");
}
#[test]
fn capturing_metrics_records_and_drains() {
let m = CapturingMetrics::new();
m.increment(counter::CODE_ISSUED, None);
m.increment(counter::CODE_CLAIM_WON, Some(Outcome::Success));
m.increment(counter::SESSION_VALIDATE, Some(Outcome::Miss));
assert_eq!(m.count(counter::CODE_ISSUED), 1);
assert_eq!(m.count(counter::SESSION_VALIDATE), 1);
let all = m.drain();
assert_eq!(all.len(), 3);
assert!(m.drain().is_empty());
}
#[test]
fn metric_names_contain_no_secret_vocabulary() {
let forbidden = [
"secret",
"key",
"hmac",
"pepper",
"code_value",
"subject_id",
];
for (name, _) in [
(counter::CODE_ISSUED, ()),
(counter::CODE_REDEEM_ATTEMPT, ()),
(counter::CODE_CLAIM_WON, ()),
(counter::CODE_CLAIM_LOST, ()),
(counter::FORM_TOKEN_CONSUME, ()),
(counter::SESSION_ISSUED, ()),
(counter::SESSION_VALIDATE, ()),
(counter::RATE_LIMIT_BLOCKED, ()),
] {
for word in forbidden {
assert!(
!name.contains(word),
"counter {name:?} contains sensitive word {word:?}"
);
}
}
}
}