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;
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 pub source_endpoint: String,
77 pub pattern: Pattern,
79 pub suggestion: String,
81 pub first_timestamp: String,
83 pub last_timestamp: String,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub green_impact: Option<GreenImpact>,
88 #[serde(default)]
98 pub confidence: Confidence,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub classification_method: Option<ClassificationMethod>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub code_location: Option<crate::event::CodeLocation>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub instrumentation_scopes: Vec<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub suggested_fix: Option<suggestions::SuggestedFix>,
128 #[serde(default)]
135 pub signature: String,
136}
137
138#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
171#[serde(rename_all = "snake_case")]
172pub enum Confidence {
173 LocalBatch,
176 #[default]
183 CiBatch,
184 DaemonStaging,
187 DaemonProduction,
190}
191
192impl Confidence {
193 #[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 #[must_use]
208 pub const fn is_batch(&self) -> bool {
209 matches!(self, Self::LocalBatch | Self::CiBatch)
210 }
211
212 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum ClassificationMethod {
250 Direct,
255 SanitizerHeuristic,
260}
261
262#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
264pub struct Pattern {
265 pub template: String,
267 pub occurrences: usize,
269 pub window_ms: u64,
271 pub distinct_params: usize,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub span_duration_us_p50: Option<u64>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub span_duration_us_p99: Option<u64>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub span_duration_cv_x1000: Option<u32>,
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291pub struct GreenImpact {
292 pub estimated_extra_io_ops: usize,
294 pub io_intensity_score: f64,
296 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 #[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 #[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 #[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 #[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 #[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 #[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#[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
463pub(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
483fn 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)] {
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
557pub fn apply_confidence(findings: &mut [Finding], confidence: Confidence) {
566 for finding in findings.iter_mut() {
567 finding.confidence = confidence;
568 }
569}
570
571#[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#[must_use]
599pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
600 let mut findings = Vec::new();
601 for trace in traces {
602 let indices = TraceIndices::build(trace);
605 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
646pub(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#[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 #[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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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); assert_eq!(p99, 900); }
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 assert!(cv > 500, "CV should be > 0.5, got {cv}");
1161 assert!(cv < 800, "CV should be < 0.8, got {cv}");
1162 }
1163}