1pub 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, GroupingAttribute};
22use serde::{Deserialize, Serialize};
23
24pub 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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct Finding {
66 #[serde(rename = "type")]
68 pub finding_type: FindingType,
69 pub severity: Severity,
71 pub trace_id: String,
73 pub service: String,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub grouping: Vec<GroupingAttribute>,
80 pub source_endpoint: String,
82 pub pattern: Pattern,
84 pub suggestion: String,
86 pub first_timestamp: String,
88 pub last_timestamp: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub green_impact: Option<GreenImpact>,
93 #[serde(default)]
103 pub confidence: Confidence,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub classification_method: Option<ClassificationMethod>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub code_location: Option<crate::event::CodeLocation>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub instrumentation_scopes: Vec<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub suggested_fix: Option<suggestions::SuggestedFix>,
133 #[serde(default)]
140 pub signature: String,
141}
142
143impl Finding {
144 #[must_use]
146 pub fn effective_grouping(&self) -> Option<&GroupingAttribute> {
147 self.grouping.first()
148 }
149
150 #[must_use]
152 pub fn grouping_value(&self) -> Option<&str> {
153 self.grouping.first().map(|g| g.value.as_ref())
154 }
155
156 #[must_use]
158 pub fn grouping_identity(&self) -> Option<(&str, &str)> {
159 self.grouping.first().map(GroupingAttribute::identity)
160 }
161}
162
163pub(crate) fn member_span_ids<'a>(
167 trace: &'a Trace,
168 indices: &[usize],
169 collect_spans: bool,
170) -> Vec<&'a str> {
171 if collect_spans {
172 indices
173 .iter()
174 .map(|&i| trace.spans[i].event.span_id.as_str())
175 .collect()
176 } else {
177 Vec::new()
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum FindingType {
185 NPlusOneSql,
186 NPlusOneHttp,
187 NPlusOneMessaging,
188 RedundantSql,
189 RedundantHttp,
190 SlowSql,
191 SlowHttp,
192 SlowMessaging,
193 ExcessiveFanout,
194 ChattyService,
195 PoolSaturation,
196 SerializedCalls,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum Severity {
203 Critical,
204 Warning,
205 Info,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
216#[serde(rename_all = "snake_case")]
217pub enum Confidence {
218 LocalBatch,
221 #[default]
228 CiBatch,
229 DaemonStaging,
232 DaemonProduction,
235}
236
237impl Confidence {
238 #[must_use]
240 pub const fn as_str(&self) -> &'static str {
241 match self {
242 Self::LocalBatch => "local_batch",
243 Self::CiBatch => "ci_batch",
244 Self::DaemonStaging => "daemon_staging",
245 Self::DaemonProduction => "daemon_production",
246 }
247 }
248
249 #[must_use]
253 pub const fn is_batch(&self) -> bool {
254 matches!(self, Self::LocalBatch | Self::CiBatch)
255 }
256
257 #[must_use]
260 pub const fn batch_for_ci(is_ci: bool) -> Self {
261 if is_ci {
262 Self::CiBatch
263 } else {
264 Self::LocalBatch
265 }
266 }
267
268 #[must_use]
275 pub const fn sarif_rank(&self) -> u32 {
276 match self {
277 Self::LocalBatch => 15,
278 Self::CiBatch => 30,
279 Self::DaemonStaging => 60,
280 Self::DaemonProduction => 90,
281 }
282 }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum ClassificationMethod {
295 Direct,
300 SanitizerHeuristic,
305}
306
307#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
309pub struct Pattern {
310 pub template: String,
312 pub occurrences: usize,
314 pub window_ms: u64,
316 pub distinct_params: usize,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub span_duration_us_p50: Option<u64>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub span_duration_us_p99: Option<u64>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub span_duration_cv_x1000: Option<u32>,
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
336pub struct GreenImpact {
337 pub estimated_extra_io_ops: usize,
339 pub io_intensity_score: f64,
341 pub io_intensity_band: crate::report::interpret::InterpretationLevel,
349}
350
351impl FindingType {
352 #[must_use]
353 pub const fn from_event_type_n_plus_one(event_type: &EventType) -> Self {
354 match event_type {
355 EventType::Sql => Self::NPlusOneSql,
356 EventType::HttpOut => Self::NPlusOneHttp,
357 EventType::Messaging => Self::NPlusOneMessaging,
358 }
359 }
360
361 #[must_use]
362 pub const fn from_event_type_redundant(event_type: &EventType) -> Option<Self> {
365 match event_type {
366 EventType::Sql => Some(Self::RedundantSql),
367 EventType::HttpOut => Some(Self::RedundantHttp),
368 EventType::Messaging => None,
369 }
370 }
371
372 #[must_use]
373 pub const fn from_event_type_slow(event_type: &EventType) -> Self {
374 match event_type {
375 EventType::Sql => Self::SlowSql,
376 EventType::HttpOut => Self::SlowHttp,
377 EventType::Messaging => Self::SlowMessaging,
378 }
379 }
380
381 #[must_use]
383 pub const fn as_str(&self) -> &'static str {
384 match self {
385 Self::NPlusOneSql => "n_plus_one_sql",
386 Self::NPlusOneHttp => "n_plus_one_http",
387 Self::NPlusOneMessaging => "n_plus_one_messaging",
388 Self::RedundantSql => "redundant_sql",
389 Self::RedundantHttp => "redundant_http",
390 Self::SlowSql => "slow_sql",
391 Self::SlowHttp => "slow_http",
392 Self::SlowMessaging => "slow_messaging",
393 Self::ExcessiveFanout => "excessive_fanout",
394 Self::ChattyService => "chatty_service",
395 Self::PoolSaturation => "pool_saturation",
396 Self::SerializedCalls => "serialized_calls",
397 }
398 }
399
400 #[must_use]
409 pub const fn rgesn_criteria(&self) -> &'static [&'static str] {
410 match self {
411 Self::NPlusOneSql | Self::NPlusOneHttp | Self::NPlusOneMessaging => &["7.1", "6.1"],
412 Self::RedundantSql | Self::RedundantHttp => &["7.1", "6.5"],
413 Self::ChattyService => &["4.9", "4.10", "6.1"],
414 Self::ExcessiveFanout | Self::PoolSaturation => &["3.2"],
415 Self::SerializedCalls => &["8.10"],
416 Self::SlowSql | Self::SlowHttp | Self::SlowMessaging => &[],
417 }
418 }
419
420 #[must_use]
423 pub fn from_kind_str(s: &str) -> Option<Self> {
424 match s {
425 "n_plus_one_sql" => Some(Self::NPlusOneSql),
426 "n_plus_one_http" => Some(Self::NPlusOneHttp),
427 "n_plus_one_messaging" => Some(Self::NPlusOneMessaging),
428 "redundant_sql" => Some(Self::RedundantSql),
429 "redundant_http" => Some(Self::RedundantHttp),
430 "slow_sql" => Some(Self::SlowSql),
431 "slow_http" => Some(Self::SlowHttp),
432 "slow_messaging" => Some(Self::SlowMessaging),
433 "excessive_fanout" => Some(Self::ExcessiveFanout),
434 "chatty_service" => Some(Self::ChattyService),
435 "pool_saturation" => Some(Self::PoolSaturation),
436 "serialized_calls" => Some(Self::SerializedCalls),
437 _ => None,
438 }
439 }
440
441 #[must_use]
443 pub const fn display_label(&self) -> &'static str {
444 match self {
445 Self::NPlusOneSql => "N+1 SQL",
446 Self::NPlusOneHttp => "N+1 HTTP",
447 Self::NPlusOneMessaging => "N+1 messaging",
448 Self::RedundantSql => "Redundant SQL",
449 Self::RedundantHttp => "Redundant HTTP",
450 Self::SlowSql => "Slow SQL",
451 Self::SlowHttp => "Slow HTTP",
452 Self::SlowMessaging => "Slow messaging",
453 Self::ExcessiveFanout => "Excessive fanout",
454 Self::ChattyService => "Chatty service",
455 Self::PoolSaturation => "Pool saturation",
456 Self::SerializedCalls => "Serialized calls",
457 }
458 }
459
460 #[must_use]
467 pub const fn is_avoidable_io(&self) -> bool {
468 matches!(
469 self,
470 Self::NPlusOneSql
471 | Self::NPlusOneHttp
472 | Self::NPlusOneMessaging
473 | Self::RedundantSql
474 | Self::RedundantHttp
475 )
476 }
477}
478
479impl Severity {
480 #[must_use]
482 pub const fn as_str(&self) -> &'static str {
483 match self {
484 Self::Critical => "critical",
485 Self::Warning => "warning",
486 Self::Info => "info",
487 }
488 }
489}
490
491#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
500pub struct DetectConfig {
501 pub n_plus_one_threshold: u32,
502 pub window_ms: u64,
503 pub slow_threshold_ms: u64,
504 pub slow_min_occurrences: u32,
505 pub max_fanout: u32,
506 pub chatty_service_min_calls: u32,
507 pub pool_saturation_concurrent_threshold: u32,
508 pub serialized_min_sequential: u32,
509 pub sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode,
510}
511
512impl Default for DetectConfig {
513 fn default() -> Self {
514 Self::from(&crate::config::Config::default())
515 }
516}
517
518impl From<&crate::config::Config> for DetectConfig {
519 fn from(config: &crate::config::Config) -> Self {
520 Self {
521 n_plus_one_threshold: config.detection.n_plus_one_threshold,
522 window_ms: config.detection.window_duration_ms,
523 slow_threshold_ms: config.detection.slow_query_threshold_ms,
524 slow_min_occurrences: config.detection.slow_query_min_occurrences,
525 max_fanout: config.detection.max_fanout,
526 chatty_service_min_calls: config.detection.chatty_service_min_calls,
527 pool_saturation_concurrent_threshold: config
528 .detection
529 .pool_saturation_concurrent_threshold,
530 serialized_min_sequential: config.detection.serialized_min_sequential,
531 sanitizer_aware_classification: config.detection.sanitizer_aware_classification,
532 }
533 }
534}
535
536pub(crate) struct PerTraceFindingArgs<'a> {
539 pub finding_type: FindingType,
540 pub severity: Severity,
541 pub trace_id: &'a str,
542 pub first_span: &'a crate::normalize::NormalizedEvent,
543 pub template: &'a str,
544 pub occurrences: usize,
545 pub window_ms: u64,
546 pub distinct_params: usize,
547 pub suggestion: String,
548 pub first_timestamp: &'a str,
549 pub last_timestamp: &'a str,
550 pub code_location: Option<crate::event::CodeLocation>,
551 pub instrumentation_scopes: Vec<String>,
552 pub classification_method: Option<ClassificationMethod>,
553 pub span_durations_us: Option<Vec<u64>>,
554}
555
556fn compute_timing_stats(durations: &mut [u64]) -> (u64, u64, u32) {
564 if durations.is_empty() {
565 return (0, 0, 0);
566 }
567 durations.sort_unstable();
568 let n = durations.len();
569 let p50 = durations[slow::percentile_index(n, 50)];
570 let p99 = durations[slow::percentile_index(n, 99)];
571 #[allow(clippy::cast_precision_loss)]
572 let n_f = n as f64;
573 let mut mean = 0.0_f64;
574 let mut m2 = 0.0_f64;
575 let mut count = 0u64;
576 for &d in durations.iter() {
577 count += 1;
578 #[allow(clippy::cast_precision_loss)]
579 let val = d as f64;
580 let delta = val - mean;
581 #[allow(clippy::cast_precision_loss)]
582 let cf = count as f64;
583 mean += delta / cf;
584 m2 += delta * (val - mean);
585 }
586 let cv_x1000 = if mean > 0.0 && n_f > 1.0 {
587 let cv = (m2 / n_f).sqrt() / mean;
588 #[allow(clippy::cast_sign_loss)] {
590 (cv * 1000.0).round() as u32
591 }
592 } else {
593 0
594 };
595 (p50, p99, cv_x1000)
596}
597
598pub(crate) fn build_per_trace_finding(args: PerTraceFindingArgs<'_>) -> Finding {
599 let timing = args
600 .span_durations_us
601 .map(|mut d| compute_timing_stats(&mut d));
602 Finding {
603 finding_type: args.finding_type,
604 severity: args.severity,
605 trace_id: args.trace_id.to_string(),
606 service: args.first_span.event.service.to_string(),
607 grouping: args.first_span.event.grouping.clone(),
608 source_endpoint: args.first_span.event.source.endpoint.clone(),
609 pattern: Pattern {
610 template: args.template.to_string(),
611 occurrences: args.occurrences,
612 window_ms: args.window_ms,
613 distinct_params: args.distinct_params,
614 span_duration_us_p50: timing.map(|(p50, _, _)| p50),
615 span_duration_us_p99: timing.map(|(_, p99, _)| p99),
616 span_duration_cv_x1000: timing.map(|(_, _, cv)| cv),
617 },
618 suggestion: args.suggestion,
619 first_timestamp: args.first_timestamp.to_string(),
620 last_timestamp: args.last_timestamp.to_string(),
621 green_impact: None,
622 confidence: Confidence::default(),
623 classification_method: args.classification_method,
624 code_location: args.code_location,
625 instrumentation_scopes: args.instrumentation_scopes,
626 suggested_fix: None,
627 signature: String::new(),
628 }
629}
630
631pub fn apply_confidence(findings: &mut [Finding], confidence: Confidence) {
640 for finding in findings.iter_mut() {
641 finding.confidence = confidence;
642 }
643}
644
645#[must_use]
655pub fn run_full_detection(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
656 let mut findings = detect(traces, config);
657 if traces.len() >= 2 {
658 let mut cross_trace = slow::detect_slow_cross_trace(
659 traces,
660 config.slow_threshold_ms,
661 config.slow_min_occurrences,
662 );
663 findings.append(&mut cross_trace);
664 }
665 findings
666}
667
668#[must_use]
673pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
674 let mut findings = Vec::new();
675 for trace in traces {
676 let indices = TraceIndices::build(trace);
679 let mut n_plus_one_findings = n_plus_one::detect_n_plus_one(
684 trace,
685 config.n_plus_one_threshold,
686 config.window_ms,
687 config.sanitizer_aware_classification,
688 );
689 let mut redundant_findings = redundant::detect_redundant(trace, &n_plus_one_findings);
690 findings.append(&mut n_plus_one_findings);
691 findings.append(&mut redundant_findings);
692 findings.append(&mut slow::detect_slow(
693 trace,
694 config.slow_threshold_ms,
695 config.slow_min_occurrences,
696 ));
697 findings.append(&mut fanout::detect_fanout(
698 trace,
699 &indices,
700 config.max_fanout,
701 ));
702 findings.append(&mut chatty::detect_chatty(
703 trace,
704 config.chatty_service_min_calls,
705 ));
706 findings.append(&mut pool_saturation::detect_pool_saturation(
707 trace,
708 config.pool_saturation_concurrent_threshold,
709 ));
710 findings.append(&mut serialized::detect_serialized(
711 trace,
712 &indices,
713 config.serialized_min_sequential,
714 ));
715 }
716 suggestions::enrich(&mut findings);
717 findings
718}
719
720pub(crate) fn sort_findings(findings: &mut [Finding]) {
724 findings.sort_by(|a, b| {
725 a.finding_type
726 .cmp(&b.finding_type)
727 .then_with(|| a.severity.cmp(&b.severity))
728 .then_with(|| a.trace_id.cmp(&b.trace_id))
729 .then_with(|| a.source_endpoint.cmp(&b.source_endpoint))
730 .then_with(|| a.pattern.template.cmp(&b.pattern.template))
731 });
732}
733
734#[cfg(test)]
737pub(crate) fn test_finding_with_template(template: &str) -> Finding {
738 Finding {
739 finding_type: FindingType::NPlusOneSql,
740 severity: Severity::Warning,
741 trace_id: "trace-1".to_string(),
742 service: "order-svc".to_string(),
743 grouping: Vec::new(),
744 source_endpoint: "POST /api/orders/42/submit".to_string(),
745 pattern: Pattern {
746 template: template.to_string(),
747 occurrences: 6,
748 window_ms: 200,
749 distinct_params: 6,
750 ..Default::default()
751 },
752 suggestion: "batch".to_string(),
753 first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
754 last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
755 green_impact: None,
756 confidence: Confidence::default(),
757 classification_method: None,
758 code_location: None,
759 instrumentation_scopes: Vec::new(),
760 suggested_fix: None,
761 signature: String::new(),
762 }
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 fn default_config() -> DetectConfig {
770 DetectConfig {
771 n_plus_one_threshold: 5,
772 window_ms: 500,
773 slow_threshold_ms: 500,
774 slow_min_occurrences: 3,
775 max_fanout: 20,
776 chatty_service_min_calls: 15,
777 pool_saturation_concurrent_threshold: 10,
778 serialized_min_sequential: 3,
779 sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode::default(),
780 }
781 }
782
783 #[test]
784 fn empty_traces_produce_no_findings() {
785 let findings = detect(&[], &default_config());
786 assert!(findings.is_empty());
787 }
788
789 #[test]
790 fn finding_type_serializes_to_snake_case() {
791 let json = serde_json::to_string(&FindingType::NPlusOneSql).unwrap();
792 assert_eq!(json, r#""n_plus_one_sql""#);
793
794 let json = serde_json::to_string(&FindingType::RedundantHttp).unwrap();
795 assert_eq!(json, r#""redundant_http""#);
796
797 let json = serde_json::to_string(&FindingType::SlowSql).unwrap();
798 assert_eq!(json, r#""slow_sql""#);
799
800 let json = serde_json::to_string(&FindingType::SlowHttp).unwrap();
801 assert_eq!(json, r#""slow_http""#);
802
803 let json = serde_json::to_string(&FindingType::ExcessiveFanout).unwrap();
804 assert_eq!(json, r#""excessive_fanout""#);
805
806 let json = serde_json::to_string(&FindingType::ChattyService).unwrap();
807 assert_eq!(json, r#""chatty_service""#);
808
809 let json = serde_json::to_string(&FindingType::PoolSaturation).unwrap();
810 assert_eq!(json, r#""pool_saturation""#);
811
812 let json = serde_json::to_string(&FindingType::SerializedCalls).unwrap();
813 assert_eq!(json, r#""serialized_calls""#);
814 }
815
816 #[test]
817 fn severity_serializes_to_snake_case() {
818 let json = serde_json::to_string(&Severity::Critical).unwrap();
819 assert_eq!(json, r#""critical""#);
820 }
821
822 #[test]
825 fn confidence_default_is_ci_batch() {
826 assert_eq!(Confidence::default(), Confidence::CiBatch);
827 }
828
829 #[test]
830 fn confidence_serializes_to_snake_case() {
831 assert_eq!(
832 serde_json::to_string(&Confidence::LocalBatch).unwrap(),
833 r#""local_batch""#
834 );
835 assert_eq!(
836 serde_json::to_string(&Confidence::CiBatch).unwrap(),
837 r#""ci_batch""#
838 );
839 assert_eq!(
840 serde_json::to_string(&Confidence::DaemonStaging).unwrap(),
841 r#""daemon_staging""#
842 );
843 assert_eq!(
844 serde_json::to_string(&Confidence::DaemonProduction).unwrap(),
845 r#""daemon_production""#
846 );
847 }
848
849 #[test]
850 fn confidence_deserializes_from_snake_case() {
851 let c: Confidence = serde_json::from_str(r#""local_batch""#).unwrap();
852 assert_eq!(c, Confidence::LocalBatch);
853 let c: Confidence = serde_json::from_str(r#""ci_batch""#).unwrap();
854 assert_eq!(c, Confidence::CiBatch);
855 let c: Confidence = serde_json::from_str(r#""daemon_staging""#).unwrap();
856 assert_eq!(c, Confidence::DaemonStaging);
857 let c: Confidence = serde_json::from_str(r#""daemon_production""#).unwrap();
858 assert_eq!(c, Confidence::DaemonProduction);
859 }
860
861 #[test]
862 fn confidence_as_str_matches_serialization() {
863 assert_eq!(Confidence::LocalBatch.as_str(), "local_batch");
864 assert_eq!(Confidence::CiBatch.as_str(), "ci_batch");
865 assert_eq!(Confidence::DaemonStaging.as_str(), "daemon_staging");
866 assert_eq!(Confidence::DaemonProduction.as_str(), "daemon_production");
867 }
868
869 #[test]
870 fn confidence_sarif_rank_increases_with_confidence() {
871 assert!(Confidence::LocalBatch.sarif_rank() < Confidence::CiBatch.sarif_rank());
874 assert!(Confidence::CiBatch.sarif_rank() < Confidence::DaemonStaging.sarif_rank());
875 assert!(Confidence::DaemonStaging.sarif_rank() < Confidence::DaemonProduction.sarif_rank());
876 assert_eq!(Confidence::LocalBatch.sarif_rank(), 15);
877 assert_eq!(Confidence::CiBatch.sarif_rank(), 30);
878 assert_eq!(Confidence::DaemonStaging.sarif_rank(), 60);
879 assert_eq!(Confidence::DaemonProduction.sarif_rank(), 90);
880 }
881
882 #[test]
883 fn batch_for_ci_maps_and_is_batch_classifies() {
884 assert_eq!(Confidence::batch_for_ci(true), Confidence::CiBatch);
885 assert_eq!(Confidence::batch_for_ci(false), Confidence::LocalBatch);
886 assert!(Confidence::LocalBatch.is_batch());
887 assert!(Confidence::CiBatch.is_batch());
888 assert!(!Confidence::DaemonStaging.is_batch());
889 assert!(!Confidence::DaemonProduction.is_batch());
890 }
891
892 #[test]
893 fn detector_findings_default_to_ci_batch_confidence() {
894 use crate::test_helpers::{make_sql_event, make_trace};
899 let events: Vec<crate::event::SpanEvent> = (1..=6)
900 .map(|i| {
901 make_sql_event(
902 "trace-1",
903 &format!("span-{i}"),
904 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
905 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
906 )
907 })
908 .collect();
909 let trace = make_trace(events);
910 let findings = detect(&[trace], &default_config());
911 assert!(!findings.is_empty());
912 for f in &findings {
913 assert_eq!(f.confidence, Confidence::CiBatch);
914 }
915 }
916
917 #[test]
918 fn detect_combines_n_plus_one_and_redundant() {
919 use crate::test_helpers::{make_sql_event, make_trace};
920 let mut events = Vec::new();
923 for i in 1..=5 {
924 events.push(make_sql_event(
925 "trace-1",
926 &format!("span-{i}"),
927 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
928 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
929 ));
930 }
931 for i in 6..=8 {
932 events.push(make_sql_event(
933 "trace-1",
934 &format!("span-{i}"),
935 "SELECT * FROM config WHERE key = 'timeout'",
936 &format!("2025-07-10T14:32:01.{:03}Z", i * 30),
937 ));
938 }
939
940 let trace = make_trace(events);
941 let findings = detect(&[trace], &default_config());
942
943 let has_n_plus_one = findings
944 .iter()
945 .any(|f| f.finding_type == FindingType::NPlusOneSql);
946 let has_redundant = findings
947 .iter()
948 .any(|f| f.finding_type == FindingType::RedundantSql);
949 assert!(has_n_plus_one, "should detect N+1");
950 assert!(has_redundant, "should detect redundant");
951 }
952
953 #[test]
954 fn detect_multiple_traces() {
955 use crate::test_helpers::{make_sql_event, make_trace};
956 let events_t1: Vec<crate::event::SpanEvent> = (1..=3)
958 .map(|i| {
959 make_sql_event(
960 "trace-A",
961 &format!("span-a{i}"),
962 "SELECT * FROM order_item WHERE order_id = 42",
963 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
964 )
965 })
966 .collect();
967
968 let events_t2: Vec<crate::event::SpanEvent> = (1..=2)
969 .map(|i| {
970 make_sql_event(
971 "trace-B",
972 &format!("span-b{i}"),
973 "SELECT * FROM orders WHERE user_id = 7",
974 &format!("2025-07-10T14:32:02.{:03}Z", i * 50),
975 )
976 })
977 .collect();
978
979 let trace_a = make_trace(events_t1);
980 let trace_b = make_trace(events_t2);
981 let findings = detect(&[trace_a, trace_b], &default_config());
982
983 assert!(
985 findings.iter().any(|f| f.trace_id == "trace-A"),
986 "trace-A should have findings"
987 );
988 assert!(
989 findings.iter().any(|f| f.trace_id == "trace-B"),
990 "trace-B should have findings"
991 );
992 }
993
994 #[test]
995 fn finding_type_as_str() {
996 assert_eq!(FindingType::NPlusOneSql.as_str(), "n_plus_one_sql");
997 assert_eq!(FindingType::SlowHttp.as_str(), "slow_http");
998 assert_eq!(FindingType::ChattyService.as_str(), "chatty_service");
999 assert_eq!(FindingType::PoolSaturation.as_str(), "pool_saturation");
1000 assert_eq!(FindingType::SerializedCalls.as_str(), "serialized_calls");
1001 }
1002
1003 #[test]
1004 fn severity_as_str() {
1005 assert_eq!(Severity::Critical.as_str(), "critical");
1006 assert_eq!(Severity::Warning.as_str(), "warning");
1007 assert_eq!(Severity::Info.as_str(), "info");
1008 }
1009
1010 #[test]
1011 fn rgesn_criteria_crosswalk() {
1012 assert_eq!(FindingType::NPlusOneSql.rgesn_criteria(), &["7.1", "6.1"]);
1014 assert_eq!(FindingType::RedundantHttp.rgesn_criteria(), &["7.1", "6.5"]);
1015 assert_eq!(
1016 FindingType::ChattyService.rgesn_criteria(),
1017 &["4.9", "4.10", "6.1"]
1018 );
1019 assert_eq!(FindingType::ExcessiveFanout.rgesn_criteria(), &["3.2"]);
1020 assert_eq!(FindingType::PoolSaturation.rgesn_criteria(), &["3.2"]);
1021 assert_eq!(FindingType::SerializedCalls.rgesn_criteria(), &["8.10"]);
1022 assert!(FindingType::SlowSql.rgesn_criteria().is_empty());
1024 assert!(FindingType::SlowHttp.rgesn_criteria().is_empty());
1025 }
1026
1027 #[test]
1028 fn from_kind_str_inverts_as_str() {
1029 use FindingType::*;
1035 for v in [
1036 NPlusOneSql,
1037 NPlusOneHttp,
1038 RedundantSql,
1039 RedundantHttp,
1040 SlowSql,
1041 SlowHttp,
1042 ExcessiveFanout,
1043 ChattyService,
1044 PoolSaturation,
1045 SerializedCalls,
1046 ] {
1047 assert_eq!(
1048 FindingType::from_kind_str(v.as_str()),
1049 Some(v.clone()),
1050 "{v:?}"
1051 );
1052 }
1053 assert_eq!(FindingType::from_kind_str("unknown_pattern"), None);
1054 }
1055
1056 #[test]
1057 fn finding_type_from_event_type_n_plus_one() {
1058 use crate::event::EventType;
1059 assert_eq!(
1060 FindingType::from_event_type_n_plus_one(&EventType::Sql),
1061 FindingType::NPlusOneSql
1062 );
1063 assert_eq!(
1064 FindingType::from_event_type_n_plus_one(&EventType::HttpOut),
1065 FindingType::NPlusOneHttp
1066 );
1067 }
1068
1069 #[test]
1070 fn finding_type_from_event_type_redundant() {
1071 use crate::event::EventType;
1072 assert_eq!(
1073 FindingType::from_event_type_redundant(&EventType::Sql),
1074 Some(FindingType::RedundantSql)
1075 );
1076 assert_eq!(
1077 FindingType::from_event_type_redundant(&EventType::HttpOut),
1078 Some(FindingType::RedundantHttp)
1079 );
1080 assert_eq!(
1082 FindingType::from_event_type_redundant(&EventType::Messaging),
1083 None
1084 );
1085 }
1086
1087 #[test]
1088 fn finding_type_from_event_type_slow() {
1089 use crate::event::EventType;
1090 assert_eq!(
1091 FindingType::from_event_type_slow(&EventType::Sql),
1092 FindingType::SlowSql
1093 );
1094 assert_eq!(
1095 FindingType::from_event_type_slow(&EventType::HttpOut),
1096 FindingType::SlowHttp
1097 );
1098 }
1099
1100 #[test]
1101 fn detect_all_three_types_on_one_trace() {
1102 use crate::test_helpers::{make_sql_event, make_sql_event_with_duration, make_trace};
1103 let mut events = Vec::new();
1104 for i in 1..=5 {
1106 events.push(make_sql_event(
1107 "trace-1",
1108 &format!("span-n{i}"),
1109 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
1110 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
1111 ));
1112 }
1113 for i in 1..=3 {
1115 events.push(make_sql_event(
1116 "trace-1",
1117 &format!("span-r{i}"),
1118 "SELECT * FROM config WHERE key = 'timeout'",
1119 &format!("2025-07-10T14:32:02.{:03}Z", i * 30),
1120 ));
1121 }
1122 for i in 1..=3 {
1124 events.push(make_sql_event_with_duration(
1125 "trace-1",
1126 &format!("span-s{i}"),
1127 &format!("SELECT * FROM big_table WHERE id = {}", i + 100),
1128 &format!("2025-07-10T14:32:03.{:03}Z", i * 30),
1129 600_000,
1130 ));
1131 }
1132 let trace = make_trace(events);
1133 let findings = detect(&[trace], &default_config());
1134
1135 let has_n1 = findings
1136 .iter()
1137 .any(|f| f.finding_type == FindingType::NPlusOneSql);
1138 let has_redundant = findings
1139 .iter()
1140 .any(|f| f.finding_type == FindingType::RedundantSql);
1141 let has_slow = findings
1142 .iter()
1143 .any(|f| f.finding_type == FindingType::SlowSql);
1144
1145 assert!(has_n1, "should detect N+1");
1146 assert!(has_redundant, "should detect redundant");
1147 assert!(has_slow, "should detect slow");
1148 }
1149
1150 #[test]
1153 fn finding_serde_roundtrip() {
1154 let finding =
1155 crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1156 let json = serde_json::to_string(&finding).unwrap();
1157 let back: Finding = serde_json::from_str(&json).unwrap();
1158 assert_eq!(finding.finding_type, back.finding_type);
1159 assert_eq!(finding.severity, back.severity);
1160 assert_eq!(finding.trace_id, back.trace_id);
1161 assert_eq!(finding.service, back.service);
1162 assert_eq!(finding.pattern.template, back.pattern.template);
1163 assert_eq!(finding.confidence, back.confidence);
1164 }
1165
1166 #[test]
1167 fn finding_with_code_location_serde_roundtrip() {
1168 let mut finding =
1169 crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1170 finding.code_location = Some(crate::event::CodeLocation {
1171 function: Some("processItems".to_string()),
1172 filepath: Some("src/Order.java".to_string()),
1173 lineno: Some(42),
1174 namespace: Some("com.example".to_string()),
1175 });
1176 let json = serde_json::to_string(&finding).unwrap();
1177 let back: Finding = serde_json::from_str(&json).unwrap();
1178 let loc = back.code_location.unwrap();
1179 assert_eq!(loc.function.as_deref(), Some("processItems"));
1180 assert_eq!(loc.lineno, Some(42));
1181 }
1182
1183 #[test]
1184 fn finding_type_deserializes_from_snake_case() {
1185 let ft: FindingType = serde_json::from_str(r#""n_plus_one_sql""#).unwrap();
1186 assert_eq!(ft, FindingType::NPlusOneSql);
1187 let ft: FindingType = serde_json::from_str(r#""chatty_service""#).unwrap();
1188 assert_eq!(ft, FindingType::ChattyService);
1189 }
1190
1191 #[test]
1192 fn severity_deserializes_from_snake_case() {
1193 let s: Severity = serde_json::from_str(r#""critical""#).unwrap();
1194 assert_eq!(s, Severity::Critical);
1195 let s: Severity = serde_json::from_str(r#""warning""#).unwrap();
1196 assert_eq!(s, Severity::Warning);
1197 }
1198
1199 #[test]
1202 fn timing_stats_empty_returns_zeroes() {
1203 assert_eq!(compute_timing_stats(&mut []), (0, 0, 0));
1204 }
1205
1206 #[test]
1207 fn timing_stats_single_element() {
1208 let (p50, p99, cv) = compute_timing_stats(&mut [800]);
1209 assert_eq!(p50, 800);
1210 assert_eq!(p99, 800);
1211 assert_eq!(cv, 0);
1212 }
1213
1214 #[test]
1215 fn timing_stats_two_elements_p99_is_max() {
1216 let (p50, p99, _cv) = compute_timing_stats(&mut [100, 900]);
1217 assert_eq!(p50, 100); assert_eq!(p99, 900); }
1220
1221 #[test]
1222 fn timing_stats_five_elements_p99_is_max() {
1223 let (p50, p99, _cv) = compute_timing_stats(&mut [10, 20, 30, 40, 50]);
1224 assert_eq!(p50, 30);
1225 assert_eq!(p99, 50);
1226 }
1227
1228 #[test]
1229 fn timing_stats_identical_durations_cv_zero() {
1230 let mut durations = [100u64; 10];
1231 let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1232 assert_eq!(cv, 0);
1233 }
1234
1235 #[test]
1236 fn timing_stats_dispersed_durations_cv_matches_variance_helper() {
1237 let mut durations = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
1238 let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1239 assert!(cv > 500, "CV should be > 0.5, got {cv}");
1241 assert!(cv < 800, "CV should be < 0.8, got {cv}");
1242 }
1243}