perfgate_server/handlers/
trend.rs1use 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#[derive(Debug, Clone, Deserialize)]
24pub struct TrendQuery {
25 pub metric: String,
27 #[serde(default = "default_window")]
29 pub window: u32,
30 #[serde(default = "default_threshold")]
32 pub threshold: f64,
33 #[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#[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
61pub 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 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 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 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}