#!/usr/bin/env python3 """ Performance Regression Detector This script compares current load test results against a baseline to detect performance regressions. It checks P95 response times and RPS throughput, failing if regressions exceed the specified threshold. Usage: python compare_performance.py baseline.json current.json python compare_performance.py baseline.json current.json --threshold 20 Reference: Phase 209 Plan 05 Task 4 """ import argparse import json import sys from pathlib import Path from typing import Dict, List, Any def load_results(filepath: str) -> dict: """ Load Locust JSON results from file. Args: filepath: Path to Locust JSON output file Returns: Parsed JSON data as dictionary Raises: FileNotFoundError: If file doesn't exist json.JSONDecodeError: If file is not valid JSON """ path = Path(filepath) if not path.exists(): raise FileNotFoundError(f"Results file not found: {filepath}") with open(path, 'r') as f: try: data = json.load(f) except json.JSONDecodeError as e: raise json.JSONDecodeError(f"Invalid JSON in {filepath}: {e}", e.doc, e.pos) return data def extract_metrics(data: dict) -> Dict[str, Dict[str, float]]: """ Extract key metrics from Locust results. Args: data: Parsed Locust JSON output Returns: Dictionary mapping endpoint names to their metrics { "endpoint_name": { "p95_response_time": float, "requests_per_second": float, "avg_response_time": float } } """ stats = data.get('stats', []) metrics = {} # Calculate total duration in seconds state = data.get('state', {}) run_time_ms = state.get('run_time', 0) duration_seconds = run_time_ms / 1000 if run_time_ms > 0 else 1 for stat in stats: name = stat.get('name', 'unknown') num_requests = stat.get('num_requests', 0) # Extract P95 response time response_times = stat.get('response_times', {}) p95 = response_times.get('0.95', 0) # Calculate RPS for this endpoint rps = num_requests / duration_seconds if duration_seconds > 0 else 0 # Extract average response time avg_response = stat.get('avg_response_time', 0) metrics[name] = { 'p95_response_time': p95, 'requests_per_second': rps, 'avg_response_time': avg_response } return metrics def compare_metrics( baseline: dict, current: dict, threshold: float ) -> List[Dict[str, Any]]: """ Compare current metrics against baseline and detect regressions. A regression is detected when: - P95 response time increases by more than threshold % - RPS (throughput) decreases by more than threshold % Args: baseline: Baseline metrics dictionary current: Current metrics dictionary threshold: Percentage threshold for regression detection Returns: List of regression dictionaries with details """ regressions = [] for endpoint_name, current_metrics in current.items(): if endpoint_name not in baseline: # New endpoint, skip comparison continue baseline_metrics = baseline[endpoint_name] # Check P95 response time regression (increase is bad) baseline_p95 = baseline_metrics.get('p95_response_time', 0) current_p95 = current_metrics.get('p95_response_time', 0) if baseline_p95 > 0: p95_change = ((current_p95 - baseline_p95) / baseline_p95) * 100 if p95_change > threshold: regressions.append({ 'endpoint': endpoint_name, 'metric': 'p95_response_time', 'baseline': baseline_p95, 'current': current_p95, 'change_percent': p95_change, 'direction': 'increase' }) # Check RPS regression (decrease is bad) baseline_rps = baseline_metrics.get('requests_per_second', 0) current_rps = current_metrics.get('requests_per_second', 0) if baseline_rps > 0: rps_change = ((current_rps - baseline_rps) / baseline_rps) * 100 if rps_change < -threshold: # Negative change is regression regressions.append({ 'endpoint': endpoint_name, 'metric': 'requests_per_second', 'baseline': baseline_rps, 'current': current_rps, 'change_percent': abs(rps_change), 'direction': 'decrease' }) return regressions def format_regression(regression: Dict[str, Any]) -> str: """ Format a regression for display. Args: regression: Regression dictionary Returns: Formatted string """ endpoint = regression['endpoint'] metric = regression['metric'].replace('_', ' ').title() change = regression['change_percent'] direction = regression['direction'] if metric == 'P95 Response Time': baseline_str = f"{regression['baseline']:.0f}ms" current_str = f"{regression['current']:.0f}ms" else: # Requests Per Second baseline_str = f"{regression['baseline']:.1f}" current_str = f"{regression['current']:.1f}" return f"šŸ”“ {endpoint} - {metric}: {baseline_str} → {current_str} ({direction[:3]} {change:.1f}%)" def main(): """Main entry point for performance comparison.""" parser = argparse.ArgumentParser( description='Compare load test results against baseline to detect regressions' ) parser.add_argument( 'baseline', help='Path to baseline JSON file' ) parser.add_argument( 'current', help='Path to current JSON file' ) parser.add_argument( '--threshold', '-t', type=float, default=15.0, help='Regression threshold in percentage (default: 15%%)' ) args = parser.parse_args() try: # Load results print(f"Loading baseline from {args.baseline}...") baseline_data = load_results(args.baseline) baseline_metrics = extract_metrics(baseline_data) print(f"Loading current results from {args.current}...") current_data = load_results(args.current) current_metrics = extract_metrics(current_data) # Compare metrics print(f"\nComparing metrics (threshold: {args.threshold}%)...") regressions = compare_metrics(baseline_metrics, current_metrics, args.threshold) # Display results if not regressions: print(f"āœ… No performance regressions detected (threshold: {args.threshold}%)") return 0 else: print(f"\nāŒ PERFORMANCE REGRESSION DETECTED ({len(regressions)} regression{'s' if len(regressions) > 1 else ''})") print("\nRegressions:") for regression in regressions: print(f" {format_regression(regression)}") print(f"\nšŸ’” Tip: Update baseline only after verifying improvements are legitimate") return 1 except FileNotFoundError as e: print(f"āŒ Error: {e}", file=sys.stderr) return 1 except json.JSONDecodeError as e: print(f"āŒ Error: Invalid JSON - {e}", file=sys.stderr) return 1 except Exception as e: print(f"āŒ Error: {e}", file=sys.stderr) return 1 if __name__ == '__main__': sys.exit(main())