#![cfg_attr(not(feature = "observability"), allow(dead_code))]
use arc_swap::{ArcSwap, Guard};
use serde::Serialize;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Serialize)]
pub struct EvaluationStats {
pub duration: Duration,
pub allowed: bool,
pub action_id: String,
pub matched_policies: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EvaluationPhases {
pub apply_labels_ms: f64,
pub construct_entities_ms: f64,
pub resolve_groups_ms: f64,
pub authorize_ms: f64,
pub total_ms: f64,
}
impl EvaluationPhases {
pub fn overhead_ms(&self) -> f64 {
(self.total_ms
- (self.apply_labels_ms
+ self.construct_entities_ms
+ self.resolve_groups_ms
+ self.authorize_ms))
.max(0.0)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadStats {
pub reload_time: SystemTime,
}
pub trait MetricsSink: Send + Sync {
fn enabled(&self) -> bool {
true
}
fn on_evaluation(&self, stats: &EvaluationStats);
fn on_reload(&self, stats: &ReloadStats);
fn on_evaluation_phases(&self, _stats: &EvaluationStats, _phases: &EvaluationPhases) {
}
}
struct NoOpSink;
impl MetricsSink for NoOpSink {
fn enabled(&self) -> bool {
false
}
fn on_evaluation(&self, _stats: &EvaluationStats) {}
fn on_reload(&self, _stats: &ReloadStats) {}
}
static SINK: OnceLock<ArcSwap<Arc<dyn MetricsSink>>> = OnceLock::new();
pub(crate) type SinkGuard = Guard<Arc<Arc<dyn MetricsSink>>>;
fn sink() -> SinkGuard {
SINK.get_or_init(|| {
let default: Arc<dyn MetricsSink> = Arc::new(NoOpSink);
ArcSwap::from(Arc::new(default))
})
.load()
}
pub fn set_sink(sink: Arc<dyn MetricsSink>) {
SINK.get_or_init(|| {
let default: Arc<dyn MetricsSink> = Arc::new(NoOpSink);
ArcSwap::from(Arc::new(default))
})
.store(Arc::new(sink));
}
pub(crate) fn get_sink() -> SinkGuard {
sink()
}
pub(crate) fn metrics_enabled(sink: &SinkGuard) -> bool {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| sink.enabled())).unwrap_or(false)
}
pub(crate) fn record_evaluation_with_phases(
sink: &SinkGuard,
stats: &EvaluationStats,
phases: &EvaluationPhases,
) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sink.on_evaluation(stats);
sink.on_evaluation_phases(stats, phases);
}));
}
pub(crate) fn record_reload() {
let sink = get_sink();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sink.on_reload(&ReloadStats {
reload_time: SystemTime::now(),
});
}));
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
struct TestSink {
eval_count: AtomicU64,
allow_count: AtomicU64,
deny_count: AtomicU64,
reload_count: AtomicU64,
last_eval_duration: Mutex<Option<Duration>>,
was_called: AtomicBool,
}
impl TestSink {
fn new() -> Self {
Self {
eval_count: AtomicU64::new(0),
allow_count: AtomicU64::new(0),
deny_count: AtomicU64::new(0),
reload_count: AtomicU64::new(0),
last_eval_duration: Mutex::new(None),
was_called: AtomicBool::new(false),
}
}
}
impl MetricsSink for TestSink {
fn on_evaluation(&self, stats: &EvaluationStats) {
self.was_called.store(true, Ordering::SeqCst);
self.eval_count.fetch_add(1, Ordering::SeqCst);
if stats.allowed {
self.allow_count.fetch_add(1, Ordering::SeqCst);
} else {
self.deny_count.fetch_add(1, Ordering::SeqCst);
}
if let Ok(mut d) = self.last_eval_duration.lock() {
*d = Some(stats.duration);
}
}
fn on_reload(&self, _stats: &ReloadStats) {
self.reload_count.fetch_add(1, Ordering::SeqCst);
}
}
struct ReloadOnlySink {
reload_count: AtomicU64,
}
impl MetricsSink for ReloadOnlySink {
fn enabled(&self) -> bool {
false
}
fn on_evaluation(&self, _stats: &EvaluationStats) {
panic!("evaluation callbacks must be disabled");
}
fn on_reload(&self, _stats: &ReloadStats) {
self.reload_count.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn test_evaluation_stats_serialization() {
let stats = EvaluationStats {
duration: Duration::from_millis(42),
allowed: true,
action_id: r#"Action::"view_host""#.to_string(),
matched_policies: vec!["policy0".to_string()],
};
let json = serde_json::to_string(&stats).unwrap();
assert!(json.contains("42") || json.contains("0.042")); assert!(json.contains("true"));
assert!(json.contains(r#"Action::\"view_host\""#));
assert!(json.contains("policy0"));
}
#[test]
fn test_reload_stats_serialization() {
let now = SystemTime::now();
let stats = ReloadStats { reload_time: now };
let json = serde_json::to_string(&stats).unwrap();
assert!(!json.is_empty());
}
#[test]
#[serial(metrics)]
fn test_record_reload_dispatches_to_current_sink() {
let sink = Arc::new(TestSink::new());
set_sink(sink.clone());
record_reload();
assert!(sink.reload_count.load(Ordering::SeqCst) >= 1);
}
#[test]
#[serial(metrics)]
fn disabling_evaluations_does_not_disable_reload_events() {
let sink = Arc::new(ReloadOnlySink {
reload_count: AtomicU64::new(0),
});
set_sink(sink.clone());
record_reload();
assert!(sink.reload_count.load(Ordering::SeqCst) >= 1);
}
#[test]
fn test_noop_sink_impl() {
let sink = NoOpSink;
let stats = EvaluationStats {
duration: Duration::from_micros(1),
allowed: true,
action_id: r#"Action::"view_host""#.to_string(),
matched_policies: vec![],
};
sink.on_evaluation(&stats);
let reload_stats = ReloadStats {
reload_time: SystemTime::now(),
};
sink.on_reload(&reload_stats);
}
#[test]
fn test_evaluation_stats_clone() {
let stats1 = EvaluationStats {
duration: Duration::from_secs(1),
allowed: false,
action_id: r#"Action::"delete_host""#.to_string(),
matched_policies: vec!["policy1".to_string()],
};
let stats2 = stats1.clone();
assert_eq!(stats1.duration, stats2.duration);
assert_eq!(stats1.allowed, stats2.allowed);
assert_eq!(stats1.action_id, stats2.action_id);
assert_eq!(stats1.matched_policies, stats2.matched_policies);
}
#[test]
fn test_reload_stats_clone() {
let now = SystemTime::now();
let stats1 = ReloadStats { reload_time: now };
let stats2 = stats1.clone();
assert_eq!(stats1.reload_time, stats2.reload_time);
}
#[test]
fn test_evaluation_stats_debug() {
let stats = EvaluationStats {
duration: Duration::from_micros(250),
allowed: true,
action_id: r#"Action::"view_host""#.to_string(),
matched_policies: vec![],
};
let debug_str = format!("{:?}", stats);
assert!(debug_str.contains("EvaluationStats"));
assert!(debug_str.contains("true"));
assert!(debug_str.contains("view_host"));
}
#[test]
fn test_reload_stats_debug() {
let stats = ReloadStats {
reload_time: SystemTime::now(),
};
let debug_str = format!("{:?}", stats);
assert!(debug_str.contains("ReloadStats"));
}
#[test]
#[serial(metrics)]
fn test_dynamic_sink_swapping() {
let sink1 = Arc::new(TestSink::new());
let sink2 = Arc::new(TestSink::new());
set_sink(sink1.clone());
let stats1 = EvaluationStats {
duration: Duration::from_millis(10),
allowed: true,
action_id: r#"Action::"view_host""#.to_string(),
matched_policies: vec![],
};
let phases = EvaluationPhases {
apply_labels_ms: 0.0,
construct_entities_ms: 0.0,
resolve_groups_ms: 0.0,
authorize_ms: 0.0,
total_ms: 10.0,
};
record_evaluation_with_phases(&get_sink(), &stats1, &phases);
set_sink(sink2.clone());
let stats2 = EvaluationStats {
duration: Duration::from_millis(20),
allowed: false,
action_id: r#"Action::"delete_host""#.to_string(),
matched_policies: vec![],
};
record_evaluation_with_phases(&get_sink(), &stats2, &phases);
assert!(sink1.eval_count.load(Ordering::SeqCst) >= 1);
assert!(sink2.eval_count.load(Ordering::SeqCst) >= 1);
}
}