use std::collections::{BTreeMap, HashSet};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use std::time::SystemTime;
pub const METRIC_RESPONSE_TIME: &str = "response_time";
pub const METRIC_REQUEST_COUNT: &str = "request_count";
pub const METRIC_ERROR_RATE: &str = "error_rate";
pub const METRIC_BANDWIDTH_USAGE: &str = "bandwidth_usage";
pub const METRIC_THREAT_LEVEL: &str = "threat_level";
pub const METRIC_BLOCK_RATE: &str = "block_rate";
pub const METRIC_CACHE_HIT_RATE: &str = "cache_hit_rate";
pub const METRIC_TYPE_VALUES: [&str; 7] = [
METRIC_RESPONSE_TIME,
METRIC_REQUEST_COUNT,
METRIC_ERROR_RATE,
METRIC_BANDWIDTH_USAGE,
METRIC_THREAT_LEVEL,
METRIC_BLOCK_RATE,
METRIC_CACHE_HIT_RATE,
];
pub type MetricHandler = Arc<dyn Fn(&SecurityMetric) + Send + Sync>;
#[derive(Debug, Clone, PartialEq)]
pub struct SecurityMetric {
pub timestamp: SystemTime,
pub metric_type: String,
pub value: f64,
pub endpoint: Option<String>,
pub tags: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default)]
pub struct MetricFilter {
muted_metric_types: HashSet<String>,
}
impl MetricFilter {
#[must_use]
pub fn new<I>(muted_metric_types: I) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
{
Self {
muted_metric_types: muted_metric_types.into_iter().map(Into::into).collect(),
}
}
#[must_use]
pub fn is_metric_allowed(&self, metric_type: &str) -> bool {
!self.muted_metric_types.contains(metric_type)
}
}
#[derive(Clone)]
pub struct MetricsCollector {
handler: Option<MetricHandler>,
enabled: bool,
filter: MetricFilter,
}
impl MetricsCollector {
#[must_use]
pub fn new(enabled: bool) -> Self {
Self {
handler: None,
enabled,
filter: MetricFilter {
muted_metric_types: HashSet::new(),
},
}
}
#[must_use]
pub fn with_handler(mut self, handler: MetricHandler) -> Self {
self.handler = Some(handler);
self
}
#[must_use]
pub fn with_filter(mut self, filter: MetricFilter) -> Self {
self.filter = filter;
self
}
pub fn send_metric(&self, metric_type: &str, value: f64, tags: BTreeMap<String, String>) {
let Some(handler) = &self.handler else {
return;
};
if !self.enabled {
return;
}
if !self.filter.is_metric_allowed(metric_type) {
return;
}
let metric = SecurityMetric {
timestamp: SystemTime::now(),
metric_type: metric_type.to_owned(),
value,
endpoint: None,
tags,
};
let _ = catch_unwind(AssertUnwindSafe(|| handler(&metric)));
}
pub fn collect_request_metrics(
&self,
endpoint: &str,
method: &str,
response_time: Option<f64>,
status_code: u16,
) {
if self.handler.is_none() || !self.enabled {
return;
}
let mut base_tags = BTreeMap::new();
base_tags.insert("endpoint".to_owned(), endpoint.to_owned());
base_tags.insert("method".to_owned(), method.to_owned());
if let Some(seconds) = response_time {
let mut tags = base_tags.clone();
tags.insert("status".to_owned(), status_code.to_string());
self.send_metric(METRIC_RESPONSE_TIME, seconds, tags);
}
self.send_metric(METRIC_REQUEST_COUNT, 1.0, base_tags.clone());
if status_code >= 400 {
let mut tags = base_tags;
tags.insert("status".to_owned(), status_code.to_string());
self.send_metric(METRIC_ERROR_RATE, 1.0, tags);
}
}
}
#[cfg(test)]
mod tests {
use super::{
METRIC_BANDWIDTH_USAGE, METRIC_BLOCK_RATE, METRIC_CACHE_HIT_RATE, METRIC_ERROR_RATE,
METRIC_REQUEST_COUNT, METRIC_RESPONSE_TIME, METRIC_THREAT_LEVEL, METRIC_TYPE_VALUES,
MetricFilter, MetricsCollector, SecurityMetric,
};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
type Seen = Arc<Mutex<Vec<SecurityMetric>>>;
fn recording_collector(enabled: bool) -> (MetricsCollector, Seen) {
let seen: Seen = Arc::new(Mutex::new(Vec::new()));
let sink = seen.clone();
let collector = MetricsCollector::new(enabled).with_handler(Arc::new(
move |metric: &SecurityMetric| {
sink.lock().expect("sink").push(metric.clone());
},
));
(collector, seen)
}
#[test]
fn the_seven_wire_metric_types_are_locked() {
assert_eq!(
METRIC_TYPE_VALUES,
[
METRIC_RESPONSE_TIME,
METRIC_REQUEST_COUNT,
METRIC_ERROR_RATE,
METRIC_BANDWIDTH_USAGE,
METRIC_THREAT_LEVEL,
METRIC_BLOCK_RATE,
METRIC_CACHE_HIT_RATE,
]
);
assert_eq!(
METRIC_TYPE_VALUES,
[
"response_time",
"request_count",
"error_rate",
"bandwidth_usage",
"threat_level",
"block_rate",
"cache_hit_rate"
]
);
}
#[test]
fn a_disabled_collector_never_sends() {
let (collector, seen) = recording_collector(false);
collector.send_metric(METRIC_REQUEST_COUNT, 1.0, BTreeMap::new());
assert!(seen.lock().expect("sink").is_empty());
}
#[test]
fn a_disabled_collector_never_collects_request_metrics() {
let (collector, seen) = recording_collector(false);
collector.collect_request_metrics("/api", "GET", Some(0.5), 500);
assert!(seen.lock().expect("sink").is_empty());
}
#[test]
fn a_handlerless_collector_never_sends() {
let collector = MetricsCollector::new(true);
collector.send_metric(METRIC_REQUEST_COUNT, 1.0, BTreeMap::new());
}
#[test]
fn a_handlerless_collector_never_collects_request_metrics() {
let collector = MetricsCollector::new(true);
collector.collect_request_metrics("/api", "GET", Some(0.5), 500);
}
#[test]
fn a_panicking_transport_cannot_break_the_caller() {
let collector = MetricsCollector::new(true).with_handler(Arc::new(|_: &SecurityMetric| {
panic!("transport exploded");
}));
collector.send_metric(METRIC_REQUEST_COUNT, 1.0, BTreeMap::new());
}
#[test]
fn a_panicking_transport_cannot_break_collect_request_metrics() {
let collector = MetricsCollector::new(true).with_handler(Arc::new(|_: &SecurityMetric| {
panic!("transport exploded");
}));
collector.collect_request_metrics("/api", "GET", Some(0.5), 500);
}
#[test]
fn muted_metric_types_never_reach_the_handler() {
let (collector, seen) = recording_collector(true);
let collector = collector.with_filter(MetricFilter::new([METRIC_REQUEST_COUNT]));
collector.send_metric(METRIC_REQUEST_COUNT, 1.0, BTreeMap::new());
collector.send_metric(METRIC_ERROR_RATE, 1.0, BTreeMap::new());
let seen = seen.lock().expect("sink").clone();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].metric_type, METRIC_ERROR_RATE);
}
#[test]
fn request_metrics_carry_the_reference_tags_and_counts() {
let (collector, seen) = recording_collector(true);
collector.collect_request_metrics("/api", "GET", Some(0.5), 200);
let collected = seen.lock().expect("sink").clone();
let seen = &collected;
assert_eq!(seen.len(), 2, "response_time + request_count, no error");
assert_eq!(seen[0].metric_type, METRIC_RESPONSE_TIME);
assert!((seen[0].value - 0.5).abs() < f64::EPSILON);
assert_eq!(seen[0].tags.get("status").map(String::as_str), Some("200"));
assert_eq!(seen[1].metric_type, METRIC_REQUEST_COUNT);
assert!((seen[1].value - 1.0).abs() < f64::EPSILON);
assert_eq!(
seen[1].tags.get("endpoint").map(String::as_str),
Some("/api")
);
assert_eq!(seen[1].tags.get("method").map(String::as_str), Some("GET"));
assert!(!seen[1].tags.contains_key("status"));
}
#[test]
fn an_error_status_adds_the_error_rate_sample() {
let (collector, seen) = recording_collector(true);
collector.collect_request_metrics("/api", "GET", Some(0.5), 404);
let seen = seen.lock().expect("sink").clone();
assert_eq!(seen.len(), 3, "response_time + request_count + error_rate");
assert_eq!(seen[2].metric_type, METRIC_ERROR_RATE);
assert_eq!(seen[2].tags.get("status").map(String::as_str), Some("404"));
}
#[test]
fn an_unmeasured_response_skips_the_response_time_sample() {
let (collector, seen) = recording_collector(true);
collector.collect_request_metrics("/api", "GET", None, 200);
let seen = seen.lock().expect("sink").clone();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].metric_type, METRIC_REQUEST_COUNT);
}
}