1use crate::{CorrelationConfidence, QueryExecution, QueryFamilyIdentity, SourceReference};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7pub const FINDING_SCHEMA_VERSION: u32 = 1;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct FindingSet {
12 pub schema_version: u32,
13 pub findings: Vec<Finding>,
14}
15
16impl FindingSet {
17 pub fn new(findings: Vec<Finding>) -> Self {
18 Self {
19 schema_version: FINDING_SCHEMA_VERSION,
20 findings,
21 }
22 }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Finding {
28 pub schema_version: u32,
29 pub finding_id: String,
30 pub kind: FindingKind,
31 pub rank: usize,
32 pub title: String,
33 pub reason: String,
34 pub reason_codes: Vec<ReasonCode>,
35 pub score: f64,
36 pub query_family: Option<QueryFamilyFinding>,
37 pub metrics: FindingMetrics,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub baseline: Option<ComparisonMetrics>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub target: Option<ComparisonMetrics>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub delta: Option<DeltaMetrics>,
44 pub evidence: Vec<SourceReference>,
45 pub confidence: FindingConfidence,
46 pub next_sql: Vec<String>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum FindingKind {
53 QueryFamily,
54 SlowQueryRegression,
55 ErrorClass,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ReasonCode {
62 HighTotalDuration,
63 HighMaxDuration,
64 CorrelatedDuration,
65 PartialCorrelation,
66 AbsentInBaseline,
67 P95Regressed,
68 RuntimeContributionIncreased,
69 MeetsEligibilityThresholds,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum FindingConfidence {
76 High,
77 Medium,
78 Low,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct QueryFamilyFinding {
84 pub query_family_id: String,
85 pub normalized_sql: String,
86 pub queryid: Option<String>,
87 pub database: Option<String>,
88 pub user: Option<String>,
89 pub application_name: Option<String>,
90}
91
92impl From<&QueryFamilyIdentity> for QueryFamilyFinding {
93 fn from(identity: &QueryFamilyIdentity) -> Self {
94 Self {
95 query_family_id: identity.family_id.clone(),
96 normalized_sql: identity.normalized_sql.clone(),
97 queryid: identity.queryid.clone(),
98 database: identity.database.clone(),
99 user: identity.user.clone(),
100 application_name: identity.application_name.clone(),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
107pub struct FindingMetrics {
108 pub execution_count: u64,
109 pub total_duration_ms: f64,
110 pub avg_duration_ms: f64,
111 pub max_duration_ms: f64,
112 pub correlated_execution_count: u64,
113 pub uncorrelated_execution_count: u64,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
118pub struct ComparisonMetrics {
119 pub execution_count: u64,
120 pub total_duration_ms: f64,
121 pub avg_duration_ms: f64,
122 pub p95_duration_ms: f64,
123 pub max_duration_ms: f64,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
128pub struct DeltaMetrics {
129 pub execution_count: i64,
130 pub total_duration_ms: f64,
131 pub avg_duration_ms: f64,
132 pub p95_duration_ms: f64,
133 pub max_duration_ms: f64,
134}
135
136#[derive(Debug, Clone, Copy)]
138pub struct SlowQueryDiffOptions {
139 pub limit: usize,
140 pub min_target_count: u64,
141 pub min_target_total_ms: f64,
142 pub min_p95_delta_ms: f64,
143}
144
145impl Default for SlowQueryDiffOptions {
146 fn default() -> Self {
147 Self {
148 limit: 10,
149 min_target_count: 1,
150 min_target_total_ms: 0.0,
151 min_p95_delta_ms: 0.0,
152 }
153 }
154}
155
156#[derive(Debug, Clone)]
157struct QueryFamilyAccumulator {
158 identity: QueryFamilyIdentity,
159 execution_count: u64,
160 total_duration_ms: f64,
161 max_duration_ms: f64,
162 correlated_execution_count: u64,
163 uncorrelated_execution_count: u64,
164 evidence: Vec<SourceReference>,
165}
166
167impl QueryFamilyAccumulator {
168 fn new(identity: QueryFamilyIdentity) -> Self {
169 Self {
170 identity,
171 execution_count: 0,
172 total_duration_ms: 0.0,
173 max_duration_ms: 0.0,
174 correlated_execution_count: 0,
175 uncorrelated_execution_count: 0,
176 evidence: Vec::new(),
177 }
178 }
179
180 fn add_execution(&mut self, execution: &QueryExecution) {
181 self.execution_count += 1;
182 if let Some(duration_ms) = execution.duration_ms {
183 self.total_duration_ms += duration_ms;
184 self.max_duration_ms = self.max_duration_ms.max(duration_ms);
185 }
186
187 match execution.confidence {
188 CorrelationConfidence::Exact => self.correlated_execution_count += 1,
189 CorrelationConfidence::StatementOnly => self.uncorrelated_execution_count += 1,
190 }
191
192 for source in &execution.evidence {
193 if self.evidence.len() >= 3 {
194 break;
195 }
196 self.evidence.push(source.clone());
197 }
198 }
199
200 fn into_finding(self, rank: usize) -> Finding {
201 let avg_duration_ms = if self.execution_count == 0 {
202 0.0
203 } else {
204 self.total_duration_ms / self.execution_count as f64
205 };
206
207 let metrics = FindingMetrics {
208 execution_count: self.execution_count,
209 total_duration_ms: self.total_duration_ms,
210 avg_duration_ms,
211 max_duration_ms: self.max_duration_ms,
212 correlated_execution_count: self.correlated_execution_count,
213 uncorrelated_execution_count: self.uncorrelated_execution_count,
214 };
215
216 let confidence = if self.uncorrelated_execution_count == 0 {
217 FindingConfidence::High
218 } else if self.correlated_execution_count > 0 {
219 FindingConfidence::Medium
220 } else {
221 FindingConfidence::Low
222 };
223
224 let mut reason_codes = vec![ReasonCode::HighTotalDuration, ReasonCode::HighMaxDuration];
225 if self.correlated_execution_count > 0 {
226 reason_codes.push(ReasonCode::CorrelatedDuration);
227 }
228 if self.uncorrelated_execution_count > 0 {
229 reason_codes.push(ReasonCode::PartialCorrelation);
230 }
231
232 let next_sql = suggest_sql_for_query_family(&self.identity);
233
234 Finding {
235 schema_version: FINDING_SCHEMA_VERSION,
236 finding_id: format!("query_family:{}", self.identity.family_id),
237 kind: FindingKind::QueryFamily,
238 rank,
239 title: "Query family with high total runtime".to_string(),
240 reason: format!(
241 "{} executions contributed {:.3} ms total runtime; max execution was {:.3} ms",
242 metrics.execution_count, metrics.total_duration_ms, metrics.max_duration_ms
243 ),
244 reason_codes,
245 score: metrics.total_duration_ms,
246 query_family: Some(QueryFamilyFinding::from(&self.identity)),
247 metrics,
248 baseline: None,
249 target: None,
250 delta: None,
251 evidence: self.evidence,
252 confidence,
253 next_sql,
254 }
255 }
256}
257
258pub fn query_family_findings(executions: &[QueryExecution], limit: usize) -> FindingSet {
260 let mut by_family: HashMap<String, QueryFamilyAccumulator> = HashMap::new();
261
262 for execution in executions {
263 let family_id = execution.query_family.family_id.clone();
264 by_family
265 .entry(family_id)
266 .or_insert_with(|| QueryFamilyAccumulator::new(execution.query_family.clone()))
267 .add_execution(execution);
268 }
269
270 let mut accumulators: Vec<_> = by_family.into_values().collect();
271 accumulators.sort_by(|a, b| {
272 b.total_duration_ms
273 .partial_cmp(&a.total_duration_ms)
274 .unwrap()
275 .then_with(|| a.identity.family_id.cmp(&b.identity.family_id))
276 });
277
278 let findings = accumulators
279 .into_iter()
280 .take(limit)
281 .enumerate()
282 .map(|(index, accumulator)| accumulator.into_finding(index + 1))
283 .collect();
284
285 FindingSet::new(findings)
286}
287
288#[derive(Debug, Clone)]
289struct DiffAccumulator {
290 identity: QueryFamilyIdentity,
291 durations: Vec<f64>,
292 correlated_execution_count: u64,
293 uncorrelated_execution_count: u64,
294 evidence: Vec<SourceReference>,
295}
296
297#[derive(Debug, Clone)]
298struct DiffCandidate {
299 score: f64,
300 accumulator: DiffAccumulator,
301 baseline: ComparisonMetrics,
302 target: ComparisonMetrics,
303 delta: DeltaMetrics,
304 absent_in_baseline: bool,
305 p95_regressed: bool,
306 runtime_increased: bool,
307}
308
309impl DiffAccumulator {
310 fn new(identity: QueryFamilyIdentity) -> Self {
311 Self {
312 identity,
313 durations: Vec::new(),
314 correlated_execution_count: 0,
315 uncorrelated_execution_count: 0,
316 evidence: Vec::new(),
317 }
318 }
319
320 fn add_execution(&mut self, execution: &QueryExecution) {
321 if let Some(duration_ms) = execution.duration_ms {
322 self.durations.push(duration_ms);
323 }
324
325 match execution.confidence {
326 CorrelationConfidence::Exact => self.correlated_execution_count += 1,
327 CorrelationConfidence::StatementOnly => self.uncorrelated_execution_count += 1,
328 }
329
330 for source in &execution.evidence {
331 if self.evidence.len() >= 3 {
332 break;
333 }
334 self.evidence.push(source.clone());
335 }
336 }
337
338 fn comparison_metrics(&self) -> ComparisonMetrics {
339 comparison_metrics(&self.durations)
340 }
341}
342
343pub fn slow_query_diff_findings(
345 baseline: &[QueryExecution],
346 target: &[QueryExecution],
347 options: SlowQueryDiffOptions,
348) -> FindingSet {
349 let baseline_by_family = diff_accumulators_by_family(baseline);
350 let target_by_family = diff_accumulators_by_family(target);
351 let mut candidates = Vec::new();
352
353 for (family_id, target_accumulator) in target_by_family {
354 let target_metrics = target_accumulator.comparison_metrics();
355 if target_metrics.execution_count < options.min_target_count
356 || target_metrics.total_duration_ms < options.min_target_total_ms
357 {
358 continue;
359 }
360
361 let baseline_metrics = baseline_by_family
362 .get(&family_id)
363 .map(|accumulator| accumulator.comparison_metrics())
364 .unwrap_or_else(|| comparison_metrics(&[]));
365 let delta = DeltaMetrics {
366 execution_count: target_metrics.execution_count as i64
367 - baseline_metrics.execution_count as i64,
368 total_duration_ms: target_metrics.total_duration_ms
369 - baseline_metrics.total_duration_ms,
370 avg_duration_ms: target_metrics.avg_duration_ms - baseline_metrics.avg_duration_ms,
371 p95_duration_ms: target_metrics.p95_duration_ms - baseline_metrics.p95_duration_ms,
372 max_duration_ms: target_metrics.max_duration_ms - baseline_metrics.max_duration_ms,
373 };
374
375 let absent_in_baseline = baseline_metrics.execution_count == 0;
376 let p95_regressed = delta.p95_duration_ms >= options.min_p95_delta_ms
377 && target_metrics.p95_duration_ms > baseline_metrics.p95_duration_ms;
378 let runtime_increased = delta.total_duration_ms > 0.0;
379
380 if !absent_in_baseline && !p95_regressed && !runtime_increased {
381 continue;
382 }
383
384 let score = if absent_in_baseline {
385 target_metrics.total_duration_ms + target_metrics.p95_duration_ms
386 } else {
387 delta.total_duration_ms.max(0.0) + delta.p95_duration_ms.max(0.0)
388 };
389
390 candidates.push(DiffCandidate {
391 score,
392 accumulator: target_accumulator,
393 baseline: baseline_metrics,
394 target: target_metrics,
395 delta,
396 absent_in_baseline,
397 p95_regressed,
398 runtime_increased,
399 });
400 }
401
402 candidates.sort_by(|a, b| {
403 b.score.partial_cmp(&a.score).unwrap().then_with(|| {
404 a.accumulator
405 .identity
406 .family_id
407 .cmp(&b.accumulator.identity.family_id)
408 })
409 });
410
411 let findings = candidates
412 .into_iter()
413 .take(options.limit)
414 .enumerate()
415 .map(|(index, candidate)| diff_finding(index + 1, candidate))
416 .collect();
417
418 FindingSet::new(findings)
419}
420
421fn diff_accumulators_by_family(executions: &[QueryExecution]) -> HashMap<String, DiffAccumulator> {
422 let mut by_family = HashMap::new();
423
424 for execution in executions {
425 let family_id = execution.query_family.family_id.clone();
426 by_family
427 .entry(family_id)
428 .or_insert_with(|| DiffAccumulator::new(execution.query_family.clone()))
429 .add_execution(execution);
430 }
431
432 by_family
433}
434
435fn comparison_metrics(durations: &[f64]) -> ComparisonMetrics {
436 if durations.is_empty() {
437 return ComparisonMetrics {
438 execution_count: 0,
439 total_duration_ms: 0.0,
440 avg_duration_ms: 0.0,
441 p95_duration_ms: 0.0,
442 max_duration_ms: 0.0,
443 };
444 }
445
446 let mut sorted = durations.to_vec();
447 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
448 let total_duration_ms = sorted.iter().sum::<f64>();
449 let execution_count = sorted.len() as u64;
450 let p95_index = (sorted.len() as f64 * 0.95) as usize;
451
452 ComparisonMetrics {
453 execution_count,
454 total_duration_ms,
455 avg_duration_ms: total_duration_ms / execution_count as f64,
456 p95_duration_ms: sorted[p95_index.min(sorted.len() - 1)],
457 max_duration_ms: *sorted.last().unwrap(),
458 }
459}
460
461fn diff_finding(rank: usize, candidate: DiffCandidate) -> Finding {
462 let DiffCandidate {
463 score,
464 accumulator,
465 baseline,
466 target,
467 delta,
468 absent_in_baseline,
469 p95_regressed,
470 runtime_increased,
471 } = candidate;
472
473 let mut reason_codes = vec![ReasonCode::MeetsEligibilityThresholds];
474 let mut reason_parts = Vec::new();
475
476 if absent_in_baseline {
477 reason_codes.push(ReasonCode::AbsentInBaseline);
478 reason_parts.push("absent in baseline".to_string());
479 }
480 if p95_regressed {
481 reason_codes.push(ReasonCode::P95Regressed);
482 reason_parts.push(format!("p95 increased by {:.3} ms", delta.p95_duration_ms));
483 }
484 if runtime_increased {
485 reason_codes.push(ReasonCode::RuntimeContributionIncreased);
486 reason_parts.push(format!(
487 "total runtime increased by {:.3} ms",
488 delta.total_duration_ms
489 ));
490 }
491 if accumulator.correlated_execution_count > 0 {
492 reason_codes.push(ReasonCode::CorrelatedDuration);
493 }
494 if accumulator.uncorrelated_execution_count > 0 {
495 reason_codes.push(ReasonCode::PartialCorrelation);
496 }
497
498 let confidence = if accumulator.uncorrelated_execution_count == 0 {
499 FindingConfidence::High
500 } else if accumulator.correlated_execution_count > 0 {
501 FindingConfidence::Medium
502 } else {
503 FindingConfidence::Low
504 };
505
506 let metrics = FindingMetrics {
507 execution_count: target.execution_count,
508 total_duration_ms: target.total_duration_ms,
509 avg_duration_ms: target.avg_duration_ms,
510 max_duration_ms: target.max_duration_ms,
511 correlated_execution_count: accumulator.correlated_execution_count,
512 uncorrelated_execution_count: accumulator.uncorrelated_execution_count,
513 };
514
515 Finding {
516 schema_version: FINDING_SCHEMA_VERSION,
517 finding_id: format!("slow_query_diff:{}", accumulator.identity.family_id),
518 kind: FindingKind::SlowQueryRegression,
519 rank,
520 title: "Query family regressed in target window".to_string(),
521 reason: reason_parts.join("; "),
522 reason_codes,
523 score,
524 query_family: Some(QueryFamilyFinding::from(&accumulator.identity)),
525 metrics,
526 baseline: Some(baseline),
527 target: Some(target),
528 delta: Some(delta),
529 evidence: accumulator.evidence,
530 confidence,
531 next_sql: suggest_sql_for_query_family(&accumulator.identity),
532 }
533}
534
535pub fn suggest_sql_for_query_family(identity: &QueryFamilyIdentity) -> Vec<String> {
537 let mut statements = Vec::new();
538
539 if let Some(queryid) = &identity.queryid {
540 statements.push(format!(
541 "select queryid, calls, total_exec_time, mean_exec_time, rows, query \
542from pg_stat_statements where queryid = {};",
543 queryid
544 ));
545 } else {
546 statements.push(format!(
547 "select queryid, calls, total_exec_time, mean_exec_time, rows, query \
548from pg_stat_statements where query ilike '%{}%' order by total_exec_time desc limit 20;",
549 escape_like_literal(&identity.normalized_sql)
550 ));
551 }
552
553 let mut activity_filters = Vec::new();
554 if let Some(database) = &identity.database {
555 activity_filters.push(format!("datname = '{}'", escape_sql_literal(database)));
556 }
557 if let Some(user) = &identity.user {
558 activity_filters.push(format!("usename = '{}'", escape_sql_literal(user)));
559 }
560 if let Some(application_name) = &identity.application_name {
561 activity_filters.push(format!(
562 "application_name = '{}'",
563 escape_sql_literal(application_name)
564 ));
565 }
566
567 let where_clause = if activity_filters.is_empty() {
568 "state <> 'idle'".to_string()
569 } else {
570 activity_filters.join(" and ")
571 };
572
573 statements.push(format!(
574 "select pid, usename, datname, application_name, state, wait_event_type, \
575wait_event, query_start, query from pg_stat_activity where {} \
576order by query_start desc nulls last limit 20;",
577 where_clause
578 ));
579
580 statements
581}
582
583fn escape_sql_literal(value: &str) -> String {
584 value.replace('\'', "''")
585}
586
587fn escape_like_literal(value: &str) -> String {
588 escape_sql_literal(value)
589 .replace('\\', "\\\\")
590 .replace('%', "\\%")
591 .replace('_', "\\_")
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::{
598 CorrelationConfidence, EventSourceKind, Query, QueryExecution, QueryFamilyIdentity,
599 SessionIdentity, SourceReference,
600 };
601 use chrono::{TimeZone, Utc};
602
603 fn execution(sql: &str, duration_ms: Option<f64>, record_index: usize) -> QueryExecution {
604 let session = SessionIdentity {
605 process_id: "12345".to_string(),
606 user: Some("app".to_string()),
607 database: Some("appdb".to_string()),
608 client_host: None,
609 application_name: Some("api".to_string()),
610 };
611 let queries = Query::from_sql(sql).unwrap();
612 let normalized_sql = queries[0].normalized_query.clone();
613 let query_family = QueryFamilyIdentity::new(normalized_sql, &session, None);
614
615 QueryExecution {
616 execution_id: format!("stderr:{record_index}"),
617 timestamp: Utc.with_ymd_and_hms(2024, 8, 15, 10, 30, 0).unwrap(),
618 session,
619 statement: sql.to_string(),
620 queries,
621 query_family,
622 duration_ms,
623 evidence: vec![SourceReference {
624 source_kind: EventSourceKind::Stderr,
625 record_index,
626 }],
627 confidence: if duration_ms.is_some() {
628 CorrelationConfidence::Exact
629 } else {
630 CorrelationConfidence::StatementOnly
631 },
632 }
633 }
634
635 #[test]
636 fn ranks_query_family_findings_by_total_duration() {
637 let executions = vec![
638 execution("SELECT * FROM users WHERE id = 1", Some(50.0), 0),
639 execution("SELECT * FROM users WHERE id = 2", Some(75.0), 1),
640 execution("SELECT * FROM orders WHERE id = 1", Some(250.0), 2),
641 ];
642
643 let findings = query_family_findings(&executions, 10);
644
645 assert_eq!(findings.schema_version, 1);
646 assert_eq!(findings.findings.len(), 2);
647 assert_eq!(findings.findings[0].rank, 1);
648 assert_eq!(findings.findings[0].metrics.total_duration_ms, 250.0);
649 assert_eq!(findings.findings[1].metrics.total_duration_ms, 125.0);
650 }
651
652 #[test]
653 fn includes_evidence_and_correlation_reason_codes() {
654 let executions = vec![
655 execution("SELECT * FROM users WHERE id = 1", Some(50.0), 0),
656 execution("SELECT * FROM users WHERE id = 2", None, 1),
657 ];
658
659 let findings = query_family_findings(&executions, 10);
660 let finding = &findings.findings[0];
661
662 assert_eq!(finding.schema_version, 1);
663 assert_eq!(finding.kind, FindingKind::QueryFamily);
664 assert_eq!(finding.confidence, FindingConfidence::Medium);
665 assert_eq!(finding.evidence.len(), 2);
666 assert!(finding
667 .reason_codes
668 .contains(&ReasonCode::CorrelatedDuration));
669 assert!(finding
670 .reason_codes
671 .contains(&ReasonCode::PartialCorrelation));
672 assert_eq!(finding.metrics.execution_count, 2);
673 assert_eq!(finding.metrics.correlated_execution_count, 1);
674 assert_eq!(finding.metrics.uncorrelated_execution_count, 1);
675 assert_eq!(finding.next_sql.len(), 2);
676 assert!(finding.next_sql[0].contains("pg_stat_statements"));
677 assert!(finding.next_sql[1].contains("pg_stat_activity"));
678 }
679
680 #[test]
681 fn slow_query_diff_flags_query_absent_in_baseline() {
682 let baseline = vec![execution("SELECT * FROM users WHERE id = 1", Some(25.0), 0)];
683 let target = vec![execution(
684 "SELECT * FROM orders WHERE id = 1",
685 Some(200.0),
686 1,
687 )];
688
689 let findings = slow_query_diff_findings(
690 &baseline,
691 &target,
692 SlowQueryDiffOptions {
693 limit: 10,
694 min_target_count: 1,
695 min_target_total_ms: 0.0,
696 min_p95_delta_ms: 0.0,
697 },
698 );
699
700 assert_eq!(findings.findings.len(), 1);
701 let finding = &findings.findings[0];
702 assert_eq!(finding.kind, FindingKind::SlowQueryRegression);
703 assert!(finding.reason_codes.contains(&ReasonCode::AbsentInBaseline));
704 assert_eq!(finding.baseline.unwrap().execution_count, 0);
705 assert_eq!(finding.target.unwrap().total_duration_ms, 200.0);
706 assert_eq!(finding.delta.unwrap().total_duration_ms, 200.0);
707 }
708
709 #[test]
710 fn slow_query_diff_flags_p95_regression() {
711 let baseline = vec![
712 execution("SELECT * FROM users WHERE id = 1", Some(20.0), 0),
713 execution("SELECT * FROM users WHERE id = 2", Some(30.0), 1),
714 ];
715 let target = vec![
716 execution("SELECT * FROM users WHERE id = 3", Some(100.0), 2),
717 execution("SELECT * FROM users WHERE id = 4", Some(150.0), 3),
718 ];
719
720 let findings = slow_query_diff_findings(
721 &baseline,
722 &target,
723 SlowQueryDiffOptions {
724 limit: 10,
725 min_target_count: 1,
726 min_target_total_ms: 0.0,
727 min_p95_delta_ms: 50.0,
728 },
729 );
730
731 assert_eq!(findings.findings.len(), 1);
732 let finding = &findings.findings[0];
733 assert!(finding.reason_codes.contains(&ReasonCode::P95Regressed));
734 assert!(finding
735 .reason_codes
736 .contains(&ReasonCode::RuntimeContributionIncreased));
737 assert_eq!(finding.baseline.unwrap().p95_duration_ms, 30.0);
738 assert_eq!(finding.target.unwrap().p95_duration_ms, 150.0);
739 assert_eq!(finding.delta.unwrap().p95_duration_ms, 120.0);
740 assert_eq!(finding.next_sql.len(), 2);
741 }
742
743 #[test]
744 fn slow_query_diff_applies_target_eligibility_thresholds() {
745 let baseline = Vec::new();
746 let target = vec![execution("SELECT * FROM users WHERE id = 1", Some(20.0), 0)];
747
748 let findings = slow_query_diff_findings(
749 &baseline,
750 &target,
751 SlowQueryDiffOptions {
752 limit: 10,
753 min_target_count: 2,
754 min_target_total_ms: 100.0,
755 min_p95_delta_ms: 0.0,
756 },
757 );
758
759 assert!(findings.findings.is_empty());
760 }
761
762 #[test]
763 fn suggest_sql_escapes_identity_fields() {
764 let session = SessionIdentity {
765 process_id: "12345".to_string(),
766 user: Some("app'user".to_string()),
767 database: Some("app_db".to_string()),
768 client_host: None,
769 application_name: Some("api%worker".to_string()),
770 };
771 let identity = QueryFamilyIdentity::new(
772 "select * from orders where note = 'abc_%'".to_string(),
773 &session,
774 None,
775 );
776
777 let sql = suggest_sql_for_query_family(&identity);
778
779 assert_eq!(sql.len(), 2);
780 assert!(sql[0].contains("pg_stat_statements"));
781 assert!(sql[0].contains("abc\\_\\%"));
782 assert!(sql[1].contains("usename = 'app''user'"));
783 assert!(sql[1].contains("application_name = 'api%worker'"));
784 }
785}