Skip to main content

pg_logstats/
findings.rs

1//! Structured findings for investigation-oriented output.
2
3use crate::{CorrelationConfidence, QueryExecution, QueryFamilyIdentity, SourceReference};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7pub const FINDING_SCHEMA_VERSION: u32 = 1;
8
9/// Collection wrapper for versioned finding output.
10#[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/// Machine-readable investigation finding.
26#[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/// Finding family.
50#[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/// Compact reason codes intended for downstream tooling.
59#[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/// Overall confidence for ranking and evidence reconstruction.
73#[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/// Query-family dimensions included in query-family findings.
82#[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/// Summary metrics attached to a finding.
106#[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/// Window-specific metrics used by baseline-vs-target findings.
117#[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/// Deterministic deltas between target and baseline windows.
127#[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/// Thresholds for baseline-vs-target query family diffing.
137#[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
258/// Build ranked query-family findings from correlated executions.
259pub 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
343/// Build baseline-vs-target slow query findings from correlated executions.
344pub 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
535/// Build follow-up SQL suggestions for a query-family finding.
536pub 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}