Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
484 lines
15 KiB
Rust
484 lines
15 KiB
Rust
//! Performance Tracking for E2E Tests
|
|
//!
|
|
//! Provides comprehensive performance monitoring and metrics collection
|
|
//! for E2E tests including latency tracking, throughput measurement,
|
|
//! and performance regression detection.
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Performance metric data point
|
|
#[derive(Debug, Clone)]
|
|
pub struct MetricPoint {
|
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
|
pub value: f64,
|
|
pub tags: HashMap<String, String>,
|
|
}
|
|
|
|
/// Performance statistics for a metric
|
|
#[derive(Debug, Clone)]
|
|
pub struct MetricStats {
|
|
pub count: u64,
|
|
pub sum: f64,
|
|
pub min: f64,
|
|
pub max: f64,
|
|
pub mean: f64,
|
|
pub std_dev: f64,
|
|
pub p50: f64,
|
|
pub p95: f64,
|
|
pub p99: f64,
|
|
}
|
|
|
|
/// Latency measurement helper
|
|
#[derive(Debug)]
|
|
pub struct LatencyTracker {
|
|
start_time: Instant,
|
|
operation_name: String,
|
|
}
|
|
|
|
impl LatencyTracker {
|
|
/// Start tracking latency for an operation
|
|
pub fn start(operation_name: &str) -> Self {
|
|
Self {
|
|
start_time: Instant::now(),
|
|
operation_name: operation_name.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Stop tracking and return the duration
|
|
pub fn stop(self) -> (String, Duration) {
|
|
let duration = self.start_time.elapsed();
|
|
(self.operation_name, duration)
|
|
}
|
|
}
|
|
|
|
/// Performance tracker for E2E tests
|
|
#[derive(Debug)]
|
|
pub struct PerformanceTracker {
|
|
session_id: String,
|
|
metrics: Arc<Mutex<HashMap<String, Vec<MetricPoint>>>>,
|
|
thresholds: HashMap<String, f64>,
|
|
start_time: Instant,
|
|
}
|
|
|
|
impl PerformanceTracker {
|
|
/// Create a new performance tracker
|
|
pub fn new(session_id: &str) -> Result<Self> {
|
|
info!(
|
|
"📊 Initializing performance tracker for session: {}",
|
|
session_id
|
|
);
|
|
|
|
let thresholds = Self::create_default_thresholds();
|
|
|
|
Ok(Self {
|
|
session_id: session_id.to_string(),
|
|
metrics: Arc::new(Mutex::new(HashMap::new())),
|
|
thresholds,
|
|
start_time: Instant::now(),
|
|
})
|
|
}
|
|
|
|
/// Record a metric value
|
|
pub fn record_metric(&self, name: &str, value: f64) -> Result<()> {
|
|
self.record_metric_with_tags(name, value, HashMap::new())
|
|
}
|
|
|
|
/// Record a metric value with tags
|
|
pub fn record_metric_with_tags(
|
|
&self,
|
|
name: &str,
|
|
value: f64,
|
|
tags: HashMap<String, String>,
|
|
) -> Result<()> {
|
|
let mut metrics = self.metrics.lock().unwrap();
|
|
|
|
let metric_point = MetricPoint {
|
|
timestamp: chrono::Utc::now(),
|
|
value,
|
|
tags,
|
|
};
|
|
|
|
metrics
|
|
.entry(name.to_string())
|
|
.or_default()
|
|
.push(metric_point);
|
|
|
|
debug!("📈 Recorded metric '{}' = {}", name, value);
|
|
|
|
// Check threshold if configured
|
|
if let Some(&threshold) = self.thresholds.get(name) {
|
|
if value > threshold {
|
|
warn!(
|
|
"⚠️ Metric '{}' ({}) exceeds threshold ({})",
|
|
name, value, threshold
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Record latency metric from duration
|
|
pub fn record_latency(&self, operation: &str, duration: Duration) -> Result<()> {
|
|
let latency_ms = duration.as_millis() as f64;
|
|
let mut tags = HashMap::new();
|
|
tags.insert("operation".to_string(), operation.to_string());
|
|
|
|
self.record_metric_with_tags("latency_ms", latency_ms, tags)
|
|
}
|
|
|
|
/// Start latency tracking
|
|
pub fn start_latency_tracking(&self, operation: &str) -> LatencyTracker {
|
|
LatencyTracker::start(operation)
|
|
}
|
|
|
|
/// Record throughput metric (operations per second)
|
|
pub fn record_throughput(
|
|
&self,
|
|
operation: &str,
|
|
operations: u64,
|
|
duration: Duration,
|
|
) -> Result<()> {
|
|
let throughput = operations as f64 / duration.as_secs_f64();
|
|
let mut tags = HashMap::new();
|
|
tags.insert("operation".to_string(), operation.to_string());
|
|
|
|
self.record_metric_with_tags("throughput_ops_per_sec", throughput, tags)
|
|
}
|
|
|
|
/// Record error rate metric
|
|
pub fn record_error_rate(&self, operation: &str, errors: u64, total: u64) -> Result<()> {
|
|
let error_rate = if total > 0 {
|
|
errors as f64 / total as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
let mut tags = HashMap::new();
|
|
tags.insert("operation".to_string(), operation.to_string());
|
|
|
|
self.record_metric_with_tags("error_rate", error_rate, tags)
|
|
}
|
|
|
|
/// Get statistics for a metric
|
|
pub fn get_metric_stats(&self, name: &str) -> Result<Option<MetricStats>> {
|
|
let metrics = self.metrics.lock().unwrap();
|
|
|
|
if let Some(points) = metrics.get(name) {
|
|
if points.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let values: Vec<f64> = points.iter().map(|p| p.value).collect();
|
|
let stats = Self::calculate_stats(&values);
|
|
Ok(Some(stats))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Get all metric names
|
|
pub fn get_metric_names(&self) -> Vec<String> {
|
|
let metrics = self.metrics.lock().unwrap();
|
|
metrics.keys().cloned().collect()
|
|
}
|
|
|
|
/// Get metric values for a specific metric
|
|
pub fn get_metric_values(&self, name: &str) -> Result<Vec<f64>> {
|
|
let metrics = self.metrics.lock().unwrap();
|
|
|
|
Ok(metrics
|
|
.get(name)
|
|
.map(|points| points.iter().map(|p| p.value).collect())
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
/// Generate performance report
|
|
pub fn generate_report(&self) -> Result<PerformanceReport> {
|
|
let metrics = self.metrics.lock().unwrap();
|
|
let session_duration = self.start_time.elapsed();
|
|
|
|
let mut metric_summaries = HashMap::new();
|
|
let mut violations = Vec::new();
|
|
|
|
for (name, points) in metrics.iter() {
|
|
if !points.is_empty() {
|
|
let values: Vec<f64> = points.iter().map(|p| p.value).collect();
|
|
let stats = Self::calculate_stats(&values);
|
|
|
|
// Check for threshold violations
|
|
if let Some(&threshold) = self.thresholds.get(name) {
|
|
if stats.max > threshold {
|
|
violations.push(ThresholdViolation {
|
|
metric_name: name.clone(),
|
|
threshold,
|
|
actual_value: stats.max,
|
|
violation_type: "max_exceeded".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
metric_summaries.insert(name.clone(), stats);
|
|
}
|
|
}
|
|
|
|
Ok(PerformanceReport {
|
|
session_id: self.session_id.clone(),
|
|
session_duration,
|
|
metric_summaries,
|
|
threshold_violations: violations,
|
|
total_metrics_collected: metrics.len(),
|
|
report_generated_at: chrono::Utc::now(),
|
|
})
|
|
}
|
|
|
|
/// Export metrics to JSON
|
|
pub fn export_metrics_json(&self) -> Result<String> {
|
|
let metrics = self.metrics.lock().unwrap();
|
|
let export_data = ExportData {
|
|
session_id: self.session_id.clone(),
|
|
session_duration_ms: self.start_time.elapsed().as_millis() as u64,
|
|
metrics: metrics.clone(),
|
|
exported_at: chrono::Utc::now(),
|
|
};
|
|
|
|
serde_json::to_string_pretty(&export_data).context("Failed to serialize metrics to JSON")
|
|
}
|
|
|
|
/// Save metrics to file
|
|
pub fn save_metrics_to_file(&self, file_path: &str) -> Result<()> {
|
|
let json_data = self.export_metrics_json()?;
|
|
std::fs::write(file_path, json_data)
|
|
.with_context(|| format!("Failed to write metrics to file: {}", file_path))?;
|
|
|
|
info!("📁 Saved performance metrics to: {}", file_path);
|
|
Ok(())
|
|
}
|
|
|
|
/// Set threshold for a metric
|
|
pub fn set_threshold(&mut self, metric_name: &str, threshold: f64) {
|
|
self.thresholds.insert(metric_name.to_string(), threshold);
|
|
debug!("🎯 Set threshold for '{}': {}", metric_name, threshold);
|
|
}
|
|
|
|
/// Calculate statistics for values
|
|
fn calculate_stats(values: &[f64]) -> MetricStats {
|
|
if values.is_empty() {
|
|
return MetricStats {
|
|
count: 0,
|
|
sum: 0.0,
|
|
min: 0.0,
|
|
max: 0.0,
|
|
mean: 0.0,
|
|
std_dev: 0.0,
|
|
p50: 0.0,
|
|
p95: 0.0,
|
|
p99: 0.0,
|
|
};
|
|
}
|
|
|
|
let mut sorted_values = values.to_vec();
|
|
sorted_values.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
|
|
let count = values.len() as u64;
|
|
let sum: f64 = values.iter().sum();
|
|
let mean = sum / values.len() as f64;
|
|
let min = sorted_values[0];
|
|
let max = sorted_values[sorted_values.len() - 1];
|
|
|
|
// Calculate standard deviation
|
|
let variance: f64 =
|
|
values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / values.len() as f64;
|
|
let std_dev = variance.sqrt();
|
|
|
|
// Calculate percentiles
|
|
let p50 = Self::percentile(&sorted_values, 0.5);
|
|
let p95 = Self::percentile(&sorted_values, 0.95);
|
|
let p99 = Self::percentile(&sorted_values, 0.99);
|
|
|
|
MetricStats {
|
|
count,
|
|
sum,
|
|
min,
|
|
max,
|
|
mean,
|
|
std_dev,
|
|
p50,
|
|
p95,
|
|
p99,
|
|
}
|
|
}
|
|
|
|
/// Calculate percentile from sorted values
|
|
fn percentile(sorted_values: &[f64], percentile: f64) -> f64 {
|
|
if sorted_values.is_empty() {
|
|
return 0.0;
|
|
}
|
|
|
|
let index = (percentile * (sorted_values.len() - 1) as f64).round() as usize;
|
|
sorted_values[index.min(sorted_values.len() - 1)]
|
|
}
|
|
|
|
/// Create default thresholds for common metrics
|
|
fn create_default_thresholds() -> HashMap<String, f64> {
|
|
let mut thresholds = HashMap::new();
|
|
|
|
// Latency thresholds (milliseconds)
|
|
thresholds.insert("latency_ms".to_string(), 1000.0); // 1 second
|
|
thresholds.insert("order_submission_latency_ms".to_string(), 100.0);
|
|
thresholds.insert("ml_inference_latency_ms".to_string(), 200.0);
|
|
thresholds.insert("config_update_latency_ms".to_string(), 500.0);
|
|
|
|
// Error rate thresholds
|
|
thresholds.insert("error_rate".to_string(), 0.05); // 5%
|
|
|
|
// Throughput thresholds (minimum ops/sec)
|
|
thresholds.insert("throughput_ops_per_sec".to_string(), 1.0);
|
|
|
|
thresholds
|
|
}
|
|
}
|
|
|
|
/// Performance report structure
|
|
#[derive(Debug)]
|
|
pub struct PerformanceReport {
|
|
pub session_id: String,
|
|
pub session_duration: Duration,
|
|
pub metric_summaries: HashMap<String, MetricStats>,
|
|
pub threshold_violations: Vec<ThresholdViolation>,
|
|
pub total_metrics_collected: usize,
|
|
pub report_generated_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
impl PerformanceReport {
|
|
/// Print a summary of the performance report
|
|
pub fn print_summary(&self) {
|
|
info!("📊 Performance Report Summary");
|
|
info!("Session ID: {}", self.session_id);
|
|
info!("Session Duration: {:?}", self.session_duration);
|
|
info!("Total Metrics: {}", self.total_metrics_collected);
|
|
info!("Threshold Violations: {}", self.threshold_violations.len());
|
|
|
|
if !self.threshold_violations.is_empty() {
|
|
warn!("⚠️ Threshold Violations:");
|
|
for violation in &self.threshold_violations {
|
|
warn!(
|
|
" {} exceeded threshold {} with value {}",
|
|
violation.metric_name, violation.threshold, violation.actual_value
|
|
);
|
|
}
|
|
}
|
|
|
|
info!("📈 Key Metrics:");
|
|
for (name, stats) in &self.metric_summaries {
|
|
if name.contains("latency") {
|
|
info!(
|
|
" {}: mean={:.2}ms, p95={:.2}ms, p99={:.2}ms",
|
|
name, stats.mean, stats.p95, stats.p99
|
|
);
|
|
} else if name.contains("throughput") {
|
|
info!(
|
|
" {}: mean={:.2} ops/sec, max={:.2} ops/sec",
|
|
name, stats.mean, stats.max
|
|
);
|
|
} else {
|
|
info!(
|
|
" {}: mean={:.2}, min={:.2}, max={:.2}",
|
|
name, stats.mean, stats.min, stats.max
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Threshold violation information
|
|
#[derive(Debug)]
|
|
pub struct ThresholdViolation {
|
|
pub metric_name: String,
|
|
pub threshold: f64,
|
|
pub actual_value: f64,
|
|
pub violation_type: String,
|
|
}
|
|
|
|
/// Export data structure for JSON serialization
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct ExportData {
|
|
session_id: String,
|
|
session_duration_ms: u64,
|
|
metrics: HashMap<String, Vec<MetricPoint>>,
|
|
exported_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
impl serde::Serialize for MetricPoint {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
use serde::ser::SerializeStruct;
|
|
let mut state = serializer.serialize_struct("MetricPoint", 3)?;
|
|
state.serialize_field("timestamp", &self.timestamp.to_rfc3339())?;
|
|
state.serialize_field("value", &self.value)?;
|
|
state.serialize_field("tags", &self.tags)?;
|
|
state.end()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_performance_tracker_creation() {
|
|
let tracker = PerformanceTracker::new("test_session");
|
|
assert!(tracker.is_ok());
|
|
|
|
let tracker = tracker.unwrap();
|
|
assert_eq!(tracker.session_id, "test_session");
|
|
}
|
|
|
|
#[test]
|
|
fn test_metric_recording() {
|
|
let tracker = PerformanceTracker::new("test").unwrap();
|
|
|
|
let result = tracker.record_metric("test_metric", 42.0);
|
|
assert!(result.is_ok());
|
|
|
|
let values = tracker.get_metric_values("test_metric").unwrap();
|
|
assert_eq!(values.len(), 1);
|
|
assert_eq!(values[0], 42.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stats_calculation() {
|
|
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
|
let stats = PerformanceTracker::calculate_stats(&values);
|
|
|
|
assert_eq!(stats.count, 5);
|
|
assert_eq!(stats.min, 1.0);
|
|
assert_eq!(stats.max, 5.0);
|
|
assert_eq!(stats.mean, 3.0);
|
|
assert_eq!(stats.p50, 3.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_latency_tracker() {
|
|
let tracker = LatencyTracker::start("test_operation");
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
let (operation, duration) = tracker.stop();
|
|
|
|
assert_eq!(operation, "test_operation");
|
|
assert!(duration.as_millis() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_percentile_calculation() {
|
|
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
|
assert_eq!(PerformanceTracker::percentile(&values, 0.0), 1.0);
|
|
assert_eq!(PerformanceTracker::percentile(&values, 0.5), 3.0);
|
|
assert_eq!(PerformanceTracker::percentile(&values, 1.0), 5.0);
|
|
}
|
|
}
|