#[cfg(feature = "std")]
use std::sync::{Arc, LazyLock};
pub trait MetricsSink: Send + Sync + core::fmt::Debug {
fn inc_counter(&self, name: &str, value: u64);
fn set_gauge(&self, name: &str, value: f64);
fn observe_histogram(&self, name: &str, value: f64);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopSink;
impl MetricsSink for NoopSink {
#[inline]
fn inc_counter(&self, _name: &str, _value: u64) {}
#[inline]
fn set_gauge(&self, _name: &str, _value: f64) {}
#[inline]
fn observe_histogram(&self, _name: &str, _value: f64) {}
}
#[cfg(feature = "std")]
static SHARED_NOOP_SINK: LazyLock<Arc<dyn MetricsSink>> = LazyLock::new(|| Arc::new(NoopSink));
#[cfg(feature = "std")]
#[must_use]
pub fn default_sink() -> Arc<dyn MetricsSink> {
Arc::clone(&SHARED_NOOP_SINK)
}
#[cfg(feature = "std")]
#[derive(Debug, Default)]
pub struct TestSink {
inner: std::sync::Mutex<TestSinkInner>,
}
#[cfg(feature = "std")]
#[derive(Debug, Default, Clone)]
pub struct TestSinkInner {
pub counters: std::collections::HashMap<String, u64>,
pub gauges: std::collections::HashMap<String, f64>,
pub histograms: std::collections::HashMap<String, Vec<f64>>,
}
#[cfg(feature = "std")]
impl TestSink {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn snapshot(&self) -> TestSinkInner {
self.lock_inner().clone()
}
#[must_use]
pub fn counter(&self, name: &str) -> u64 {
*self.lock_inner().counters.get(name).unwrap_or(&0)
}
#[must_use]
pub fn gauge(&self, name: &str) -> Option<f64> {
self.lock_inner().gauges.get(name).copied()
}
#[must_use]
pub fn histogram(&self, name: &str) -> Vec<f64> {
self.lock_inner()
.histograms
.get(name)
.cloned()
.unwrap_or_default()
}
fn lock_inner(&self) -> std::sync::MutexGuard<'_, TestSinkInner> {
self.inner
.lock()
.expect("TestSink mutex poisoned — another thread panicked holding it")
}
}
#[cfg(feature = "std")]
impl MetricsSink for TestSink {
fn inc_counter(&self, name: &str, value: u64) {
let mut guard = self.lock_inner();
*guard.counters.entry(name.to_string()).or_insert(0) = guard
.counters
.get(name)
.copied()
.unwrap_or(0)
.saturating_add(value);
}
fn set_gauge(&self, name: &str, value: f64) {
let mut guard = self.lock_inner();
guard.gauges.insert(name.to_string(), value);
}
fn observe_histogram(&self, name: &str, value: f64) {
let mut guard = self.lock_inner();
guard
.histograms
.entry(name.to_string())
.or_default()
.push(value);
}
}
pub mod names {
pub const UPDATES_TOTAL: &str = "rcf_updates_total";
pub const PROCESS_TOTAL: &str = "rcf_process_total";
pub const ANOMALIES_FIRED_TOTAL: &str = "rcf_anomalies_fired_total";
pub const DRIFT_FIRES_TOTAL: &str = "rcf_drift_fires_total";
pub const DRIFT_UP_TOTAL: &str = "rcf_drift_up_total";
pub const DRIFT_DOWN_TOTAL: &str = "rcf_drift_down_total";
pub const DELETES_TOTAL: &str = "rcf_deletes_total";
pub const ATTRIBUTION_TOTAL: &str = "rcf_attribution_total";
pub const REJECTED_NAN_TOTAL: &str = "rcf_rejected_nan_total";
pub const EARLY_TERM_STOPPED_TOTAL: &str = "rcf_early_term_stopped_total";
pub const TENANT_EVICTIONS_TOTAL: &str = "rcf_tenant_evictions_total";
pub const TENANT_IDLE_EVICTIONS_TOTAL: &str = "rcf_tenant_idle_evictions_total";
pub const TENANT_CREATED_TOTAL: &str = "rcf_tenant_created_total";
pub const BOOTSTRAP_POINTS_TOTAL: &str = "rcf_bootstrap_points_total";
pub const BOOTSTRAP_SKIPPED_TOTAL: &str = "rcf_bootstrap_skipped_total";
pub const FEATURE_DRIFT_OBSERVED_TOTAL: &str = "rcf_feature_drift_observed_total";
pub const ALERTS_OBSERVED_TOTAL: &str = "rcf_alerts_observed_total";
pub const ALERT_CLUSTERS_NEW_TOTAL: &str = "rcf_alert_clusters_new_total";
pub const ALERT_CLUSTERS_JOINED_TOTAL: &str = "rcf_alert_clusters_joined_total";
pub const ALERT_CLUSTERS_PRUNED_TOTAL: &str = "rcf_alert_clusters_pruned_total";
pub const FOREST_TREES: &str = "rcf_forest_trees";
pub const THRESHOLD_CURRENT: &str = "rcf_threshold_current";
pub const EMA_MEAN: &str = "rcf_ema_mean";
pub const EMA_STDDEV: &str = "rcf_ema_stddev";
pub const OBSERVATIONS_SEEN: &str = "rcf_observations_seen";
pub const TENANTS_RESIDENT: &str = "rcf_tenants_resident";
pub const TENANT_CAPACITY: &str = "rcf_tenant_capacity";
pub const ALERT_CLUSTERS_ACTIVE: &str = "rcf_alert_clusters_active";
pub const FEATURE_DRIFT_MAX_PSI: &str = "rcf_feature_drift_max_psi";
pub const SCORE_OBSERVATION: &str = "rcf_score";
pub const GRADE_OBSERVATION: &str = "rcf_grade";
pub const DRIFT_S_HIGH: &str = "rcf_drift_s_high";
pub const DRIFT_S_LOW: &str = "rcf_drift_s_low";
pub const EARLY_TERM_TREES: &str = "rcf_early_term_trees";
pub const HOT_PATH_SAMPLER_ACCEPTED_TOTAL: &str = "rcf_hot_path_sampler_accepted_total";
pub const HOT_PATH_SAMPLER_REJECTED_TOTAL: &str = "rcf_hot_path_sampler_rejected_total";
pub const HOT_PATH_QUEUE_ENQUEUED_TOTAL: &str = "rcf_hot_path_queue_enqueued_total";
pub const HOT_PATH_QUEUE_DROPPED_TOTAL: &str = "rcf_hot_path_queue_dropped_total";
pub const HOT_PATH_PREFIX_ADMITTED_TOTAL: &str = "rcf_hot_path_prefix_admitted_total";
pub const HOT_PATH_PREFIX_CAPPED_TOTAL: &str = "rcf_hot_path_prefix_capped_total";
pub const DRIFT_AWARE_SWAPS_TOTAL: &str = "rcf_drift_aware_swaps_total";
pub const DRIFT_AWARE_ON_DRIFT_TOTAL: &str = "rcf_drift_aware_on_drift_total";
pub const DRIFT_AWARE_SHADOW_ACTIVE: &str = "rcf_drift_aware_shadow_active";
pub const ADWIN_OBSERVED_TOTAL: &str = "rcf_adwin_observed_total";
pub const ADWIN_DRIFT_FIRES_TOTAL: &str = "rcf_adwin_drift_fires_total";
pub const LSH_ALERTS_OBSERVED_TOTAL: &str = "rcf_lsh_alerts_observed_total";
pub const LSH_CLUSTERS_NEW_TOTAL: &str = "rcf_lsh_clusters_new_total";
pub const LSH_CLUSTERS_JOINED_TOTAL: &str = "rcf_lsh_clusters_joined_total";
pub const LSH_CLUSTERS_ACTIVE: &str = "rcf_lsh_clusters_active";
pub const FEEDBACK_LABELS_OBSERVED_TOTAL: &str = "rcf_feedback_labels_observed_total";
pub const FEEDBACK_LABELS_BENIGN_TOTAL: &str = "rcf_feedback_labels_benign_total";
pub const FEEDBACK_LABELS_CONFIRMED_TOTAL: &str = "rcf_feedback_labels_confirmed_total";
pub const SPOT_OBSERVATIONS_TOTAL: &str = "rcf_spot_observations_total";
pub const SPOT_PEAKS_TOTAL: &str = "rcf_spot_peaks_total";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noop_sink_is_noop() {
let s = NoopSink;
s.inc_counter("x", 1);
s.set_gauge("y", 2.0);
s.observe_histogram("z", 3.0);
}
#[test]
fn default_sink_builds_noop_arc() {
let s = default_sink();
s.inc_counter("x", 1);
}
#[test]
fn test_sink_records_counter_gauge_histogram() {
let s = TestSink::new();
s.inc_counter("a", 3);
s.inc_counter("a", 4);
s.set_gauge("b", 1.25);
s.set_gauge("b", 2.5);
s.observe_histogram("c", 0.1);
s.observe_histogram("c", 0.2);
assert_eq!(s.counter("a"), 7);
assert_eq!(s.gauge("b"), Some(2.5));
assert_eq!(s.histogram("c"), vec![0.1, 0.2]);
}
#[test]
fn test_sink_unseen_metrics_default() {
let s = TestSink::new();
assert_eq!(s.counter("nope"), 0);
assert!(s.gauge("nope").is_none());
assert!(s.histogram("nope").is_empty());
}
#[test]
fn default_sink_returns_shared_arc() {
let a = default_sink();
let b = default_sink();
assert!(
Arc::ptr_eq(&a, &b),
"default_sink() should clone a process-wide shared Arc"
);
}
#[test]
fn default_sink_hits_strong_count_greater_than_one() {
let pre = Arc::strong_count(&default_sink());
let _pins: Vec<Arc<dyn MetricsSink>> = (0..16).map(|_| default_sink()).collect();
let post = Arc::strong_count(&default_sink());
assert!(post >= pre + 16);
}
}