Skip to main content

perfgate_server/handlers/
trend.rs

1//! Trend analysis handlers.
2//!
3//! Provides an endpoint to analyze metric trends for a benchmark
4//! and predict budget threshold breaches.
5
6use axum::{
7    Extension, Json,
8    extract::{Path, Query, State},
9    http::StatusCode,
10    response::IntoResponse,
11};
12use serde::{Deserialize, Serialize};
13use tracing::error;
14
15use crate::auth::{AuthContext, Scope, check_scope};
16use crate::models::ApiError;
17use crate::server::AppState;
18use perfgate_domain::{TrendConfig, metric_value};
19use perfgate_stats::trend::{TrendAnalysis, analyze_trend, spark_chart};
20use perfgate_types::Metric;
21
22/// Query parameters for trend analysis.
23#[derive(Debug, Clone, Deserialize)]
24pub struct TrendQuery {
25    /// Metric to analyze (e.g., wall_ms, cpu_ms, max_rss_kb).
26    pub metric: String,
27    /// Number of recent baselines to use for trend analysis.
28    #[serde(default = "default_window")]
29    pub window: u32,
30    /// Budget threshold as a fraction (e.g., 0.20 for 20% regression allowed).
31    #[serde(default = "default_threshold")]
32    pub threshold: f64,
33    /// Number of runs within which a breach is considered "critical".
34    #[serde(default = "default_critical_window")]
35    pub critical_window: u32,
36}
37
38fn default_window() -> u32 {
39    30
40}
41
42fn default_threshold() -> f64 {
43    0.20
44}
45
46fn default_critical_window() -> u32 {
47    10
48}
49
50/// Response for trend analysis.
51#[derive(Debug, Clone, Serialize)]
52pub struct TrendResponse {
53    pub project: String,
54    pub benchmark: String,
55    pub analysis: Option<TrendAnalysis>,
56    pub values: Vec<f64>,
57    pub spark: String,
58    pub data_points: usize,
59}
60
61/// Analyze metric trends for a benchmark.
62///
63/// `GET /api/v1/projects/{project}/baselines/{benchmark}/trend?metric=wall_ms&window=30`
64pub async fn get_trend(
65    Path((project, benchmark)): Path<(String, String)>,
66    Extension(auth_ctx): Extension<AuthContext>,
67    State(state): State<AppState>,
68    Query(query): Query<TrendQuery>,
69) -> Result<impl IntoResponse, (StatusCode, Json<ApiError>)> {
70    check_scope(Some(&auth_ctx), &project, Some(&benchmark), Scope::Read)?;
71    let store = &state.store;
72
73    let metric = Metric::parse_key(&query.metric).ok_or_else(|| {
74        (
75            StatusCode::BAD_REQUEST,
76            Json(ApiError::validation(&format!(
77                "Unknown metric: {}",
78                query.metric
79            ))),
80        )
81    })?;
82
83    // Fetch recent baselines for this benchmark
84    let list_query = crate::models::ListBaselinesQuery {
85        benchmark: Some(benchmark.clone()),
86        include_receipt: true,
87        limit: query.window,
88        ..Default::default()
89    };
90
91    let baselines = match store.list(&project, &list_query).await {
92        Ok(response) => response.baselines,
93        Err(e) => {
94            error!(error = %e, "Failed to list baselines for trend analysis");
95            return Err((
96                StatusCode::INTERNAL_SERVER_ERROR,
97                Json(ApiError::internal_error(&e.to_string())),
98            ));
99        }
100    };
101
102    // Extract metric values from baseline receipts (sorted chronologically)
103    let mut entries: Vec<(chrono::DateTime<chrono::Utc>, f64)> = baselines
104        .iter()
105        .filter_map(|b| {
106            let receipt = b.receipt.as_ref()?;
107            let value = metric_value(&receipt.stats, metric)?;
108            Some((b.created_at, value))
109        })
110        .collect();
111
112    // Sort by creation time (oldest first)
113    entries.sort_by_key(|(ts, _)| *ts);
114
115    let values: Vec<f64> = entries.iter().map(|(_, v)| *v).collect();
116    let data_points = values.len();
117    let spark = spark_chart(&values);
118
119    let direction = metric.default_direction();
120    let lower_is_better = direction == perfgate_types::Direction::Lower;
121
122    let analysis = if values.len() >= 2 {
123        let baseline_value = values[0];
124        let absolute_threshold = if lower_is_better {
125            baseline_value * (1.0 + query.threshold)
126        } else {
127            baseline_value * (1.0 - query.threshold)
128        };
129
130        let config = TrendConfig {
131            critical_window: query.critical_window,
132            ..TrendConfig::default()
133        };
134
135        analyze_trend(
136            &values,
137            metric.as_str(),
138            absolute_threshold,
139            lower_is_better,
140            &config,
141        )
142    } else {
143        None
144    };
145
146    Ok(Json(TrendResponse {
147        project,
148        benchmark,
149        analysis,
150        values,
151        spark,
152        data_points,
153    }))
154}