use std::collections::{HashMap, HashSet};
use crate::correlate::Trace;
use crate::event::EventType;
use super::sanitizer_aware::{self, SanitizerAwareMode, SanitizerVerdict};
use super::{ClassificationMethod, Finding, FindingType, Severity};
pub(crate) const CRITICAL_OCCURRENCE_THRESHOLD: usize = 10;
#[must_use]
pub fn detect_n_plus_one(
trace: &Trace,
threshold: u32,
window_limit: u64,
mode: SanitizerAwareMode,
) -> Vec<Finding> {
let threshold = threshold as usize;
let mut groups: HashMap<(&EventType, &str), Vec<usize>> =
HashMap::with_capacity(trace.spans.len().min(64));
for (i, span) in trace.spans.iter().enumerate() {
groups
.entry((&span.event.event_type, &span.template))
.or_default()
.push(i);
}
let mut findings = Vec::new();
for ((event_type, template), indices) in &groups {
let Some((distinct_params, classification_method)) =
classify_group(trace, event_type, indices, threshold, mode)
else {
continue;
};
if let Some(finding) = build_finding(
trace,
event_type,
template,
indices,
window_limit,
distinct_params,
classification_method,
) {
findings.push(finding);
}
}
findings
}
fn classify_group(
trace: &Trace,
event_type: &EventType,
indices: &[usize],
threshold: usize,
mode: SanitizerAwareMode,
) -> Option<(usize, Option<ClassificationMethod>)> {
if indices.len() < threshold {
return None;
}
let distinct_params: HashSet<&[String]> = indices
.iter()
.map(|&i| trace.spans[i].params.as_slice())
.collect();
if distinct_params.len() >= threshold {
return Some((distinct_params.len(), None));
}
if mode == SanitizerAwareMode::Never
|| *event_type != EventType::Sql
|| !sanitizer_aware::looks_sanitized_indexed(&trace.spans, indices)
{
return None;
}
if mode == SanitizerAwareMode::Always {
return Some((1, Some(ClassificationMethod::SanitizerHeuristic)));
}
let verdict =
sanitizer_aware::classify_sanitized_sql_group_indexed(&trace.spans, indices, mode);
matches!(verdict, SanitizerVerdict::LikelyNPlusOne)
.then_some((1, Some(ClassificationMethod::SanitizerHeuristic)))
}
#[allow(clippy::too_many_arguments)] fn build_finding(
trace: &Trace,
event_type: &EventType,
template: &str,
indices: &[usize],
window_limit: u64,
distinct_params: usize,
classification_method: Option<ClassificationMethod>,
) -> Option<Finding> {
let (window_ms, min_ts, max_ts) = compute_window_and_bounds_iter(
indices
.iter()
.map(|&i| trace.spans[i].event.timestamp.as_str()),
);
if window_ms > window_limit {
return None;
}
let first = &trace.spans[indices[0]];
let severity = if indices.len() >= CRITICAL_OCCURRENCE_THRESHOLD {
Severity::Critical
} else {
Severity::Warning
};
let suggestion = match event_type {
EventType::Sql => format!(
"Use WHERE ... IN (?) to batch {} queries into one",
indices.len()
),
EventType::HttpOut => format!(
"Use batch endpoint with ?ids=... to batch {} calls into one",
indices.len()
),
};
Some(super::build_per_trace_finding(super::PerTraceFindingArgs {
finding_type: FindingType::from_event_type_n_plus_one(event_type),
severity,
trace_id: &trace.trace_id,
first_span: first,
template,
occurrences: indices.len(),
window_ms,
distinct_params,
suggestion,
first_timestamp: min_ts,
last_timestamp: max_ts,
code_location: first.event.code_location(),
instrumentation_scopes: first
.event
.instrumentation_scopes
.iter()
.map(ToString::to_string)
.collect(),
classification_method,
}))
}
#[cfg(test)]
pub(crate) fn compute_window_and_bounds<'a>(timestamps: &[&'a str]) -> (u64, &'a str, &'a str) {
compute_window_and_bounds_iter(timestamps.iter().copied())
}
pub(crate) fn compute_window_and_bounds_iter<'a>(
mut iter: impl Iterator<Item = &'a str>,
) -> (u64, &'a str, &'a str) {
let Some(first) = iter.next() else {
return (0, "", "");
};
let mut min_ts = first;
let mut max_ts = first;
let mut has_second = false;
for ts in iter {
has_second = true;
if ts < min_ts {
min_ts = ts;
}
if ts > max_ts {
max_ts = ts;
}
}
if !has_second {
return (0, min_ts, max_ts);
}
let window_ms = match (parse_timestamp_ms(min_ts), parse_timestamp_ms(max_ts)) {
(Some(a), Some(b)) => b.saturating_sub(a),
_ => 0,
};
(window_ms, min_ts, max_ts)
}
#[cfg(test)]
pub(crate) fn compute_window_ms(timestamps: &[&str]) -> u64 {
compute_window_and_bounds(timestamps).0
}
pub(crate) fn parse_timestamp_ms(ts: &str) -> Option<u64> {
crate::time::parse_iso8601_utc_to_ms(ts).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::SpanEvent;
use crate::test_helpers::{make_http_event, make_sql_event, make_trace};
#[test]
fn detects_n_plus_one_sql() {
let events = crate::test_helpers::make_n_plus_one_events();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].finding_type, FindingType::NPlusOneSql);
assert_eq!(findings[0].severity, Severity::Warning);
assert_eq!(findings[0].pattern.occurrences, 6);
assert_eq!(findings[0].pattern.distinct_params, 6);
assert!(findings[0].suggestion.contains("batch"));
}
#[test]
fn detects_n_plus_one_http() {
let events: Vec<SpanEvent> = (101..=106)
.map(|i| {
make_http_event(
"trace-1",
&format!("span-{i}"),
&format!("http://user-svc:5000/api/users/{i}"),
&format!("2025-07-10T14:32:01.{:03}Z", (i - 100) * 50),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].finding_type, FindingType::NPlusOneHttp);
assert_eq!(findings[0].pattern.occurrences, 6);
assert!(findings[0].suggestion.contains("batch endpoint"));
}
#[test]
fn below_threshold_no_finding() {
let events = crate::test_helpers::make_sql_series_events(4);
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert!(findings.is_empty());
}
#[test]
fn mixed_templates_no_finding() {
let events = vec![
make_sql_event(
"trace-1",
"span-1",
"SELECT * FROM users WHERE id = 1",
"2025-07-10T14:32:01.000Z",
),
make_sql_event(
"trace-1",
"span-2",
"SELECT * FROM orders WHERE id = 2",
"2025-07-10T14:32:01.050Z",
),
make_sql_event(
"trace-1",
"span-3",
"INSERT INTO logs (msg) VALUES ('hello')",
"2025-07-10T14:32:01.100Z",
),
];
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert!(findings.is_empty());
}
#[test]
fn critical_severity_for_10_or_more() {
let events = crate::test_helpers::make_sql_series_events_with_stride(12, 10);
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Critical);
assert_eq!(findings[0].pattern.occurrences, 12);
}
#[test]
fn same_params_not_n_plus_one() {
let events: Vec<SpanEvent> = (1..=6)
.map(|i| {
make_sql_event(
"trace-1",
&format!("span-{i}"),
"SELECT * FROM order_item WHERE order_id = 42",
&format!("2025-07-10T14:32:01.{:03}Z", i * 50),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert!(findings.is_empty());
}
#[test]
fn window_exceeded_no_finding() {
let events: Vec<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:{:02}.000Z", i * 2),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert!(findings.is_empty());
}
#[test]
fn compute_window_ms_basic() {
let timestamps = vec![
"2025-07-10T14:32:01.000Z",
"2025-07-10T14:32:01.250Z",
"2025-07-10T14:32:01.100Z",
];
assert_eq!(compute_window_ms(×tamps), 250);
}
const JUL10_2025_MS: u64 = 20_279 * 86_400_000;
#[test]
fn parse_timestamp_ms_basic() {
assert_eq!(
parse_timestamp_ms("2025-07-10T14:32:01.123Z"),
Some(JUL10_2025_MS + 14 * 3_600_000 + 32 * 60_000 + 1_000 + 123)
);
}
#[test]
fn parse_timestamp_ms_single_frac_digit() {
assert_eq!(
parse_timestamp_ms("2025-07-10T00:00:01.1Z"),
Some(JUL10_2025_MS + 1_100)
);
}
#[test]
fn parse_timestamp_ms_two_frac_digits() {
assert_eq!(
parse_timestamp_ms("2025-07-10T00:00:01.12Z"),
Some(JUL10_2025_MS + 1_120)
);
}
#[test]
fn window_at_exact_limit_still_detected() {
let events: Vec<SpanEvent> = (0..5)
.map(|i| {
make_sql_event(
"trace-1",
&format!("span-{i}"),
&format!("SELECT * FROM order_item WHERE order_id = {}", i + 1),
&format!("2025-07-10T14:32:01.{:03}Z", i * 125),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].pattern.window_ms, 500);
}
#[test]
fn window_zero_limit_filters_all() {
let events = crate::test_helpers::make_sql_series_events(5);
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 0, SanitizerAwareMode::Auto);
assert!(findings.is_empty());
}
#[test]
fn severity_boundary_9_is_warning() {
let events = crate::test_helpers::make_sql_series_events_with_stride(9, 10);
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warning);
assert_eq!(findings[0].pattern.occurrences, 9);
}
#[test]
fn severity_boundary_10_is_critical() {
let events: Vec<SpanEvent> = (1..=10)
.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 * 10),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Critical);
assert_eq!(findings[0].pattern.occurrences, 10);
}
#[test]
fn compute_window_ms_single_timestamp() {
let timestamps = vec!["2025-07-10T14:32:01.000Z"];
assert_eq!(compute_window_ms(×tamps), 0);
}
#[test]
fn compute_window_ms_empty() {
let timestamps: Vec<&str> = vec![];
assert_eq!(compute_window_ms(×tamps), 0);
}
#[test]
fn parse_timestamp_ms_no_fractional() {
assert_eq!(
parse_timestamp_ms("2025-07-10T14:32:01Z"),
Some(JUL10_2025_MS + 14 * 3_600_000 + 32 * 60_000 + 1_000)
);
}
#[test]
fn compute_window_ms_across_midnight() {
let timestamps = vec!["2025-07-10T23:59:59.900Z", "2025-07-11T00:00:00.100Z"];
assert_eq!(compute_window_ms(×tamps), 200);
}
#[test]
fn parse_timestamp_ms_invalid_returns_none() {
assert_eq!(parse_timestamp_ms("not-a-timestamp"), None);
}
#[test]
fn parse_timestamp_ms_missing_parts() {
assert_eq!(parse_timestamp_ms("2025-07-10T14:32Z"), None);
}
#[test]
fn n_plus_one_finding_has_first_last_timestamps() {
let events = crate::test_helpers::make_n_plus_one_events();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].first_timestamp, "2025-07-10T14:32:01.050Z");
assert_eq!(findings[0].last_timestamp, "2025-07-10T14:32:01.300Z");
}
#[test]
fn reclassifies_n_plus_one_when_sanitizer_on_with_orm_scope() {
let events = crate::test_helpers::make_sanitized_n_plus_one_events(
10,
Some("io.opentelemetry.spring-data-jpa-3.0"),
None,
);
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].finding_type, FindingType::NPlusOneSql);
assert_eq!(
findings[0].classification_method,
Some(ClassificationMethod::SanitizerHeuristic)
);
assert_eq!(findings[0].pattern.occurrences, 10);
assert_eq!(findings[0].pattern.distinct_params, 1);
}
#[test]
fn reclassifies_n_plus_one_when_sanitizer_on_with_high_variance_timing() {
let durations = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
let events =
crate::test_helpers::make_sanitized_n_plus_one_events(10, None, Some(&durations));
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].finding_type, FindingType::NPlusOneSql);
assert_eq!(
findings[0].classification_method,
Some(ClassificationMethod::SanitizerHeuristic)
);
assert_eq!(findings[0].pattern.distinct_params, 1);
}
#[test]
fn does_not_reclassify_when_sanitizer_on_but_low_variance() {
let durations = [100u64, 102, 98, 101, 99, 100, 101, 99, 100, 102];
let events =
crate::test_helpers::make_sanitized_n_plus_one_events(10, None, Some(&durations));
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert!(findings.is_empty());
}
#[test]
fn mode_never_disables_reclassification_entirely() {
let events = crate::test_helpers::make_sanitized_n_plus_one_events(
10,
Some("io.opentelemetry.spring-data-jpa-3.0"),
None,
);
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Never);
assert!(findings.is_empty());
}
#[test]
fn mode_always_reclassifies_regardless_of_signals() {
let durations = [100u64, 102, 98, 101, 99, 100, 101, 99, 100, 102];
let events =
crate::test_helpers::make_sanitized_n_plus_one_events(10, None, Some(&durations));
let trace = make_trace(events);
let n_plus_one = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Always);
assert_eq!(n_plus_one.len(), 1);
assert_eq!(n_plus_one[0].finding_type, FindingType::NPlusOneSql);
assert_eq!(
n_plus_one[0].classification_method,
Some(ClassificationMethod::SanitizerHeuristic)
);
let redundant_findings = crate::detect::redundant::detect_redundant(&trace, &n_plus_one);
assert!(
redundant_findings.is_empty(),
"redundant detector should skip a template already classified as n+1, got: {:?}",
redundant_findings
.iter()
.map(|f| &f.finding_type)
.collect::<Vec<_>>()
);
}
#[test]
fn strict_mode_keeps_redundant_for_orm_scope_with_low_variance() {
let durations = [100u64; 15];
let events = crate::test_helpers::make_sanitized_n_plus_one_events(
15,
Some("io.opentelemetry.hibernate-6.0"),
Some(&durations),
);
let trace = make_trace(events);
let n_plus_one = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Strict);
assert!(
n_plus_one.is_empty(),
"Strict must not reclassify when timing variance is flat: got {:?}",
n_plus_one
.iter()
.map(|f| &f.finding_type)
.collect::<Vec<_>>()
);
let redundant = crate::detect::redundant::detect_redundant(&trace, &n_plus_one);
assert_eq!(redundant.len(), 1);
assert_eq!(redundant[0].finding_type, FindingType::RedundantSql);
assert_eq!(redundant[0].pattern.occurrences, 15);
assert_eq!(redundant[0].classification_method, None);
}
#[test]
fn strict_mode_still_flags_n_plus_one_when_variance_high() {
let durations = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
let events = crate::test_helpers::make_sanitized_n_plus_one_events(
10,
Some("io.opentelemetry.spring-data-jpa-3.0"),
Some(&durations),
);
let trace = make_trace(events);
let n_plus_one = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Strict);
assert_eq!(n_plus_one.len(), 1);
assert_eq!(n_plus_one[0].finding_type, FindingType::NPlusOneSql);
assert_eq!(
n_plus_one[0].classification_method,
Some(ClassificationMethod::SanitizerHeuristic)
);
}
#[test]
fn first_pass_findings_carry_no_classification_method() {
let events = crate::test_helpers::make_n_plus_one_events();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].classification_method, None);
}
#[test]
fn n_plus_one_timestamps_unsorted_input() {
let timestamps = [
"2025-07-10T14:32:01.200Z",
"2025-07-10T14:32:01.050Z",
"2025-07-10T14:32:01.300Z",
"2025-07-10T14:32:01.100Z",
"2025-07-10T14:32:01.150Z",
];
let events: Vec<SpanEvent> = timestamps
.iter()
.enumerate()
.map(|(i, ts)| {
make_sql_event(
"trace-1",
&format!("span-{i}"),
&format!("SELECT * FROM order_item WHERE order_id = {}", i + 1),
ts,
)
})
.collect();
let trace = make_trace(events);
let findings = detect_n_plus_one(&trace, 5, 500, SanitizerAwareMode::Auto);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].first_timestamp, "2025-07-10T14:32:01.050Z");
assert_eq!(findings[0].last_timestamp, "2025-07-10T14:32:01.300Z");
}
}