1pub mod chatty;
4pub mod correlate_cross;
5pub mod fanout;
6pub mod n_plus_one;
7pub mod pool_saturation;
8pub mod redundant;
9pub mod sanitizer_aware;
10pub mod serialized;
11pub mod slow;
12pub mod suggestions;
13
14pub use n_plus_one::DISCLOSURE_N_PLUS_ONE_THRESHOLD;
15
16use std::collections::HashMap;
17
18use crate::correlate::Trace;
19use crate::event::EventType;
20use serde::{Deserialize, Serialize};
21
22pub struct TraceIndices<'a> {
34 pub children_by_parent: HashMap<&'a str, Vec<usize>>,
35 pub span_index: HashMap<&'a str, usize>,
36}
37
38impl<'a> TraceIndices<'a> {
39 #[must_use]
41 pub fn build(trace: &'a Trace) -> Self {
42 let mut children_by_parent: HashMap<&str, Vec<usize>> =
43 HashMap::with_capacity(trace.spans.len() / 4 + 1);
44 let mut span_index: HashMap<&str, usize> = HashMap::with_capacity(trace.spans.len());
45 for (idx, span) in trace.spans.iter().enumerate() {
46 span_index.insert(span.event.span_id.as_str(), idx);
47 if let Some(ref parent_id) = span.event.parent_span_id {
48 children_by_parent
49 .entry(parent_id.as_str())
50 .or_default()
51 .push(idx);
52 }
53 }
54 Self {
55 children_by_parent,
56 span_index,
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct Finding {
64 #[serde(rename = "type")]
66 pub finding_type: FindingType,
67 pub severity: Severity,
69 pub trace_id: String,
71 pub service: String,
73 pub source_endpoint: String,
75 pub pattern: Pattern,
77 pub suggestion: String,
79 pub first_timestamp: String,
81 pub last_timestamp: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub green_impact: Option<GreenImpact>,
86 #[serde(default)]
96 pub confidence: Confidence,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub classification_method: Option<ClassificationMethod>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub code_location: Option<crate::event::CodeLocation>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub instrumentation_scopes: Vec<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub suggested_fix: Option<suggestions::SuggestedFix>,
126 #[serde(default)]
133 pub signature: String,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum FindingType {
140 NPlusOneSql,
141 NPlusOneHttp,
142 RedundantSql,
143 RedundantHttp,
144 SlowSql,
145 SlowHttp,
146 ExcessiveFanout,
147 ChattyService,
148 PoolSaturation,
149 SerializedCalls,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum Severity {
156 Critical,
157 Warning,
158 Info,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
169#[serde(rename_all = "snake_case")]
170pub enum Confidence {
171 #[default]
178 CiBatch,
179 DaemonStaging,
182 DaemonProduction,
185}
186
187impl Confidence {
188 #[must_use]
190 pub const fn as_str(&self) -> &'static str {
191 match self {
192 Self::CiBatch => "ci_batch",
193 Self::DaemonStaging => "daemon_staging",
194 Self::DaemonProduction => "daemon_production",
195 }
196 }
197
198 #[must_use]
205 pub const fn sarif_rank(&self) -> u32 {
206 match self {
207 Self::CiBatch => 30,
208 Self::DaemonStaging => 60,
209 Self::DaemonProduction => 90,
210 }
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum ClassificationMethod {
224 Direct,
229 SanitizerHeuristic,
234}
235
236#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
238pub struct Pattern {
239 pub template: String,
241 pub occurrences: usize,
243 pub window_ms: u64,
245 pub distinct_params: usize,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub span_duration_us_p50: Option<u64>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub span_duration_us_p99: Option<u64>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub span_duration_cv_x1000: Option<u32>,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct GreenImpact {
266 pub estimated_extra_io_ops: usize,
268 pub io_intensity_score: f64,
270 pub io_intensity_band: crate::report::interpret::InterpretationLevel,
278}
279
280impl FindingType {
281 #[must_use]
282 pub const fn from_event_type_n_plus_one(event_type: &EventType) -> Self {
283 match event_type {
284 EventType::Sql => Self::NPlusOneSql,
285 EventType::HttpOut => Self::NPlusOneHttp,
286 }
287 }
288
289 #[must_use]
290 pub const fn from_event_type_redundant(event_type: &EventType) -> Self {
291 match event_type {
292 EventType::Sql => Self::RedundantSql,
293 EventType::HttpOut => Self::RedundantHttp,
294 }
295 }
296
297 #[must_use]
298 pub const fn from_event_type_slow(event_type: &EventType) -> Self {
299 match event_type {
300 EventType::Sql => Self::SlowSql,
301 EventType::HttpOut => Self::SlowHttp,
302 }
303 }
304
305 #[must_use]
307 pub const fn as_str(&self) -> &'static str {
308 match self {
309 Self::NPlusOneSql => "n_plus_one_sql",
310 Self::NPlusOneHttp => "n_plus_one_http",
311 Self::RedundantSql => "redundant_sql",
312 Self::RedundantHttp => "redundant_http",
313 Self::SlowSql => "slow_sql",
314 Self::SlowHttp => "slow_http",
315 Self::ExcessiveFanout => "excessive_fanout",
316 Self::ChattyService => "chatty_service",
317 Self::PoolSaturation => "pool_saturation",
318 Self::SerializedCalls => "serialized_calls",
319 }
320 }
321
322 #[must_use]
331 pub const fn rgesn_criteria(&self) -> &'static [&'static str] {
332 match self {
333 Self::NPlusOneSql | Self::NPlusOneHttp => &["7.1", "6.1"],
334 Self::RedundantSql | Self::RedundantHttp => &["7.1", "6.5"],
335 Self::ChattyService => &["4.9", "4.10", "6.1"],
336 Self::ExcessiveFanout | Self::PoolSaturation => &["3.2"],
337 Self::SerializedCalls => &["8.10"],
338 Self::SlowSql | Self::SlowHttp => &[],
339 }
340 }
341
342 #[must_use]
345 pub fn from_kind_str(s: &str) -> Option<Self> {
346 match s {
347 "n_plus_one_sql" => Some(Self::NPlusOneSql),
348 "n_plus_one_http" => Some(Self::NPlusOneHttp),
349 "redundant_sql" => Some(Self::RedundantSql),
350 "redundant_http" => Some(Self::RedundantHttp),
351 "slow_sql" => Some(Self::SlowSql),
352 "slow_http" => Some(Self::SlowHttp),
353 "excessive_fanout" => Some(Self::ExcessiveFanout),
354 "chatty_service" => Some(Self::ChattyService),
355 "pool_saturation" => Some(Self::PoolSaturation),
356 "serialized_calls" => Some(Self::SerializedCalls),
357 _ => None,
358 }
359 }
360
361 #[must_use]
363 pub const fn display_label(&self) -> &'static str {
364 match self {
365 Self::NPlusOneSql => "N+1 SQL",
366 Self::NPlusOneHttp => "N+1 HTTP",
367 Self::RedundantSql => "Redundant SQL",
368 Self::RedundantHttp => "Redundant HTTP",
369 Self::SlowSql => "Slow SQL",
370 Self::SlowHttp => "Slow HTTP",
371 Self::ExcessiveFanout => "Excessive fanout",
372 Self::ChattyService => "Chatty service",
373 Self::PoolSaturation => "Pool saturation",
374 Self::SerializedCalls => "Serialized calls",
375 }
376 }
377
378 #[must_use]
385 pub const fn is_avoidable_io(&self) -> bool {
386 matches!(
387 self,
388 Self::NPlusOneSql | Self::NPlusOneHttp | Self::RedundantSql | Self::RedundantHttp
389 )
390 }
391}
392
393impl Severity {
394 #[must_use]
396 pub const fn as_str(&self) -> &'static str {
397 match self {
398 Self::Critical => "critical",
399 Self::Warning => "warning",
400 Self::Info => "info",
401 }
402 }
403}
404
405#[derive(Debug, Clone)]
407pub struct DetectConfig {
408 pub n_plus_one_threshold: u32,
409 pub window_ms: u64,
410 pub slow_threshold_ms: u64,
411 pub slow_min_occurrences: u32,
412 pub max_fanout: u32,
413 pub chatty_service_min_calls: u32,
414 pub pool_saturation_concurrent_threshold: u32,
415 pub serialized_min_sequential: u32,
416 pub sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode,
417}
418
419impl From<&crate::config::Config> for DetectConfig {
420 fn from(config: &crate::config::Config) -> Self {
421 Self {
422 n_plus_one_threshold: config.detection.n_plus_one_threshold,
423 window_ms: config.detection.window_duration_ms,
424 slow_threshold_ms: config.detection.slow_query_threshold_ms,
425 slow_min_occurrences: config.detection.slow_query_min_occurrences,
426 max_fanout: config.detection.max_fanout,
427 chatty_service_min_calls: config.detection.chatty_service_min_calls,
428 pool_saturation_concurrent_threshold: config
429 .detection
430 .pool_saturation_concurrent_threshold,
431 serialized_min_sequential: config.detection.serialized_min_sequential,
432 sanitizer_aware_classification: config.detection.sanitizer_aware_classification,
433 }
434 }
435}
436
437pub(crate) struct PerTraceFindingArgs<'a> {
440 pub finding_type: FindingType,
441 pub severity: Severity,
442 pub trace_id: &'a str,
443 pub first_span: &'a crate::normalize::NormalizedEvent,
444 pub template: &'a str,
445 pub occurrences: usize,
446 pub window_ms: u64,
447 pub distinct_params: usize,
448 pub suggestion: String,
449 pub first_timestamp: &'a str,
450 pub last_timestamp: &'a str,
451 pub code_location: Option<crate::event::CodeLocation>,
452 pub instrumentation_scopes: Vec<String>,
453 pub classification_method: Option<ClassificationMethod>,
454 pub span_durations_us: Option<Vec<u64>>,
455}
456
457fn compute_timing_stats(durations: &mut [u64]) -> (u64, u64, u32) {
465 if durations.is_empty() {
466 return (0, 0, 0);
467 }
468 durations.sort_unstable();
469 let n = durations.len();
470 let p50 = durations[slow::percentile_index(n, 50)];
471 let p99 = durations[slow::percentile_index(n, 99)];
472 #[allow(clippy::cast_precision_loss)]
473 let n_f = n as f64;
474 let mut mean = 0.0_f64;
475 let mut m2 = 0.0_f64;
476 let mut count = 0u64;
477 for &d in durations.iter() {
478 count += 1;
479 #[allow(clippy::cast_precision_loss)]
480 let val = d as f64;
481 let delta = val - mean;
482 #[allow(clippy::cast_precision_loss)]
483 let cf = count as f64;
484 mean += delta / cf;
485 m2 += delta * (val - mean);
486 }
487 let cv_x1000 = if mean > 0.0 && n_f > 1.0 {
488 let cv = (m2 / n_f).sqrt() / mean;
489 #[allow(clippy::cast_sign_loss)] {
491 (cv * 1000.0).round() as u32
492 }
493 } else {
494 0
495 };
496 (p50, p99, cv_x1000)
497}
498
499pub(crate) fn build_per_trace_finding(args: PerTraceFindingArgs<'_>) -> Finding {
500 let timing = args
501 .span_durations_us
502 .map(|mut d| compute_timing_stats(&mut d));
503 Finding {
504 finding_type: args.finding_type,
505 severity: args.severity,
506 trace_id: args.trace_id.to_string(),
507 service: args.first_span.event.service.to_string(),
508 source_endpoint: args.first_span.event.source.endpoint.clone(),
509 pattern: Pattern {
510 template: args.template.to_string(),
511 occurrences: args.occurrences,
512 window_ms: args.window_ms,
513 distinct_params: args.distinct_params,
514 span_duration_us_p50: timing.map(|(p50, _, _)| p50),
515 span_duration_us_p99: timing.map(|(_, p99, _)| p99),
516 span_duration_cv_x1000: timing.map(|(_, _, cv)| cv),
517 },
518 suggestion: args.suggestion,
519 first_timestamp: args.first_timestamp.to_string(),
520 last_timestamp: args.last_timestamp.to_string(),
521 green_impact: None,
522 confidence: Confidence::default(),
523 classification_method: args.classification_method,
524 code_location: args.code_location,
525 instrumentation_scopes: args.instrumentation_scopes,
526 suggested_fix: None,
527 signature: String::new(),
528 }
529}
530
531pub fn apply_confidence(findings: &mut [Finding], confidence: Confidence) {
540 for finding in findings.iter_mut() {
541 finding.confidence = confidence;
542 }
543}
544
545#[must_use]
555pub fn run_full_detection(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
556 let mut findings = detect(traces, config);
557 if traces.len() >= 2 {
558 let mut cross_trace = slow::detect_slow_cross_trace(
559 traces,
560 config.slow_threshold_ms,
561 config.slow_min_occurrences,
562 );
563 findings.append(&mut cross_trace);
564 }
565 findings
566}
567
568#[must_use]
573pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
574 let mut findings = Vec::new();
575 for trace in traces {
576 let indices = TraceIndices::build(trace);
579 let mut n_plus_one_findings = n_plus_one::detect_n_plus_one(
584 trace,
585 config.n_plus_one_threshold,
586 config.window_ms,
587 config.sanitizer_aware_classification,
588 );
589 let mut redundant_findings = redundant::detect_redundant(trace, &n_plus_one_findings);
590 findings.append(&mut n_plus_one_findings);
591 findings.append(&mut redundant_findings);
592 findings.append(&mut slow::detect_slow(
593 trace,
594 config.slow_threshold_ms,
595 config.slow_min_occurrences,
596 ));
597 findings.append(&mut fanout::detect_fanout(
598 trace,
599 &indices,
600 config.max_fanout,
601 ));
602 findings.append(&mut chatty::detect_chatty(
603 trace,
604 config.chatty_service_min_calls,
605 ));
606 findings.append(&mut pool_saturation::detect_pool_saturation(
607 trace,
608 config.pool_saturation_concurrent_threshold,
609 ));
610 findings.append(&mut serialized::detect_serialized(
611 trace,
612 &indices,
613 config.serialized_min_sequential,
614 ));
615 }
616 suggestions::enrich(&mut findings);
617 findings
618}
619
620pub(crate) fn sort_findings(findings: &mut [Finding]) {
624 findings.sort_by(|a, b| {
625 a.finding_type
626 .cmp(&b.finding_type)
627 .then_with(|| a.severity.cmp(&b.severity))
628 .then_with(|| a.trace_id.cmp(&b.trace_id))
629 .then_with(|| a.source_endpoint.cmp(&b.source_endpoint))
630 .then_with(|| a.pattern.template.cmp(&b.pattern.template))
631 });
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 fn default_config() -> DetectConfig {
639 DetectConfig {
640 n_plus_one_threshold: 5,
641 window_ms: 500,
642 slow_threshold_ms: 500,
643 slow_min_occurrences: 3,
644 max_fanout: 20,
645 chatty_service_min_calls: 15,
646 pool_saturation_concurrent_threshold: 10,
647 serialized_min_sequential: 3,
648 sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode::default(),
649 }
650 }
651
652 #[test]
653 fn empty_traces_produce_no_findings() {
654 let findings = detect(&[], &default_config());
655 assert!(findings.is_empty());
656 }
657
658 #[test]
659 fn finding_type_serializes_to_snake_case() {
660 let json = serde_json::to_string(&FindingType::NPlusOneSql).unwrap();
661 assert_eq!(json, r#""n_plus_one_sql""#);
662
663 let json = serde_json::to_string(&FindingType::RedundantHttp).unwrap();
664 assert_eq!(json, r#""redundant_http""#);
665
666 let json = serde_json::to_string(&FindingType::SlowSql).unwrap();
667 assert_eq!(json, r#""slow_sql""#);
668
669 let json = serde_json::to_string(&FindingType::SlowHttp).unwrap();
670 assert_eq!(json, r#""slow_http""#);
671
672 let json = serde_json::to_string(&FindingType::ExcessiveFanout).unwrap();
673 assert_eq!(json, r#""excessive_fanout""#);
674
675 let json = serde_json::to_string(&FindingType::ChattyService).unwrap();
676 assert_eq!(json, r#""chatty_service""#);
677
678 let json = serde_json::to_string(&FindingType::PoolSaturation).unwrap();
679 assert_eq!(json, r#""pool_saturation""#);
680
681 let json = serde_json::to_string(&FindingType::SerializedCalls).unwrap();
682 assert_eq!(json, r#""serialized_calls""#);
683 }
684
685 #[test]
686 fn severity_serializes_to_snake_case() {
687 let json = serde_json::to_string(&Severity::Critical).unwrap();
688 assert_eq!(json, r#""critical""#);
689 }
690
691 #[test]
694 fn confidence_default_is_ci_batch() {
695 assert_eq!(Confidence::default(), Confidence::CiBatch);
696 }
697
698 #[test]
699 fn confidence_serializes_to_snake_case() {
700 assert_eq!(
701 serde_json::to_string(&Confidence::CiBatch).unwrap(),
702 r#""ci_batch""#
703 );
704 assert_eq!(
705 serde_json::to_string(&Confidence::DaemonStaging).unwrap(),
706 r#""daemon_staging""#
707 );
708 assert_eq!(
709 serde_json::to_string(&Confidence::DaemonProduction).unwrap(),
710 r#""daemon_production""#
711 );
712 }
713
714 #[test]
715 fn confidence_deserializes_from_snake_case() {
716 let c: Confidence = serde_json::from_str(r#""ci_batch""#).unwrap();
717 assert_eq!(c, Confidence::CiBatch);
718 let c: Confidence = serde_json::from_str(r#""daemon_staging""#).unwrap();
719 assert_eq!(c, Confidence::DaemonStaging);
720 let c: Confidence = serde_json::from_str(r#""daemon_production""#).unwrap();
721 assert_eq!(c, Confidence::DaemonProduction);
722 }
723
724 #[test]
725 fn confidence_as_str_matches_serialization() {
726 assert_eq!(Confidence::CiBatch.as_str(), "ci_batch");
727 assert_eq!(Confidence::DaemonStaging.as_str(), "daemon_staging");
728 assert_eq!(Confidence::DaemonProduction.as_str(), "daemon_production");
729 }
730
731 #[test]
732 fn confidence_sarif_rank_increases_with_confidence() {
733 assert!(Confidence::CiBatch.sarif_rank() < Confidence::DaemonStaging.sarif_rank());
736 assert!(Confidence::DaemonStaging.sarif_rank() < Confidence::DaemonProduction.sarif_rank());
737 assert_eq!(Confidence::CiBatch.sarif_rank(), 30);
738 assert_eq!(Confidence::DaemonStaging.sarif_rank(), 60);
739 assert_eq!(Confidence::DaemonProduction.sarif_rank(), 90);
740 }
741
742 #[test]
743 fn detector_findings_default_to_ci_batch_confidence() {
744 use crate::test_helpers::{make_sql_event, make_trace};
749 let events: Vec<crate::event::SpanEvent> = (1..=6)
750 .map(|i| {
751 make_sql_event(
752 "trace-1",
753 &format!("span-{i}"),
754 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
755 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
756 )
757 })
758 .collect();
759 let trace = make_trace(events);
760 let findings = detect(&[trace], &default_config());
761 assert!(!findings.is_empty());
762 for f in &findings {
763 assert_eq!(f.confidence, Confidence::CiBatch);
764 }
765 }
766
767 #[test]
768 fn detect_combines_n_plus_one_and_redundant() {
769 use crate::test_helpers::{make_sql_event, make_trace};
770 let mut events = Vec::new();
773 for i in 1..=5 {
774 events.push(make_sql_event(
775 "trace-1",
776 &format!("span-{i}"),
777 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
778 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
779 ));
780 }
781 for i in 6..=8 {
782 events.push(make_sql_event(
783 "trace-1",
784 &format!("span-{i}"),
785 "SELECT * FROM config WHERE key = 'timeout'",
786 &format!("2025-07-10T14:32:01.{:03}Z", i * 30),
787 ));
788 }
789
790 let trace = make_trace(events);
791 let findings = detect(&[trace], &default_config());
792
793 let has_n_plus_one = findings
794 .iter()
795 .any(|f| f.finding_type == FindingType::NPlusOneSql);
796 let has_redundant = findings
797 .iter()
798 .any(|f| f.finding_type == FindingType::RedundantSql);
799 assert!(has_n_plus_one, "should detect N+1");
800 assert!(has_redundant, "should detect redundant");
801 }
802
803 #[test]
804 fn detect_multiple_traces() {
805 use crate::test_helpers::{make_sql_event, make_trace};
806 let events_t1: Vec<crate::event::SpanEvent> = (1..=3)
808 .map(|i| {
809 make_sql_event(
810 "trace-A",
811 &format!("span-a{i}"),
812 "SELECT * FROM order_item WHERE order_id = 42",
813 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
814 )
815 })
816 .collect();
817
818 let events_t2: Vec<crate::event::SpanEvent> = (1..=2)
819 .map(|i| {
820 make_sql_event(
821 "trace-B",
822 &format!("span-b{i}"),
823 "SELECT * FROM orders WHERE user_id = 7",
824 &format!("2025-07-10T14:32:02.{:03}Z", i * 50),
825 )
826 })
827 .collect();
828
829 let trace_a = make_trace(events_t1);
830 let trace_b = make_trace(events_t2);
831 let findings = detect(&[trace_a, trace_b], &default_config());
832
833 assert!(
835 findings.iter().any(|f| f.trace_id == "trace-A"),
836 "trace-A should have findings"
837 );
838 assert!(
839 findings.iter().any(|f| f.trace_id == "trace-B"),
840 "trace-B should have findings"
841 );
842 }
843
844 #[test]
845 fn finding_type_as_str() {
846 assert_eq!(FindingType::NPlusOneSql.as_str(), "n_plus_one_sql");
847 assert_eq!(FindingType::SlowHttp.as_str(), "slow_http");
848 assert_eq!(FindingType::ChattyService.as_str(), "chatty_service");
849 assert_eq!(FindingType::PoolSaturation.as_str(), "pool_saturation");
850 assert_eq!(FindingType::SerializedCalls.as_str(), "serialized_calls");
851 }
852
853 #[test]
854 fn severity_as_str() {
855 assert_eq!(Severity::Critical.as_str(), "critical");
856 assert_eq!(Severity::Warning.as_str(), "warning");
857 assert_eq!(Severity::Info.as_str(), "info");
858 }
859
860 #[test]
861 fn rgesn_criteria_crosswalk() {
862 assert_eq!(FindingType::NPlusOneSql.rgesn_criteria(), &["7.1", "6.1"]);
864 assert_eq!(FindingType::RedundantHttp.rgesn_criteria(), &["7.1", "6.5"]);
865 assert_eq!(
866 FindingType::ChattyService.rgesn_criteria(),
867 &["4.9", "4.10", "6.1"]
868 );
869 assert_eq!(FindingType::ExcessiveFanout.rgesn_criteria(), &["3.2"]);
870 assert_eq!(FindingType::PoolSaturation.rgesn_criteria(), &["3.2"]);
871 assert_eq!(FindingType::SerializedCalls.rgesn_criteria(), &["8.10"]);
872 assert!(FindingType::SlowSql.rgesn_criteria().is_empty());
874 assert!(FindingType::SlowHttp.rgesn_criteria().is_empty());
875 }
876
877 #[test]
878 fn from_kind_str_inverts_as_str() {
879 use FindingType::*;
885 for v in [
886 NPlusOneSql,
887 NPlusOneHttp,
888 RedundantSql,
889 RedundantHttp,
890 SlowSql,
891 SlowHttp,
892 ExcessiveFanout,
893 ChattyService,
894 PoolSaturation,
895 SerializedCalls,
896 ] {
897 assert_eq!(
898 FindingType::from_kind_str(v.as_str()),
899 Some(v.clone()),
900 "{v:?}"
901 );
902 }
903 assert_eq!(FindingType::from_kind_str("unknown_pattern"), None);
904 }
905
906 #[test]
907 fn finding_type_from_event_type_n_plus_one() {
908 use crate::event::EventType;
909 assert_eq!(
910 FindingType::from_event_type_n_plus_one(&EventType::Sql),
911 FindingType::NPlusOneSql
912 );
913 assert_eq!(
914 FindingType::from_event_type_n_plus_one(&EventType::HttpOut),
915 FindingType::NPlusOneHttp
916 );
917 }
918
919 #[test]
920 fn finding_type_from_event_type_redundant() {
921 use crate::event::EventType;
922 assert_eq!(
923 FindingType::from_event_type_redundant(&EventType::Sql),
924 FindingType::RedundantSql
925 );
926 assert_eq!(
927 FindingType::from_event_type_redundant(&EventType::HttpOut),
928 FindingType::RedundantHttp
929 );
930 }
931
932 #[test]
933 fn finding_type_from_event_type_slow() {
934 use crate::event::EventType;
935 assert_eq!(
936 FindingType::from_event_type_slow(&EventType::Sql),
937 FindingType::SlowSql
938 );
939 assert_eq!(
940 FindingType::from_event_type_slow(&EventType::HttpOut),
941 FindingType::SlowHttp
942 );
943 }
944
945 #[test]
946 fn detect_all_three_types_on_one_trace() {
947 use crate::test_helpers::{make_sql_event, make_sql_event_with_duration, make_trace};
948 let mut events = Vec::new();
949 for i in 1..=5 {
951 events.push(make_sql_event(
952 "trace-1",
953 &format!("span-n{i}"),
954 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
955 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
956 ));
957 }
958 for i in 1..=3 {
960 events.push(make_sql_event(
961 "trace-1",
962 &format!("span-r{i}"),
963 "SELECT * FROM config WHERE key = 'timeout'",
964 &format!("2025-07-10T14:32:02.{:03}Z", i * 30),
965 ));
966 }
967 for i in 1..=3 {
969 events.push(make_sql_event_with_duration(
970 "trace-1",
971 &format!("span-s{i}"),
972 &format!("SELECT * FROM big_table WHERE id = {}", i + 100),
973 &format!("2025-07-10T14:32:03.{:03}Z", i * 30),
974 600_000,
975 ));
976 }
977 let trace = make_trace(events);
978 let findings = detect(&[trace], &default_config());
979
980 let has_n1 = findings
981 .iter()
982 .any(|f| f.finding_type == FindingType::NPlusOneSql);
983 let has_redundant = findings
984 .iter()
985 .any(|f| f.finding_type == FindingType::RedundantSql);
986 let has_slow = findings
987 .iter()
988 .any(|f| f.finding_type == FindingType::SlowSql);
989
990 assert!(has_n1, "should detect N+1");
991 assert!(has_redundant, "should detect redundant");
992 assert!(has_slow, "should detect slow");
993 }
994
995 #[test]
998 fn finding_serde_roundtrip() {
999 let finding =
1000 crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1001 let json = serde_json::to_string(&finding).unwrap();
1002 let back: Finding = serde_json::from_str(&json).unwrap();
1003 assert_eq!(finding.finding_type, back.finding_type);
1004 assert_eq!(finding.severity, back.severity);
1005 assert_eq!(finding.trace_id, back.trace_id);
1006 assert_eq!(finding.service, back.service);
1007 assert_eq!(finding.pattern.template, back.pattern.template);
1008 assert_eq!(finding.confidence, back.confidence);
1009 }
1010
1011 #[test]
1012 fn finding_with_code_location_serde_roundtrip() {
1013 let mut finding =
1014 crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1015 finding.code_location = Some(crate::event::CodeLocation {
1016 function: Some("processItems".to_string()),
1017 filepath: Some("src/Order.java".to_string()),
1018 lineno: Some(42),
1019 namespace: Some("com.example".to_string()),
1020 });
1021 let json = serde_json::to_string(&finding).unwrap();
1022 let back: Finding = serde_json::from_str(&json).unwrap();
1023 let loc = back.code_location.unwrap();
1024 assert_eq!(loc.function.as_deref(), Some("processItems"));
1025 assert_eq!(loc.lineno, Some(42));
1026 }
1027
1028 #[test]
1029 fn finding_type_deserializes_from_snake_case() {
1030 let ft: FindingType = serde_json::from_str(r#""n_plus_one_sql""#).unwrap();
1031 assert_eq!(ft, FindingType::NPlusOneSql);
1032 let ft: FindingType = serde_json::from_str(r#""chatty_service""#).unwrap();
1033 assert_eq!(ft, FindingType::ChattyService);
1034 }
1035
1036 #[test]
1037 fn severity_deserializes_from_snake_case() {
1038 let s: Severity = serde_json::from_str(r#""critical""#).unwrap();
1039 assert_eq!(s, Severity::Critical);
1040 let s: Severity = serde_json::from_str(r#""warning""#).unwrap();
1041 assert_eq!(s, Severity::Warning);
1042 }
1043
1044 #[test]
1047 fn timing_stats_empty_returns_zeroes() {
1048 assert_eq!(compute_timing_stats(&mut []), (0, 0, 0));
1049 }
1050
1051 #[test]
1052 fn timing_stats_single_element() {
1053 let (p50, p99, cv) = compute_timing_stats(&mut [800]);
1054 assert_eq!(p50, 800);
1055 assert_eq!(p99, 800);
1056 assert_eq!(cv, 0);
1057 }
1058
1059 #[test]
1060 fn timing_stats_two_elements_p99_is_max() {
1061 let (p50, p99, _cv) = compute_timing_stats(&mut [100, 900]);
1062 assert_eq!(p50, 100); assert_eq!(p99, 900); }
1065
1066 #[test]
1067 fn timing_stats_five_elements_p99_is_max() {
1068 let (p50, p99, _cv) = compute_timing_stats(&mut [10, 20, 30, 40, 50]);
1069 assert_eq!(p50, 30);
1070 assert_eq!(p99, 50);
1071 }
1072
1073 #[test]
1074 fn timing_stats_identical_durations_cv_zero() {
1075 let mut durations = [100u64; 10];
1076 let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1077 assert_eq!(cv, 0);
1078 }
1079
1080 #[test]
1081 fn timing_stats_dispersed_durations_cv_matches_variance_helper() {
1082 let mut durations = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
1083 let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1084 assert!(cv > 500, "CV should be > 0.5, got {cv}");
1086 assert!(cv < 800, "CV should be < 0.8, got {cv}");
1087 }
1088}