Skip to main content

sentinel_core/detect/
mod.rs

1//! Detection stage: identifies performance anti-patterns in traces.
2
3pub mod chatty;
4pub mod correlate_cross;
5pub mod fanout;
6#[cfg(test)]
7mod metamorphic;
8pub mod n_plus_one;
9pub mod pool_saturation;
10pub mod redundant;
11pub mod sanitizer_aware;
12pub mod serialized;
13pub mod slow;
14pub mod suggestions;
15
16pub use n_plus_one::DISCLOSURE_N_PLUS_ONE_THRESHOLD;
17
18use std::collections::HashMap;
19
20use crate::correlate::Trace;
21use crate::event::EventType;
22use serde::{Deserialize, Serialize};
23
24/// Precomputed per-trace indices shared by the fanout and serialized
25/// detectors. Both detectors need `children_by_parent` +
26/// `span_index`; building them once per trace and passing the struct
27/// halves the hot-path `HashMap` cost on traces that trigger both
28/// detectors.
29///
30/// `pub` visibility is required because [`fanout::detect_fanout`] and
31/// [`serialized::detect_serialized`] are public entry points that take
32/// a `&TraceIndices<'_>`. The internal `build` constructor stays
33/// `pub(super)` so external callers cannot bypass the `detect()`
34/// orchestrator to produce an inconsistent indices / trace pair.
35pub struct TraceIndices<'a> {
36    pub children_by_parent: HashMap<&'a str, Vec<usize>>,
37    pub span_index: HashMap<&'a str, usize>,
38}
39
40impl<'a> TraceIndices<'a> {
41    /// Build both indices in a single pass over the trace's spans.
42    #[must_use]
43    pub fn build(trace: &'a Trace) -> Self {
44        let mut children_by_parent: HashMap<&str, Vec<usize>> =
45            HashMap::with_capacity(trace.spans.len() / 4 + 1);
46        let mut span_index: HashMap<&str, usize> = HashMap::with_capacity(trace.spans.len());
47        for (idx, span) in trace.spans.iter().enumerate() {
48            span_index.insert(span.event.span_id.as_str(), idx);
49            if let Some(ref parent_id) = span.event.parent_span_id {
50                children_by_parent
51                    .entry(parent_id.as_str())
52                    .or_default()
53                    .push(idx);
54            }
55        }
56        Self {
57            children_by_parent,
58            span_index,
59        }
60    }
61}
62
63/// A detected performance anti-pattern.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct Finding {
66    /// The anti-pattern category (N+1, redundant, slow, fanout, etc.).
67    #[serde(rename = "type")]
68    pub finding_type: FindingType,
69    /// Severity level: critical, warning or info.
70    pub severity: Severity,
71    /// Trace identifier where the anti-pattern was detected.
72    pub trace_id: String,
73    /// Name of the service emitting the spans involved in the finding.
74    pub service: String,
75    /// Normalized inbound endpoint (route template) hosting the pattern.
76    pub source_endpoint: String,
77    /// Details of the matched pattern: template, occurrences, window, params.
78    pub pattern: Pattern,
79    /// Human-readable remediation hint for this finding.
80    pub suggestion: String,
81    /// Earliest timestamp among spans in the detected group.
82    pub first_timestamp: String,
83    /// Latest timestamp among spans in the detected group.
84    pub last_timestamp: String,
85    /// `GreenOps` impact estimate. Absent when green scoring is disabled.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub green_impact: Option<GreenImpact>,
88    /// Source context of this finding: CI batch run, staging daemon, or
89    /// production daemon. Used by downstream consumers (perf-lint) to
90    /// boost or reduce severity based on how the finding was produced.
91    ///
92    /// **Contract:** detectors always emit [`Confidence::default()`]
93    /// (= `CiBatch`); the real value is stamped by the pipeline caller
94    /// (`pipeline::analyze_with_traces` for batch, `daemon::process_traces`
95    /// for the daemon) after detection returns. This keeps the detector
96    /// layer oblivious to runtime context.
97    #[serde(default)]
98    pub confidence: Confidence,
99    /// How this finding's type was classified.
100    ///
101    /// `None` (default, omitted from JSON) means direct classification
102    /// via the standard pipeline rules (`distinct_params >= threshold`
103    /// for N+1, repeated identical `(template, params)` for redundant).
104    /// `Some(SanitizerHeuristic)` means the type was inferred via the
105    /// sanitizer-aware heuristic, because the OpenTelemetry agent
106    /// collapsed every parameter to `?` and the standard distinct-params
107    /// signal was unusable. Operators can filter on this field to spot
108    /// where the heuristic is firing.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub classification_method: Option<ClassificationMethod>,
111    /// Source code location from `OTel` `code.*` span attributes.
112    /// `None` when the instrumentation agent does not emit these attributes.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub code_location: Option<crate::event::CodeLocation>,
115    /// OpenTelemetry instrumentation scope chain from the originating
116    /// span and its ancestors (leaf to root, deduplicated). Primary
117    /// framework signal for [`suggestions::enrich`]. Empty when the
118    /// upstream format carries no scope info (Jaeger, Zipkin) or the
119    /// trace is synthetic.
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub instrumentation_scopes: Vec<String>,
122    /// Framework-specific actionable fix, populated by
123    /// [`suggestions::enrich`] after the per-trace detectors run. `None`
124    /// when no framework can be inferred or the `(finding_type,
125    /// framework)` pair has no mapping in the fixes table.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub suggested_fix: Option<suggestions::SuggestedFix>,
128    /// Canonical signature for ack matching, e.g.
129    /// `n_plus_one_sql:order-svc:POST_/api/orders:a3f8b2c1`. Always
130    /// present in JSON output so users can copy-paste it into
131    /// `.perf-sentinel-acknowledgments.toml`. Filled by
132    /// [`crate::acknowledgments::enrich_with_signatures`] at end of
133    /// `pipeline::analyze` and after deserializing baselines.
134    #[serde(default)]
135    pub signature: String,
136}
137
138/// Types of performance anti-patterns.
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum FindingType {
142    NPlusOneSql,
143    NPlusOneHttp,
144    RedundantSql,
145    RedundantHttp,
146    SlowSql,
147    SlowHttp,
148    ExcessiveFanout,
149    ChattyService,
150    PoolSaturation,
151    SerializedCalls,
152}
153
154/// Severity levels for findings.
155#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum Severity {
158    Critical,
159    Warning,
160    Info,
161}
162
163/// Source context for a [`Finding`]: where and how it was produced.
164///
165/// perf-lint consumes this field via its runtime-findings import path and
166/// uses it to adjust severity in the IDE. A `daemon_production` finding
167/// (observed on real production traffic) is a much stronger signal than a
168/// `ci_batch` finding (observed on a controlled integration test run with
169/// limited traffic shapes).
170#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
171#[serde(rename_all = "snake_case")]
172pub enum Confidence {
173    /// Batch `analyze` run on a developer machine, no CI environment
174    /// detected. Lowest confidence: ad-hoc local traces, uncontrolled run.
175    LocalBatch,
176    /// Batch `analyze` run in CI (integration tests). Low confidence:
177    /// limited traffic shapes, controlled environment.
178    ///
179    /// Marked `#[default]` so detectors that emit `Confidence::default()`
180    /// get a safe batch fallback, a forgotten stamp never inflates
181    /// perf-lint's severity to a daemon level.
182    #[default]
183    CiBatch,
184    /// Daemon `watch` run on staging traffic. Medium confidence: real
185    /// patterns but not production scale.
186    DaemonStaging,
187    /// Daemon `watch` run on production traffic. Highest confidence:
188    /// real patterns at real scale.
189    DaemonProduction,
190}
191
192impl Confidence {
193    /// Returns the `snake_case` string representation.
194    #[must_use]
195    pub const fn as_str(&self) -> &'static str {
196        match self {
197            Self::LocalBatch => "local_batch",
198            Self::CiBatch => "ci_batch",
199            Self::DaemonStaging => "daemon_staging",
200            Self::DaemonProduction => "daemon_production",
201        }
202    }
203
204    /// `true` for the two batch contexts (local / CI). The terminal report
205    /// stays quiet about confidence for batch runs and only surfaces it for
206    /// the stronger daemon signals.
207    #[must_use]
208    pub const fn is_batch(&self) -> bool {
209        matches!(self, Self::LocalBatch | Self::CiBatch)
210    }
211
212    /// Pick the batch confidence from whether a CI environment was detected.
213    /// Pure so the env detection (impure) stays at the call site.
214    #[must_use]
215    pub const fn batch_for_ci(is_ci: bool) -> Self {
216        if is_ci {
217            Self::CiBatch
218        } else {
219            Self::LocalBatch
220        }
221    }
222
223    /// Map confidence to a SARIF `rank` value (0-100).
224    ///
225    /// Rank is SARIF v2.1.0's standard "how much should this matter"
226    /// signal: 0 = low priority, 100 = highest. Populating it means
227    /// SARIF consumers that ignore the custom `properties` bag still
228    /// get a usable ordering.
229    #[must_use]
230    pub const fn sarif_rank(&self) -> u32 {
231        match self {
232            Self::LocalBatch => 15,
233            Self::CiBatch => 30,
234            Self::DaemonStaging => 60,
235            Self::DaemonProduction => 90,
236        }
237    }
238}
239
240/// How a [`Finding`]'s type was determined.
241///
242/// Orthogonal to [`Confidence`]: confidence describes the runtime context
243/// (CI vs production daemon), `ClassificationMethod` describes which
244/// detection rule produced the type. Stored in
245/// [`Finding::classification_method`] as `Option`; `None` means the
246/// standard direct rule fired.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum ClassificationMethod {
250    /// Standard pipeline classification (e.g. `distinct_params >=
251    /// threshold` for N+1, repeated identical `(template, params)` for
252    /// redundant). Equivalent to the absence of the field; emitted
253    /// explicitly only when a caller wants to be unambiguous.
254    Direct,
255    /// Reclassified via a heuristic path. For SQL: the `OTel` agent's
256    /// sanitizer collapsed parameters to `?`, and the timing/scope
257    /// signals suggest N+1 over redundant. For HTTP: repeated identical
258    /// params with high timing variance suggest N+1 over redundant.
259    SanitizerHeuristic,
260}
261
262/// Pattern details for a finding.
263#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
264pub struct Pattern {
265    /// Normalized query or URL template shared by the matched spans.
266    pub template: String,
267    /// Number of spans that matched this template within the window.
268    pub occurrences: usize,
269    /// Time span, in milliseconds, covering all matched occurrences.
270    pub window_ms: u64,
271    /// Count of distinct parameter sets observed across occurrences.
272    pub distinct_params: usize,
273    /// Median per-span duration in the group (µs). Diagnostic field
274    /// populated by the n+1 and slow detectors. Not used in the
275    /// detection verdict, exposed so downstream consumers can profile
276    /// cache-warm patterns without needing daemon-log access.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub span_duration_us_p50: Option<u64>,
279    /// 99th-percentile per-span duration in the group (µs).
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub span_duration_us_p99: Option<u64>,
282    /// Coefficient of variation of per-span durations, scaled by 1000
283    /// (523 means CV = 0.523). Avoids floating-point fields so
284    /// `Pattern` can keep its `Eq` derive.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub span_duration_cv_x1000: Option<u32>,
287}
288
289/// `GreenOps` impact for a single finding.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291pub struct GreenImpact {
292    /// Extra I/O operations caused by this anti-pattern (occurrences - 1).
293    pub estimated_extra_io_ops: usize,
294    /// I/O Intensity Score of the endpoint where this finding occurs.
295    pub io_intensity_score: f64,
296    /// Classification band for `io_intensity_score`
297    /// (`healthy` / `moderate` / `high` / `critical`).
298    ///
299    /// Computed by [`crate::report::interpret::InterpretationLevel::for_iis`].
300    /// The enum values are stable across versions; the thresholds behind
301    /// them are versioned with the binary. See
302    /// [`crate::report::interpret`] for the stability contract.
303    pub io_intensity_band: crate::report::interpret::InterpretationLevel,
304}
305
306impl FindingType {
307    #[must_use]
308    pub const fn from_event_type_n_plus_one(event_type: &EventType) -> Self {
309        match event_type {
310            EventType::Sql => Self::NPlusOneSql,
311            EventType::HttpOut => Self::NPlusOneHttp,
312        }
313    }
314
315    #[must_use]
316    pub const fn from_event_type_redundant(event_type: &EventType) -> Self {
317        match event_type {
318            EventType::Sql => Self::RedundantSql,
319            EventType::HttpOut => Self::RedundantHttp,
320        }
321    }
322
323    #[must_use]
324    pub const fn from_event_type_slow(event_type: &EventType) -> Self {
325        match event_type {
326            EventType::Sql => Self::SlowSql,
327            EventType::HttpOut => Self::SlowHttp,
328        }
329    }
330
331    /// Returns the `snake_case` string representation of this finding type.
332    #[must_use]
333    pub const fn as_str(&self) -> &'static str {
334        match self {
335            Self::NPlusOneSql => "n_plus_one_sql",
336            Self::NPlusOneHttp => "n_plus_one_http",
337            Self::RedundantSql => "redundant_sql",
338            Self::RedundantHttp => "redundant_http",
339            Self::SlowSql => "slow_sql",
340            Self::SlowHttp => "slow_http",
341            Self::ExcessiveFanout => "excessive_fanout",
342            Self::ChattyService => "chatty_service",
343            Self::PoolSaturation => "pool_saturation",
344            Self::SerializedCalls => "serialized_calls",
345        }
346    }
347
348    /// RGESN 2024 criteria (ARCEP/Arcom/ADEME) this finding type relates to.
349    ///
350    /// An interpretive crosswalk, not a compliance certification: the RGESN
351    /// criterion titles do not name "N+1" or "slow query", these are the
352    /// criteria whose intent the anti-pattern bears on. `slow_*` returns an
353    /// empty slice on purpose, RGESN family 9 "Algorithmie" is ML-specific and
354    /// no criterion targets single-operation latency. Rationale and the full
355    /// crosswalk live in `docs/METHODOLOGY.md`.
356    #[must_use]
357    pub const fn rgesn_criteria(&self) -> &'static [&'static str] {
358        match self {
359            Self::NPlusOneSql | Self::NPlusOneHttp => &["7.1", "6.1"],
360            Self::RedundantSql | Self::RedundantHttp => &["7.1", "6.5"],
361            Self::ChattyService => &["4.9", "4.10", "6.1"],
362            Self::ExcessiveFanout | Self::PoolSaturation => &["3.2"],
363            Self::SerializedCalls => &["8.10"],
364            Self::SlowSql | Self::SlowHttp => &[],
365        }
366    }
367
368    /// Parse a `FindingType` from its `snake_case` string, the inverse of
369    /// [`as_str`](Self::as_str). Returns `None` for an unknown string.
370    #[must_use]
371    pub fn from_kind_str(s: &str) -> Option<Self> {
372        match s {
373            "n_plus_one_sql" => Some(Self::NPlusOneSql),
374            "n_plus_one_http" => Some(Self::NPlusOneHttp),
375            "redundant_sql" => Some(Self::RedundantSql),
376            "redundant_http" => Some(Self::RedundantHttp),
377            "slow_sql" => Some(Self::SlowSql),
378            "slow_http" => Some(Self::SlowHttp),
379            "excessive_fanout" => Some(Self::ExcessiveFanout),
380            "chatty_service" => Some(Self::ChattyService),
381            "pool_saturation" => Some(Self::PoolSaturation),
382            "serialized_calls" => Some(Self::SerializedCalls),
383            _ => None,
384        }
385    }
386
387    /// Returns a short human-readable label for CLI and TUI display.
388    #[must_use]
389    pub const fn display_label(&self) -> &'static str {
390        match self {
391            Self::NPlusOneSql => "N+1 SQL",
392            Self::NPlusOneHttp => "N+1 HTTP",
393            Self::RedundantSql => "Redundant SQL",
394            Self::RedundantHttp => "Redundant HTTP",
395            Self::SlowSql => "Slow SQL",
396            Self::SlowHttp => "Slow HTTP",
397            Self::ExcessiveFanout => "Excessive fanout",
398            Self::ChattyService => "Chatty service",
399            Self::PoolSaturation => "Pool saturation",
400            Self::SerializedCalls => "Serialized calls",
401        }
402    }
403
404    /// Whether this finding type represents avoidable I/O operations.
405    ///
406    /// Only N+1 and redundant qualify (batchable or cacheable). Slow,
407    /// fanout, chatty, pool saturation and serialized calls are excluded
408    /// from waste scoring; the per-type rationale is in the "Not part of
409    /// waste ratio" sections of `docs/design/04-DETECTION.md`.
410    #[must_use]
411    pub const fn is_avoidable_io(&self) -> bool {
412        matches!(
413            self,
414            Self::NPlusOneSql | Self::NPlusOneHttp | Self::RedundantSql | Self::RedundantHttp
415        )
416    }
417}
418
419impl Severity {
420    /// Returns the `snake_case` string representation of this severity.
421    #[must_use]
422    pub const fn as_str(&self) -> &'static str {
423        match self {
424            Self::Critical => "critical",
425            Self::Warning => "warning",
426            Self::Info => "info",
427        }
428    }
429}
430
431/// Configuration for the detection stage.
432#[derive(Debug, Clone)]
433pub struct DetectConfig {
434    pub n_plus_one_threshold: u32,
435    pub window_ms: u64,
436    pub slow_threshold_ms: u64,
437    pub slow_min_occurrences: u32,
438    pub max_fanout: u32,
439    pub chatty_service_min_calls: u32,
440    pub pool_saturation_concurrent_threshold: u32,
441    pub serialized_min_sequential: u32,
442    pub sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode,
443}
444
445impl From<&crate::config::Config> for DetectConfig {
446    fn from(config: &crate::config::Config) -> Self {
447        Self {
448            n_plus_one_threshold: config.detection.n_plus_one_threshold,
449            window_ms: config.detection.window_duration_ms,
450            slow_threshold_ms: config.detection.slow_query_threshold_ms,
451            slow_min_occurrences: config.detection.slow_query_min_occurrences,
452            max_fanout: config.detection.max_fanout,
453            chatty_service_min_calls: config.detection.chatty_service_min_calls,
454            pool_saturation_concurrent_threshold: config
455                .detection
456                .pool_saturation_concurrent_threshold,
457            serialized_min_sequential: config.detection.serialized_min_sequential,
458            sanitizer_aware_classification: config.detection.sanitizer_aware_classification,
459        }
460    }
461}
462
463/// Arguments for [`build_per_trace_finding`], grouped to stay under
464/// clippy's argument-count limit.
465pub(crate) struct PerTraceFindingArgs<'a> {
466    pub finding_type: FindingType,
467    pub severity: Severity,
468    pub trace_id: &'a str,
469    pub first_span: &'a crate::normalize::NormalizedEvent,
470    pub template: &'a str,
471    pub occurrences: usize,
472    pub window_ms: u64,
473    pub distinct_params: usize,
474    pub suggestion: String,
475    pub first_timestamp: &'a str,
476    pub last_timestamp: &'a str,
477    pub code_location: Option<crate::event::CodeLocation>,
478    pub instrumentation_scopes: Vec<String>,
479    pub classification_method: Option<ClassificationMethod>,
480    pub span_durations_us: Option<Vec<u64>>,
481}
482
483// Build a [`Finding`] from the common fields shared by per-trace
484// detectors (N+1, redundant, slow). Avoids duplicating the struct
485// literal across detection modules. (doc kept as non-doc comment to
486// avoid an empty-line-after-doc-comment clippy error with the next fn.)
487
488/// Compute diagnostic timing stats from a mutable slice of per-span
489/// durations (microseconds). Returns `(p50_us, p99_us, cv_x1000)`.
490fn compute_timing_stats(durations: &mut [u64]) -> (u64, u64, u32) {
491    if durations.is_empty() {
492        return (0, 0, 0);
493    }
494    durations.sort_unstable();
495    let n = durations.len();
496    let p50 = durations[slow::percentile_index(n, 50)];
497    let p99 = durations[slow::percentile_index(n, 99)];
498    #[allow(clippy::cast_precision_loss)]
499    let n_f = n as f64;
500    let mut mean = 0.0_f64;
501    let mut m2 = 0.0_f64;
502    let mut count = 0u64;
503    for &d in durations.iter() {
504        count += 1;
505        #[allow(clippy::cast_precision_loss)]
506        let val = d as f64;
507        let delta = val - mean;
508        #[allow(clippy::cast_precision_loss)]
509        let cf = count as f64;
510        mean += delta / cf;
511        m2 += delta * (val - mean);
512    }
513    let cv_x1000 = if mean > 0.0 && n_f > 1.0 {
514        let cv = (m2 / n_f).sqrt() / mean;
515        #[allow(clippy::cast_sign_loss)] // cv * 1000 is always non-negative
516        {
517            (cv * 1000.0).round() as u32
518        }
519    } else {
520        0
521    };
522    (p50, p99, cv_x1000)
523}
524
525pub(crate) fn build_per_trace_finding(args: PerTraceFindingArgs<'_>) -> Finding {
526    let timing = args
527        .span_durations_us
528        .map(|mut d| compute_timing_stats(&mut d));
529    Finding {
530        finding_type: args.finding_type,
531        severity: args.severity,
532        trace_id: args.trace_id.to_string(),
533        service: args.first_span.event.service.to_string(),
534        source_endpoint: args.first_span.event.source.endpoint.clone(),
535        pattern: Pattern {
536            template: args.template.to_string(),
537            occurrences: args.occurrences,
538            window_ms: args.window_ms,
539            distinct_params: args.distinct_params,
540            span_duration_us_p50: timing.map(|(p50, _, _)| p50),
541            span_duration_us_p99: timing.map(|(_, p99, _)| p99),
542            span_duration_cv_x1000: timing.map(|(_, _, cv)| cv),
543        },
544        suggestion: args.suggestion,
545        first_timestamp: args.first_timestamp.to_string(),
546        last_timestamp: args.last_timestamp.to_string(),
547        green_impact: None,
548        confidence: Confidence::default(),
549        classification_method: args.classification_method,
550        code_location: args.code_location,
551        instrumentation_scopes: args.instrumentation_scopes,
552        suggested_fix: None,
553        signature: String::new(),
554    }
555}
556
557/// Stamp `confidence` on every finding in the slice.
558///
559/// Detectors emit `Confidence::default()` (= [`Confidence::CiBatch`])
560/// per the contract on [`Finding::confidence`]. Pipeline callers
561/// override the value with the runtime context (`CiBatch` for batch
562/// `analyze`, `DaemonStaging` or `DaemonProduction` for the daemon)
563/// using this helper so neither the batch nor the daemon path has to
564/// duplicate the loop.
565pub fn apply_confidence(findings: &mut [Finding], confidence: Confidence) {
566    for finding in findings.iter_mut() {
567        finding.confidence = confidence;
568    }
569}
570
571/// Run per-trace + cross-trace detection on a set of traces.
572///
573/// Returns the unsorted, unconfidence-stamped `Vec<Finding>`. Callers
574/// stamp confidence via [`apply_confidence`] then sort via
575/// [`sort_findings`] before emission.
576///
577/// Cross-trace detection is gated on `traces.len() >= 2` because the
578/// percentile-based `detect_slow_cross_trace` requires multiple
579/// observations to compute a meaningful p50/p95/p99.
580#[must_use]
581pub fn run_full_detection(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
582    let mut findings = detect(traces, config);
583    if traces.len() >= 2 {
584        let mut cross_trace = slow::detect_slow_cross_trace(
585            traces,
586            config.slow_threshold_ms,
587            config.slow_min_occurrences,
588        );
589        findings.append(&mut cross_trace);
590    }
591    findings
592}
593
594/// Run all per-trace detectors on a set of traces.
595///
596/// Does not include cross-trace analysis; see [`slow::detect_slow_cross_trace`]
597/// or use [`run_full_detection`] for the combined pass.
598#[must_use]
599pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
600    let mut findings = Vec::new();
601    for trace in traces {
602        // Span-relationship indices are built once per trace and shared
603        // by the detectors that need them (fanout, serialized).
604        let indices = TraceIndices::build(trace);
605        // append() moves the backing allocation in O(1), no iterator
606        // overhead. n_plus_one must run before redundant: redundant
607        // receives its findings to skip templates already classified
608        // as N+1 (including sanitizer-heuristic reclassifications).
609        let mut n_plus_one_findings = n_plus_one::detect_n_plus_one(
610            trace,
611            config.n_plus_one_threshold,
612            config.window_ms,
613            config.sanitizer_aware_classification,
614        );
615        let mut redundant_findings = redundant::detect_redundant(trace, &n_plus_one_findings);
616        findings.append(&mut n_plus_one_findings);
617        findings.append(&mut redundant_findings);
618        findings.append(&mut slow::detect_slow(
619            trace,
620            config.slow_threshold_ms,
621            config.slow_min_occurrences,
622        ));
623        findings.append(&mut fanout::detect_fanout(
624            trace,
625            &indices,
626            config.max_fanout,
627        ));
628        findings.append(&mut chatty::detect_chatty(
629            trace,
630            config.chatty_service_min_calls,
631        ));
632        findings.append(&mut pool_saturation::detect_pool_saturation(
633            trace,
634            config.pool_saturation_concurrent_threshold,
635        ));
636        findings.append(&mut serialized::detect_serialized(
637            trace,
638            &indices,
639            config.serialized_min_sequential,
640        ));
641    }
642    suggestions::enrich(&mut findings);
643    findings
644}
645
646/// Sort findings deterministically for stable output.
647///
648/// Orders by finding type, severity, trace ID, source endpoint, and template.
649pub(crate) fn sort_findings(findings: &mut [Finding]) {
650    findings.sort_by(|a, b| {
651        a.finding_type
652            .cmp(&b.finding_type)
653            .then_with(|| a.severity.cmp(&b.severity))
654            .then_with(|| a.trace_id.cmp(&b.trace_id))
655            .then_with(|| a.source_endpoint.cmp(&b.source_endpoint))
656            .then_with(|| a.pattern.template.cmp(&b.pattern.template))
657    });
658}
659
660/// Test-only `Finding` factory shared by the `pg_stat` and `mysql_stat`
661/// cross-reference tests (only the pattern template matters to them).
662#[cfg(test)]
663pub(crate) fn test_finding_with_template(template: &str) -> Finding {
664    Finding {
665        finding_type: FindingType::NPlusOneSql,
666        severity: Severity::Warning,
667        trace_id: "trace-1".to_string(),
668        service: "order-svc".to_string(),
669        source_endpoint: "POST /api/orders/42/submit".to_string(),
670        pattern: Pattern {
671            template: template.to_string(),
672            occurrences: 6,
673            window_ms: 200,
674            distinct_params: 6,
675            ..Default::default()
676        },
677        suggestion: "batch".to_string(),
678        first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
679        last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
680        green_impact: None,
681        confidence: Confidence::default(),
682        classification_method: None,
683        code_location: None,
684        instrumentation_scopes: Vec::new(),
685        suggested_fix: None,
686        signature: String::new(),
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    fn default_config() -> DetectConfig {
695        DetectConfig {
696            n_plus_one_threshold: 5,
697            window_ms: 500,
698            slow_threshold_ms: 500,
699            slow_min_occurrences: 3,
700            max_fanout: 20,
701            chatty_service_min_calls: 15,
702            pool_saturation_concurrent_threshold: 10,
703            serialized_min_sequential: 3,
704            sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode::default(),
705        }
706    }
707
708    #[test]
709    fn empty_traces_produce_no_findings() {
710        let findings = detect(&[], &default_config());
711        assert!(findings.is_empty());
712    }
713
714    #[test]
715    fn finding_type_serializes_to_snake_case() {
716        let json = serde_json::to_string(&FindingType::NPlusOneSql).unwrap();
717        assert_eq!(json, r#""n_plus_one_sql""#);
718
719        let json = serde_json::to_string(&FindingType::RedundantHttp).unwrap();
720        assert_eq!(json, r#""redundant_http""#);
721
722        let json = serde_json::to_string(&FindingType::SlowSql).unwrap();
723        assert_eq!(json, r#""slow_sql""#);
724
725        let json = serde_json::to_string(&FindingType::SlowHttp).unwrap();
726        assert_eq!(json, r#""slow_http""#);
727
728        let json = serde_json::to_string(&FindingType::ExcessiveFanout).unwrap();
729        assert_eq!(json, r#""excessive_fanout""#);
730
731        let json = serde_json::to_string(&FindingType::ChattyService).unwrap();
732        assert_eq!(json, r#""chatty_service""#);
733
734        let json = serde_json::to_string(&FindingType::PoolSaturation).unwrap();
735        assert_eq!(json, r#""pool_saturation""#);
736
737        let json = serde_json::to_string(&FindingType::SerializedCalls).unwrap();
738        assert_eq!(json, r#""serialized_calls""#);
739    }
740
741    #[test]
742    fn severity_serializes_to_snake_case() {
743        let json = serde_json::to_string(&Severity::Critical).unwrap();
744        assert_eq!(json, r#""critical""#);
745    }
746
747    // --- Confidence field tests ---
748
749    #[test]
750    fn confidence_default_is_ci_batch() {
751        assert_eq!(Confidence::default(), Confidence::CiBatch);
752    }
753
754    #[test]
755    fn confidence_serializes_to_snake_case() {
756        assert_eq!(
757            serde_json::to_string(&Confidence::LocalBatch).unwrap(),
758            r#""local_batch""#
759        );
760        assert_eq!(
761            serde_json::to_string(&Confidence::CiBatch).unwrap(),
762            r#""ci_batch""#
763        );
764        assert_eq!(
765            serde_json::to_string(&Confidence::DaemonStaging).unwrap(),
766            r#""daemon_staging""#
767        );
768        assert_eq!(
769            serde_json::to_string(&Confidence::DaemonProduction).unwrap(),
770            r#""daemon_production""#
771        );
772    }
773
774    #[test]
775    fn confidence_deserializes_from_snake_case() {
776        let c: Confidence = serde_json::from_str(r#""local_batch""#).unwrap();
777        assert_eq!(c, Confidence::LocalBatch);
778        let c: Confidence = serde_json::from_str(r#""ci_batch""#).unwrap();
779        assert_eq!(c, Confidence::CiBatch);
780        let c: Confidence = serde_json::from_str(r#""daemon_staging""#).unwrap();
781        assert_eq!(c, Confidence::DaemonStaging);
782        let c: Confidence = serde_json::from_str(r#""daemon_production""#).unwrap();
783        assert_eq!(c, Confidence::DaemonProduction);
784    }
785
786    #[test]
787    fn confidence_as_str_matches_serialization() {
788        assert_eq!(Confidence::LocalBatch.as_str(), "local_batch");
789        assert_eq!(Confidence::CiBatch.as_str(), "ci_batch");
790        assert_eq!(Confidence::DaemonStaging.as_str(), "daemon_staging");
791        assert_eq!(Confidence::DaemonProduction.as_str(), "daemon_production");
792    }
793
794    #[test]
795    fn confidence_sarif_rank_increases_with_confidence() {
796        // Ordering must be strictly ascending so SARIF consumers that sort
797        // by rank produce the expected "production > staging > CI > local" order.
798        assert!(Confidence::LocalBatch.sarif_rank() < Confidence::CiBatch.sarif_rank());
799        assert!(Confidence::CiBatch.sarif_rank() < Confidence::DaemonStaging.sarif_rank());
800        assert!(Confidence::DaemonStaging.sarif_rank() < Confidence::DaemonProduction.sarif_rank());
801        assert_eq!(Confidence::LocalBatch.sarif_rank(), 15);
802        assert_eq!(Confidence::CiBatch.sarif_rank(), 30);
803        assert_eq!(Confidence::DaemonStaging.sarif_rank(), 60);
804        assert_eq!(Confidence::DaemonProduction.sarif_rank(), 90);
805    }
806
807    #[test]
808    fn batch_for_ci_maps_and_is_batch_classifies() {
809        assert_eq!(Confidence::batch_for_ci(true), Confidence::CiBatch);
810        assert_eq!(Confidence::batch_for_ci(false), Confidence::LocalBatch);
811        assert!(Confidence::LocalBatch.is_batch());
812        assert!(Confidence::CiBatch.is_batch());
813        assert!(!Confidence::DaemonStaging.is_batch());
814        assert!(!Confidence::DaemonProduction.is_batch());
815    }
816
817    #[test]
818    fn detector_findings_default_to_ci_batch_confidence() {
819        // Detectors emit `Confidence::default()`, the pipeline/daemon
820        // caller is responsible for stamping the real value. Verify the
821        // default here so a regression that changes Confidence::default()
822        // surfaces loudly.
823        use crate::test_helpers::{make_sql_event, make_trace};
824        let events: Vec<crate::event::SpanEvent> = (1..=6)
825            .map(|i| {
826                make_sql_event(
827                    "trace-1",
828                    &format!("span-{i}"),
829                    &format!("SELECT * FROM order_item WHERE order_id = {i}"),
830                    &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
831                )
832            })
833            .collect();
834        let trace = make_trace(events);
835        let findings = detect(&[trace], &default_config());
836        assert!(!findings.is_empty());
837        for f in &findings {
838            assert_eq!(f.confidence, Confidence::CiBatch);
839        }
840    }
841
842    #[test]
843    fn detect_combines_n_plus_one_and_redundant() {
844        use crate::test_helpers::{make_sql_event, make_trace};
845        // 5 events with different params -> N+1
846        // 3 events with same params -> redundant
847        let mut events = Vec::new();
848        for i in 1..=5 {
849            events.push(make_sql_event(
850                "trace-1",
851                &format!("span-{i}"),
852                &format!("SELECT * FROM order_item WHERE order_id = {i}"),
853                &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
854            ));
855        }
856        for i in 6..=8 {
857            events.push(make_sql_event(
858                "trace-1",
859                &format!("span-{i}"),
860                "SELECT * FROM config WHERE key = 'timeout'",
861                &format!("2025-07-10T14:32:01.{:03}Z", i * 30),
862            ));
863        }
864
865        let trace = make_trace(events);
866        let findings = detect(&[trace], &default_config());
867
868        let has_n_plus_one = findings
869            .iter()
870            .any(|f| f.finding_type == FindingType::NPlusOneSql);
871        let has_redundant = findings
872            .iter()
873            .any(|f| f.finding_type == FindingType::RedundantSql);
874        assert!(has_n_plus_one, "should detect N+1");
875        assert!(has_redundant, "should detect redundant");
876    }
877
878    #[test]
879    fn detect_multiple_traces() {
880        use crate::test_helpers::{make_sql_event, make_trace};
881        // Two separate traces, each with redundant queries
882        let events_t1: Vec<crate::event::SpanEvent> = (1..=3)
883            .map(|i| {
884                make_sql_event(
885                    "trace-A",
886                    &format!("span-a{i}"),
887                    "SELECT * FROM order_item WHERE order_id = 42",
888                    &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
889                )
890            })
891            .collect();
892
893        let events_t2: Vec<crate::event::SpanEvent> = (1..=2)
894            .map(|i| {
895                make_sql_event(
896                    "trace-B",
897                    &format!("span-b{i}"),
898                    "SELECT * FROM orders WHERE user_id = 7",
899                    &format!("2025-07-10T14:32:02.{:03}Z", i * 50),
900                )
901            })
902            .collect();
903
904        let trace_a = make_trace(events_t1);
905        let trace_b = make_trace(events_t2);
906        let findings = detect(&[trace_a, trace_b], &default_config());
907
908        // Both traces have redundant queries
909        assert!(
910            findings.iter().any(|f| f.trace_id == "trace-A"),
911            "trace-A should have findings"
912        );
913        assert!(
914            findings.iter().any(|f| f.trace_id == "trace-B"),
915            "trace-B should have findings"
916        );
917    }
918
919    #[test]
920    fn finding_type_as_str() {
921        assert_eq!(FindingType::NPlusOneSql.as_str(), "n_plus_one_sql");
922        assert_eq!(FindingType::SlowHttp.as_str(), "slow_http");
923        assert_eq!(FindingType::ChattyService.as_str(), "chatty_service");
924        assert_eq!(FindingType::PoolSaturation.as_str(), "pool_saturation");
925        assert_eq!(FindingType::SerializedCalls.as_str(), "serialized_calls");
926    }
927
928    #[test]
929    fn severity_as_str() {
930        assert_eq!(Severity::Critical.as_str(), "critical");
931        assert_eq!(Severity::Warning.as_str(), "warning");
932        assert_eq!(Severity::Info.as_str(), "info");
933    }
934
935    #[test]
936    fn rgesn_criteria_crosswalk() {
937        // N+1 and redundant relate to server caching (7.1).
938        assert_eq!(FindingType::NPlusOneSql.rgesn_criteria(), &["7.1", "6.1"]);
939        assert_eq!(FindingType::RedundantHttp.rgesn_criteria(), &["7.1", "6.5"]);
940        assert_eq!(
941            FindingType::ChattyService.rgesn_criteria(),
942            &["4.9", "4.10", "6.1"]
943        );
944        assert_eq!(FindingType::ExcessiveFanout.rgesn_criteria(), &["3.2"]);
945        assert_eq!(FindingType::PoolSaturation.rgesn_criteria(), &["3.2"]);
946        assert_eq!(FindingType::SerializedCalls.rgesn_criteria(), &["8.10"]);
947        // slow_* has no direct RGESN criterion (family 9 is ML-specific).
948        assert!(FindingType::SlowSql.rgesn_criteria().is_empty());
949        assert!(FindingType::SlowHttp.rgesn_criteria().is_empty());
950    }
951
952    #[test]
953    fn from_kind_str_inverts_as_str() {
954        // Lock from_kind_str against as_str drift. as_str is an exhaustive
955        // match (the compiler forces an arm for every new variant), but
956        // from_kind_str matches on `&str` with a `_ => None` fallback, so a
957        // new variant would silently parse to None and drop its rgesn_criteria
958        // from disclosure. This round-trip fails if the two ever disagree.
959        use FindingType::*;
960        for v in [
961            NPlusOneSql,
962            NPlusOneHttp,
963            RedundantSql,
964            RedundantHttp,
965            SlowSql,
966            SlowHttp,
967            ExcessiveFanout,
968            ChattyService,
969            PoolSaturation,
970            SerializedCalls,
971        ] {
972            assert_eq!(
973                FindingType::from_kind_str(v.as_str()),
974                Some(v.clone()),
975                "{v:?}"
976            );
977        }
978        assert_eq!(FindingType::from_kind_str("unknown_pattern"), None);
979    }
980
981    #[test]
982    fn finding_type_from_event_type_n_plus_one() {
983        use crate::event::EventType;
984        assert_eq!(
985            FindingType::from_event_type_n_plus_one(&EventType::Sql),
986            FindingType::NPlusOneSql
987        );
988        assert_eq!(
989            FindingType::from_event_type_n_plus_one(&EventType::HttpOut),
990            FindingType::NPlusOneHttp
991        );
992    }
993
994    #[test]
995    fn finding_type_from_event_type_redundant() {
996        use crate::event::EventType;
997        assert_eq!(
998            FindingType::from_event_type_redundant(&EventType::Sql),
999            FindingType::RedundantSql
1000        );
1001        assert_eq!(
1002            FindingType::from_event_type_redundant(&EventType::HttpOut),
1003            FindingType::RedundantHttp
1004        );
1005    }
1006
1007    #[test]
1008    fn finding_type_from_event_type_slow() {
1009        use crate::event::EventType;
1010        assert_eq!(
1011            FindingType::from_event_type_slow(&EventType::Sql),
1012            FindingType::SlowSql
1013        );
1014        assert_eq!(
1015            FindingType::from_event_type_slow(&EventType::HttpOut),
1016            FindingType::SlowHttp
1017        );
1018    }
1019
1020    #[test]
1021    fn detect_all_three_types_on_one_trace() {
1022        use crate::test_helpers::{make_sql_event, make_sql_event_with_duration, make_trace};
1023        let mut events = Vec::new();
1024        // 5 different params -> N+1
1025        for i in 1..=5 {
1026            events.push(make_sql_event(
1027                "trace-1",
1028                &format!("span-n{i}"),
1029                &format!("SELECT * FROM order_item WHERE order_id = {i}"),
1030                &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
1031            ));
1032        }
1033        // 3 identical queries -> redundant
1034        for i in 1..=3 {
1035            events.push(make_sql_event(
1036                "trace-1",
1037                &format!("span-r{i}"),
1038                "SELECT * FROM config WHERE key = 'timeout'",
1039                &format!("2025-07-10T14:32:02.{:03}Z", i * 30),
1040            ));
1041        }
1042        // 3 slow queries -> slow
1043        for i in 1..=3 {
1044            events.push(make_sql_event_with_duration(
1045                "trace-1",
1046                &format!("span-s{i}"),
1047                &format!("SELECT * FROM big_table WHERE id = {}", i + 100),
1048                &format!("2025-07-10T14:32:03.{:03}Z", i * 30),
1049                600_000,
1050            ));
1051        }
1052        let trace = make_trace(events);
1053        let findings = detect(&[trace], &default_config());
1054
1055        let has_n1 = findings
1056            .iter()
1057            .any(|f| f.finding_type == FindingType::NPlusOneSql);
1058        let has_redundant = findings
1059            .iter()
1060            .any(|f| f.finding_type == FindingType::RedundantSql);
1061        let has_slow = findings
1062            .iter()
1063            .any(|f| f.finding_type == FindingType::SlowSql);
1064
1065        assert!(has_n1, "should detect N+1");
1066        assert!(has_redundant, "should detect redundant");
1067        assert!(has_slow, "should detect slow");
1068    }
1069
1070    // --- Serde roundtrip for Finding (Deserialize added for query CLI) ---
1071
1072    #[test]
1073    fn finding_serde_roundtrip() {
1074        let finding =
1075            crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1076        let json = serde_json::to_string(&finding).unwrap();
1077        let back: Finding = serde_json::from_str(&json).unwrap();
1078        assert_eq!(finding.finding_type, back.finding_type);
1079        assert_eq!(finding.severity, back.severity);
1080        assert_eq!(finding.trace_id, back.trace_id);
1081        assert_eq!(finding.service, back.service);
1082        assert_eq!(finding.pattern.template, back.pattern.template);
1083        assert_eq!(finding.confidence, back.confidence);
1084    }
1085
1086    #[test]
1087    fn finding_with_code_location_serde_roundtrip() {
1088        let mut finding =
1089            crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1090        finding.code_location = Some(crate::event::CodeLocation {
1091            function: Some("processItems".to_string()),
1092            filepath: Some("src/Order.java".to_string()),
1093            lineno: Some(42),
1094            namespace: Some("com.example".to_string()),
1095        });
1096        let json = serde_json::to_string(&finding).unwrap();
1097        let back: Finding = serde_json::from_str(&json).unwrap();
1098        let loc = back.code_location.unwrap();
1099        assert_eq!(loc.function.as_deref(), Some("processItems"));
1100        assert_eq!(loc.lineno, Some(42));
1101    }
1102
1103    #[test]
1104    fn finding_type_deserializes_from_snake_case() {
1105        let ft: FindingType = serde_json::from_str(r#""n_plus_one_sql""#).unwrap();
1106        assert_eq!(ft, FindingType::NPlusOneSql);
1107        let ft: FindingType = serde_json::from_str(r#""chatty_service""#).unwrap();
1108        assert_eq!(ft, FindingType::ChattyService);
1109    }
1110
1111    #[test]
1112    fn severity_deserializes_from_snake_case() {
1113        let s: Severity = serde_json::from_str(r#""critical""#).unwrap();
1114        assert_eq!(s, Severity::Critical);
1115        let s: Severity = serde_json::from_str(r#""warning""#).unwrap();
1116        assert_eq!(s, Severity::Warning);
1117    }
1118
1119    // --- compute_timing_stats ---
1120
1121    #[test]
1122    fn timing_stats_empty_returns_zeroes() {
1123        assert_eq!(compute_timing_stats(&mut []), (0, 0, 0));
1124    }
1125
1126    #[test]
1127    fn timing_stats_single_element() {
1128        let (p50, p99, cv) = compute_timing_stats(&mut [800]);
1129        assert_eq!(p50, 800);
1130        assert_eq!(p99, 800);
1131        assert_eq!(cv, 0);
1132    }
1133
1134    #[test]
1135    fn timing_stats_two_elements_p99_is_max() {
1136        let (p50, p99, _cv) = compute_timing_stats(&mut [100, 900]);
1137        assert_eq!(p50, 100); // n=2, p50 index = 0 (lower value)
1138        assert_eq!(p99, 900); // n=2, p99 index = 1 (max)
1139    }
1140
1141    #[test]
1142    fn timing_stats_five_elements_p99_is_max() {
1143        let (p50, p99, _cv) = compute_timing_stats(&mut [10, 20, 30, 40, 50]);
1144        assert_eq!(p50, 30);
1145        assert_eq!(p99, 50);
1146    }
1147
1148    #[test]
1149    fn timing_stats_identical_durations_cv_zero() {
1150        let mut durations = [100u64; 10];
1151        let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1152        assert_eq!(cv, 0);
1153    }
1154
1155    #[test]
1156    fn timing_stats_dispersed_durations_cv_matches_variance_helper() {
1157        let mut durations = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
1158        let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1159        // CV ~ 0.68 on this set → cv_x1000 ~ 680
1160        assert!(cv > 500, "CV should be > 0.5, got {cv}");
1161        assert!(cv < 800, "CV should be < 0.8, got {cv}");
1162    }
1163}