use crate::error::DriftError;
use chrono::{DateTime, Utc};
use scouter_dispatch::AlertDispatcher;
use scouter_sql::sql::traits::CustomMetricSqlLogic;
use scouter_sql::{sql::cache::entity_cache, PostgresClient};
use scouter_types::custom::{ComparisonMetricAlert, CustomDriftProfile};
use scouter_types::{AlertMap, ProfileBaseArgs};
use sqlx::{Pool, Postgres};
use std::collections::HashMap;
use tracing::error;
use tracing::info;
pub struct CustomDrifter {
profile: CustomDriftProfile,
}
impl CustomDrifter {
pub fn new(profile: CustomDriftProfile) -> Self {
Self { profile }
}
fn profile_id(&self) -> String {
format!(
"{}/{}/{}",
self.profile.space(),
self.profile.name(),
self.profile.version()
)
}
pub async fn get_observed_custom_metric_values(
&self,
limit_datetime: &DateTime<Utc>,
db_pool: &Pool<Postgres>,
) -> Result<HashMap<String, f64>, DriftError> {
let metrics: Vec<String> = self.profile.metrics.keys().cloned().collect();
let entity_id = entity_cache()
.get_entity_id_from_uid(db_pool, &self.profile.config.uid)
.await?;
PostgresClient::get_custom_metric_values(db_pool, limit_datetime, &metrics, &entity_id)
.await
.inspect_err(|e| {
error!(
"Unable to obtain custom metric data from DB for {}: {}",
self.profile_id(),
e
);
})
.map_err(Into::into)
}
pub async fn get_metric_map(
&self,
limit_datetime: &DateTime<Utc>,
db_pool: &Pool<Postgres>,
) -> Result<Option<HashMap<String, f64>>, DriftError> {
let metric_map = self
.get_observed_custom_metric_values(limit_datetime, db_pool)
.await?;
if metric_map.is_empty() {
info!(
"No custom metric data found for {}. Skipping alert processing.",
self.profile_id()
);
return Ok(None);
}
Ok(Some(metric_map))
}
pub async fn generate_alerts(
&self,
metric_map: &HashMap<String, f64>,
) -> Result<Option<Vec<AlertMap>>, DriftError> {
let Some(alert_conditions) = &self.profile.config.alert_config.alert_conditions else {
info!(
"No alert conditions configured for {}. Skipping alert processing.",
self.profile_id()
);
return Ok(None);
};
let metric_alerts: Vec<ComparisonMetricAlert> = metric_map
.iter()
.filter_map(|(name, observed_value)| {
let alert_condition = alert_conditions.get(name)?;
if alert_condition.should_alert(*observed_value) {
Some(ComparisonMetricAlert {
metric_name: name.clone(),
baseline_value: alert_condition.baseline_value,
observed_value: *observed_value,
delta: alert_condition.delta,
alert_threshold: alert_condition.alert_threshold.clone(),
})
} else {
None
}
})
.collect();
if metric_alerts.is_empty() {
info!(
"No alerts to process for {} (checked {} metrics)",
self.profile_id(),
metric_map.len()
);
return Ok(None);
}
let alert_dispatcher = AlertDispatcher::new(&self.profile.config).inspect_err(|e| {
error!(
"Error creating alert dispatcher for {}: {}",
self.profile_id(),
e
);
})?;
for alert in &metric_alerts {
alert_dispatcher
.process_alerts(alert)
.await
.inspect_err(|e| {
error!(
"Error processing alert for metric '{}' in {}: {}",
alert.metric_name,
self.profile_id(),
e
);
})?;
}
Ok(Some(metric_alerts.into_iter().map(|a| a.into()).collect()))
}
pub async fn check_for_alerts(
&self,
db_pool: &Pool<Postgres>,
previous_run: &DateTime<Utc>,
) -> Result<Option<Vec<AlertMap>>, DriftError> {
let Some(metric_map) = self.get_metric_map(previous_run, db_pool).await? else {
return Ok(None);
};
self.generate_alerts(&metric_map).await.inspect_err(|e| {
error!("Error generating alerts for {}: {}", self.profile_id(), e);
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use scouter_types::custom::{CustomMetric, CustomMetricAlertConfig, CustomMetricDriftConfig};
use scouter_types::AlertThreshold;
fn get_test_drifter() -> CustomDrifter {
let custom_metrics = vec![
CustomMetric::new("mse", 12.02, AlertThreshold::Above, Some(1.0)).unwrap(),
CustomMetric::new("accuracy", 0.75, AlertThreshold::Below, None).unwrap(),
];
let drift_config = CustomMetricDriftConfig::new(
"scouter",
"model",
"0.1.0",
25,
CustomMetricAlertConfig::default(),
None,
)
.unwrap();
let profile = CustomDriftProfile::new(drift_config, custom_metrics).unwrap();
CustomDrifter::new(profile)
}
#[tokio::test]
async fn test_generate_alerts_triggers_for_out_of_bounds_metrics() {
let drifter = get_test_drifter();
let mut metric_map = HashMap::new();
metric_map.insert("mse".to_string(), 14.0);
metric_map.insert("accuracy".to_string(), 0.65);
let alerts = drifter
.generate_alerts(&metric_map)
.await
.expect("Should generate alerts successfully")
.expect("Should have alerts");
assert_eq!(alerts.len(), 2, "Should generate 2 alerts");
let has_mse_alert = alerts
.iter()
.any(|a| matches!(a, AlertMap::Custom(alert) if alert.metric_name == "mse"));
let has_accuracy_alert = alerts
.iter()
.any(|a| matches!(a, AlertMap::Custom(alert) if alert.metric_name == "accuracy"));
assert!(has_mse_alert, "Should have MSE alert");
assert!(has_accuracy_alert, "Should have accuracy alert");
}
#[tokio::test]
async fn test_generate_alerts_no_trigger_within_threshold() {
let drifter = get_test_drifter();
let mut metric_map = HashMap::new();
metric_map.insert("mse".to_string(), 12.5);
metric_map.insert("accuracy".to_string(), 0.76);
let alerts = drifter
.generate_alerts(&metric_map)
.await
.expect("Should handle no-alert case successfully");
assert!(
alerts.is_none(),
"Should not generate alerts for values within threshold"
);
}
#[tokio::test]
async fn test_generate_alerts_partial_triggers() {
let drifter = get_test_drifter();
let mut metric_map = HashMap::new();
metric_map.insert("mse".to_string(), 14.0);
metric_map.insert("accuracy".to_string(), 0.76);
let alerts = drifter
.generate_alerts(&metric_map)
.await
.expect("Should generate alerts successfully")
.expect("Should have alerts");
assert_eq!(alerts.len(), 1, "Should generate 1 alert");
assert!(
matches!(&alerts[0], AlertMap::Custom(alert) if alert.metric_name == "mse"),
"Alert should be for MSE metric"
);
}
#[test]
fn test_profile_id_formatting() {
let drifter = get_test_drifter();
let profile_id = drifter.profile_id();
assert_eq!(profile_id, "scouter/model/0.1.0");
}
}