use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Router,
};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
const DEFAULT_COST_PER_1K_TOKENS: f64 = 0.003;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelPricing {
pub input_cost_per_1k: f64,
pub output_cost_per_1k: f64,
}
impl ModelPricing {
pub fn blended_cost_per_1k(&self) -> f64 {
(self.input_cost_per_1k + self.output_cost_per_1k) / 2.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricingTable {
pub models: HashMap<String, ModelPricing>,
pub fallback_cost_per_1k: f64,
}
impl Default for PricingTable {
fn default() -> Self {
default_pricing()
}
}
impl PricingTable {
pub fn cost_per_1k(&self, model: Option<&str>) -> f64 {
model
.and_then(|m| self.models.get(m))
.map(ModelPricing::blended_cost_per_1k)
.unwrap_or(self.fallback_cost_per_1k)
}
}
pub fn default_pricing() -> PricingTable {
let mut models = HashMap::new();
models.insert(
"claude-sonnet-4-20250514".into(),
ModelPricing {
input_cost_per_1k: 0.003,
output_cost_per_1k: 0.015,
},
);
models.insert(
"claude-opus-4-20250514".into(),
ModelPricing {
input_cost_per_1k: 0.015,
output_cost_per_1k: 0.075,
},
);
models.insert(
"claude-3-5-sonnet-20241022".into(),
ModelPricing {
input_cost_per_1k: 0.003,
output_cost_per_1k: 0.015,
},
);
models.insert(
"claude-3-5-haiku-20241022".into(),
ModelPricing {
input_cost_per_1k: 0.001,
output_cost_per_1k: 0.005,
},
);
models.insert(
"gpt-4o".into(),
ModelPricing {
input_cost_per_1k: 0.0025,
output_cost_per_1k: 0.01,
},
);
models.insert(
"gpt-4o-mini".into(),
ModelPricing {
input_cost_per_1k: 0.00015,
output_cost_per_1k: 0.0006,
},
);
models.insert(
"gpt-4-turbo".into(),
ModelPricing {
input_cost_per_1k: 0.01,
output_cost_per_1k: 0.03,
},
);
models.insert(
"gemini-2.0-flash".into(),
ModelPricing {
input_cost_per_1k: 0.0001,
output_cost_per_1k: 0.0004,
},
);
models.insert(
"gemini-1.5-pro".into(),
ModelPricing {
input_cost_per_1k: 0.00125,
output_cost_per_1k: 0.005,
},
);
PricingTable {
models,
fallback_cost_per_1k: DEFAULT_COST_PER_1K_TOKENS,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InteractionOutcome {
Resolved,
Escalated,
Pending,
Abandoned,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionEvent {
pub tenant_id: String,
pub agent_role: String,
pub channel: String,
pub customer_id: Option<String>,
pub outcome: InteractionOutcome,
pub duration_ms: u64,
pub tokens_used: u64,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FunnelStage {
Lead,
Qualified,
Contacted,
Responded,
DemoScheduled,
Converted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityEvent {
pub tenant_id: String,
pub agent_role: String,
pub overall_score: f32,
pub criteria_scores: HashMap<String, f32>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentDashboard {
pub interactions: u64,
pub avg_quality: f32,
pub resolution_rate: f64,
pub avg_duration_ms: u64,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChannelStats {
pub interactions: u64,
pub resolution_rate: f64,
pub avg_duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyMetric {
pub date: String,
pub interactions: u64,
pub resolutions: u64,
pub escalations: u64,
pub avg_quality: f32,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsDashboard {
pub tenant_id: String,
pub period: String,
pub total_interactions: u64,
pub resolution_rate: f64,
pub avg_response_time_ms: u64,
pub avg_quality_score: f32,
pub csat_estimate: f32,
pub cost_total_usd: f64,
pub cost_per_interaction_usd: f64,
pub by_agent: HashMap<String, AgentDashboard>,
pub by_channel: HashMap<String, ChannelStats>,
pub trend: Vec<DailyMetric>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentPerformance {
pub agent_role: String,
pub total_calls: u64,
pub avg_quality: f32,
pub resolution_rate: f64,
pub avg_duration_ms: u64,
pub escalation_rate: f64,
pub top_topics: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversionFunnel {
pub total_leads: u64,
pub qualified: u64,
pub contacted: u64,
pub responded: u64,
pub demo_scheduled: u64,
pub converted: u64,
pub conversion_rate: f64,
pub avg_time_to_conversion_days: f64,
}
#[derive(Debug)]
pub enum AnalyticsError {
NotFound(String),
BadRequest(String),
}
impl std::fmt::Display for AnalyticsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(msg) => write!(f, "Not found: {msg}"),
Self::BadRequest(msg) => write!(f, "Bad request: {msg}"),
}
}
}
impl IntoResponse for AnalyticsError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
};
let body = serde_json::json!({ "error": message });
(status, axum::Json(body)).into_response()
}
}
#[derive(Debug, Clone, Default)]
struct TenantData {
interactions: Vec<InteractionEvent>,
quality: Vec<QualityEvent>,
funnel: HashMap<FunnelStage, u64>,
conversion_days_sum: f64,
conversion_count: u64,
}
#[derive(Clone)]
pub struct AnalyticsEngine {
data: Arc<RwLock<HashMap<String, TenantData>>>,
pricing: Arc<PricingTable>,
}
impl AnalyticsEngine {
pub fn new() -> Self {
Self {
data: Arc::new(RwLock::new(HashMap::new())),
pricing: Arc::new(PricingTable::default()),
}
}
pub fn with_pricing(pricing: PricingTable) -> Self {
Self {
data: Arc::new(RwLock::new(HashMap::new())),
pricing: Arc::new(pricing),
}
}
pub fn pricing(&self) -> &PricingTable {
&self.pricing
}
pub async fn record_interaction(&self, event: InteractionEvent) {
let mut data = self.data.write().await;
let tenant = data.entry(event.tenant_id.clone()).or_default();
tenant.interactions.push(event);
}
pub async fn record_quality_score(&self, event: QualityEvent) {
let mut data = self.data.write().await;
let tenant = data.entry(event.tenant_id.clone()).or_default();
tenant.quality.push(event);
}
pub async fn record_funnel_event(
&self,
tenant_id: &str,
stage: FunnelStage,
days_to_conversion: Option<f64>,
) {
let mut data = self.data.write().await;
let tenant = data.entry(tenant_id.to_string()).or_default();
*tenant.funnel.entry(stage).or_insert(0) += 1;
if let Some(days) = days_to_conversion {
tenant.conversion_days_sum += days;
tenant.conversion_count += 1;
}
}
pub async fn get_dashboard(&self, tenant_id: &str, period: &str) -> AnalyticsDashboard {
let data = self.data.read().await;
let empty = TenantData::default();
let tenant = data.get(tenant_id).unwrap_or(&empty);
let cutoff = period_to_cutoff(period);
let interactions: Vec<&InteractionEvent> = tenant
.interactions
.iter()
.filter(|i| i.timestamp >= cutoff)
.collect();
let quality: Vec<&QualityEvent> = tenant
.quality
.iter()
.filter(|q| q.timestamp >= cutoff)
.collect();
let total = interactions.len() as u64;
let resolved = interactions
.iter()
.filter(|i| i.outcome == InteractionOutcome::Resolved)
.count() as u64;
let resolution_rate = if total > 0 {
resolved as f64 / total as f64
} else {
0.0
};
let total_duration: u64 = interactions.iter().map(|i| i.duration_ms).sum();
let avg_response_time_ms = if total > 0 { total_duration / total } else { 0 };
let total_tokens: u64 = interactions.iter().map(|i| i.tokens_used).sum();
let cost_per_1k = self.pricing.cost_per_1k(None);
let cost_total_usd = (total_tokens as f64 / 1000.0) * cost_per_1k;
let cost_per_interaction_usd = if total > 0 {
cost_total_usd / total as f64
} else {
0.0
};
let avg_quality_score = if quality.is_empty() {
0.0
} else {
quality.iter().map(|q| q.overall_score).sum::<f32>() / quality.len() as f32
};
let csat_estimate = avg_quality_score * 0.6 + (resolution_rate as f32) * 0.4;
let mut by_agent: HashMap<String, AgentDashboard> = HashMap::new();
for i in &interactions {
let entry = by_agent.entry(i.agent_role.clone()).or_default();
entry.interactions += 1;
entry.avg_duration_ms += i.duration_ms; entry.cost_usd += (i.tokens_used as f64 / 1000.0) * cost_per_1k;
if i.outcome == InteractionOutcome::Resolved {
entry.resolution_rate += 1.0; }
}
let mut agent_quality: HashMap<String, (f32, u32)> = HashMap::new();
for q in &quality {
let entry = agent_quality
.entry(q.agent_role.clone())
.or_insert((0.0, 0));
entry.0 += q.overall_score;
entry.1 += 1;
}
for (role, dashboard) in &mut by_agent {
let count = dashboard.interactions;
if count > 0 {
dashboard.resolution_rate /= count as f64;
dashboard.avg_duration_ms /= count;
}
if let Some((sum, cnt)) = agent_quality.get(role) {
if *cnt > 0 {
dashboard.avg_quality = sum / *cnt as f32;
}
}
}
let mut by_channel: HashMap<String, ChannelStats> = HashMap::new();
for i in &interactions {
let entry = by_channel.entry(i.channel.clone()).or_default();
entry.interactions += 1;
entry.avg_duration_ms += i.duration_ms;
if i.outcome == InteractionOutcome::Resolved {
entry.resolution_rate += 1.0;
}
}
for stats in by_channel.values_mut() {
let count = stats.interactions;
if count > 0 {
stats.resolution_rate /= count as f64;
stats.avg_duration_ms /= count;
}
}
let trend = build_daily_trend(&interactions, &quality, cost_per_1k);
AnalyticsDashboard {
tenant_id: tenant_id.to_string(),
period: period.to_string(),
total_interactions: total,
resolution_rate,
avg_response_time_ms,
avg_quality_score,
csat_estimate,
cost_total_usd,
cost_per_interaction_usd,
by_agent,
by_channel,
trend,
}
}
pub async fn get_agent_performance(
&self,
tenant_id: &str,
agent_role: &str,
) -> AgentPerformance {
let data = self.data.read().await;
let empty = TenantData::default();
let tenant = data.get(tenant_id).unwrap_or(&empty);
let interactions: Vec<&InteractionEvent> = tenant
.interactions
.iter()
.filter(|i| i.agent_role == agent_role)
.collect();
let quality: Vec<&QualityEvent> = tenant
.quality
.iter()
.filter(|q| q.agent_role == agent_role)
.collect();
let total = interactions.len() as u64;
let resolved = interactions
.iter()
.filter(|i| i.outcome == InteractionOutcome::Resolved)
.count() as u64;
let escalated = interactions
.iter()
.filter(|i| i.outcome == InteractionOutcome::Escalated)
.count() as u64;
let resolution_rate = if total > 0 {
resolved as f64 / total as f64
} else {
0.0
};
let escalation_rate = if total > 0 {
escalated as f64 / total as f64
} else {
0.0
};
let total_duration: u64 = interactions.iter().map(|i| i.duration_ms).sum();
let avg_duration_ms = if total > 0 { total_duration / total } else { 0 };
let avg_quality = if quality.is_empty() {
0.0
} else {
quality.iter().map(|q| q.overall_score).sum::<f32>() / quality.len() as f32
};
let mut channel_counts: HashMap<String, u64> = HashMap::new();
for i in &interactions {
*channel_counts.entry(i.channel.clone()).or_insert(0) += 1;
}
let mut sorted_channels: Vec<(String, u64)> = channel_counts.into_iter().collect();
sorted_channels.sort_by(|a, b| b.1.cmp(&a.1));
let top_topics: Vec<String> = sorted_channels
.into_iter()
.take(5)
.map(|(ch, _)| ch)
.collect();
AgentPerformance {
agent_role: agent_role.to_string(),
total_calls: total,
avg_quality,
resolution_rate,
avg_duration_ms,
escalation_rate,
top_topics,
}
}
pub async fn get_conversion_funnel(&self, tenant_id: &str) -> ConversionFunnel {
let data = self.data.read().await;
let empty = TenantData::default();
let tenant = data.get(tenant_id).unwrap_or(&empty);
let total_leads = *tenant.funnel.get(&FunnelStage::Lead).unwrap_or(&0);
let qualified = *tenant.funnel.get(&FunnelStage::Qualified).unwrap_or(&0);
let contacted = *tenant.funnel.get(&FunnelStage::Contacted).unwrap_or(&0);
let responded = *tenant.funnel.get(&FunnelStage::Responded).unwrap_or(&0);
let demo_scheduled = *tenant.funnel.get(&FunnelStage::DemoScheduled).unwrap_or(&0);
let converted = *tenant.funnel.get(&FunnelStage::Converted).unwrap_or(&0);
let conversion_rate = if total_leads > 0 {
converted as f64 / total_leads as f64
} else {
0.0
};
let avg_time_to_conversion_days = if tenant.conversion_count > 0 {
tenant.conversion_days_sum / tenant.conversion_count as f64
} else {
0.0
};
ConversionFunnel {
total_leads,
qualified,
contacted,
responded,
demo_scheduled,
converted,
conversion_rate,
avg_time_to_conversion_days,
}
}
pub async fn get_trends(&self, tenant_id: &str, days: u32) -> Vec<DailyMetric> {
let data = self.data.read().await;
let empty = TenantData::default();
let tenant = data.get(tenant_id).unwrap_or(&empty);
let cutoff = Utc::now() - chrono::Duration::days(days as i64);
let interactions: Vec<&InteractionEvent> = tenant
.interactions
.iter()
.filter(|i| i.timestamp >= cutoff)
.collect();
let quality: Vec<&QualityEvent> = tenant
.quality
.iter()
.filter(|q| q.timestamp >= cutoff)
.collect();
build_daily_trend(&interactions, &quality, self.pricing.cost_per_1k(None))
}
}
impl Default for AnalyticsEngine {
fn default() -> Self {
Self::new()
}
}
fn period_to_cutoff(period: &str) -> DateTime<Utc> {
let days = if period.starts_with("last_") && period.ends_with('d') {
period
.trim_start_matches("last_")
.trim_end_matches('d')
.parse::<i64>()
.unwrap_or(30)
} else {
30
};
Utc::now() - chrono::Duration::days(days)
}
fn build_daily_trend(
interactions: &[&InteractionEvent],
quality: &[&QualityEvent],
cost_per_1k: f64,
) -> Vec<DailyMetric> {
let mut by_date: HashMap<NaiveDate, (u64, u64, u64, u64)> = HashMap::new(); for i in interactions {
let date = i.timestamp.date_naive();
let entry = by_date.entry(date).or_insert((0, 0, 0, 0));
entry.0 += 1;
if i.outcome == InteractionOutcome::Resolved {
entry.1 += 1;
}
if i.outcome == InteractionOutcome::Escalated {
entry.2 += 1;
}
entry.3 += i.tokens_used;
}
let mut quality_by_date: HashMap<NaiveDate, (f32, u32)> = HashMap::new();
for q in quality {
let date = q.timestamp.date_naive();
let entry = quality_by_date.entry(date).or_insert((0.0, 0));
entry.0 += q.overall_score;
entry.1 += 1;
}
let mut all_dates: Vec<NaiveDate> = by_date
.keys()
.chain(quality_by_date.keys())
.copied()
.collect();
all_dates.sort();
all_dates.dedup();
all_dates
.into_iter()
.map(|date| {
let (count, resolved, escalated, tokens) =
by_date.get(&date).copied().unwrap_or((0, 0, 0, 0));
let avg_quality = quality_by_date
.get(&date)
.map(|(sum, cnt)| if *cnt > 0 { sum / *cnt as f32 } else { 0.0 })
.unwrap_or(0.0);
let cost_usd = (tokens as f64 / 1000.0) * cost_per_1k;
DailyMetric {
date: date.format("%Y-%m-%d").to_string(),
interactions: count,
resolutions: resolved,
escalations: escalated,
avg_quality,
cost_usd,
}
})
.collect()
}
pub struct AnalyticsState {
pub engine: AnalyticsEngine,
}
#[derive(Debug, Deserialize)]
struct TrendsQuery {
days: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct DashboardQuery {
period: Option<String>,
}
pub fn analytics_router(state: Arc<AnalyticsState>) -> Router {
Router::new()
.route(
"/api/v1/analytics/{tenant_id}/dashboard",
get(dashboard_handler),
)
.route(
"/api/v1/analytics/{tenant_id}/agents/{role}",
get(agent_performance_handler),
)
.route("/api/v1/analytics/{tenant_id}/funnel", get(funnel_handler))
.route("/api/v1/analytics/{tenant_id}/trends", get(trends_handler))
.with_state(state)
}
async fn dashboard_handler(
State(state): State<Arc<AnalyticsState>>,
Path(tenant_id): Path<String>,
axum::extract::Query(params): axum::extract::Query<DashboardQuery>,
) -> impl IntoResponse {
let period = params.period.unwrap_or_else(|| "last_30d".to_string());
let dashboard = state.engine.get_dashboard(&tenant_id, &period).await;
axum::Json(dashboard).into_response()
}
async fn agent_performance_handler(
State(state): State<Arc<AnalyticsState>>,
Path((tenant_id, role)): Path<(String, String)>,
) -> impl IntoResponse {
let performance = state.engine.get_agent_performance(&tenant_id, &role).await;
axum::Json(performance).into_response()
}
async fn funnel_handler(
State(state): State<Arc<AnalyticsState>>,
Path(tenant_id): Path<String>,
) -> impl IntoResponse {
let funnel = state.engine.get_conversion_funnel(&tenant_id).await;
axum::Json(funnel).into_response()
}
async fn trends_handler(
State(state): State<Arc<AnalyticsState>>,
Path(tenant_id): Path<String>,
axum::extract::Query(params): axum::extract::Query<TrendsQuery>,
) -> impl IntoResponse {
let days = params.days.unwrap_or(30);
let trends = state.engine.get_trends(&tenant_id, days).await;
axum::Json(trends).into_response()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn make_interaction(
tenant: &str,
role: &str,
channel: &str,
outcome: InteractionOutcome,
duration_ms: u64,
tokens: u64,
) -> InteractionEvent {
InteractionEvent {
tenant_id: tenant.to_string(),
agent_role: role.to_string(),
channel: channel.to_string(),
customer_id: None,
outcome,
duration_ms,
tokens_used: tokens,
timestamp: Utc::now(),
}
}
fn make_interaction_at(
tenant: &str,
role: &str,
channel: &str,
outcome: InteractionOutcome,
duration_ms: u64,
tokens: u64,
timestamp: DateTime<Utc>,
) -> InteractionEvent {
InteractionEvent {
tenant_id: tenant.to_string(),
agent_role: role.to_string(),
channel: channel.to_string(),
customer_id: None,
outcome,
duration_ms,
tokens_used: tokens,
timestamp,
}
}
fn make_quality(tenant: &str, role: &str, score: f32) -> QualityEvent {
QualityEvent {
tenant_id: tenant.to_string(),
agent_role: role.to_string(),
overall_score: score,
criteria_scores: HashMap::new(),
timestamp: Utc::now(),
}
}
fn make_quality_at(
tenant: &str,
role: &str,
score: f32,
timestamp: DateTime<Utc>,
) -> QualityEvent {
QualityEvent {
tenant_id: tenant.to_string(),
agent_role: role.to_string(),
overall_score: score,
criteria_scores: HashMap::new(),
timestamp,
}
}
fn make_state() -> Arc<AnalyticsState> {
Arc::new(AnalyticsState {
engine: AnalyticsEngine::new(),
})
}
#[tokio::test]
async fn test_engine_new_is_empty() {
let engine = AnalyticsEngine::new();
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.total_interactions, 0);
assert_eq!(dashboard.resolution_rate, 0.0);
assert_eq!(dashboard.avg_quality_score, 0.0);
}
#[tokio::test]
async fn test_record_interaction() {
let engine = AnalyticsEngine::new();
let event = make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
);
engine.record_interaction(event).await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.total_interactions, 1);
}
#[tokio::test]
async fn test_resolution_rate() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Escalated,
800,
200,
))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.total_interactions, 2);
assert!((dashboard.resolution_rate - 0.5).abs() < f64::EPSILON);
}
#[tokio::test]
async fn test_avg_response_time() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
400,
100,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
600,
100,
))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.avg_response_time_ms, 500);
}
#[tokio::test]
async fn test_quality_scoring() {
let engine = AnalyticsEngine::new();
engine
.record_quality_score(make_quality("t1", "sales", 0.8))
.await;
engine
.record_quality_score(make_quality("t1", "sales", 0.6))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert!((dashboard.avg_quality_score - 0.7).abs() < 0.001);
}
#[tokio::test]
async fn test_csat_estimate() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
engine
.record_quality_score(make_quality("t1", "sales", 0.8))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert!((dashboard.csat_estimate - 0.88).abs() < 0.001);
}
#[tokio::test]
async fn test_cost_calculation() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
10_000,
))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert!((dashboard.cost_total_usd - 0.03).abs() < 0.0001);
assert!((dashboard.cost_per_interaction_usd - 0.03).abs() < 0.0001);
}
#[tokio::test]
async fn test_by_agent_breakdown() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"support",
"email",
InteractionOutcome::Escalated,
1000,
200,
))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.by_agent.len(), 2);
assert_eq!(dashboard.by_agent["sales"].interactions, 1);
assert_eq!(dashboard.by_agent["support"].interactions, 1);
}
#[tokio::test]
async fn test_by_channel_breakdown() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"sales",
"email",
InteractionOutcome::Resolved,
700,
100,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Escalated,
600,
100,
))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.by_channel.len(), 2);
assert_eq!(dashboard.by_channel["chat"].interactions, 2);
assert_eq!(dashboard.by_channel["email"].interactions, 1);
}
#[tokio::test]
async fn test_agent_performance() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"sales",
"email",
InteractionOutcome::Escalated,
700,
200,
))
.await;
engine
.record_quality_score(make_quality("t1", "sales", 0.9))
.await;
let perf = engine.get_agent_performance("t1", "sales").await;
assert_eq!(perf.total_calls, 2);
assert!((perf.resolution_rate - 0.5).abs() < f64::EPSILON);
assert!((perf.escalation_rate - 0.5).abs() < f64::EPSILON);
assert_eq!(perf.avg_duration_ms, 600);
assert!((perf.avg_quality - 0.9).abs() < 0.001);
assert_eq!(perf.top_topics.len(), 2);
}
#[tokio::test]
async fn test_agent_performance_empty() {
let engine = AnalyticsEngine::new();
let perf = engine.get_agent_performance("t1", "nonexistent").await;
assert_eq!(perf.total_calls, 0);
assert_eq!(perf.avg_quality, 0.0);
assert_eq!(perf.resolution_rate, 0.0);
}
#[tokio::test]
async fn test_conversion_funnel() {
let engine = AnalyticsEngine::new();
for _ in 0..100 {
engine
.record_funnel_event("t1", FunnelStage::Lead, None)
.await;
}
for _ in 0..60 {
engine
.record_funnel_event("t1", FunnelStage::Qualified, None)
.await;
}
for _ in 0..40 {
engine
.record_funnel_event("t1", FunnelStage::Contacted, None)
.await;
}
for _ in 0..25 {
engine
.record_funnel_event("t1", FunnelStage::Responded, None)
.await;
}
for _ in 0..10 {
engine
.record_funnel_event("t1", FunnelStage::DemoScheduled, None)
.await;
}
for _ in 0..5 {
engine
.record_funnel_event("t1", FunnelStage::Converted, Some(14.0))
.await;
}
let funnel = engine.get_conversion_funnel("t1").await;
assert_eq!(funnel.total_leads, 100);
assert_eq!(funnel.qualified, 60);
assert_eq!(funnel.contacted, 40);
assert_eq!(funnel.responded, 25);
assert_eq!(funnel.demo_scheduled, 10);
assert_eq!(funnel.converted, 5);
assert!((funnel.conversion_rate - 0.05).abs() < f64::EPSILON);
assert!((funnel.avg_time_to_conversion_days - 14.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn test_conversion_funnel_empty() {
let engine = AnalyticsEngine::new();
let funnel = engine.get_conversion_funnel("t1").await;
assert_eq!(funnel.total_leads, 0);
assert_eq!(funnel.conversion_rate, 0.0);
assert_eq!(funnel.avg_time_to_conversion_days, 0.0);
}
#[tokio::test]
async fn test_daily_trend_ordering() {
let engine = AnalyticsEngine::new();
let now = Utc::now();
let yesterday = now - chrono::Duration::days(1);
let two_days_ago = now - chrono::Duration::days(2);
engine
.record_interaction(make_interaction_at(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
two_days_ago,
))
.await;
engine
.record_interaction(make_interaction_at(
"t1",
"sales",
"chat",
InteractionOutcome::Escalated,
700,
200,
yesterday,
))
.await;
engine
.record_interaction(make_interaction_at(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
300,
50,
now,
))
.await;
let trends = engine.get_trends("t1", 7).await;
assert!(trends.len() >= 2); for window in trends.windows(2) {
assert!(window[0].date <= window[1].date);
}
}
#[tokio::test]
async fn test_daily_trend_metrics() {
let engine = AnalyticsEngine::new();
let now = Utc::now();
engine
.record_interaction(make_interaction_at(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
1000,
now,
))
.await;
engine
.record_interaction(make_interaction_at(
"t1",
"sales",
"chat",
InteractionOutcome::Escalated,
700,
2000,
now,
))
.await;
engine
.record_quality_score(make_quality_at("t1", "sales", 0.85, now))
.await;
let trends = engine.get_trends("t1", 1).await;
assert!(!trends.is_empty());
let today = &trends[trends.len() - 1];
assert_eq!(today.interactions, 2);
assert_eq!(today.resolutions, 1);
assert_eq!(today.escalations, 1);
assert!((today.avg_quality - 0.85).abs() < 0.001);
assert!((today.cost_usd - 0.009).abs() < 0.0001);
}
#[tokio::test]
async fn test_period_filtering() {
let engine = AnalyticsEngine::new();
let now = Utc::now();
let old = now - chrono::Duration::days(60);
engine
.record_interaction(make_interaction_at(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
old,
))
.await;
engine
.record_interaction(make_interaction_at(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
now,
))
.await;
let dashboard_7 = engine.get_dashboard("t1", "last_7d").await;
assert_eq!(dashboard_7.total_interactions, 1);
let dashboard_90 = engine.get_dashboard("t1", "last_90d").await;
assert_eq!(dashboard_90.total_interactions, 2);
}
#[tokio::test]
async fn test_multi_tenant_isolation() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
engine
.record_interaction(make_interaction(
"t2",
"support",
"email",
InteractionOutcome::Escalated,
700,
200,
))
.await;
let d1 = engine.get_dashboard("t1", "last_30d").await;
let d2 = engine.get_dashboard("t2", "last_30d").await;
assert_eq!(d1.total_interactions, 1);
assert_eq!(d2.total_interactions, 1);
assert!((d1.resolution_rate - 1.0).abs() < f64::EPSILON);
assert!((d2.resolution_rate - 0.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn test_default_period_parsing() {
let cutoff = period_to_cutoff("last_30d");
let expected = Utc::now() - chrono::Duration::days(30);
let diff = (cutoff - expected).num_seconds().abs();
assert!(diff < 2); }
#[tokio::test]
async fn test_invalid_period_defaults_to_30d() {
let cutoff = period_to_cutoff("invalid");
let expected = Utc::now() - chrono::Duration::days(30);
let diff = (cutoff - expected).num_seconds().abs();
assert!(diff < 2);
}
#[tokio::test]
async fn test_interaction_outcome_serialization() {
let resolved = serde_json::to_string(&InteractionOutcome::Resolved).unwrap();
assert_eq!(resolved, "\"Resolved\"");
let escalated: InteractionOutcome = serde_json::from_str("\"Escalated\"").unwrap();
assert_eq!(escalated, InteractionOutcome::Escalated);
}
#[tokio::test]
async fn test_engine_clone() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
let cloned = engine.clone();
let dashboard = cloned.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.total_interactions, 1);
}
#[tokio::test]
async fn test_dashboard_endpoint() {
let state = make_state();
state
.engine
.record_interaction(make_interaction(
"acme",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
let app = analytics_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/analytics/acme/dashboard?period=last_30d")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let dashboard: AnalyticsDashboard = serde_json::from_slice(&body).unwrap();
assert_eq!(dashboard.tenant_id, "acme");
assert_eq!(dashboard.total_interactions, 1);
}
#[tokio::test]
async fn test_agent_performance_endpoint() {
let state = make_state();
state
.engine
.record_interaction(make_interaction(
"acme",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
state
.engine
.record_quality_score(make_quality("acme", "sales", 0.9))
.await;
let app = analytics_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/analytics/acme/agents/sales")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let perf: AgentPerformance = serde_json::from_slice(&body).unwrap();
assert_eq!(perf.agent_role, "sales");
assert_eq!(perf.total_calls, 1);
}
#[tokio::test]
async fn test_funnel_endpoint() {
let state = make_state();
state
.engine
.record_funnel_event("acme", FunnelStage::Lead, None)
.await;
state
.engine
.record_funnel_event("acme", FunnelStage::Converted, Some(7.0))
.await;
let app = analytics_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/analytics/acme/funnel")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let funnel: ConversionFunnel = serde_json::from_slice(&body).unwrap();
assert_eq!(funnel.total_leads, 1);
assert_eq!(funnel.converted, 1);
}
#[tokio::test]
async fn test_trends_endpoint() {
let state = make_state();
state
.engine
.record_interaction(make_interaction(
"acme",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
let app = analytics_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/analytics/acme/trends?days=7")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let trends: Vec<DailyMetric> = serde_json::from_slice(&body).unwrap();
assert!(!trends.is_empty());
}
#[tokio::test]
async fn test_trends_endpoint_default_days() {
let state = make_state();
state
.engine
.record_interaction(make_interaction(
"acme",
"sales",
"chat",
InteractionOutcome::Resolved,
500,
100,
))
.await;
let app = analytics_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/analytics/acme/trends")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_quality_criteria_scores() {
let engine = AnalyticsEngine::new();
let mut criteria = HashMap::new();
criteria.insert("accuracy".to_string(), 0.95);
criteria.insert("clarity".to_string(), 0.85);
let event = QualityEvent {
tenant_id: "t1".to_string(),
agent_role: "sales".to_string(),
overall_score: 0.9,
criteria_scores: criteria,
timestamp: Utc::now(),
};
engine.record_quality_score(event).await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert!((dashboard.avg_quality_score - 0.9).abs() < 0.001);
}
#[tokio::test]
async fn test_all_outcome_variants() {
let engine = AnalyticsEngine::new();
engine
.record_interaction(make_interaction(
"t1",
"a",
"chat",
InteractionOutcome::Resolved,
100,
10,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"a",
"chat",
InteractionOutcome::Escalated,
200,
20,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"a",
"chat",
InteractionOutcome::Pending,
300,
30,
))
.await;
engine
.record_interaction(make_interaction(
"t1",
"a",
"chat",
InteractionOutcome::Abandoned,
400,
40,
))
.await;
let dashboard = engine.get_dashboard("t1", "last_30d").await;
assert_eq!(dashboard.total_interactions, 4);
assert!((dashboard.resolution_rate - 0.25).abs() < f64::EPSILON);
}
}