use crate::normalize::NormalizedEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SanitizerAwareMode {
#[default]
Auto,
Always,
Never,
Strict,
}
impl SanitizerAwareMode {
#[must_use]
pub fn from_config(value: Option<&str>) -> Self {
match value.map(str::trim) {
None | Some("") => Self::Auto,
Some(raw) => {
if raw.eq_ignore_ascii_case("auto") {
Self::Auto
} else if raw.eq_ignore_ascii_case("always") {
Self::Always
} else if raw.eq_ignore_ascii_case("never") {
Self::Never
} else if raw.eq_ignore_ascii_case("strict") {
Self::Strict
} else {
tracing::warn!(
value = sanitize_for_log(raw).as_ref(),
"unknown sanitizer_aware_classification value, defaulting to 'auto'"
);
Self::Auto
}
}
}
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Always => "always",
Self::Never => "never",
Self::Strict => "strict",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SanitizerVerdict {
LikelyNPlusOne,
Inconclusive,
}
const ORM_SCOPE_MARKERS: &[&str] = &[
"spring-data",
"hibernate",
"jpa",
"micronaut-data",
"jdbi",
"r2dbc",
"entityframeworkcore",
"entity-framework",
"sqlalchemy",
"django",
"active-record",
"activerecord",
"gorm",
"sequelize",
"prisma",
"typeorm",
"mongoose",
"sea-orm",
"diesel",
];
#[must_use]
pub fn looks_sanitized(spans: &[&NormalizedEvent]) -> bool {
!spans.is_empty()
&& spans
.iter()
.all(|s| s.params.is_empty() && template_has_placeholder(&s.template))
}
pub(super) fn looks_sanitized_indexed(spans: &[NormalizedEvent], indices: &[usize]) -> bool {
!indices.is_empty()
&& indices.iter().all(|&i| {
let s = &spans[i];
s.params.is_empty() && template_has_placeholder(&s.template)
})
}
fn template_has_placeholder(template: &str) -> bool {
if template.contains('?') || template.contains("%s") {
return true;
}
let bytes = template.as_bytes();
for i in 0..bytes.len().saturating_sub(1) {
let next = bytes[i + 1];
match bytes[i] {
b'@' if next.is_ascii_alphanumeric() && (i == 0 || bytes[i - 1] != b'@') => {
return true;
}
b':' if next.is_ascii_alphabetic() && (i == 0 || bytes[i - 1] != b':') => {
return true;
}
b'$' if next.is_ascii_digit() => return true,
_ => {}
}
}
false
}
#[must_use]
pub fn has_orm_scope(scopes: &[String]) -> bool {
scopes
.iter()
.any(|scope| ORM_SCOPE_MARKERS.iter().any(|m| contains_marker(scope, m)))
}
fn contains_marker(haystack: &str, needle: &str) -> bool {
let h = haystack.as_bytes();
let n = needle.as_bytes();
if n.is_empty() || h.len() < n.len() {
return false;
}
h.windows(n.len()).enumerate().any(|(i, w)| {
if !w.eq_ignore_ascii_case(n) {
return false;
}
let before_ok = i == 0 || !is_word_byte(h[i - 1]);
let after = i + n.len();
let after_ok = after == h.len() || !is_word_byte(h[after]);
before_ok && after_ok
})
}
const fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[must_use]
pub fn collect_scopes(spans: &[&NormalizedEvent]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for span in spans {
for scope in &span.event.instrumentation_scopes {
let scope_str: &str = scope.as_ref();
if !out.iter().any(|existing| existing == scope_str) {
out.push(scope.to_string());
}
}
}
out
}
#[must_use]
pub fn timing_variance_suggests_n_plus_one(spans: &[&NormalizedEvent]) -> bool {
if spans.len() < 3 {
return false;
}
let mut count: u64 = 0;
let mut mean: f64 = 0.0;
let mut m2: f64 = 0.0;
for span in spans {
count += 1;
#[allow(clippy::cast_precision_loss)] let d = span.event.duration_us as f64;
let delta = d - mean;
#[allow(clippy::cast_precision_loss)]
let count_f = count as f64;
mean += delta / count_f;
m2 += delta * (d - mean);
}
if mean <= 0.0 {
return false;
}
#[allow(clippy::cast_precision_loss)]
let variance = m2 / count as f64;
let cv = variance.sqrt() / mean;
cv > 0.5
}
#[must_use]
pub fn classify_sanitized_sql_group(
spans: &[&NormalizedEvent],
scopes: &[String],
) -> SanitizerVerdict {
if has_orm_scope(scopes) || timing_variance_suggests_n_plus_one(spans) {
SanitizerVerdict::LikelyNPlusOne
} else {
SanitizerVerdict::Inconclusive
}
}
#[must_use]
pub fn classify_sanitized_sql_group_strict(
spans: &[&NormalizedEvent],
scopes: &[String],
sequential: impl FnOnce() -> bool,
high_occurrence: bool,
) -> SanitizerVerdict {
let orm = has_orm_scope(scopes);
let primary_ok = orm || high_occurrence || sequential();
if !primary_ok {
return SanitizerVerdict::Inconclusive;
}
let variance_ok = timing_variance_suggests_n_plus_one(spans);
let corroborated = variance_ok || high_occurrence;
if corroborated {
SanitizerVerdict::LikelyNPlusOne
} else {
SanitizerVerdict::Inconclusive
}
}
pub(super) fn classify_sanitized_sql_group_indexed(
spans: &[NormalizedEvent],
indices: &[usize],
mode: SanitizerAwareMode,
sequential_siblings: impl FnOnce() -> bool,
high_occurrence: bool,
) -> SanitizerVerdict {
let group: Vec<&NormalizedEvent> = indices.iter().map(|&i| &spans[i]).collect();
let scopes = collect_scopes(&group);
match mode {
SanitizerAwareMode::Strict => classify_sanitized_sql_group_strict(
&group,
&scopes,
sequential_siblings,
high_occurrence,
),
SanitizerAwareMode::Auto | SanitizerAwareMode::Always | SanitizerAwareMode::Never => {
classify_sanitized_sql_group(&group, &scopes)
}
}
}
pub(super) fn template_has_http_placeholder(template: &str) -> bool {
template.contains('{')
}
pub(super) fn classify_http_group_indexed(
spans: &[NormalizedEvent],
indices: &[usize],
mode: SanitizerAwareMode,
sequential_siblings: impl FnOnce() -> bool,
high_occurrence: bool,
) -> SanitizerVerdict {
let group: Vec<&NormalizedEvent> = indices.iter().map(|&i| &spans[i]).collect();
let variance = timing_variance_suggests_n_plus_one(&group);
match mode {
SanitizerAwareMode::Never => SanitizerVerdict::Inconclusive,
SanitizerAwareMode::Auto | SanitizerAwareMode::Always => {
if variance {
SanitizerVerdict::LikelyNPlusOne
} else {
SanitizerVerdict::Inconclusive
}
}
SanitizerAwareMode::Strict => {
let has_placeholder = indices
.iter()
.any(|&i| template_has_http_placeholder(&spans[i].template));
let primary = has_placeholder || high_occurrence || sequential_siblings();
if !primary {
return SanitizerVerdict::Inconclusive;
}
if variance {
SanitizerVerdict::LikelyNPlusOne
} else {
SanitizerVerdict::Inconclusive
}
}
}
}
fn sanitize_for_log(value: &str) -> std::borrow::Cow<'_, str> {
const MAX_LEN: usize = 32;
let truncated = if value.len() > MAX_LEN {
let cut = value
.char_indices()
.take_while(|(i, _)| *i <= MAX_LEN)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
&value[..cut.min(value.len())]
} else {
value
};
if truncated.chars().any(char::is_control) {
std::borrow::Cow::Owned(
truncated
.chars()
.map(|c| if c.is_control() { '_' } else { c })
.collect(),
)
} else {
std::borrow::Cow::Borrowed(truncated)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::event::SpanEvent;
use crate::normalize;
use crate::test_helpers::make_sql_event_with_duration;
fn sanitized_event_with_scope(span_id: &str, ts: &str, duration_us: u64) -> SpanEvent {
let mut e = make_sql_event_with_duration(
"trace-1",
span_id,
"SELECT * FROM order_items WHERE order_id = ?",
ts,
duration_us,
);
e.instrumentation_scopes = vec![Arc::from("io.opentelemetry.spring-data-jpa-3.0")];
e
}
fn normalize_one(event: SpanEvent) -> NormalizedEvent {
normalize::normalize_all(vec![event]).remove(0)
}
fn sanitized_normalized_with_durations(durations: &[u64]) -> Vec<NormalizedEvent> {
durations
.iter()
.enumerate()
.map(|(i, d)| {
let mut e = make_sql_event_with_duration(
"trace-1",
&format!("span-{i}"),
"SELECT * FROM order_items WHERE order_id = ?",
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
*d,
);
e.instrumentation_scopes = Vec::new();
normalize_one(e)
})
.collect()
}
#[test]
fn from_config_parses_known_values() {
assert_eq!(
SanitizerAwareMode::from_config(None),
SanitizerAwareMode::Auto
);
assert_eq!(
SanitizerAwareMode::from_config(Some("auto")),
SanitizerAwareMode::Auto
);
assert_eq!(
SanitizerAwareMode::from_config(Some("ALWAYS")),
SanitizerAwareMode::Always
);
assert_eq!(
SanitizerAwareMode::from_config(Some(" Never ")),
SanitizerAwareMode::Never
);
assert_eq!(
SanitizerAwareMode::from_config(Some("strict")),
SanitizerAwareMode::Strict
);
assert_eq!(
SanitizerAwareMode::from_config(Some("STRICT")),
SanitizerAwareMode::Strict
);
}
#[test]
fn as_str_round_trips_every_variant() {
for mode in [
SanitizerAwareMode::Auto,
SanitizerAwareMode::Always,
SanitizerAwareMode::Never,
SanitizerAwareMode::Strict,
] {
assert_eq!(SanitizerAwareMode::from_config(Some(mode.as_str())), mode);
}
}
#[test]
fn from_config_unknown_value_warns_and_defaults_to_auto() {
assert_eq!(
SanitizerAwareMode::from_config(Some("foo")),
SanitizerAwareMode::Auto
);
assert_eq!(
SanitizerAwareMode::from_config(Some("")),
SanitizerAwareMode::Auto
);
}
#[test]
fn looks_sanitized_true_for_sanitized_template() {
let events: Vec<SpanEvent> = (1..=3)
.map(|i| {
sanitized_event_with_scope(
&format!("span-{i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 50),
100,
)
})
.collect();
let normalized: Vec<NormalizedEvent> = events.into_iter().map(normalize_one).collect();
for event in &normalized {
assert_eq!(event.params, Vec::<String>::new());
assert!(event.template.contains('?'));
}
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert!(looks_sanitized(&refs));
}
#[test]
fn looks_sanitized_false_when_any_param_is_literal() {
let mut e1 = sanitized_event_with_scope("span-1", "2025-07-10T14:32:01.000Z", 100);
let mut e2 = sanitized_event_with_scope("span-2", "2025-07-10T14:32:01.050Z", 100);
e1.target = "SELECT * FROM order_items WHERE order_id = ?".to_string();
e2.target = "SELECT * FROM order_items WHERE order_id = 42".to_string();
let normalized: Vec<NormalizedEvent> =
vec![e1, e2].into_iter().map(normalize_one).collect();
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert!(!looks_sanitized(&refs));
}
#[test]
fn looks_sanitized_false_when_template_has_no_placeholder() {
let event = make_sql_event_with_duration(
"trace-1",
"span-1",
"SELECT NOW()",
"2025-07-10T14:32:01.000Z",
100,
);
let normalized = normalize_one(event);
let refs = vec![&normalized];
assert!(!looks_sanitized(&refs));
}
#[test]
fn template_has_placeholder_recognizes_all_driver_styles() {
assert!(template_has_placeholder("WHERE id = ?"));
assert!(template_has_placeholder("WHERE id = $?"));
assert!(template_has_placeholder("WHERE id = %s"));
assert!(template_has_placeholder("WHERE id = @p0"));
assert!(template_has_placeholder("WHERE id = @Name"));
assert!(template_has_placeholder("WHERE id = :oid"));
assert!(template_has_placeholder("WHERE id = $1"));
assert!(!template_has_placeholder("SELECT count(*)::int FROM t"));
assert!(!template_has_placeholder("SELECT arr[1:2] FROM t"));
assert!(!template_has_placeholder("SELECT @@ROWCOUNT"));
assert!(!template_has_placeholder("SELECT @@VERSION"));
assert!(!template_has_placeholder("SELECT NOW()"));
assert!(!template_has_placeholder("SELECT 1"));
}
#[test]
fn classify_auto_stays_inconclusive_without_orm_or_variance() {
let durations = [100u64, 102, 98, 101, 99, 100, 101, 99, 100, 102];
let events: Vec<SpanEvent> = durations
.iter()
.enumerate()
.map(|(i, d)| {
let mut e = make_sql_event_with_duration(
"trace-1",
&format!("span-{i}"),
"SELECT * FROM order_items WHERE order_id = $?",
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
*d,
);
e.instrumentation_scopes = Vec::new();
e
})
.collect();
let normalized: Vec<NormalizedEvent> = events.into_iter().map(normalize_one).collect();
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
let scopes = collect_scopes(&refs);
assert_eq!(
classify_sanitized_sql_group(&refs, &scopes),
SanitizerVerdict::Inconclusive,
"Auto must NOT fire on high_occurrence alone (cache-warm precision guard)"
);
}
#[test]
fn has_orm_scope_matches_case_insensitively() {
assert!(has_orm_scope(&[
"io.opentelemetry.spring-data-3.0".to_string()
]));
assert!(has_orm_scope(&[
"IO.OPENTELEMETRY.HIBERNATE-ORM-6.0".to_string()
]));
assert!(has_orm_scope(&["EntityFrameworkCore".to_string()]));
assert!(has_orm_scope(&["opentelemetry.gorm.v1".to_string()]));
assert!(!has_orm_scope(&["io.opentelemetry.jdbc-3.1".to_string()]));
assert!(!has_orm_scope(&[]));
}
#[test]
fn has_orm_scope_respects_word_boundary() {
assert!(!has_orm_scope(&["myappjpastats".to_string()]));
assert!(!has_orm_scope(&["my-jpastore".to_string()]));
assert!(!has_orm_scope(&["spring-database".to_string()]));
assert!(has_orm_scope(&[
"io.opentelemetry.spring-data-jpa-3.0".to_string()
]));
assert!(has_orm_scope(&["io.opentelemetry.go.gorm.v1".to_string()]));
}
#[test]
fn bare_driver_sqlx_scope_does_not_match_orm_marker() {
assert!(!has_orm_scope(&["sqlx::query".to_string()]));
assert!(!has_orm_scope(&["sqlx_core::pool::pool_inner".to_string()]));
assert!(!has_orm_scope(&["github.com/jmoiron/sqlx".to_string()]));
assert!(has_orm_scope(&[
"io.opentelemetry.spring-data-jpa-3.0".to_string()
]));
}
#[test]
fn sanitize_for_log_redacts_control_chars_and_truncates() {
assert_eq!(sanitize_for_log("ab\x00c\nd").as_ref(), "ab_c_d");
assert_eq!(sanitize_for_log("abc").as_ref(), "abc");
let long = "x".repeat(200);
let out = sanitize_for_log(&long);
assert!(
out.len() <= 40,
"expected truncation, got {} bytes",
out.len()
);
}
#[test]
fn timing_variance_high_cv_returns_true() {
let normalized =
sanitized_normalized_with_durations(&[100, 50, 200, 60, 250, 80, 300, 70, 150, 400]);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert!(timing_variance_suggests_n_plus_one(&refs));
}
#[test]
fn timing_variance_low_cv_returns_false() {
let normalized =
sanitized_normalized_with_durations(&[100, 102, 98, 101, 99, 100, 101, 99, 100, 102]);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert!(!timing_variance_suggests_n_plus_one(&refs));
}
#[test]
fn timing_variance_too_few_spans_returns_false() {
let events: Vec<SpanEvent> = (1u64..=2)
.map(|i| {
sanitized_event_with_scope(
&format!("span-{i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
100 * i,
)
})
.collect();
let normalized: Vec<NormalizedEvent> = events.into_iter().map(normalize_one).collect();
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert!(!timing_variance_suggests_n_plus_one(&refs));
}
#[test]
fn classify_returns_n_plus_one_when_orm_scope_present() {
let events: Vec<SpanEvent> = (1..=10)
.map(|i| {
sanitized_event_with_scope(
&format!("span-{i}"),
&format!("2025-07-10T14:32:01.{:03}Z", i * 10),
100,
)
})
.collect();
let normalized: Vec<NormalizedEvent> = events.into_iter().map(normalize_one).collect();
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
let scopes = collect_scopes(&refs);
assert_eq!(
classify_sanitized_sql_group(&refs, &scopes),
SanitizerVerdict::LikelyNPlusOne
);
}
#[test]
fn classify_returns_inconclusive_when_no_signal() {
let durations = [100u64, 102, 98, 101, 99, 100, 101, 99, 100, 102];
let events: Vec<SpanEvent> = durations
.iter()
.enumerate()
.map(|(i, d)| {
let mut e = make_sql_event_with_duration(
"trace-1",
&format!("span-{i}"),
"SELECT * FROM order_items WHERE order_id = ?",
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
*d,
);
e.instrumentation_scopes = Vec::new();
e
})
.collect();
let normalized: Vec<NormalizedEvent> = events.into_iter().map(normalize_one).collect();
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
let scopes = collect_scopes(&refs);
assert_eq!(
classify_sanitized_sql_group(&refs, &scopes),
SanitizerVerdict::Inconclusive
);
}
fn build_sanitized_group_for_strict(
scope: Option<&str>,
durations: &[u64],
) -> (Vec<NormalizedEvent>, Vec<String>) {
let events: Vec<SpanEvent> = durations
.iter()
.enumerate()
.map(|(i, d)| {
let mut e = make_sql_event_with_duration(
"trace-1",
&format!("span-{i}"),
"SELECT * FROM order_items WHERE order_id = ?",
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
*d,
);
e.instrumentation_scopes = scope.map(|s| vec![Arc::from(s)]).unwrap_or_default();
e
})
.collect();
let normalized: Vec<NormalizedEvent> = events.into_iter().map(normalize_one).collect();
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
let scopes = collect_scopes(&refs);
(normalized, scopes)
}
#[test]
fn strict_orm_scope_only_low_variance_returns_inconclusive() {
let low_variance = [
100u64, 102, 98, 101, 99, 100, 101, 99, 100, 102, 98, 101, 99, 100, 102,
];
let (normalized, scopes) =
build_sanitized_group_for_strict(Some("io.opentelemetry.hibernate-6.0"), &low_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, false),
SanitizerVerdict::Inconclusive
);
}
#[test]
fn strict_orm_scope_and_high_variance_returns_likely_n_plus_one() {
let high_variance = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
let (normalized, scopes) = build_sanitized_group_for_strict(
Some("io.opentelemetry.spring-data-jpa-3.0"),
&high_variance,
);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, false),
SanitizerVerdict::LikelyNPlusOne
);
}
#[test]
fn strict_no_orm_scope_high_variance_returns_inconclusive_when_not_sequential() {
let high_variance = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
let (normalized, scopes) = build_sanitized_group_for_strict(None, &high_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, false),
SanitizerVerdict::Inconclusive
);
}
#[test]
fn strict_no_signal_returns_inconclusive() {
let low_variance = [100u64, 102, 98, 101, 99, 100, 101, 99, 100, 102];
let (normalized, scopes) = build_sanitized_group_for_strict(None, &low_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, false),
SanitizerVerdict::Inconclusive
);
}
#[test]
fn strict_bare_driver_sequential_and_variance_returns_likely_n_plus_one() {
let high_variance = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
let (normalized, scopes) = build_sanitized_group_for_strict(None, &high_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || true, false),
SanitizerVerdict::LikelyNPlusOne
);
}
#[test]
fn strict_bare_driver_sequential_but_low_variance_returns_inconclusive() {
let low_variance = [100u64, 102, 98, 101, 99, 100, 101, 99, 100, 102];
let (normalized, scopes) = build_sanitized_group_for_strict(None, &low_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || true, false),
SanitizerVerdict::Inconclusive
);
}
#[test]
fn strict_bare_driver_concurrent_with_high_variance_returns_inconclusive() {
let high_variance = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
let (normalized, scopes) = build_sanitized_group_for_strict(None, &high_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, false),
SanitizerVerdict::Inconclusive
);
}
#[test]
fn strict_orm_scope_with_high_occurrence_low_variance_returns_likely_n_plus_one() {
let low_variance = [100u64; 15];
let (normalized, scopes) = build_sanitized_group_for_strict(
Some("OpenTelemetry.Instrumentation.EntityFrameworkCore"),
&low_variance,
);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, true),
SanitizerVerdict::LikelyNPlusOne
);
}
#[test]
fn strict_bare_driver_high_occurrence_sequential_returns_likely_n_plus_one() {
let low_variance = [100u64; 15];
let (normalized, scopes) = build_sanitized_group_for_strict(None, &low_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || true, true),
SanitizerVerdict::LikelyNPlusOne
);
}
#[test]
fn strict_high_occurrence_alone_no_orm_no_sequential_returns_likely_n_plus_one() {
let low_variance = [100u64; 15];
let (normalized, scopes) = build_sanitized_group_for_strict(None, &low_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, true),
SanitizerVerdict::LikelyNPlusOne
);
}
#[test]
fn strict_orm_scope_with_low_occurrence_low_variance_still_inconclusive() {
let low_variance = [100u64; 5];
let (normalized, scopes) =
build_sanitized_group_for_strict(Some("io.opentelemetry.hibernate-6.0"), &low_variance);
let refs: Vec<&NormalizedEvent> = normalized.iter().collect();
assert_eq!(
classify_sanitized_sql_group_strict(&refs, &scopes, || false, false),
SanitizerVerdict::Inconclusive
);
}
#[test]
fn http_placeholder_recognizes_id() {
assert!(template_has_http_placeholder("GET /api/users/{id}"));
}
#[test]
fn http_placeholder_recognizes_uuid() {
assert!(template_has_http_placeholder(
"GET /api/orders/{uuid}/items"
));
}
#[test]
fn http_placeholder_rejects_plain_path() {
assert!(!template_has_http_placeholder("GET /api/health"));
assert!(!template_has_http_placeholder("GET /api/users"));
}
fn http_normalized_with_durations(durations: &[u64]) -> Vec<NormalizedEvent> {
durations
.iter()
.enumerate()
.map(|(i, d)| {
let e = crate::test_helpers::make_http_event_with_duration(
"trace-1",
&format!("span-{i}"),
"http://user-svc:5000/api/users/42",
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
*d,
);
normalize_one(e)
})
.collect()
}
#[test]
fn http_auto_reclassifies_on_high_variance() {
let spans = http_normalized_with_durations(&[100, 50, 200, 60, 250]);
let indices: Vec<usize> = (0..spans.len()).collect();
let verdict = classify_http_group_indexed(
&spans,
&indices,
SanitizerAwareMode::Auto,
|| false,
false,
);
assert_eq!(verdict, SanitizerVerdict::LikelyNPlusOne);
}
#[test]
fn http_auto_inconclusive_on_low_variance() {
let spans = http_normalized_with_durations(&[100, 100, 100, 100, 100]);
let indices: Vec<usize> = (0..spans.len()).collect();
let verdict = classify_http_group_indexed(
&spans,
&indices,
SanitizerAwareMode::Auto,
|| false,
false,
);
assert_eq!(verdict, SanitizerVerdict::Inconclusive);
}
#[test]
fn http_never_always_inconclusive() {
let spans = http_normalized_with_durations(&[100, 50, 200, 60, 250]);
let indices: Vec<usize> = (0..spans.len()).collect();
let verdict = classify_http_group_indexed(
&spans,
&indices,
SanitizerAwareMode::Never,
|| false,
false,
);
assert_eq!(verdict, SanitizerVerdict::Inconclusive);
}
#[test]
fn http_strict_placeholder_plus_variance() {
let spans = http_normalized_with_durations(&[100, 50, 200, 60, 250]);
let indices: Vec<usize> = (0..spans.len()).collect();
let verdict = classify_http_group_indexed(
&spans,
&indices,
SanitizerAwareMode::Strict,
|| false,
false,
);
assert_eq!(verdict, SanitizerVerdict::LikelyNPlusOne);
}
#[test]
fn http_strict_high_occurrence_no_variance_stays_inconclusive() {
let spans = http_normalized_with_durations(&[100, 100, 100, 100, 100]);
let indices: Vec<usize> = (0..spans.len()).collect();
let verdict = classify_http_group_indexed(
&spans,
&indices,
SanitizerAwareMode::Strict,
|| false,
true,
);
assert_eq!(verdict, SanitizerVerdict::Inconclusive);
}
#[test]
fn http_strict_no_signal_inconclusive() {
let durations = [100u64; 5];
let spans: Vec<NormalizedEvent> = durations
.iter()
.enumerate()
.map(|(i, d)| {
let e = crate::test_helpers::make_http_event_with_duration(
"trace-1",
&format!("span-{i}"),
"http://svc:5000/api/health",
&format!("2025-07-10T14:32:01.{:03}Z", i * 30),
*d,
);
normalize_one(e)
})
.collect();
let indices: Vec<usize> = (0..spans.len()).collect();
let verdict = classify_http_group_indexed(
&spans,
&indices,
SanitizerAwareMode::Strict,
|| false,
false,
);
assert_eq!(verdict, SanitizerVerdict::Inconclusive);
}
#[test]
fn http_strict_sequential_plus_variance() {
let spans = http_normalized_with_durations(&[100, 50, 200, 60, 250]);
let indices: Vec<usize> = (0..spans.len()).collect();
let verdict = classify_http_group_indexed(
&spans,
&indices,
SanitizerAwareMode::Strict,
|| true,
false,
);
assert_eq!(verdict, SanitizerVerdict::LikelyNPlusOne);
}
}