use std::collections::HashMap;
use std::fmt::Write as _;
use crate::correlate::Trace;
use crate::event::EventType;
use super::{Confidence, Finding, FindingType, Pattern, Severity};
#[must_use]
pub fn detect_chatty(trace: &Trace, min_calls: u32) -> Vec<Finding> {
let http_indices: Vec<usize> = trace
.spans
.iter()
.enumerate()
.filter(|(_, s)| s.event.event_type == EventType::HttpOut)
.map(|(i, _)| i)
.collect();
let count = http_indices.len();
if count <= min_calls as usize {
return vec![];
}
let severity = if count > (min_calls as usize) * 3 {
Severity::Critical
} else {
Severity::Warning
};
let mut template_counts: HashMap<&str, usize> =
HashMap::with_capacity(http_indices.len().min(64));
for &idx in &http_indices {
*template_counts
.entry(trace.spans[idx].template.as_ref())
.or_default() += 1;
}
let mut entries: Vec<(&str, usize)> = template_counts.iter().map(|(&k, &v)| (k, v)).collect();
let top_two = if entries.len() <= 2 {
entries.sort_unstable_by_key(|b| std::cmp::Reverse(b.1));
entries
} else {
entries.select_nth_unstable_by(1, |a, b| b.1.cmp(&a.1));
entries.truncate(2);
entries.sort_unstable_by_key(|b| std::cmp::Reverse(b.1));
entries
};
let mut top_str = String::with_capacity(64);
for (i, (tmpl, cnt)) in top_two.iter().enumerate() {
if i > 0 {
top_str.push_str(", ");
}
let _ = write!(top_str, "{tmpl} x{cnt}");
}
let first = &trace.spans[http_indices[0]];
let entry_endpoint = first.event.source.endpoint.clone();
let distinct_targets = template_counts.len();
let (window_ms, first_ts, last_ts) = super::n_plus_one::compute_window_and_bounds_iter(
http_indices
.iter()
.map(|&i| trace.spans[i].event.timestamp.as_str()),
);
let suggestion = format!(
"Chatty trace: {entry_endpoint} triggers {count} inter-service HTTP calls \
(top: {top_str}). Consider aggregating calls with a batch endpoint \
or a BFF (Backend for Frontend) layer"
);
vec![Finding {
finding_type: FindingType::ChattyService,
severity,
trace_id: trace.trace_id.clone(),
service: first.event.service.to_string(),
source_endpoint: entry_endpoint.clone(),
pattern: Pattern {
template: entry_endpoint,
occurrences: count,
window_ms,
distinct_params: distinct_targets,
..Default::default()
},
suggestion,
first_timestamp: first_ts.to_string(),
last_timestamp: last_ts.to_string(),
green_impact: None,
confidence: Confidence::default(),
classification_method: None,
code_location: None,
instrumentation_scopes: Vec::new(),
signature: String::new(),
suggested_fix: None,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{make_http_event, make_sql_event, make_trace};
#[test]
fn detects_chatty_trace() {
let events: Vec<_> = (1..=20)
.map(|i| {
make_http_event(
"trace-1",
&format!("span-{i}"),
&format!("http://svc-{}/api/resource/{i}", i % 5),
&format!("2025-07-10T14:32:01.{:03}Z", i * 10),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_chatty(&trace, 15);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].finding_type, FindingType::ChattyService);
assert_eq!(findings[0].severity, Severity::Warning);
assert_eq!(findings[0].pattern.occurrences, 20);
}
#[test]
fn critical_at_3x_threshold() {
let events: Vec<_> = (1..=50)
.map(|i| {
make_http_event(
"trace-1",
&format!("span-{i}"),
&format!("http://svc-{}/api/resource/{i}", i % 5),
&format!("2025-07-10T14:32:01.{:03}Z", i % 1000),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_chatty(&trace, 15);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Critical);
}
#[test]
fn no_finding_below_threshold() {
let events: Vec<_> = (1..=10)
.map(|i| {
make_http_event(
"trace-1",
&format!("span-{i}"),
&format!("http://svc/api/resource/{i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 50),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_chatty(&trace, 15);
assert!(findings.is_empty());
}
#[test]
fn no_finding_at_threshold() {
let events: Vec<_> = (1..=15)
.map(|i| {
make_http_event(
"trace-1",
&format!("span-{i}"),
&format!("http://svc/api/resource/{i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 10),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_chatty(&trace, 15);
assert!(findings.is_empty());
}
#[test]
fn sql_events_not_counted() {
let events: Vec<_> = (1..=20)
.map(|i| {
make_sql_event(
"trace-1",
&format!("span-{i}"),
&format!("SELECT * FROM t WHERE id = {i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 10),
)
})
.collect();
let trace = make_trace(events);
let findings = detect_chatty(&trace, 15);
assert!(findings.is_empty());
}
#[test]
fn mixed_events_only_counts_http() {
let mut events: Vec<_> = (1..=10)
.map(|i| {
make_sql_event(
"trace-1",
&format!("span-sql-{i}"),
&format!("SELECT * FROM t WHERE id = {i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 10),
)
})
.collect();
events.extend((1..=10).map(|i| {
make_http_event(
"trace-1",
&format!("span-http-{i}"),
&format!("http://svc/api/resource/{i}"),
&format!("2025-07-10T14:32:02.{:03}Z", i * 10),
)
}));
let trace = make_trace(events);
let findings = detect_chatty(&trace, 15);
assert!(findings.is_empty(), "10 HTTP calls <= 15 threshold");
}
#[test]
fn distinct_params_counts_templates() {
let mut events: Vec<_> = (1..=5)
.map(|i| {
make_http_event(
"trace-1",
&format!("span-a{i}"),
&format!("http://svc-a/api/users/{i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 10),
)
})
.collect();
events.extend((1..=15).map(|i| {
make_http_event(
"trace-1",
&format!("span-b{i}"),
&format!("http://svc-b/api/orders/{i}"),
&format!("2025-07-10T14:32:02.{:03}Z", i * 10),
)
}));
let trace = make_trace(events);
let findings = detect_chatty(&trace, 15);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].pattern.distinct_params, 2);
}
}