use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsightData {
pub request_id: String,
pub method: String,
pub path: String,
pub query_params: HashMap<String, String>,
pub status: u16,
pub duration_ms: u64,
pub request_size: usize,
pub response_size: usize,
pub timestamp: u64,
pub client_ip: String,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub request_headers: HashMap<String, String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub response_headers: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub route_pattern: Option<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub tags: HashMap<String, String>,
}
impl InsightData {
pub fn new(
request_id: impl Into<String>,
method: impl Into<String>,
path: impl Into<String>,
) -> Self {
Self {
request_id: request_id.into(),
method: method.into(),
path: path.into(),
query_params: HashMap::new(),
status: 0,
duration_ms: 0,
request_size: 0,
response_size: 0,
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
client_ip: String::new(),
request_headers: HashMap::new(),
response_headers: HashMap::new(),
request_body: None,
response_body: None,
route_pattern: None,
tags: HashMap::new(),
}
}
pub fn with_status(mut self, status: u16) -> Self {
self.status = status;
self
}
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration_ms = duration.as_millis() as u64;
self
}
pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
self.client_ip = ip.into();
self
}
pub fn with_request_size(mut self, size: usize) -> Self {
self.request_size = size;
self
}
pub fn with_response_size(mut self, size: usize) -> Self {
self.response_size = size;
self
}
pub fn with_route_pattern(mut self, pattern: impl Into<String>) -> Self {
self.route_pattern = Some(pattern.into());
self
}
pub fn add_query_param(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.query_params.insert(key.into(), value.into());
}
pub fn add_request_header(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.request_headers.insert(key.into(), value.into());
}
pub fn add_response_header(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.response_headers.insert(key.into(), value.into());
}
pub fn set_request_body(&mut self, body: String) {
self.request_body = Some(body);
}
pub fn set_response_body(&mut self, body: String) {
self.response_body = Some(body);
}
pub fn add_tag(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.tags.insert(key.into(), value.into());
}
pub fn is_success(&self) -> bool {
self.status >= 200 && self.status < 300
}
pub fn is_client_error(&self) -> bool {
self.status >= 400 && self.status < 500
}
pub fn is_server_error(&self) -> bool {
self.status >= 500
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InsightStats {
pub total_requests: u64,
pub successful_requests: u64,
pub client_errors: u64,
pub server_errors: u64,
pub avg_duration_ms: f64,
pub min_duration_ms: u64,
pub max_duration_ms: u64,
pub p95_duration_ms: u64,
pub p99_duration_ms: u64,
pub total_request_bytes: u64,
pub total_response_bytes: u64,
pub requests_by_route: HashMap<String, u64>,
pub requests_by_method: HashMap<String, u64>,
pub requests_by_status: HashMap<u16, u64>,
pub avg_duration_by_route: HashMap<String, f64>,
pub requests_per_second: f64,
pub time_period_secs: u64,
}
impl InsightStats {
pub fn new() -> Self {
Self::default()
}
pub fn from_insights(insights: &[InsightData]) -> Self {
if insights.is_empty() {
return Self::default();
}
let mut stats = Self::new();
stats.total_requests = insights.len() as u64;
let mut durations: Vec<u64> = Vec::with_capacity(insights.len());
let mut route_durations: HashMap<String, Vec<u64>> = HashMap::new();
let min_timestamp = insights.iter().map(|i| i.timestamp).min().unwrap_or(0);
let max_timestamp = insights.iter().map(|i| i.timestamp).max().unwrap_or(0);
stats.time_period_secs = max_timestamp.saturating_sub(min_timestamp).max(1);
for insight in insights {
if insight.is_success() {
stats.successful_requests += 1;
} else if insight.is_client_error() {
stats.client_errors += 1;
} else if insight.is_server_error() {
stats.server_errors += 1;
}
durations.push(insight.duration_ms);
stats.total_request_bytes += insight.request_size as u64;
stats.total_response_bytes += insight.response_size as u64;
let route = insight
.route_pattern
.clone()
.unwrap_or_else(|| insight.path.clone());
*stats.requests_by_route.entry(route.clone()).or_insert(0) += 1;
route_durations
.entry(route)
.or_default()
.push(insight.duration_ms);
*stats
.requests_by_method
.entry(insight.method.clone())
.or_insert(0) += 1;
*stats.requests_by_status.entry(insight.status).or_insert(0) += 1;
}
if !durations.is_empty() {
durations.sort_unstable();
let sum: u64 = durations.iter().sum();
stats.avg_duration_ms = sum as f64 / durations.len() as f64;
stats.min_duration_ms = durations[0];
stats.max_duration_ms = durations[durations.len() - 1];
stats.p95_duration_ms = percentile(&durations, 95);
stats.p99_duration_ms = percentile(&durations, 99);
}
for (route, route_durs) in route_durations {
let sum: u64 = route_durs.iter().sum();
let avg = sum as f64 / route_durs.len() as f64;
stats.avg_duration_by_route.insert(route, avg);
}
stats.requests_per_second = stats.total_requests as f64 / stats.time_period_secs as f64;
stats
}
}
fn percentile(sorted: &[u64], n: u8) -> u64 {
if sorted.is_empty() {
return 0;
}
let idx = (sorted.len() as f64 * (n as f64 / 100.0)).ceil() as usize;
sorted[idx.saturating_sub(1).min(sorted.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insight_data_creation() {
let insight = InsightData::new("req-123", "GET", "/users")
.with_status(200)
.with_duration(Duration::from_millis(42))
.with_client_ip("192.168.1.1");
assert_eq!(insight.request_id, "req-123");
assert_eq!(insight.method, "GET");
assert_eq!(insight.path, "/users");
assert_eq!(insight.status, 200);
assert_eq!(insight.duration_ms, 42);
assert_eq!(insight.client_ip, "192.168.1.1");
}
#[test]
fn test_status_categorization() {
assert!(InsightData::new("", "", "").with_status(200).is_success());
assert!(InsightData::new("", "", "").with_status(201).is_success());
assert!(InsightData::new("", "", "")
.with_status(404)
.is_client_error());
assert!(InsightData::new("", "", "")
.with_status(500)
.is_server_error());
}
#[test]
fn test_stats_calculation() {
let insights = vec![
InsightData::new("1", "GET", "/users")
.with_status(200)
.with_duration(Duration::from_millis(10)),
InsightData::new("2", "POST", "/users")
.with_status(201)
.with_duration(Duration::from_millis(20)),
InsightData::new("3", "GET", "/users")
.with_status(404)
.with_duration(Duration::from_millis(5)),
InsightData::new("4", "GET", "/items")
.with_status(500)
.with_duration(Duration::from_millis(100)),
];
let stats = InsightStats::from_insights(&insights);
assert_eq!(stats.total_requests, 4);
assert_eq!(stats.successful_requests, 2);
assert_eq!(stats.client_errors, 1);
assert_eq!(stats.server_errors, 1);
assert_eq!(stats.requests_by_method.get("GET"), Some(&3));
assert_eq!(stats.requests_by_method.get("POST"), Some(&1));
}
#[test]
fn test_percentile_calculation() {
let sorted = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
assert_eq!(percentile(&sorted, 50), 5);
assert_eq!(percentile(&sorted, 95), 10);
assert_eq!(percentile(&sorted, 99), 10);
}
}