pub mod chatty;
pub mod correlate_cross;
pub mod fanout;
pub mod n_plus_one;
pub mod pool_saturation;
pub mod redundant;
pub mod sanitizer_aware;
pub mod serialized;
pub mod slow;
pub mod suggestions;
use std::collections::HashMap;
use crate::correlate::Trace;
use crate::event::EventType;
use serde::{Deserialize, Serialize};
pub struct TraceIndices<'a> {
pub children_by_parent: HashMap<&'a str, Vec<usize>>,
pub span_index: HashMap<&'a str, usize>,
}
impl<'a> TraceIndices<'a> {
#[must_use]
pub fn build(trace: &'a Trace) -> Self {
let mut children_by_parent: HashMap<&str, Vec<usize>> =
HashMap::with_capacity(trace.spans.len() / 4 + 1);
let mut span_index: HashMap<&str, usize> = HashMap::with_capacity(trace.spans.len());
for (idx, span) in trace.spans.iter().enumerate() {
span_index.insert(span.event.span_id.as_str(), idx);
if let Some(ref parent_id) = span.event.parent_span_id {
children_by_parent
.entry(parent_id.as_str())
.or_default()
.push(idx);
}
}
Self {
children_by_parent,
span_index,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
#[serde(rename = "type")]
pub finding_type: FindingType,
pub severity: Severity,
pub trace_id: String,
pub service: String,
pub source_endpoint: String,
pub pattern: Pattern,
pub suggestion: String,
pub first_timestamp: String,
pub last_timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub green_impact: Option<GreenImpact>,
#[serde(default)]
pub confidence: Confidence,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classification_method: Option<ClassificationMethod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code_location: Option<crate::event::CodeLocation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub instrumentation_scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_fix: Option<suggestions::SuggestedFix>,
#[serde(default)]
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingType {
NPlusOneSql,
NPlusOneHttp,
RedundantSql,
RedundantHttp,
SlowSql,
SlowHttp,
ExcessiveFanout,
ChattyService,
PoolSaturation,
SerializedCalls,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Critical,
Warning,
Info,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Confidence {
#[default]
CiBatch,
DaemonStaging,
DaemonProduction,
}
impl Confidence {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::CiBatch => "ci_batch",
Self::DaemonStaging => "daemon_staging",
Self::DaemonProduction => "daemon_production",
}
}
#[must_use]
pub const fn sarif_rank(&self) -> u32 {
match self {
Self::CiBatch => 30,
Self::DaemonStaging => 60,
Self::DaemonProduction => 90,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClassificationMethod {
Direct,
SanitizerHeuristic,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pattern {
pub template: String,
pub occurrences: usize,
pub window_ms: u64,
pub distinct_params: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GreenImpact {
pub estimated_extra_io_ops: usize,
pub io_intensity_score: f64,
pub io_intensity_band: crate::report::interpret::InterpretationLevel,
}
impl FindingType {
#[must_use]
pub const fn from_event_type_n_plus_one(event_type: &EventType) -> Self {
match event_type {
EventType::Sql => Self::NPlusOneSql,
EventType::HttpOut => Self::NPlusOneHttp,
}
}
#[must_use]
pub const fn from_event_type_redundant(event_type: &EventType) -> Self {
match event_type {
EventType::Sql => Self::RedundantSql,
EventType::HttpOut => Self::RedundantHttp,
}
}
#[must_use]
pub const fn from_event_type_slow(event_type: &EventType) -> Self {
match event_type {
EventType::Sql => Self::SlowSql,
EventType::HttpOut => Self::SlowHttp,
}
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::NPlusOneSql => "n_plus_one_sql",
Self::NPlusOneHttp => "n_plus_one_http",
Self::RedundantSql => "redundant_sql",
Self::RedundantHttp => "redundant_http",
Self::SlowSql => "slow_sql",
Self::SlowHttp => "slow_http",
Self::ExcessiveFanout => "excessive_fanout",
Self::ChattyService => "chatty_service",
Self::PoolSaturation => "pool_saturation",
Self::SerializedCalls => "serialized_calls",
}
}
#[must_use]
pub const fn display_label(&self) -> &'static str {
match self {
Self::NPlusOneSql => "N+1 SQL",
Self::NPlusOneHttp => "N+1 HTTP",
Self::RedundantSql => "Redundant SQL",
Self::RedundantHttp => "Redundant HTTP",
Self::SlowSql => "Slow SQL",
Self::SlowHttp => "Slow HTTP",
Self::ExcessiveFanout => "Excessive fanout",
Self::ChattyService => "Chatty service",
Self::PoolSaturation => "Pool saturation",
Self::SerializedCalls => "Serialized calls",
}
}
#[must_use]
pub const fn is_avoidable_io(&self) -> bool {
matches!(
self,
Self::NPlusOneSql | Self::NPlusOneHttp | Self::RedundantSql | Self::RedundantHttp
)
}
}
impl Severity {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Critical => "critical",
Self::Warning => "warning",
Self::Info => "info",
}
}
}
#[derive(Debug, Clone)]
pub struct DetectConfig {
pub n_plus_one_threshold: u32,
pub window_ms: u64,
pub slow_threshold_ms: u64,
pub slow_min_occurrences: u32,
pub max_fanout: u32,
pub chatty_service_min_calls: u32,
pub pool_saturation_concurrent_threshold: u32,
pub serialized_min_sequential: u32,
pub sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode,
}
impl From<&crate::config::Config> for DetectConfig {
fn from(config: &crate::config::Config) -> Self {
Self {
n_plus_one_threshold: config.detection.n_plus_one_threshold,
window_ms: config.detection.window_duration_ms,
slow_threshold_ms: config.detection.slow_query_threshold_ms,
slow_min_occurrences: config.detection.slow_query_min_occurrences,
max_fanout: config.detection.max_fanout,
chatty_service_min_calls: config.detection.chatty_service_min_calls,
pool_saturation_concurrent_threshold: config
.detection
.pool_saturation_concurrent_threshold,
serialized_min_sequential: config.detection.serialized_min_sequential,
sanitizer_aware_classification: config.detection.sanitizer_aware_classification,
}
}
}
pub(crate) struct PerTraceFindingArgs<'a> {
pub finding_type: FindingType,
pub severity: Severity,
pub trace_id: &'a str,
pub first_span: &'a crate::normalize::NormalizedEvent,
pub template: &'a str,
pub occurrences: usize,
pub window_ms: u64,
pub distinct_params: usize,
pub suggestion: String,
pub first_timestamp: &'a str,
pub last_timestamp: &'a str,
pub code_location: Option<crate::event::CodeLocation>,
pub instrumentation_scopes: Vec<String>,
pub classification_method: Option<ClassificationMethod>,
}
pub(crate) fn build_per_trace_finding(args: PerTraceFindingArgs<'_>) -> Finding {
Finding {
finding_type: args.finding_type,
severity: args.severity,
trace_id: args.trace_id.to_string(),
service: args.first_span.event.service.to_string(),
source_endpoint: args.first_span.event.source.endpoint.clone(),
pattern: Pattern {
template: args.template.to_string(),
occurrences: args.occurrences,
window_ms: args.window_ms,
distinct_params: args.distinct_params,
},
suggestion: args.suggestion,
first_timestamp: args.first_timestamp.to_string(),
last_timestamp: args.last_timestamp.to_string(),
green_impact: None,
confidence: Confidence::default(),
classification_method: args.classification_method,
code_location: args.code_location,
instrumentation_scopes: args.instrumentation_scopes,
suggested_fix: None,
signature: String::new(),
}
}
pub fn apply_confidence(findings: &mut [Finding], confidence: Confidence) {
for finding in findings.iter_mut() {
finding.confidence = confidence;
}
}
#[must_use]
pub fn run_full_detection(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
let mut findings = detect(traces, config);
if traces.len() >= 2 {
let mut cross_trace = slow::detect_slow_cross_trace(
traces,
config.slow_threshold_ms,
config.slow_min_occurrences,
);
findings.append(&mut cross_trace);
}
findings
}
#[must_use]
pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
let mut findings = Vec::new();
for trace in traces {
let indices = TraceIndices::build(trace);
let mut n_plus_one_findings = n_plus_one::detect_n_plus_one(
trace,
config.n_plus_one_threshold,
config.window_ms,
config.sanitizer_aware_classification,
);
let mut redundant_findings = redundant::detect_redundant(trace, &n_plus_one_findings);
findings.append(&mut n_plus_one_findings);
findings.append(&mut redundant_findings);
findings.append(&mut slow::detect_slow(
trace,
config.slow_threshold_ms,
config.slow_min_occurrences,
));
findings.append(&mut fanout::detect_fanout(
trace,
&indices,
config.max_fanout,
));
findings.append(&mut chatty::detect_chatty(
trace,
config.chatty_service_min_calls,
));
findings.append(&mut pool_saturation::detect_pool_saturation(
trace,
config.pool_saturation_concurrent_threshold,
));
findings.append(&mut serialized::detect_serialized(
trace,
&indices,
config.serialized_min_sequential,
));
}
suggestions::enrich(&mut findings);
findings
}
pub(crate) fn sort_findings(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
a.finding_type
.cmp(&b.finding_type)
.then_with(|| a.severity.cmp(&b.severity))
.then_with(|| a.trace_id.cmp(&b.trace_id))
.then_with(|| a.source_endpoint.cmp(&b.source_endpoint))
.then_with(|| a.pattern.template.cmp(&b.pattern.template))
});
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> DetectConfig {
DetectConfig {
n_plus_one_threshold: 5,
window_ms: 500,
slow_threshold_ms: 500,
slow_min_occurrences: 3,
max_fanout: 20,
chatty_service_min_calls: 15,
pool_saturation_concurrent_threshold: 10,
serialized_min_sequential: 3,
sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode::default(),
}
}
#[test]
fn empty_traces_produce_no_findings() {
let findings = detect(&[], &default_config());
assert!(findings.is_empty());
}
#[test]
fn finding_type_serializes_to_snake_case() {
let json = serde_json::to_string(&FindingType::NPlusOneSql).unwrap();
assert_eq!(json, r#""n_plus_one_sql""#);
let json = serde_json::to_string(&FindingType::RedundantHttp).unwrap();
assert_eq!(json, r#""redundant_http""#);
let json = serde_json::to_string(&FindingType::SlowSql).unwrap();
assert_eq!(json, r#""slow_sql""#);
let json = serde_json::to_string(&FindingType::SlowHttp).unwrap();
assert_eq!(json, r#""slow_http""#);
let json = serde_json::to_string(&FindingType::ExcessiveFanout).unwrap();
assert_eq!(json, r#""excessive_fanout""#);
let json = serde_json::to_string(&FindingType::ChattyService).unwrap();
assert_eq!(json, r#""chatty_service""#);
let json = serde_json::to_string(&FindingType::PoolSaturation).unwrap();
assert_eq!(json, r#""pool_saturation""#);
let json = serde_json::to_string(&FindingType::SerializedCalls).unwrap();
assert_eq!(json, r#""serialized_calls""#);
}
#[test]
fn severity_serializes_to_snake_case() {
let json = serde_json::to_string(&Severity::Critical).unwrap();
assert_eq!(json, r#""critical""#);
}
#[test]
fn confidence_default_is_ci_batch() {
assert_eq!(Confidence::default(), Confidence::CiBatch);
}
#[test]
fn confidence_serializes_to_snake_case() {
assert_eq!(
serde_json::to_string(&Confidence::CiBatch).unwrap(),
r#""ci_batch""#
);
assert_eq!(
serde_json::to_string(&Confidence::DaemonStaging).unwrap(),
r#""daemon_staging""#
);
assert_eq!(
serde_json::to_string(&Confidence::DaemonProduction).unwrap(),
r#""daemon_production""#
);
}
#[test]
fn confidence_deserializes_from_snake_case() {
let c: Confidence = serde_json::from_str(r#""ci_batch""#).unwrap();
assert_eq!(c, Confidence::CiBatch);
let c: Confidence = serde_json::from_str(r#""daemon_staging""#).unwrap();
assert_eq!(c, Confidence::DaemonStaging);
let c: Confidence = serde_json::from_str(r#""daemon_production""#).unwrap();
assert_eq!(c, Confidence::DaemonProduction);
}
#[test]
fn confidence_as_str_matches_serialization() {
assert_eq!(Confidence::CiBatch.as_str(), "ci_batch");
assert_eq!(Confidence::DaemonStaging.as_str(), "daemon_staging");
assert_eq!(Confidence::DaemonProduction.as_str(), "daemon_production");
}
#[test]
fn confidence_sarif_rank_increases_with_confidence() {
assert!(Confidence::CiBatch.sarif_rank() < Confidence::DaemonStaging.sarif_rank());
assert!(Confidence::DaemonStaging.sarif_rank() < Confidence::DaemonProduction.sarif_rank());
assert_eq!(Confidence::CiBatch.sarif_rank(), 30);
assert_eq!(Confidence::DaemonStaging.sarif_rank(), 60);
assert_eq!(Confidence::DaemonProduction.sarif_rank(), 90);
}
#[test]
fn detector_findings_default_to_ci_batch_confidence() {
use crate::test_helpers::{make_sql_event, make_trace};
let events: Vec<crate::event::SpanEvent> = (1..=6)
.map(|i| {
make_sql_event(
"trace-1",
&format!("span-{i}"),
&format!("SELECT * FROM order_item WHERE order_id = {i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 50),
)
})
.collect();
let trace = make_trace(events);
let findings = detect(&[trace], &default_config());
assert!(!findings.is_empty());
for f in &findings {
assert_eq!(f.confidence, Confidence::CiBatch);
}
}
#[test]
fn detect_combines_n_plus_one_and_redundant() {
use crate::test_helpers::{make_sql_event, make_trace};
let mut events = Vec::new();
for i in 1..=5 {
events.push(make_sql_event(
"trace-1",
&format!("span-{i}"),
&format!("SELECT * FROM order_item WHERE order_id = {i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 50),
));
}
for i in 6..=8 {
events.push(make_sql_event(
"trace-1",
&format!("span-{i}"),
"SELECT * FROM config WHERE key = 'timeout'",
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
));
}
let trace = make_trace(events);
let findings = detect(&[trace], &default_config());
let has_n_plus_one = findings
.iter()
.any(|f| f.finding_type == FindingType::NPlusOneSql);
let has_redundant = findings
.iter()
.any(|f| f.finding_type == FindingType::RedundantSql);
assert!(has_n_plus_one, "should detect N+1");
assert!(has_redundant, "should detect redundant");
}
#[test]
fn detect_multiple_traces() {
use crate::test_helpers::{make_sql_event, make_trace};
let events_t1: Vec<crate::event::SpanEvent> = (1..=3)
.map(|i| {
make_sql_event(
"trace-A",
&format!("span-a{i}"),
"SELECT * FROM order_item WHERE order_id = 42",
&format!("2025-07-10T14:32:01.{:03}Z", i * 50),
)
})
.collect();
let events_t2: Vec<crate::event::SpanEvent> = (1..=2)
.map(|i| {
make_sql_event(
"trace-B",
&format!("span-b{i}"),
"SELECT * FROM orders WHERE user_id = 7",
&format!("2025-07-10T14:32:02.{:03}Z", i * 50),
)
})
.collect();
let trace_a = make_trace(events_t1);
let trace_b = make_trace(events_t2);
let findings = detect(&[trace_a, trace_b], &default_config());
assert!(
findings.iter().any(|f| f.trace_id == "trace-A"),
"trace-A should have findings"
);
assert!(
findings.iter().any(|f| f.trace_id == "trace-B"),
"trace-B should have findings"
);
}
#[test]
fn finding_type_as_str() {
assert_eq!(FindingType::NPlusOneSql.as_str(), "n_plus_one_sql");
assert_eq!(FindingType::SlowHttp.as_str(), "slow_http");
assert_eq!(FindingType::ChattyService.as_str(), "chatty_service");
assert_eq!(FindingType::PoolSaturation.as_str(), "pool_saturation");
assert_eq!(FindingType::SerializedCalls.as_str(), "serialized_calls");
}
#[test]
fn severity_as_str() {
assert_eq!(Severity::Critical.as_str(), "critical");
assert_eq!(Severity::Warning.as_str(), "warning");
assert_eq!(Severity::Info.as_str(), "info");
}
#[test]
fn finding_type_from_event_type_n_plus_one() {
use crate::event::EventType;
assert_eq!(
FindingType::from_event_type_n_plus_one(&EventType::Sql),
FindingType::NPlusOneSql
);
assert_eq!(
FindingType::from_event_type_n_plus_one(&EventType::HttpOut),
FindingType::NPlusOneHttp
);
}
#[test]
fn finding_type_from_event_type_redundant() {
use crate::event::EventType;
assert_eq!(
FindingType::from_event_type_redundant(&EventType::Sql),
FindingType::RedundantSql
);
assert_eq!(
FindingType::from_event_type_redundant(&EventType::HttpOut),
FindingType::RedundantHttp
);
}
#[test]
fn finding_type_from_event_type_slow() {
use crate::event::EventType;
assert_eq!(
FindingType::from_event_type_slow(&EventType::Sql),
FindingType::SlowSql
);
assert_eq!(
FindingType::from_event_type_slow(&EventType::HttpOut),
FindingType::SlowHttp
);
}
#[test]
fn detect_all_three_types_on_one_trace() {
use crate::test_helpers::{make_sql_event, make_sql_event_with_duration, make_trace};
let mut events = Vec::new();
for i in 1..=5 {
events.push(make_sql_event(
"trace-1",
&format!("span-n{i}"),
&format!("SELECT * FROM order_item WHERE order_id = {i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 50),
));
}
for i in 1..=3 {
events.push(make_sql_event(
"trace-1",
&format!("span-r{i}"),
"SELECT * FROM config WHERE key = 'timeout'",
&format!("2025-07-10T14:32:02.{:03}Z", i * 30),
));
}
for i in 1..=3 {
events.push(make_sql_event_with_duration(
"trace-1",
&format!("span-s{i}"),
&format!("SELECT * FROM big_table WHERE id = {}", i + 100),
&format!("2025-07-10T14:32:03.{:03}Z", i * 30),
600_000,
));
}
let trace = make_trace(events);
let findings = detect(&[trace], &default_config());
let has_n1 = findings
.iter()
.any(|f| f.finding_type == FindingType::NPlusOneSql);
let has_redundant = findings
.iter()
.any(|f| f.finding_type == FindingType::RedundantSql);
let has_slow = findings
.iter()
.any(|f| f.finding_type == FindingType::SlowSql);
assert!(has_n1, "should detect N+1");
assert!(has_redundant, "should detect redundant");
assert!(has_slow, "should detect slow");
}
#[test]
fn finding_serde_roundtrip() {
let finding =
crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
let json = serde_json::to_string(&finding).unwrap();
let back: Finding = serde_json::from_str(&json).unwrap();
assert_eq!(finding.finding_type, back.finding_type);
assert_eq!(finding.severity, back.severity);
assert_eq!(finding.trace_id, back.trace_id);
assert_eq!(finding.service, back.service);
assert_eq!(finding.pattern.template, back.pattern.template);
assert_eq!(finding.confidence, back.confidence);
}
#[test]
fn finding_with_code_location_serde_roundtrip() {
let mut finding =
crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
finding.code_location = Some(crate::event::CodeLocation {
function: Some("processItems".to_string()),
filepath: Some("src/Order.java".to_string()),
lineno: Some(42),
namespace: Some("com.example".to_string()),
});
let json = serde_json::to_string(&finding).unwrap();
let back: Finding = serde_json::from_str(&json).unwrap();
let loc = back.code_location.unwrap();
assert_eq!(loc.function.as_deref(), Some("processItems"));
assert_eq!(loc.lineno, Some(42));
}
#[test]
fn finding_type_deserializes_from_snake_case() {
let ft: FindingType = serde_json::from_str(r#""n_plus_one_sql""#).unwrap();
assert_eq!(ft, FindingType::NPlusOneSql);
let ft: FindingType = serde_json::from_str(r#""chatty_service""#).unwrap();
assert_eq!(ft, FindingType::ChattyService);
}
#[test]
fn severity_deserializes_from_snake_case() {
let s: Severity = serde_json::from_str(r#""critical""#).unwrap();
assert_eq!(s, Severity::Critical);
let s: Severity = serde_json::from_str(r#""warning""#).unwrap();
assert_eq!(s, Severity::Warning);
}
}