use crate::{cost_by_agent_of, metrics_totals_of, AgentCost, Event, EventKind};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MetricsSummary {
pub total_events: usize,
pub actions_succeeded: u64,
pub actions_failed: u64,
pub actions_rejected: u64,
pub success_rate: f64,
pub error_rate: f64,
pub cost_usd: f64,
#[serde(default)]
pub cumulative_cost_usd: f64,
pub tokens_in: u64,
pub tokens_out: u64,
pub avg_latency_ms: f64,
pub approvals_recorded: u64,
pub permission_decisions: u64,
pub gate_rejections: u64,
pub policy_violations: u64,
#[serde(default)]
pub goal_evaluations: u64,
#[serde(default)]
pub goals_met: u64,
#[serde(default)]
pub goals_ungrounded: u64,
pub cost_by_agent: Vec<AgentCost>,
}
pub fn summarize(events: &[Event]) -> MetricsSummary {
let totals = metrics_totals_of(events);
let mut s = MetricsSummary {
total_events: events.len(),
cost_usd: totals.cost_usd,
cumulative_cost_usd: totals.cost_usd,
tokens_in: totals.tokens_in,
tokens_out: totals.tokens_out,
cost_by_agent: cost_by_agent_of(events),
..Default::default()
};
for e in events {
match e.kind {
EventKind::ActionSucceeded => s.actions_succeeded += 1,
EventKind::ActionFailed => s.actions_failed += 1,
EventKind::ActionRejected => s.actions_rejected += 1,
EventKind::ApprovalRecorded => s.approvals_recorded += 1,
EventKind::PermissionDecision => s.permission_decisions += 1,
EventKind::PolicyViolation => s.policy_violations += 1,
EventKind::AdmissionGateDecision => {
let decision = e.data.get("decision").and_then(|v| v.as_str());
if matches!(decision, Some("reject") | Some("needs_approval")) {
s.gate_rejections += 1;
}
}
EventKind::GoalEvaluated => {
s.goal_evaluations += 1;
if e.data.get("met").and_then(|v| v.as_bool()) == Some(true) {
s.goals_met += 1;
}
if e.data.get("grounded").and_then(|v| v.as_bool()) == Some(false) {
s.goals_ungrounded += 1;
}
}
_ => {}
}
}
let attempted = s.actions_succeeded + s.actions_failed + s.actions_rejected;
s.success_rate = if attempted == 0 {
1.0
} else {
s.actions_succeeded as f64 / attempted as f64
};
s.error_rate = 1.0 - s.success_rate;
s.avg_latency_ms = if totals.metered_events == 0 {
0.0
} else {
totals.duration_ms / totals.metered_events as f64
};
s
}
pub fn summarize_log(log: &crate::EventLog) -> MetricsSummary {
let mut s = summarize(log.events());
s.cumulative_cost_usd = log.cumulative_cost_usd();
s
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AlertThresholds {
#[serde(default)]
pub max_cost_usd: Option<f64>,
#[serde(default)]
pub max_error_rate: Option<f64>,
#[serde(default)]
pub max_avg_latency_ms: Option<f64>,
#[serde(default)]
pub max_goals_ungrounded: Option<u64>,
#[serde(default)]
pub min_actions: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AlertKind {
CostOverage,
ErrorRate,
Latency,
GoalUngrounded,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Alert {
pub kind: AlertKind,
pub message: String,
pub observed: f64,
pub threshold: f64,
}
pub fn evaluate_alerts(summary: &MetricsSummary, thresholds: &AlertThresholds) -> Vec<Alert> {
let mut alerts = Vec::new();
if let Some(max) = thresholds.max_cost_usd {
if summary.cumulative_cost_usd > max {
alerts.push(Alert {
kind: AlertKind::CostOverage,
message: format!(
"cumulative cost ${:.4} exceeds budget ${:.4}",
summary.cumulative_cost_usd, max
),
observed: summary.cumulative_cost_usd,
threshold: max,
});
}
}
if let Some(max) = thresholds.max_error_rate {
let attempted =
summary.actions_succeeded + summary.actions_failed + summary.actions_rejected;
let min = thresholds.min_actions.unwrap_or(5);
if attempted >= min && summary.error_rate > max {
alerts.push(Alert {
kind: AlertKind::ErrorRate,
message: format!(
"error rate {:.1}% exceeds {:.1}% over {attempted} actions",
summary.error_rate * 100.0,
max * 100.0
),
observed: summary.error_rate,
threshold: max,
});
}
}
if let Some(max) = thresholds.max_avg_latency_ms {
if summary.avg_latency_ms > max {
alerts.push(Alert {
kind: AlertKind::Latency,
message: format!(
"avg latency {:.0}ms exceeds {:.0}ms",
summary.avg_latency_ms, max
),
observed: summary.avg_latency_ms,
threshold: max,
});
}
}
if let Some(max) = thresholds.max_goals_ungrounded {
if summary.goals_ungrounded > max {
alerts.push(Alert {
kind: AlertKind::GoalUngrounded,
message: format!(
"{} ungrounded goal verifier pass{} exceed threshold {}",
summary.goals_ungrounded,
if summary.goals_ungrounded == 1 {
""
} else {
"es"
},
max
),
observed: summary.goals_ungrounded as f64,
threshold: max as f64,
});
}
}
alerts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Metrics;
fn ev(kind: EventKind) -> Event {
Event {
kind,
action_id: None,
proposal_id: None,
data: Default::default(),
timestamp: chrono::Utc::now(),
prev_hash: None,
hash: None,
}
}
fn goal_ev(met: bool, grounded: bool) -> Event {
let mut e = ev(EventKind::GoalEvaluated);
e.data.insert("met".to_string(), serde_json::json!(met));
e.data
.insert("grounded".to_string(), serde_json::json!(grounded));
e
}
#[test]
fn summary_computes_rates_and_counts() {
let mut events = vec![
ev(EventKind::ActionSucceeded),
ev(EventKind::ActionSucceeded),
ev(EventKind::ActionFailed),
ev(EventKind::ActionRejected),
ev(EventKind::ApprovalRecorded),
ev(EventKind::PolicyViolation),
goal_ev(false, true),
goal_ev(true, true),
goal_ev(true, false),
];
let mut gate = ev(EventKind::AdmissionGateDecision);
gate.data
.insert("decision".to_string(), serde_json::Value::from("reject"));
events.push(gate);
let s = summarize(&events);
assert_eq!(s.actions_succeeded, 2);
assert_eq!(s.actions_failed, 1);
assert_eq!(s.actions_rejected, 1);
assert!((s.success_rate - 0.5).abs() < 1e-9);
assert!((s.error_rate - 0.5).abs() < 1e-9);
assert_eq!(s.approvals_recorded, 1);
assert_eq!(s.policy_violations, 1);
assert_eq!(s.gate_rejections, 1);
assert_eq!(s.goal_evaluations, 3);
assert_eq!(s.goals_met, 2);
assert_eq!(s.goals_ungrounded, 1);
}
#[test]
fn cost_overage_alert_fires() {
let mut log = crate::EventLog::new();
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics {
cost_usd: Some(12.5),
..Default::default()
},
);
let s = summarize(log.events());
assert!((s.cost_usd - 12.5).abs() < 1e-9);
let alerts = evaluate_alerts(
&s,
&AlertThresholds {
max_cost_usd: Some(10.0),
..Default::default()
},
);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].kind, AlertKind::CostOverage);
assert_eq!(alerts[0].observed, 12.5);
}
#[test]
fn cost_budget_alert_survives_retention_trim() {
let mut log = crate::EventLog::new();
log.set_retention(Some(crate::RetentionPolicy {
max_events: Some(1),
max_age_secs: None,
}));
for _ in 0..3 {
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics {
cost_usd: Some(6.0),
..Default::default()
},
);
}
assert_eq!(log.events().len(), 1);
let s = summarize_log(&log);
assert!((s.cost_usd - 6.0).abs() < 1e-9, "windowed fold trimmed");
assert!((s.cumulative_cost_usd - 18.0).abs() < 1e-9);
let alerts = evaluate_alerts(
&s,
&AlertThresholds {
max_cost_usd: Some(10.0),
..Default::default()
},
);
assert_eq!(alerts.len(), 1, "budget alert must not un-fire on trim");
assert_eq!(alerts[0].kind, AlertKind::CostOverage);
assert_eq!(alerts[0].observed, 18.0);
}
#[test]
fn ungrounded_goal_alert_fires() {
let s = summarize(&[goal_ev(false, true), goal_ev(true, false)]);
assert_eq!(s.goal_evaluations, 2);
assert_eq!(s.goals_ungrounded, 1);
let alerts = evaluate_alerts(
&s,
&AlertThresholds {
max_goals_ungrounded: Some(0),
..Default::default()
},
);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].kind, AlertKind::GoalUngrounded);
assert_eq!(alerts[0].observed, 1.0);
assert_eq!(alerts[0].threshold, 0.0);
}
#[test]
fn error_rate_alert_suppressed_below_min_actions() {
let s = summarize(&[ev(EventKind::ActionFailed)]);
assert!((s.error_rate - 1.0).abs() < 1e-9);
let alerts = evaluate_alerts(
&s,
&AlertThresholds {
max_error_rate: Some(0.5),
..Default::default()
},
);
assert!(alerts.is_empty(), "should not fire under min_actions");
}
#[test]
fn no_thresholds_never_fires() {
let s = summarize(&[ev(EventKind::ActionFailed)]);
assert!(evaluate_alerts(&s, &AlertThresholds::default()).is_empty());
}
}