fraiseql_cli/commands/perf/
mod.rs1pub mod analysis;
17pub mod reader;
18
19#[cfg(test)]
20mod tests;
21
22use anyhow::Result;
23
24use self::{
25 analysis::{NullRateReport, NullRateRow, RegressionParams, RegressionReport, SkipReason},
26 reader::{ChangeLogSample, PerfReader},
27};
28use crate::commands::migrate::resolve_database_url;
29
30pub struct RegressionScanArgs {
32 pub database: Option<String>,
34 pub recent_days: i64,
36 pub baseline_days: i64,
38 pub min_samples: usize,
40 pub threshold_pct: f64,
42 pub min_delta_ms: f64,
44 pub object_type: Option<String>,
46 pub fail_on_regression: bool,
48 pub json: bool,
50 pub quiet: bool,
52}
53
54pub async fn run_regression_scan(args: RegressionScanArgs) -> Result<bool> {
66 let url = resolve_database_url(args.database.as_deref())?;
67 let reader = PerfReader::connect(&url)?;
68
69 let now_epoch = reader.db_now_epoch().await?;
70 let window_days = i32::try_from(args.recent_days + args.baseline_days).unwrap_or(i32::MAX);
71 let samples = reader.load_samples(window_days, args.object_type.as_deref()).await?;
72
73 let params = RegressionParams {
74 recent_days: args.recent_days,
75 baseline_days: args.baseline_days,
76 min_samples: args.min_samples,
77 threshold_pct: args.threshold_pct,
78 min_delta_ms: args.min_delta_ms,
79 };
80 let report = analysis::regression_scan(&samples, ¶ms, now_epoch);
81
82 render_regression(&report, args.json, args.quiet)?;
83
84 let has_regressions = !report.findings.is_empty();
85 Ok(!(args.fail_on_regression && has_regressions))
86}
87
88fn render_regression(report: &RegressionReport, json: bool, quiet: bool) -> Result<()> {
92 if json {
93 println!("{}", serde_json::to_string_pretty(report)?);
94 return Ok(());
95 }
96 if quiet {
97 return Ok(());
98 }
99
100 let s = &report.summary;
101 println!(
102 "perf regression-scan — {} regression(s) across {} operation(s) · {} samples · {} \
103 excluded (non-v2)",
104 s.regressions, s.groups_analyzed, s.total_samples, s.excluded_samples
105 );
106
107 if report.findings.is_empty() && report.skipped.is_empty() {
108 println!("no regressions detected");
109 return Ok(());
110 }
111
112 for f in &report.findings {
113 println!(
114 "WARN {}/{} p50 {:.0}ms → {:.0}ms ({:+.1}%) p95 {:.0}ms → {:.0}ms [baseline={} \
115 recent={}]",
116 f.object_type,
117 f.modification_type,
118 f.baseline_p50,
119 f.recent_p50,
120 f.pct_change,
121 f.baseline_p95,
122 f.recent_p95,
123 f.baseline_samples,
124 f.recent_samples,
125 );
126 }
127 for sk in &report.skipped {
128 let reason = match sk.reason {
129 SkipReason::InsufficientSamples => {
130 format!(
131 "insufficient samples (baseline={} recent={})",
132 sk.baseline_samples, sk.recent_samples
133 )
134 },
135 SkipReason::NoComparableV2Data => {
136 format!("no comparable v2 data ({} excluded)", sk.excluded_samples)
137 },
138 };
139 println!("SKIP {}/{} {reason}", sk.object_type, sk.modification_type);
140 }
141 Ok(())
142}
143
144async fn load(
146 database: Option<&str>,
147 days: i32,
148 object_type: Option<&str>,
149) -> Result<Vec<ChangeLogSample>> {
150 let url = resolve_database_url(database)?;
151 let reader = PerfReader::connect(&url)?;
152 reader.load_samples(days, object_type).await
153}
154
155pub async fn run_explore_slowest(
161 database: Option<String>,
162 days: i32,
163 object_type: Option<String>,
164 limit: usize,
165 json: bool,
166 quiet: bool,
167) -> Result<()> {
168 let samples = load(database.as_deref(), days, object_type.as_deref()).await?;
169 let rows = analysis::slowest(&samples, limit);
170
171 if json {
172 println!("{}", serde_json::to_string_pretty(&rows)?);
173 } else if !quiet {
174 if rows.is_empty() {
175 println!("perf explore slowest — no comparable (v2) mutations in the window");
176 } else {
177 println!("perf explore slowest — top {} by duration", rows.len());
178 for (i, r) in rows.iter().enumerate() {
179 println!(
180 "{:>3}. {:>7}ms {}/{} {} {}",
181 i + 1,
182 r.duration_ms,
183 r.object_type,
184 r.modification_type,
185 r.object_id.as_deref().unwrap_or("-"),
186 r.trace_id.as_deref().unwrap_or("-"),
187 );
188 }
189 }
190 }
191 Ok(())
192}
193
194pub async fn run_explore_null_rate(
200 database: Option<String>,
201 days: i32,
202 object_type: Option<String>,
203 json: bool,
204 quiet: bool,
205) -> Result<()> {
206 let samples = load(database.as_deref(), days, object_type.as_deref()).await?;
207 let report = analysis::null_rate(&samples);
208
209 if json {
210 println!("{}", serde_json::to_string_pretty(&report)?);
211 } else if !quiet {
212 render_null_rate(&report);
213 }
214 Ok(())
215}
216
217pub async fn run_explore_summary(
223 database: Option<String>,
224 days: i32,
225 object_type: Option<String>,
226 json: bool,
227 quiet: bool,
228) -> Result<()> {
229 let samples = load(database.as_deref(), days, object_type.as_deref()).await?;
230 let rows = analysis::summary(&samples);
231
232 if json {
233 println!("{}", serde_json::to_string_pretty(&rows)?);
234 } else if !quiet {
235 if rows.is_empty() {
236 println!("perf explore summary — no comparable (v2) mutations in the window");
237 } else {
238 println!("perf explore summary — latency percentiles (ms), slowest p95 first");
239 for r in &rows {
240 println!(
241 "{}/{} n={} p50={:.0} p95={:.0} p99={:.0} max={:.0}",
242 r.object_type, r.modification_type, r.count, r.p50, r.p95, r.p99, r.max,
243 );
244 }
245 }
246 }
247 Ok(())
248}
249
250fn render_null_rate(report: &NullRateReport) {
251 println!("perf explore null-rate — duration_ms completeness");
252 for row in &report.rows {
253 print_null_rate_row(row);
254 }
255 println!("---");
256 print_null_rate_row(&report.overall);
257}
258
259fn print_null_rate_row(r: &NullRateRow) {
260 println!(
261 "{}/{} total={} null={} non_v2={} null_rate={:.1}%",
262 r.object_type, r.modification_type, r.total, r.null_duration, r.non_v2, r.null_rate_pct,
263 );
264}