#![cfg_attr(not(feature = "observability"), allow(dead_code))]
use arc_swap::{ArcSwap, Guard};
use cedar_policy::{Diagnostics, PolicyId};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, SystemTime};
use crate::types::{Action, PermitPolicies};
#[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)
}
}
pub struct EvaluationObservation<'a> {
pub duration: Duration,
pub allowed: bool,
pub action: &'a Action,
pub phases: EvaluationPhases,
matched_policies: MatchedPolicySource<'a>,
}
pub(crate) enum MatchedPolicySource<'a> {
Allow(&'a PermitPolicies),
Deny {
diagnostics: &'a Diagnostics,
policy_ids: &'a HashMap<PolicyId, String>,
},
}
impl<'a> EvaluationObservation<'a> {
pub(crate) fn new(
duration: Duration,
allowed: bool,
action: &'a Action,
phases: EvaluationPhases,
matched_policies: MatchedPolicySource<'a>,
) -> Self {
Self {
duration,
allowed,
action,
phases,
matched_policies,
}
}
pub fn matched_policy_ids(&self) -> impl Iterator<Item = &str> + '_ {
let permit_policies = match &self.matched_policies {
MatchedPolicySource::Allow(policies) => Some(*policies),
MatchedPolicySource::Deny { .. } => None,
};
let forbid_source = match &self.matched_policies {
MatchedPolicySource::Allow(_) => None,
MatchedPolicySource::Deny {
diagnostics,
policy_ids,
} => Some((*diagnostics, *policy_ids)),
};
let permit_ids = permit_policies
.into_iter()
.flat_map(|policies| policies.iter().map(|policy| policy.id()));
let forbid_ids = forbid_source
.into_iter()
.flat_map(|(diagnostics, policy_ids)| {
diagnostics
.reason()
.filter_map(|id| policy_ids.get(id).map(String::as_str))
});
permit_ids.chain(forbid_ids)
}
pub fn to_owned_stats(&self) -> EvaluationStats {
let mut matched_policies = self
.matched_policy_ids()
.map(str::to_owned)
.collect::<Vec<_>>();
matched_policies.sort();
if !self.allowed {
matched_policies.dedup();
}
EvaluationStats {
duration: self.duration,
allowed: self.allowed,
action_id: self.action.to_string(),
matched_policies,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadStats {
pub reload_time: SystemTime,
}
pub trait MetricsSink: Send + Sync {
fn enabled(&self) -> bool {
true
}
fn on_evaluation_observation(&self, observation: &EvaluationObservation<'_>) {
let stats = observation.to_owned_stats();
self.on_evaluation(&stats);
self.on_evaluation_phases(&stats, &observation.phases);
}
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)
}
#[cfg(any(test, feature = "bench-internal"))]
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_evaluation_observation(
sink: &SinkGuard,
observation: &EvaluationObservation<'_>,
) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sink.on_evaluation_observation(observation);
}));
}
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);
}
}