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]
159 pub fn grouping_identity(&self) -> Option<(&str, &str)> {
160 self.grouping
161 .first()
162 .map(|g| (g.key.as_ref(), g.value.as_ref()))
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum FindingType {
170 NPlusOneSql,
171 NPlusOneHttp,
172 NPlusOneMessaging,
173 RedundantSql,
174 RedundantHttp,
175 SlowSql,
176 SlowHttp,
177 SlowMessaging,
178 ExcessiveFanout,
179 ChattyService,
180 PoolSaturation,
181 SerializedCalls,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum Severity {
188 Critical,
189 Warning,
190 Info,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
201#[serde(rename_all = "snake_case")]
202pub enum Confidence {
203 LocalBatch,
206 #[default]
213 CiBatch,
214 DaemonStaging,
217 DaemonProduction,
220}
221
222impl Confidence {
223 #[must_use]
225 pub const fn as_str(&self) -> &'static str {
226 match self {
227 Self::LocalBatch => "local_batch",
228 Self::CiBatch => "ci_batch",
229 Self::DaemonStaging => "daemon_staging",
230 Self::DaemonProduction => "daemon_production",
231 }
232 }
233
234 #[must_use]
238 pub const fn is_batch(&self) -> bool {
239 matches!(self, Self::LocalBatch | Self::CiBatch)
240 }
241
242 #[must_use]
245 pub const fn batch_for_ci(is_ci: bool) -> Self {
246 if is_ci {
247 Self::CiBatch
248 } else {
249 Self::LocalBatch
250 }
251 }
252
253 #[must_use]
260 pub const fn sarif_rank(&self) -> u32 {
261 match self {
262 Self::LocalBatch => 15,
263 Self::CiBatch => 30,
264 Self::DaemonStaging => 60,
265 Self::DaemonProduction => 90,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum ClassificationMethod {
280 Direct,
285 SanitizerHeuristic,
290}
291
292#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
294pub struct Pattern {
295 pub template: String,
297 pub occurrences: usize,
299 pub window_ms: u64,
301 pub distinct_params: usize,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub span_duration_us_p50: Option<u64>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub span_duration_us_p99: Option<u64>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub span_duration_cv_x1000: Option<u32>,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct GreenImpact {
322 pub estimated_extra_io_ops: usize,
324 pub io_intensity_score: f64,
326 pub io_intensity_band: crate::report::interpret::InterpretationLevel,
334}
335
336impl FindingType {
337 #[must_use]
338 pub const fn from_event_type_n_plus_one(event_type: &EventType) -> Self {
339 match event_type {
340 EventType::Sql => Self::NPlusOneSql,
341 EventType::HttpOut => Self::NPlusOneHttp,
342 EventType::Messaging => Self::NPlusOneMessaging,
343 }
344 }
345
346 #[must_use]
347 pub const fn from_event_type_redundant(event_type: &EventType) -> Option<Self> {
350 match event_type {
351 EventType::Sql => Some(Self::RedundantSql),
352 EventType::HttpOut => Some(Self::RedundantHttp),
353 EventType::Messaging => None,
354 }
355 }
356
357 #[must_use]
358 pub const fn from_event_type_slow(event_type: &EventType) -> Self {
359 match event_type {
360 EventType::Sql => Self::SlowSql,
361 EventType::HttpOut => Self::SlowHttp,
362 EventType::Messaging => Self::SlowMessaging,
363 }
364 }
365
366 #[must_use]
368 pub const fn as_str(&self) -> &'static str {
369 match self {
370 Self::NPlusOneSql => "n_plus_one_sql",
371 Self::NPlusOneHttp => "n_plus_one_http",
372 Self::NPlusOneMessaging => "n_plus_one_messaging",
373 Self::RedundantSql => "redundant_sql",
374 Self::RedundantHttp => "redundant_http",
375 Self::SlowSql => "slow_sql",
376 Self::SlowHttp => "slow_http",
377 Self::SlowMessaging => "slow_messaging",
378 Self::ExcessiveFanout => "excessive_fanout",
379 Self::ChattyService => "chatty_service",
380 Self::PoolSaturation => "pool_saturation",
381 Self::SerializedCalls => "serialized_calls",
382 }
383 }
384
385 #[must_use]
394 pub const fn rgesn_criteria(&self) -> &'static [&'static str] {
395 match self {
396 Self::NPlusOneSql | Self::NPlusOneHttp | Self::NPlusOneMessaging => &["7.1", "6.1"],
397 Self::RedundantSql | Self::RedundantHttp => &["7.1", "6.5"],
398 Self::ChattyService => &["4.9", "4.10", "6.1"],
399 Self::ExcessiveFanout | Self::PoolSaturation => &["3.2"],
400 Self::SerializedCalls => &["8.10"],
401 Self::SlowSql | Self::SlowHttp | Self::SlowMessaging => &[],
402 }
403 }
404
405 #[must_use]
408 pub fn from_kind_str(s: &str) -> Option<Self> {
409 match s {
410 "n_plus_one_sql" => Some(Self::NPlusOneSql),
411 "n_plus_one_http" => Some(Self::NPlusOneHttp),
412 "n_plus_one_messaging" => Some(Self::NPlusOneMessaging),
413 "redundant_sql" => Some(Self::RedundantSql),
414 "redundant_http" => Some(Self::RedundantHttp),
415 "slow_sql" => Some(Self::SlowSql),
416 "slow_http" => Some(Self::SlowHttp),
417 "slow_messaging" => Some(Self::SlowMessaging),
418 "excessive_fanout" => Some(Self::ExcessiveFanout),
419 "chatty_service" => Some(Self::ChattyService),
420 "pool_saturation" => Some(Self::PoolSaturation),
421 "serialized_calls" => Some(Self::SerializedCalls),
422 _ => None,
423 }
424 }
425
426 #[must_use]
428 pub const fn display_label(&self) -> &'static str {
429 match self {
430 Self::NPlusOneSql => "N+1 SQL",
431 Self::NPlusOneHttp => "N+1 HTTP",
432 Self::NPlusOneMessaging => "N+1 messaging",
433 Self::RedundantSql => "Redundant SQL",
434 Self::RedundantHttp => "Redundant HTTP",
435 Self::SlowSql => "Slow SQL",
436 Self::SlowHttp => "Slow HTTP",
437 Self::SlowMessaging => "Slow messaging",
438 Self::ExcessiveFanout => "Excessive fanout",
439 Self::ChattyService => "Chatty service",
440 Self::PoolSaturation => "Pool saturation",
441 Self::SerializedCalls => "Serialized calls",
442 }
443 }
444
445 #[must_use]
452 pub const fn is_avoidable_io(&self) -> bool {
453 matches!(
454 self,
455 Self::NPlusOneSql
456 | Self::NPlusOneHttp
457 | Self::NPlusOneMessaging
458 | Self::RedundantSql
459 | Self::RedundantHttp
460 )
461 }
462}
463
464impl Severity {
465 #[must_use]
467 pub const fn as_str(&self) -> &'static str {
468 match self {
469 Self::Critical => "critical",
470 Self::Warning => "warning",
471 Self::Info => "info",
472 }
473 }
474}
475
476#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
485pub struct DetectConfig {
486 pub n_plus_one_threshold: u32,
487 pub window_ms: u64,
488 pub slow_threshold_ms: u64,
489 pub slow_min_occurrences: u32,
490 pub max_fanout: u32,
491 pub chatty_service_min_calls: u32,
492 pub pool_saturation_concurrent_threshold: u32,
493 pub serialized_min_sequential: u32,
494 pub sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode,
495}
496
497impl Default for DetectConfig {
498 fn default() -> Self {
499 Self::from(&crate::config::Config::default())
500 }
501}
502
503impl From<&crate::config::Config> for DetectConfig {
504 fn from(config: &crate::config::Config) -> Self {
505 Self {
506 n_plus_one_threshold: config.detection.n_plus_one_threshold,
507 window_ms: config.detection.window_duration_ms,
508 slow_threshold_ms: config.detection.slow_query_threshold_ms,
509 slow_min_occurrences: config.detection.slow_query_min_occurrences,
510 max_fanout: config.detection.max_fanout,
511 chatty_service_min_calls: config.detection.chatty_service_min_calls,
512 pool_saturation_concurrent_threshold: config
513 .detection
514 .pool_saturation_concurrent_threshold,
515 serialized_min_sequential: config.detection.serialized_min_sequential,
516 sanitizer_aware_classification: config.detection.sanitizer_aware_classification,
517 }
518 }
519}
520
521pub(crate) struct PerTraceFindingArgs<'a> {
524 pub finding_type: FindingType,
525 pub severity: Severity,
526 pub trace_id: &'a str,
527 pub first_span: &'a crate::normalize::NormalizedEvent,
528 pub template: &'a str,
529 pub occurrences: usize,
530 pub window_ms: u64,
531 pub distinct_params: usize,
532 pub suggestion: String,
533 pub first_timestamp: &'a str,
534 pub last_timestamp: &'a str,
535 pub code_location: Option<crate::event::CodeLocation>,
536 pub instrumentation_scopes: Vec<String>,
537 pub classification_method: Option<ClassificationMethod>,
538 pub span_durations_us: Option<Vec<u64>>,
539}
540
541fn compute_timing_stats(durations: &mut [u64]) -> (u64, u64, u32) {
549 if durations.is_empty() {
550 return (0, 0, 0);
551 }
552 durations.sort_unstable();
553 let n = durations.len();
554 let p50 = durations[slow::percentile_index(n, 50)];
555 let p99 = durations[slow::percentile_index(n, 99)];
556 #[allow(clippy::cast_precision_loss)]
557 let n_f = n as f64;
558 let mut mean = 0.0_f64;
559 let mut m2 = 0.0_f64;
560 let mut count = 0u64;
561 for &d in durations.iter() {
562 count += 1;
563 #[allow(clippy::cast_precision_loss)]
564 let val = d as f64;
565 let delta = val - mean;
566 #[allow(clippy::cast_precision_loss)]
567 let cf = count as f64;
568 mean += delta / cf;
569 m2 += delta * (val - mean);
570 }
571 let cv_x1000 = if mean > 0.0 && n_f > 1.0 {
572 let cv = (m2 / n_f).sqrt() / mean;
573 #[allow(clippy::cast_sign_loss)] {
575 (cv * 1000.0).round() as u32
576 }
577 } else {
578 0
579 };
580 (p50, p99, cv_x1000)
581}
582
583pub(crate) fn build_per_trace_finding(args: PerTraceFindingArgs<'_>) -> Finding {
584 let timing = args
585 .span_durations_us
586 .map(|mut d| compute_timing_stats(&mut d));
587 Finding {
588 finding_type: args.finding_type,
589 severity: args.severity,
590 trace_id: args.trace_id.to_string(),
591 service: args.first_span.event.service.to_string(),
592 grouping: args.first_span.event.grouping.clone(),
593 source_endpoint: args.first_span.event.source.endpoint.clone(),
594 pattern: Pattern {
595 template: args.template.to_string(),
596 occurrences: args.occurrences,
597 window_ms: args.window_ms,
598 distinct_params: args.distinct_params,
599 span_duration_us_p50: timing.map(|(p50, _, _)| p50),
600 span_duration_us_p99: timing.map(|(_, p99, _)| p99),
601 span_duration_cv_x1000: timing.map(|(_, _, cv)| cv),
602 },
603 suggestion: args.suggestion,
604 first_timestamp: args.first_timestamp.to_string(),
605 last_timestamp: args.last_timestamp.to_string(),
606 green_impact: None,
607 confidence: Confidence::default(),
608 classification_method: args.classification_method,
609 code_location: args.code_location,
610 instrumentation_scopes: args.instrumentation_scopes,
611 suggested_fix: None,
612 signature: String::new(),
613 }
614}
615
616pub fn apply_confidence(findings: &mut [Finding], confidence: Confidence) {
625 for finding in findings.iter_mut() {
626 finding.confidence = confidence;
627 }
628}
629
630#[must_use]
640pub fn run_full_detection(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
641 let mut findings = detect(traces, config);
642 if traces.len() >= 2 {
643 let mut cross_trace = slow::detect_slow_cross_trace(
644 traces,
645 config.slow_threshold_ms,
646 config.slow_min_occurrences,
647 );
648 findings.append(&mut cross_trace);
649 }
650 findings
651}
652
653#[must_use]
658pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
659 let mut findings = Vec::new();
660 for trace in traces {
661 let indices = TraceIndices::build(trace);
664 let mut n_plus_one_findings = n_plus_one::detect_n_plus_one(
669 trace,
670 config.n_plus_one_threshold,
671 config.window_ms,
672 config.sanitizer_aware_classification,
673 );
674 let mut redundant_findings = redundant::detect_redundant(trace, &n_plus_one_findings);
675 findings.append(&mut n_plus_one_findings);
676 findings.append(&mut redundant_findings);
677 findings.append(&mut slow::detect_slow(
678 trace,
679 config.slow_threshold_ms,
680 config.slow_min_occurrences,
681 ));
682 findings.append(&mut fanout::detect_fanout(
683 trace,
684 &indices,
685 config.max_fanout,
686 ));
687 findings.append(&mut chatty::detect_chatty(
688 trace,
689 config.chatty_service_min_calls,
690 ));
691 findings.append(&mut pool_saturation::detect_pool_saturation(
692 trace,
693 config.pool_saturation_concurrent_threshold,
694 ));
695 findings.append(&mut serialized::detect_serialized(
696 trace,
697 &indices,
698 config.serialized_min_sequential,
699 ));
700 }
701 suggestions::enrich(&mut findings);
702 findings
703}
704
705pub(crate) fn sort_findings(findings: &mut [Finding]) {
709 findings.sort_by(|a, b| {
710 a.finding_type
711 .cmp(&b.finding_type)
712 .then_with(|| a.severity.cmp(&b.severity))
713 .then_with(|| a.trace_id.cmp(&b.trace_id))
714 .then_with(|| a.source_endpoint.cmp(&b.source_endpoint))
715 .then_with(|| a.pattern.template.cmp(&b.pattern.template))
716 });
717}
718
719#[cfg(test)]
722pub(crate) fn test_finding_with_template(template: &str) -> Finding {
723 Finding {
724 finding_type: FindingType::NPlusOneSql,
725 severity: Severity::Warning,
726 trace_id: "trace-1".to_string(),
727 service: "order-svc".to_string(),
728 grouping: Vec::new(),
729 source_endpoint: "POST /api/orders/42/submit".to_string(),
730 pattern: Pattern {
731 template: template.to_string(),
732 occurrences: 6,
733 window_ms: 200,
734 distinct_params: 6,
735 ..Default::default()
736 },
737 suggestion: "batch".to_string(),
738 first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
739 last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
740 green_impact: None,
741 confidence: Confidence::default(),
742 classification_method: None,
743 code_location: None,
744 instrumentation_scopes: Vec::new(),
745 suggested_fix: None,
746 signature: String::new(),
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 fn default_config() -> DetectConfig {
755 DetectConfig {
756 n_plus_one_threshold: 5,
757 window_ms: 500,
758 slow_threshold_ms: 500,
759 slow_min_occurrences: 3,
760 max_fanout: 20,
761 chatty_service_min_calls: 15,
762 pool_saturation_concurrent_threshold: 10,
763 serialized_min_sequential: 3,
764 sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode::default(),
765 }
766 }
767
768 #[test]
769 fn empty_traces_produce_no_findings() {
770 let findings = detect(&[], &default_config());
771 assert!(findings.is_empty());
772 }
773
774 #[test]
775 fn finding_type_serializes_to_snake_case() {
776 let json = serde_json::to_string(&FindingType::NPlusOneSql).unwrap();
777 assert_eq!(json, r#""n_plus_one_sql""#);
778
779 let json = serde_json::to_string(&FindingType::RedundantHttp).unwrap();
780 assert_eq!(json, r#""redundant_http""#);
781
782 let json = serde_json::to_string(&FindingType::SlowSql).unwrap();
783 assert_eq!(json, r#""slow_sql""#);
784
785 let json = serde_json::to_string(&FindingType::SlowHttp).unwrap();
786 assert_eq!(json, r#""slow_http""#);
787
788 let json = serde_json::to_string(&FindingType::ExcessiveFanout).unwrap();
789 assert_eq!(json, r#""excessive_fanout""#);
790
791 let json = serde_json::to_string(&FindingType::ChattyService).unwrap();
792 assert_eq!(json, r#""chatty_service""#);
793
794 let json = serde_json::to_string(&FindingType::PoolSaturation).unwrap();
795 assert_eq!(json, r#""pool_saturation""#);
796
797 let json = serde_json::to_string(&FindingType::SerializedCalls).unwrap();
798 assert_eq!(json, r#""serialized_calls""#);
799 }
800
801 #[test]
802 fn severity_serializes_to_snake_case() {
803 let json = serde_json::to_string(&Severity::Critical).unwrap();
804 assert_eq!(json, r#""critical""#);
805 }
806
807 #[test]
810 fn confidence_default_is_ci_batch() {
811 assert_eq!(Confidence::default(), Confidence::CiBatch);
812 }
813
814 #[test]
815 fn confidence_serializes_to_snake_case() {
816 assert_eq!(
817 serde_json::to_string(&Confidence::LocalBatch).unwrap(),
818 r#""local_batch""#
819 );
820 assert_eq!(
821 serde_json::to_string(&Confidence::CiBatch).unwrap(),
822 r#""ci_batch""#
823 );
824 assert_eq!(
825 serde_json::to_string(&Confidence::DaemonStaging).unwrap(),
826 r#""daemon_staging""#
827 );
828 assert_eq!(
829 serde_json::to_string(&Confidence::DaemonProduction).unwrap(),
830 r#""daemon_production""#
831 );
832 }
833
834 #[test]
835 fn confidence_deserializes_from_snake_case() {
836 let c: Confidence = serde_json::from_str(r#""local_batch""#).unwrap();
837 assert_eq!(c, Confidence::LocalBatch);
838 let c: Confidence = serde_json::from_str(r#""ci_batch""#).unwrap();
839 assert_eq!(c, Confidence::CiBatch);
840 let c: Confidence = serde_json::from_str(r#""daemon_staging""#).unwrap();
841 assert_eq!(c, Confidence::DaemonStaging);
842 let c: Confidence = serde_json::from_str(r#""daemon_production""#).unwrap();
843 assert_eq!(c, Confidence::DaemonProduction);
844 }
845
846 #[test]
847 fn confidence_as_str_matches_serialization() {
848 assert_eq!(Confidence::LocalBatch.as_str(), "local_batch");
849 assert_eq!(Confidence::CiBatch.as_str(), "ci_batch");
850 assert_eq!(Confidence::DaemonStaging.as_str(), "daemon_staging");
851 assert_eq!(Confidence::DaemonProduction.as_str(), "daemon_production");
852 }
853
854 #[test]
855 fn confidence_sarif_rank_increases_with_confidence() {
856 assert!(Confidence::LocalBatch.sarif_rank() < Confidence::CiBatch.sarif_rank());
859 assert!(Confidence::CiBatch.sarif_rank() < Confidence::DaemonStaging.sarif_rank());
860 assert!(Confidence::DaemonStaging.sarif_rank() < Confidence::DaemonProduction.sarif_rank());
861 assert_eq!(Confidence::LocalBatch.sarif_rank(), 15);
862 assert_eq!(Confidence::CiBatch.sarif_rank(), 30);
863 assert_eq!(Confidence::DaemonStaging.sarif_rank(), 60);
864 assert_eq!(Confidence::DaemonProduction.sarif_rank(), 90);
865 }
866
867 #[test]
868 fn batch_for_ci_maps_and_is_batch_classifies() {
869 assert_eq!(Confidence::batch_for_ci(true), Confidence::CiBatch);
870 assert_eq!(Confidence::batch_for_ci(false), Confidence::LocalBatch);
871 assert!(Confidence::LocalBatch.is_batch());
872 assert!(Confidence::CiBatch.is_batch());
873 assert!(!Confidence::DaemonStaging.is_batch());
874 assert!(!Confidence::DaemonProduction.is_batch());
875 }
876
877 #[test]
878 fn detector_findings_default_to_ci_batch_confidence() {
879 use crate::test_helpers::{make_sql_event, make_trace};
884 let events: Vec<crate::event::SpanEvent> = (1..=6)
885 .map(|i| {
886 make_sql_event(
887 "trace-1",
888 &format!("span-{i}"),
889 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
890 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
891 )
892 })
893 .collect();
894 let trace = make_trace(events);
895 let findings = detect(&[trace], &default_config());
896 assert!(!findings.is_empty());
897 for f in &findings {
898 assert_eq!(f.confidence, Confidence::CiBatch);
899 }
900 }
901
902 #[test]
903 fn detect_combines_n_plus_one_and_redundant() {
904 use crate::test_helpers::{make_sql_event, make_trace};
905 let mut events = Vec::new();
908 for i in 1..=5 {
909 events.push(make_sql_event(
910 "trace-1",
911 &format!("span-{i}"),
912 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
913 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
914 ));
915 }
916 for i in 6..=8 {
917 events.push(make_sql_event(
918 "trace-1",
919 &format!("span-{i}"),
920 "SELECT * FROM config WHERE key = 'timeout'",
921 &format!("2025-07-10T14:32:01.{:03}Z", i * 30),
922 ));
923 }
924
925 let trace = make_trace(events);
926 let findings = detect(&[trace], &default_config());
927
928 let has_n_plus_one = findings
929 .iter()
930 .any(|f| f.finding_type == FindingType::NPlusOneSql);
931 let has_redundant = findings
932 .iter()
933 .any(|f| f.finding_type == FindingType::RedundantSql);
934 assert!(has_n_plus_one, "should detect N+1");
935 assert!(has_redundant, "should detect redundant");
936 }
937
938 #[test]
939 fn detect_multiple_traces() {
940 use crate::test_helpers::{make_sql_event, make_trace};
941 let events_t1: Vec<crate::event::SpanEvent> = (1..=3)
943 .map(|i| {
944 make_sql_event(
945 "trace-A",
946 &format!("span-a{i}"),
947 "SELECT * FROM order_item WHERE order_id = 42",
948 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
949 )
950 })
951 .collect();
952
953 let events_t2: Vec<crate::event::SpanEvent> = (1..=2)
954 .map(|i| {
955 make_sql_event(
956 "trace-B",
957 &format!("span-b{i}"),
958 "SELECT * FROM orders WHERE user_id = 7",
959 &format!("2025-07-10T14:32:02.{:03}Z", i * 50),
960 )
961 })
962 .collect();
963
964 let trace_a = make_trace(events_t1);
965 let trace_b = make_trace(events_t2);
966 let findings = detect(&[trace_a, trace_b], &default_config());
967
968 assert!(
970 findings.iter().any(|f| f.trace_id == "trace-A"),
971 "trace-A should have findings"
972 );
973 assert!(
974 findings.iter().any(|f| f.trace_id == "trace-B"),
975 "trace-B should have findings"
976 );
977 }
978
979 #[test]
980 fn finding_type_as_str() {
981 assert_eq!(FindingType::NPlusOneSql.as_str(), "n_plus_one_sql");
982 assert_eq!(FindingType::SlowHttp.as_str(), "slow_http");
983 assert_eq!(FindingType::ChattyService.as_str(), "chatty_service");
984 assert_eq!(FindingType::PoolSaturation.as_str(), "pool_saturation");
985 assert_eq!(FindingType::SerializedCalls.as_str(), "serialized_calls");
986 }
987
988 #[test]
989 fn severity_as_str() {
990 assert_eq!(Severity::Critical.as_str(), "critical");
991 assert_eq!(Severity::Warning.as_str(), "warning");
992 assert_eq!(Severity::Info.as_str(), "info");
993 }
994
995 #[test]
996 fn rgesn_criteria_crosswalk() {
997 assert_eq!(FindingType::NPlusOneSql.rgesn_criteria(), &["7.1", "6.1"]);
999 assert_eq!(FindingType::RedundantHttp.rgesn_criteria(), &["7.1", "6.5"]);
1000 assert_eq!(
1001 FindingType::ChattyService.rgesn_criteria(),
1002 &["4.9", "4.10", "6.1"]
1003 );
1004 assert_eq!(FindingType::ExcessiveFanout.rgesn_criteria(), &["3.2"]);
1005 assert_eq!(FindingType::PoolSaturation.rgesn_criteria(), &["3.2"]);
1006 assert_eq!(FindingType::SerializedCalls.rgesn_criteria(), &["8.10"]);
1007 assert!(FindingType::SlowSql.rgesn_criteria().is_empty());
1009 assert!(FindingType::SlowHttp.rgesn_criteria().is_empty());
1010 }
1011
1012 #[test]
1013 fn from_kind_str_inverts_as_str() {
1014 use FindingType::*;
1020 for v in [
1021 NPlusOneSql,
1022 NPlusOneHttp,
1023 RedundantSql,
1024 RedundantHttp,
1025 SlowSql,
1026 SlowHttp,
1027 ExcessiveFanout,
1028 ChattyService,
1029 PoolSaturation,
1030 SerializedCalls,
1031 ] {
1032 assert_eq!(
1033 FindingType::from_kind_str(v.as_str()),
1034 Some(v.clone()),
1035 "{v:?}"
1036 );
1037 }
1038 assert_eq!(FindingType::from_kind_str("unknown_pattern"), None);
1039 }
1040
1041 #[test]
1042 fn finding_type_from_event_type_n_plus_one() {
1043 use crate::event::EventType;
1044 assert_eq!(
1045 FindingType::from_event_type_n_plus_one(&EventType::Sql),
1046 FindingType::NPlusOneSql
1047 );
1048 assert_eq!(
1049 FindingType::from_event_type_n_plus_one(&EventType::HttpOut),
1050 FindingType::NPlusOneHttp
1051 );
1052 }
1053
1054 #[test]
1055 fn finding_type_from_event_type_redundant() {
1056 use crate::event::EventType;
1057 assert_eq!(
1058 FindingType::from_event_type_redundant(&EventType::Sql),
1059 Some(FindingType::RedundantSql)
1060 );
1061 assert_eq!(
1062 FindingType::from_event_type_redundant(&EventType::HttpOut),
1063 Some(FindingType::RedundantHttp)
1064 );
1065 assert_eq!(
1067 FindingType::from_event_type_redundant(&EventType::Messaging),
1068 None
1069 );
1070 }
1071
1072 #[test]
1073 fn finding_type_from_event_type_slow() {
1074 use crate::event::EventType;
1075 assert_eq!(
1076 FindingType::from_event_type_slow(&EventType::Sql),
1077 FindingType::SlowSql
1078 );
1079 assert_eq!(
1080 FindingType::from_event_type_slow(&EventType::HttpOut),
1081 FindingType::SlowHttp
1082 );
1083 }
1084
1085 #[test]
1086 fn detect_all_three_types_on_one_trace() {
1087 use crate::test_helpers::{make_sql_event, make_sql_event_with_duration, make_trace};
1088 let mut events = Vec::new();
1089 for i in 1..=5 {
1091 events.push(make_sql_event(
1092 "trace-1",
1093 &format!("span-n{i}"),
1094 &format!("SELECT * FROM order_item WHERE order_id = {i}"),
1095 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
1096 ));
1097 }
1098 for i in 1..=3 {
1100 events.push(make_sql_event(
1101 "trace-1",
1102 &format!("span-r{i}"),
1103 "SELECT * FROM config WHERE key = 'timeout'",
1104 &format!("2025-07-10T14:32:02.{:03}Z", i * 30),
1105 ));
1106 }
1107 for i in 1..=3 {
1109 events.push(make_sql_event_with_duration(
1110 "trace-1",
1111 &format!("span-s{i}"),
1112 &format!("SELECT * FROM big_table WHERE id = {}", i + 100),
1113 &format!("2025-07-10T14:32:03.{:03}Z", i * 30),
1114 600_000,
1115 ));
1116 }
1117 let trace = make_trace(events);
1118 let findings = detect(&[trace], &default_config());
1119
1120 let has_n1 = findings
1121 .iter()
1122 .any(|f| f.finding_type == FindingType::NPlusOneSql);
1123 let has_redundant = findings
1124 .iter()
1125 .any(|f| f.finding_type == FindingType::RedundantSql);
1126 let has_slow = findings
1127 .iter()
1128 .any(|f| f.finding_type == FindingType::SlowSql);
1129
1130 assert!(has_n1, "should detect N+1");
1131 assert!(has_redundant, "should detect redundant");
1132 assert!(has_slow, "should detect slow");
1133 }
1134
1135 #[test]
1138 fn finding_serde_roundtrip() {
1139 let finding =
1140 crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1141 let json = serde_json::to_string(&finding).unwrap();
1142 let back: Finding = serde_json::from_str(&json).unwrap();
1143 assert_eq!(finding.finding_type, back.finding_type);
1144 assert_eq!(finding.severity, back.severity);
1145 assert_eq!(finding.trace_id, back.trace_id);
1146 assert_eq!(finding.service, back.service);
1147 assert_eq!(finding.pattern.template, back.pattern.template);
1148 assert_eq!(finding.confidence, back.confidence);
1149 }
1150
1151 #[test]
1152 fn finding_with_code_location_serde_roundtrip() {
1153 let mut finding =
1154 crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1155 finding.code_location = Some(crate::event::CodeLocation {
1156 function: Some("processItems".to_string()),
1157 filepath: Some("src/Order.java".to_string()),
1158 lineno: Some(42),
1159 namespace: Some("com.example".to_string()),
1160 });
1161 let json = serde_json::to_string(&finding).unwrap();
1162 let back: Finding = serde_json::from_str(&json).unwrap();
1163 let loc = back.code_location.unwrap();
1164 assert_eq!(loc.function.as_deref(), Some("processItems"));
1165 assert_eq!(loc.lineno, Some(42));
1166 }
1167
1168 #[test]
1169 fn finding_type_deserializes_from_snake_case() {
1170 let ft: FindingType = serde_json::from_str(r#""n_plus_one_sql""#).unwrap();
1171 assert_eq!(ft, FindingType::NPlusOneSql);
1172 let ft: FindingType = serde_json::from_str(r#""chatty_service""#).unwrap();
1173 assert_eq!(ft, FindingType::ChattyService);
1174 }
1175
1176 #[test]
1177 fn severity_deserializes_from_snake_case() {
1178 let s: Severity = serde_json::from_str(r#""critical""#).unwrap();
1179 assert_eq!(s, Severity::Critical);
1180 let s: Severity = serde_json::from_str(r#""warning""#).unwrap();
1181 assert_eq!(s, Severity::Warning);
1182 }
1183
1184 #[test]
1187 fn timing_stats_empty_returns_zeroes() {
1188 assert_eq!(compute_timing_stats(&mut []), (0, 0, 0));
1189 }
1190
1191 #[test]
1192 fn timing_stats_single_element() {
1193 let (p50, p99, cv) = compute_timing_stats(&mut [800]);
1194 assert_eq!(p50, 800);
1195 assert_eq!(p99, 800);
1196 assert_eq!(cv, 0);
1197 }
1198
1199 #[test]
1200 fn timing_stats_two_elements_p99_is_max() {
1201 let (p50, p99, _cv) = compute_timing_stats(&mut [100, 900]);
1202 assert_eq!(p50, 100); assert_eq!(p99, 900); }
1205
1206 #[test]
1207 fn timing_stats_five_elements_p99_is_max() {
1208 let (p50, p99, _cv) = compute_timing_stats(&mut [10, 20, 30, 40, 50]);
1209 assert_eq!(p50, 30);
1210 assert_eq!(p99, 50);
1211 }
1212
1213 #[test]
1214 fn timing_stats_identical_durations_cv_zero() {
1215 let mut durations = [100u64; 10];
1216 let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1217 assert_eq!(cv, 0);
1218 }
1219
1220 #[test]
1221 fn timing_stats_dispersed_durations_cv_matches_variance_helper() {
1222 let mut durations = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
1223 let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1224 assert!(cv > 500, "CV should be > 0.5, got {cv}");
1226 assert!(cv < 800, "CV should be < 0.8, got {cv}");
1227 }
1228}