mod spans_reader;
#[cfg(test)]
use spans_reader::parse_map_column;
use spans_reader::{
ExemplarOnlyConfig, ExemplarOnlySummary, SpanStatsInput, SpanStatsRow, SpansBatchReader,
};
use std::collections::HashMap;
use std::convert::Infallible;
#[cfg(test)]
use arrow::array::Array;
use axum::Extension;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum_extra::extract::Query as QueryExtra;
use futures::stream::Stream;
use serde::{Deserialize, Serialize};
use crate::ingest::aggregate::{self, AggContext, Coverage, FoldLimits, Scope};
use crate::ingest::refine::{self, FoldErrors, Folded, RefineOpts, Resolved};
use crate::server::AppState;
use crate::server::credentials::MaybeCreds;
use crate::server::fold_stream;
use crate::server::metrics::{OperationMetrics, SpanStatsPhaseDurations, SpanStatsStreamMetrics};
use crate::storage::StorageBackend;
const SPAN_STATS_OPERATION: &str = "/api/span-stats";
const HIST_SUBDIV: u32 = 4;
const ZERO_DURATION_BUCKET: u32 = u32::MAX;
const MAX_SPAN_TYPES: usize = 10_000;
const MAX_RESPONSE_SPAN_TYPES: usize = 1_000;
const MAX_EXEMPLARS: usize = 5;
const MAX_ATTRIBUTE_VALUES: usize = 10;
const MAX_ATTRIBUTE_KEYS: usize = 50;
#[derive(Deserialize)]
pub struct SpanStatsParams {
pub service: Option<String>,
#[allow(dead_code)]
pub from: Option<String>,
#[allow(dead_code)]
pub to: Option<String>,
#[serde(default)]
pub host: Vec<String>,
pub start_ns: Option<i64>,
pub end_ns: Option<i64>,
pub min_span_ns: Option<i64>,
pub max_span_ns: Option<i64>,
#[serde(default)]
pub attr: Vec<String>,
pub span_type_uid: Option<String>,
pub max_files: Option<usize>,
pub bucket: Option<String>,
pub prefix: Option<String>,
pub aws_region: Option<String>,
#[serde(default)]
pub exemplars_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AttrFilter {
key: String,
value: String,
}
fn parse_attr_filters(raw: &[String]) -> Result<Vec<AttrFilter>, String> {
let mut filters = Vec::with_capacity(raw.len());
for entry in raw {
let Some((key, value)) = entry.split_once('=') else {
return Err(format!("invalid attr filter {entry:?}: expected key=value"));
};
if key.is_empty() {
return Err(format!("invalid attr filter {entry:?}: empty key"));
}
filters.push(AttrFilter {
key: key.to_string(),
value: value.to_string(),
});
}
Ok(filters)
}
fn parse_span_type_uid(raw: Option<&str>) -> Result<Option<[u8; 16]>, String> {
let Some(raw) = raw else {
return Ok(None);
};
let decoded = hex::decode(raw).map_err(|error| {
format!("invalid span_type_uid {raw:?}: expected 32 hex digits: {error}")
})?;
let uid = decoded.try_into().map_err(|bytes: Vec<u8>| {
format!(
"invalid span_type_uid {raw:?}: expected 16 bytes, got {}",
bytes.len()
)
})?;
Ok(Some(uid))
}
#[derive(Debug, Clone, Serialize)]
pub struct SpanTypeStats {
pub span_type_uid: String,
pub kind: String,
pub name: String,
pub target: Option<String>,
pub callsite_file: Option<String>,
pub callsite_line: Option<u32>,
pub count: u64,
pub p50_ns: Option<i64>,
pub p95_ns: Option<i64>,
pub p99_ns: Option<i64>,
pub max_ns: Option<i64>,
pub min_ns: Option<i64>,
pub histogram: Vec<SpanDurationBucket>,
#[serde(skip_serializing_if = "Option::is_none")]
pub composition: Option<TimeComposition>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub composition_histogram: Vec<CompositionBucket>,
pub details_complete_count: u64,
pub partial_count: u64,
pub exemplars: Vec<Exemplar>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_duration_count: Option<u64>,
pub attribute_facets: Vec<AttributeFacet>,
pub attribute_keys_overflow: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpanDurationBucket {
pub lo_ns: i64,
pub hi_ns: i64,
pub count: u64,
}
fn hist_bucket_key(elapsed_ns: i64) -> u32 {
if elapsed_ns > 0 {
let log2 = (elapsed_ns as f64).log2();
(log2 * HIST_SUBDIV as f64).floor() as u32
} else {
ZERO_DURATION_BUCKET
}
}
fn hist_bucket_bounds(k: u32) -> (i64, i64) {
if k == ZERO_DURATION_BUCKET {
(0, 1)
} else {
let lo_raw = 2f64.powf(k as f64 / HIST_SUBDIV as f64).round() as i64;
let hi_raw = 2f64.powf((k + 1) as f64 / HIST_SUBDIV as f64).round() as i64;
let lo = lo_raw.min(i64::MAX - 1);
let hi = hi_raw.max(lo + 1);
(lo, hi)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CompositionBucket {
pub lo_ns: i64,
pub hi_ns: i64,
pub on_cpu_ns: i64,
pub blocked_ns: i64,
pub async_wait_ns: i64,
pub scheduler_delay_ns: i64,
pub unknown_ns: i64,
pub instance_count: u64,
pub on_cpu_frac_sum: f64,
pub blocked_frac_sum: f64,
pub async_wait_frac_sum: f64,
pub scheduler_delay_frac_sum: f64,
pub unknown_frac_sum: f64,
}
#[derive(Clone, Default)]
struct CompSums {
on_cpu_ns: i64,
blocked_ns: i64,
async_wait_ns: i64,
scheduler_delay_ns: i64,
unknown_ns: i64,
instance_count: u64,
on_cpu_frac_sum: f64,
blocked_frac_sum: f64,
async_wait_frac_sum: f64,
scheduler_delay_frac_sum: f64,
unknown_frac_sum: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct TimeComposition {
pub on_cpu_ns: i64,
pub blocked_ns: i64,
pub async_wait_ns: i64,
pub scheduler_delay_ns: i64,
pub unknown_ns: i64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub instance_count: u64,
#[serde(default, skip_serializing_if = "is_zero_f64")]
pub on_cpu_frac_sum: f64,
#[serde(default, skip_serializing_if = "is_zero_f64")]
pub blocked_frac_sum: f64,
#[serde(default, skip_serializing_if = "is_zero_f64")]
pub async_wait_frac_sum: f64,
#[serde(default, skip_serializing_if = "is_zero_f64")]
pub scheduler_delay_frac_sum: f64,
#[serde(default, skip_serializing_if = "is_zero_f64")]
pub unknown_frac_sum: f64,
}
fn is_zero_u64(v: &u64) -> bool {
*v == 0
}
fn is_zero_f64(v: &f64) -> bool {
*v == 0.0
}
#[derive(Debug, Clone, Serialize)]
pub struct Exemplar {
pub elapsed_ns: i64,
pub span_uid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub callsite_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callsite_line: Option<u32>,
pub host: String,
pub start_ns: i64,
pub end_ns: i64,
pub source_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub composition: Option<TimeComposition>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub attributes: Vec<ExemplarAttribute>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExemplarAttribute {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AttributeFacet {
pub key: String,
pub first_seen_values: Vec<String>,
pub occurrences: u64,
pub truncated: bool,
pub high_cardinality: bool,
}
#[derive(Serialize)]
pub struct SpanStatsResponse {
pub span_types: Vec<SpanTypeStats>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coverage: Option<Coverage>,
pub types_truncated: bool,
pub total_span_types_tracked: usize,
pub types_overflow_instances: u64,
}
pub async fn get_span_stats(
State(state): State<AppState>,
creds: MaybeCreds,
QueryExtra(params): QueryExtra<SpanStatsParams>,
) -> Result<
(
Extension<OperationMetrics>,
Sse<impl Stream<Item = Result<Event, Infallible>>>,
),
(StatusCode, String),
> {
if let (Some(start), Some(end)) = (params.start_ns, params.end_ns)
&& start > end
{
return Err((
StatusCode::BAD_REQUEST,
format!("inverted time range: start_ns={start} > end_ns={end}"),
));
}
if let (Some(min), Some(max)) = (params.min_span_ns, params.max_span_ns) {
if min < 0 || max < 0 {
return Err((
StatusCode::BAD_REQUEST,
format!(
"span duration bounds must be non-negative: min_span_ns={min}, max_span_ns={max}"
),
));
}
if min > max {
return Err((
StatusCode::BAD_REQUEST,
format!("inverted span duration bounds: min_span_ns={min} > max_span_ns={max}"),
));
}
} else if params.min_span_ns.is_some_and(|value| value < 0)
|| params.max_span_ns.is_some_and(|value| value < 0)
{
return Err((
StatusCode::BAD_REQUEST,
"span duration bounds must be non-negative".to_string(),
));
}
let attr_filters =
parse_attr_filters(¶ms.attr).map_err(|message| (StatusCode::BAD_REQUEST, message))?;
let span_type_uid = parse_span_type_uid(params.span_type_uid.as_deref())
.map_err(|message| (StatusCode::BAD_REQUEST, message))?;
let Some(agg) = state
.agg_context_for(
params.bucket.as_deref(),
params.prefix.as_deref(),
params.aws_region.as_deref(),
creds,
)
.await?
else {
return Err((
StatusCode::NOT_FOUND,
"span-stats requires demand-driven aggregation".to_string(),
));
};
let scope = Scope {
start_ns: params.start_ns,
end_ns: params.end_ns,
service: params.service.clone(),
hosts: params.host.clone(),
};
let opts = RefineOpts {
max_files: params.max_files,
};
let Some(resolved) = refine::resolve(&agg, &scope, opts).await else {
return Err((
StatusCode::NOT_FOUND,
"no source files match this scope".to_string(),
));
};
let op = OperationMetrics::flamegraph(
resolved.files_matched as u32,
resolved.files_folded_in(resolved.folded()) as u32,
None,
);
let accum = SpanStatsAccum::new(params.start_ns, params.end_ns)
.with_exemplar_bounds(params.min_span_ns, params.max_span_ns)
.with_attr_filters(attr_filters)
.with_span_type_uid(span_type_uid)
.with_exemplars_only(params.exemplars_only);
let stream_metrics = SpanStatsStreamMetrics::arm(SPAN_STATS_OPERATION);
let stream = span_stats_stream(
agg,
resolved,
state.fold_limits.clone(),
accum,
params.exemplars_only,
stream_metrics,
);
Ok((
Extension(op),
Sse::new(stream).keep_alive(KeepAlive::default()),
))
}
#[derive(Debug, Clone)]
struct AttributeKeyAccum {
values: Vec<String>,
high_cardinality: bool,
_total_seen: u64,
}
impl AttributeKeyAccum {
fn new() -> Self {
Self {
values: Vec::new(),
high_cardinality: false,
_total_seen: 0,
}
}
fn record(&mut self, value: &str) {
self._total_seen += 1;
if self.high_cardinality {
return;
}
if !self.values.iter().any(|v| v == value) {
if self.values.len() >= MAX_ATTRIBUTE_VALUES {
self.high_cardinality = true;
} else {
self.values.push(value.to_string());
}
}
}
fn to_facet(&self, key: &str) -> AttributeFacet {
AttributeFacet {
key: key.to_string(),
first_seen_values: self.values.clone(),
occurrences: self._total_seen,
truncated: self.high_cardinality,
high_cardinality: self.high_cardinality,
}
}
}
#[derive(Clone)]
struct SpanTypeAccum {
kind: String,
name: String,
target: Option<String>,
callsite_file: Option<String>,
callsite_line: Option<u32>,
count: u64,
histogram: HashMap<u32, u64>,
min_ns: Option<i64>,
max_ns: Option<i64>,
on_cpu_ns_sum: i64,
blocked_ns_sum: i64,
async_wait_ns_sum: i64,
scheduler_delay_ns_sum: i64,
unknown_ns_sum: i64,
composition_by_bucket: HashMap<u32, CompSums>,
has_composition: bool,
details_complete_count: u64,
partial_count: u64,
exemplars: Vec<Exemplar>,
selected_duration_count: Option<u64>,
attribute_accums: HashMap<String, AttributeKeyAccum>,
attribute_keys_overflow: bool,
}
impl SpanTypeAccum {
fn new(
kind: String,
name: String,
target: Option<String>,
callsite_file: Option<String>,
callsite_line: Option<u32>,
) -> Self {
Self {
kind,
name,
target,
callsite_file,
callsite_line,
count: 0,
histogram: HashMap::new(),
min_ns: None,
max_ns: None,
on_cpu_ns_sum: 0,
blocked_ns_sum: 0,
async_wait_ns_sum: 0,
scheduler_delay_ns_sum: 0,
unknown_ns_sum: 0,
composition_by_bucket: HashMap::new(),
has_composition: false,
details_complete_count: 0,
partial_count: 0,
exemplars: Vec::new(),
selected_duration_count: None,
attribute_accums: HashMap::new(),
attribute_keys_overflow: false,
}
}
fn record(&mut self, elapsed_ns: i64, exemplar: Option<Exemplar>) {
self.count += 1;
self.min_ns = Some(self.min_ns.map_or(elapsed_ns, |m| m.min(elapsed_ns)));
self.max_ns = Some(self.max_ns.map_or(elapsed_ns, |m| m.max(elapsed_ns)));
*self
.histogram
.entry(hist_bucket_key(elapsed_ns))
.or_insert(0) += 1;
self.record_exemplar(exemplar);
}
fn record_exemplar(&mut self, exemplar: Option<Exemplar>) {
if let Some(ex) = exemplar {
if self.exemplars.len() < MAX_EXEMPLARS {
self.exemplars.push(ex);
} else if let Some(min_pos) = self
.exemplars
.iter()
.enumerate()
.min_by_key(|(_, e)| e.elapsed_ns)
.map(|(i, _)| i)
&& ex.elapsed_ns > self.exemplars[min_pos].elapsed_ns
{
self.exemplars[min_pos] = ex;
}
}
}
fn record_attributes(&mut self, attrs: &[(String, String)]) {
for (key, value) in attrs {
if key.is_empty() {
continue;
}
if let Some(accum) = self.attribute_accums.get_mut(key.as_str()) {
accum.record(value);
} else if !self.attribute_keys_overflow {
if self.attribute_accums.len() >= MAX_ATTRIBUTE_KEYS {
self.attribute_keys_overflow = true;
} else {
let mut accum = AttributeKeyAccum::new();
accum.record(value);
self.attribute_accums.insert(key.clone(), accum);
}
}
}
}
fn percentile_from_histogram(&self, p: f64) -> Option<i64> {
if self.count == 0 {
return None;
}
let target = (p / 100.0 * self.count as f64).ceil() as u64;
let mut keys: Vec<u32> = self.histogram.keys().copied().collect();
keys.sort_unstable_by(|a, b| {
match (*a == ZERO_DURATION_BUCKET, *b == ZERO_DURATION_BUCKET) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.cmp(b),
}
});
let mut cumulative = 0u64;
for &k in &keys {
cumulative += self.histogram[&k];
if cumulative >= target {
if k == ZERO_DURATION_BUCKET {
return Some(0);
}
let lo = 2f64.powf(k as f64 / HIST_SUBDIV as f64);
let hi = 2f64.powf((k + 1) as f64 / HIST_SUBDIV as f64);
return Some(((lo + hi) / 2.0).round() as i64);
}
}
self.max_ns
}
fn to_stats(&self, uid: &[u8; 16]) -> SpanTypeStats {
let mut hist_keys: Vec<u32> = self.histogram.keys().copied().collect();
hist_keys.sort_unstable();
let histogram: Vec<SpanDurationBucket> = hist_keys
.into_iter()
.map(|k| {
let (lo_ns, hi_ns) = hist_bucket_bounds(k);
SpanDurationBucket {
lo_ns,
hi_ns,
count: self.histogram[&k],
}
})
.collect();
let composition = if self.has_composition {
let mut instance_count = 0u64;
let mut on_cpu_frac_sum = 0.0;
let mut blocked_frac_sum = 0.0;
let mut async_wait_frac_sum = 0.0;
let mut scheduler_delay_frac_sum = 0.0;
let mut unknown_frac_sum = 0.0;
for s in self.composition_by_bucket.values() {
instance_count += s.instance_count;
on_cpu_frac_sum += s.on_cpu_frac_sum;
blocked_frac_sum += s.blocked_frac_sum;
async_wait_frac_sum += s.async_wait_frac_sum;
scheduler_delay_frac_sum += s.scheduler_delay_frac_sum;
unknown_frac_sum += s.unknown_frac_sum;
}
Some(TimeComposition {
on_cpu_ns: self.on_cpu_ns_sum,
blocked_ns: self.blocked_ns_sum,
async_wait_ns: self.async_wait_ns_sum,
scheduler_delay_ns: self.scheduler_delay_ns_sum,
unknown_ns: self.unknown_ns_sum,
instance_count,
on_cpu_frac_sum,
blocked_frac_sum,
async_wait_frac_sum,
scheduler_delay_frac_sum,
unknown_frac_sum,
})
} else {
None
};
let mut comp_keys: Vec<u32> = self.composition_by_bucket.keys().copied().collect();
comp_keys.sort_unstable();
let composition_histogram: Vec<CompositionBucket> = comp_keys
.into_iter()
.map(|k| {
let (lo_ns, hi_ns) = hist_bucket_bounds(k);
let s = &self.composition_by_bucket[&k];
CompositionBucket {
lo_ns,
hi_ns,
on_cpu_ns: s.on_cpu_ns,
blocked_ns: s.blocked_ns,
async_wait_ns: s.async_wait_ns,
scheduler_delay_ns: s.scheduler_delay_ns,
unknown_ns: s.unknown_ns,
instance_count: s.instance_count,
on_cpu_frac_sum: s.on_cpu_frac_sum,
blocked_frac_sum: s.blocked_frac_sum,
async_wait_frac_sum: s.async_wait_frac_sum,
scheduler_delay_frac_sum: s.scheduler_delay_frac_sum,
unknown_frac_sum: s.unknown_frac_sum,
}
})
.collect();
let mut exemplars = self.exemplars.clone();
exemplars.sort_by_key(|e| std::cmp::Reverse(e.elapsed_ns));
let attribute_facets: Vec<AttributeFacet> = self
.attribute_accums
.iter()
.map(|(key, accum)| accum.to_facet(key))
.collect();
SpanTypeStats {
span_type_uid: hex::encode(uid),
kind: self.kind.clone(),
name: self.name.clone(),
target: self.target.clone(),
callsite_file: self.callsite_file.clone(),
callsite_line: self.callsite_line,
count: self.count,
p50_ns: self.percentile_from_histogram(50.0),
p95_ns: self.percentile_from_histogram(95.0),
p99_ns: self.percentile_from_histogram(99.0),
max_ns: self.max_ns,
min_ns: self.min_ns,
histogram,
composition,
composition_histogram,
details_complete_count: self.details_complete_count,
partial_count: self.partial_count,
exemplars,
selected_duration_count: self.selected_duration_count,
attribute_facets,
attribute_keys_overflow: self.attribute_keys_overflow,
}
}
}
#[derive(Clone)]
struct SpanStatsAccum {
types: HashMap<[u8; 16], SpanTypeAccum>,
start_ns: Option<i64>,
end_ns: Option<i64>,
exemplar_min_ns: Option<i64>,
exemplar_max_ns: Option<i64>,
attr_filters: Vec<AttrFilter>,
span_type_uid: Option<[u8; 16]>,
exemplars_only: bool,
types_overflow_instances: u64,
}
impl SpanStatsAccum {
fn new(start_ns: Option<i64>, end_ns: Option<i64>) -> Self {
Self {
types: HashMap::new(),
start_ns,
end_ns,
exemplar_min_ns: None,
exemplar_max_ns: None,
attr_filters: Vec::new(),
span_type_uid: None,
exemplars_only: false,
types_overflow_instances: 0,
}
}
fn with_exemplar_bounds(mut self, min_ns: Option<i64>, max_ns: Option<i64>) -> Self {
self.exemplar_min_ns = min_ns;
self.exemplar_max_ns = max_ns;
self
}
fn with_attr_filters(mut self, filters: Vec<AttrFilter>) -> Self {
self.attr_filters = filters;
self
}
fn with_span_type_uid(mut self, span_type_uid: Option<[u8; 16]>) -> Self {
self.span_type_uid = span_type_uid;
self
}
fn with_exemplars_only(mut self, exemplars_only: bool) -> Self {
self.exemplars_only = exemplars_only;
self
}
fn attrs_match(&self, attributes: &[(String, String)]) -> bool {
self.attr_filters.iter().all(|filter| {
attributes
.iter()
.any(|(key, value)| key == &filter.key && value == &filter.value)
})
}
fn exemplar_matches(&self, elapsed_ns: i64) -> bool {
self.exemplar_min_ns.is_none_or(|min| elapsed_ns >= min)
&& self.exemplar_max_ns.is_none_or(|max| elapsed_ns <= max)
}
#[cfg(test)]
fn merge_spans_part(&mut self, data: Vec<u8>) -> anyhow::Result<()> {
self.merge_spans_part_timed(data).1
}
fn merge_spans_part_timed(
&mut self,
data: Vec<u8>,
) -> (SpanStatsPhaseDurations, anyhow::Result<()>) {
let mut staged = self.clone();
let exemplar_only =
(self.exemplars_only && self.attr_filters.is_empty() && self.span_type_uid.is_some())
.then_some(ExemplarOnlyConfig {
min_ns: self.exemplar_min_ns,
max_ns: self.exemplar_max_ns,
max_exemplars: MAX_EXEMPLARS,
});
let (phases, result) = SpansBatchReader::new(
self.start_ns,
self.end_ns,
self.span_type_uid,
exemplar_only,
)
.read(data, |input| match input {
SpanStatsInput::Row(row) => staged.merge_row(*row),
SpanStatsInput::ExemplarOnlySummary(summary) => {
staged.merge_exemplar_only_summary(summary);
}
});
if result.is_ok() {
*self = staged;
}
(phases, result)
}
fn merge_exemplar_only_summary(&mut self, summary: ExemplarOnlySummary) {
debug_assert!(self.exemplars_only);
let ExemplarOnlySummary {
span_type_uid,
kind,
name,
target,
callsite_file,
callsite_line,
count,
selected_duration_count,
} = summary;
if !self.types.contains_key(&span_type_uid) && self.types.len() >= MAX_SPAN_TYPES {
self.types_overflow_instances += count;
return;
}
let entry = self.types.entry(span_type_uid).or_insert_with(|| {
SpanTypeAccum::new(kind, name, target, callsite_file, callsite_line)
});
entry.count += count;
if let Some(selected_count) = selected_duration_count {
*entry.selected_duration_count.get_or_insert(0) += selected_count;
}
}
fn merge_row(&mut self, row: SpanStatsRow) {
let SpanStatsRow {
span_type_uid,
kind,
name,
target,
callsite_file,
callsite_line,
elapsed_ns,
exemplar,
attributes,
composition,
details_complete,
} = row;
if !self.attrs_match(&attributes) {
return;
}
let has_exemplar_bounds = self.exemplar_min_ns.is_some() || self.exemplar_max_ns.is_some();
let matches_exemplar_bounds = self.exemplar_matches(elapsed_ns);
let exemplar = exemplar.filter(|_| matches_exemplar_bounds);
if !self.types.contains_key(&span_type_uid) && self.types.len() >= MAX_SPAN_TYPES {
self.types_overflow_instances += 1;
return;
}
let exemplars_only = self.exemplars_only;
let entry = self.types.entry(span_type_uid).or_insert_with(|| {
SpanTypeAccum::new(kind, name, target, callsite_file, callsite_line)
});
if has_exemplar_bounds {
let matching_count = entry.selected_duration_count.get_or_insert(0);
if matches_exemplar_bounds {
*matching_count += 1;
}
}
if exemplars_only {
entry.count += 1;
entry.record_exemplar(exemplar);
return;
}
entry.record(elapsed_ns, exemplar);
if !attributes.is_empty() {
entry.record_attributes(&attributes);
}
if let Some(composition) = composition {
entry.has_composition = true;
entry.unknown_ns_sum += composition.unknown_ns;
entry.on_cpu_ns_sum += composition.on_cpu_ns;
entry.blocked_ns_sum += composition.blocked_ns;
entry.async_wait_ns_sum += composition.async_wait_ns;
entry.scheduler_delay_ns_sum += composition.scheduler_delay_ns;
let classified_total = composition.on_cpu_ns
+ composition.blocked_ns
+ composition.async_wait_ns
+ composition.scheduler_delay_ns
+ composition.unknown_ns;
let bucket = entry
.composition_by_bucket
.entry(hist_bucket_key(elapsed_ns))
.or_default();
bucket.unknown_ns += composition.unknown_ns;
bucket.on_cpu_ns += composition.on_cpu_ns;
bucket.blocked_ns += composition.blocked_ns;
bucket.async_wait_ns += composition.async_wait_ns;
bucket.scheduler_delay_ns += composition.scheduler_delay_ns;
if classified_total > 0 {
let total = classified_total as f64;
bucket.instance_count += 1;
bucket.on_cpu_frac_sum += composition.on_cpu_ns as f64 / total;
bucket.blocked_frac_sum += composition.blocked_ns as f64 / total;
bucket.async_wait_frac_sum += composition.async_wait_ns as f64 / total;
bucket.scheduler_delay_frac_sum += composition.scheduler_delay_ns as f64 / total;
bucket.unknown_frac_sum += composition.unknown_ns as f64 / total;
}
}
if let Some(details_complete) = details_complete {
if details_complete {
entry.details_complete_count += 1;
} else {
entry.partial_count += 1;
}
}
}
fn total_instances(&self) -> usize {
self.types.values().map(|t| t.count as usize).sum()
}
fn snapshot(&self) -> SpanStatsResponse {
let total_span_types_tracked = self.types.len();
let mut results: Vec<SpanTypeStats> = self
.types
.iter()
.map(|(uid, accum)| accum.to_stats(uid))
.collect();
results.sort_by_key(|s| std::cmp::Reverse(s.count));
let types_truncated = results.len() > MAX_RESPONSE_SPAN_TYPES;
results.truncate(MAX_RESPONSE_SPAN_TYPES);
SpanStatsResponse {
span_types: results,
coverage: None, types_truncated,
total_span_types_tracked,
types_overflow_instances: self.types_overflow_instances,
}
}
}
async fn fetch_folded_spans_parts(
output: &dyn StorageBackend,
bucket: &str,
output_prefix: &str,
source_keys: &[String],
) -> Vec<(String, Result<Vec<u8>, String>)> {
use futures::stream::StreamExt;
futures::stream::iter(source_keys.iter().cloned())
.map(|sk| {
let leaf = aggregate::part_leaf_of(&sk);
let spans_key = aggregate::spans_part_key_pub(output_prefix, &sk);
async move {
match output.get_object(bucket, &spans_key).await {
Ok(data) => (leaf, Ok(data)),
Err(e) => (
leaf,
Err(format!("{}: {e}", sk.rsplit('/').next().unwrap_or(&sk))),
),
}
}
})
.buffer_unordered(24)
.collect()
.await
}
struct SpanStatsSink {
accum: SpanStatsAccum,
pending_phase_durations: SpanStatsPhaseDurations,
}
impl fold_stream::FoldSink for SpanStatsSink {
fn seed_batch_size(&self, first_batch: bool) -> usize {
if self.accum.exemplars_only && first_batch {
4
} else {
24
}
}
async fn seed_batch(
&mut self,
agg: &AggContext,
full_keys: &[String],
) -> Vec<fold_stream::PartOutcome> {
let download_started = std::time::Instant::now();
let results = fetch_folded_spans_parts(
&*agg.output,
&agg.output_bucket,
&agg.output_prefix,
full_keys,
)
.await;
self.pending_phase_durations.download += download_started.elapsed();
let mut outcomes = Vec::with_capacity(results.len());
for (leaf, result) in results {
let outcome = match result {
Ok(part) => {
let (phases, result) = self.accum.merge_spans_part_timed(part);
self.pending_phase_durations += phases;
match result {
Ok(()) => fold_stream::PartOutcome::Folded { leaf },
Err(e) => fold_stream::PartOutcome::Failed {
key: "seed".to_string(),
error: e.to_string(),
},
}
}
Err(msg) => fold_stream::PartOutcome::Failed {
key: "seed".to_string(),
error: msg,
},
};
outcomes.push(outcome);
}
outcomes
}
async fn fold_one(&mut self, agg: &AggContext, f: &Folded) -> fold_stream::PartOutcome {
let spans_key = aggregate::spans_part_key_pub(&agg.output_prefix, &f.full_key);
let download_started = std::time::Instant::now();
let result = agg.output.get_object(&agg.output_bucket, &spans_key).await;
self.pending_phase_durations.download += download_started.elapsed();
match result {
Ok(data) => {
let (phases, result) = self.accum.merge_spans_part_timed(data);
self.pending_phase_durations += phases;
match result {
Ok(()) => fold_stream::PartOutcome::Folded {
leaf: aggregate::part_leaf_of(&f.full_key),
},
Err(e) => fold_stream::PartOutcome::Failed {
key: f.raw_key.clone(),
error: format!("decode/merge: {e}"),
},
}
}
Err(e) => fold_stream::PartOutcome::Failed {
key: f.raw_key.clone(),
error: format!("spans part GET: {e}"),
},
}
}
fn take_phase_durations(&mut self) -> SpanStatsPhaseDurations {
std::mem::take(&mut self.pending_phase_durations)
}
fn snapshot_event(
&self,
resolved: &Resolved,
files_folded: usize,
folded_set_id: &str,
target_folded_set_id: Option<&str>,
hosts_folded: usize,
errors: &FoldErrors,
) -> Event {
let coverage = fold_stream::coverage_from(
resolved,
files_folded,
folded_set_id,
target_folded_set_id,
hosts_folded,
errors,
self.accum.total_instances(),
);
let mut resp = self.accum.snapshot();
resp.coverage = Some(coverage);
Event::default().json_data(&resp).unwrap_or_else(|e| {
fold_stream::rate_limited_warn("span-stats: serialize failed", &anyhow::anyhow!(e));
Event::default().comment("serialize error")
})
}
}
fn span_stats_stream(
agg: AggContext,
resolved: Resolved,
limits: FoldLimits,
accum: SpanStatsAccum,
seed_only: bool,
stream_metrics: crate::server::metrics::SpanStatsStreamMetricsGuard,
) -> impl Stream<Item = Result<Event, Infallible>> + use<> {
fold_stream::drive_with_options(
agg,
resolved,
limits,
SpanStatsSink {
accum,
pending_phase_durations: SpanStatsPhaseDurations::default(),
},
seed_only,
Some(stream_metrics),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_spans_parquet(spans: &[(i64, i64, i64, &str, &str, bool)]) -> Vec<u8> {
make_spans_parquet_with_attrs(
&spans
.iter()
.map(|&(start, end, elapsed, name, host, complete)| {
(start, end, elapsed, name, host, complete, vec![])
})
.collect::<Vec<_>>(),
)
}
#[allow(clippy::type_complexity)]
fn make_spans_parquet_with_attrs(
spans: &[(i64, i64, i64, &str, &str, bool, Vec<(String, String)>)],
) -> Vec<u8> {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let resolved: Vec<ResolvedSpan> = spans
.iter()
.enumerate()
.map(
|(i, (start, end, elapsed, name, host, complete, attrs))| ResolvedSpan {
span_uid: {
let mut uid = [0u8; 16];
uid[0] = i as u8;
uid[1] = (i >> 8) as u8;
uid
},
span_type_uid: {
let mut uid = [0u8; 16];
uid[15] = name.as_bytes().first().copied().unwrap_or(0);
uid
},
kind: "tracing",
name: name.to_string(),
target: "test".to_string(),
callsite_file: Some("src/main.rs".to_string()),
callsite_line: Some(42),
start_ns: *start as u64,
end_ns: *end as u64,
elapsed_ns: *elapsed as u64,
active_ns: None,
observed_active_wall_ns: 0,
detail_coverage_ns: 0,
details_complete: *complete,
concurrent: false,
parent_span_uid: None,
attributes: attrs.clone(),
on_cpu_ns_est: None,
blocked_ns_est: None,
async_wait_ns: None,
scheduler_delay_ns: None,
unknown_ns: *elapsed as u64,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0b1111,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "metadata",
source_key: "test".to_string(),
host: host.to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
},
)
.collect();
let mut buf = Vec::new();
write_spans(&mut buf, &resolved).unwrap();
buf
}
#[test]
fn span_filter_exact_window_boundaries() {
let data = make_spans_parquet(&[
(10, 99, 89, "A", "h1", true), (10, 100, 90, "A", "h1", true), (10, 199, 189, "A", "h1", true), (10, 200, 190, "A", "h1", true), (120, 180, 60, "A", "h1", false), ]);
let mut accum = SpanStatsAccum::new(Some(100), Some(200));
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
assert_eq!(
snap.span_types[0].count, 3,
"half-open [100, 200) on end_ns should include exactly 3 spans"
);
}
#[test]
fn reject_negative_elapsed() {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let spans = vec![ResolvedSpan {
span_uid: [1u8; 16],
span_type_uid: [2u8; 16],
kind: "tracing",
name: "test".to_string(),
target: "test".to_string(),
callsite_file: None,
callsite_line: None,
start_ns: 100,
end_ns: 200,
elapsed_ns: 100,
active_ns: None,
observed_active_wall_ns: 0,
detail_coverage_ns: 0,
details_complete: true,
concurrent: false,
parent_span_uid: None,
attributes: Vec::new(),
on_cpu_ns_est: None,
blocked_ns_est: None,
async_wait_ns: None,
scheduler_delay_ns: None,
unknown_ns: 100,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "metadata",
source_key: "test".to_string(),
host: "h1".to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
}];
let mut buf = Vec::new();
write_spans(&mut buf, &spans).unwrap();
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(buf).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types[0].count, 1, "valid row is counted");
}
#[test]
fn zero_duration_spans_in_histogram() {
let data = make_spans_parquet(&[
(100, 100, 0, "A", "h1", true), (100, 101, 1, "A", "h1", true), (100, 200, 100, "A", "h1", true), ]);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
assert_eq!(snap.span_types[0].count, 3, "all three spans counted");
let hist = &snap.span_types[0].histogram;
let total_hist_count: u64 = hist.iter().map(|b| b.count).sum();
assert_eq!(
total_hist_count, 3,
"histogram must include all spans including zero-duration"
);
let zero_bucket = hist.iter().find(|b| b.lo_ns == 0);
assert!(
zero_bucket.is_some(),
"must have a distinct zero-duration bucket [0, 1)"
);
let zero_bucket = zero_bucket.unwrap();
assert_eq!(zero_bucket.lo_ns, 0);
assert_eq!(zero_bucket.hi_ns, 1);
assert_eq!(
zero_bucket.count, 1,
"only the zero-duration span in [0, 1)"
);
let one_ns_bucket = hist.iter().find(|b| b.lo_ns == 1);
assert!(
one_ns_bucket.is_some(),
"must have a bucket starting at 1ns for 1ns spans"
);
let one_ns_bucket = one_ns_bucket.unwrap();
assert_eq!(one_ns_bucket.count, 1, "only the 1ns span in [1, ~1.19)");
assert_ne!(
(zero_bucket.lo_ns, zero_bucket.hi_ns),
(one_ns_bucket.lo_ns, one_ns_bucket.hi_ns),
"zero-duration [0,1) and 1ns buckets must be distinct bars"
);
}
#[test]
fn high_cardinality_truncation() {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let num_types = MAX_RESPONSE_SPAN_TYPES + 50;
let spans: Vec<ResolvedSpan> = (0..num_types)
.map(|i| {
let mut type_uid = [0u8; 16];
type_uid[0..8].copy_from_slice(&(i as u64).to_le_bytes());
ResolvedSpan {
span_uid: {
let mut uid = [0u8; 16];
uid[8..16].copy_from_slice(&(i as u64).to_le_bytes());
uid
},
span_type_uid: type_uid,
kind: "tracing",
name: format!("span_{i}"),
target: "test".to_string(),
callsite_file: None,
callsite_line: None,
start_ns: 1000,
end_ns: 2000,
elapsed_ns: 1000,
active_ns: None,
observed_active_wall_ns: 0,
detail_coverage_ns: 0,
details_complete: true,
concurrent: false,
parent_span_uid: None,
attributes: Vec::new(),
on_cpu_ns_est: None,
blocked_ns_est: None,
async_wait_ns: None,
scheduler_delay_ns: None,
unknown_ns: 1000,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "metadata",
source_key: "test".to_string(),
host: "h1".to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
}
})
.collect();
let mut buf = Vec::new();
write_spans(&mut buf, &spans).unwrap();
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(buf).unwrap();
let snap = accum.snapshot();
assert_eq!(
snap.span_types.len(),
MAX_RESPONSE_SPAN_TYPES,
"response must be truncated to MAX_RESPONSE_SPAN_TYPES"
);
assert!(snap.types_truncated, "types_truncated flag must be set");
assert_eq!(
snap.total_span_types_tracked, num_types,
"total_span_types_tracked reflects all types within the tracking cap"
);
assert_eq!(snap.types_overflow_instances, 0);
}
#[test]
fn types_overflow_counted() {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let num_types = MAX_SPAN_TYPES + 5;
let spans: Vec<ResolvedSpan> = (0..num_types)
.map(|i| {
let mut type_uid = [0u8; 16];
type_uid[0..8].copy_from_slice(&(i as u64).to_le_bytes());
ResolvedSpan {
span_uid: {
let mut uid = [0u8; 16];
uid[8..16].copy_from_slice(&(i as u64).to_le_bytes());
uid
},
span_type_uid: type_uid,
kind: "tracing",
name: format!("span_{i}"),
target: "test".to_string(),
callsite_file: None,
callsite_line: None,
start_ns: 1000,
end_ns: 2000,
elapsed_ns: 1000,
active_ns: None,
observed_active_wall_ns: 0,
detail_coverage_ns: 0,
details_complete: true,
concurrent: false,
parent_span_uid: None,
attributes: Vec::new(),
on_cpu_ns_est: None,
blocked_ns_est: None,
async_wait_ns: None,
scheduler_delay_ns: None,
unknown_ns: 1000,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "metadata",
source_key: "test".to_string(),
host: "h1".to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
}
})
.collect();
let mut buf = Vec::new();
write_spans(&mut buf, &spans).unwrap();
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(buf).unwrap();
let snap = accum.snapshot();
assert_eq!(
snap.total_span_types_tracked, MAX_SPAN_TYPES,
"tracked types capped at MAX_SPAN_TYPES"
);
assert_eq!(
snap.types_overflow_instances, 5,
"5 instances dropped due to overflow"
);
assert!(snap.types_truncated, "types_truncated must be set");
}
#[test]
fn five_way_composition_and_quality_counts() {
let data = make_spans_parquet(&[
(100, 200, 100, "A", "h1", true),
(300, 500, 200, "A", "h2", false),
]);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
let st = &snap.span_types[0];
assert_eq!(st.count, 2);
assert_eq!(st.details_complete_count, 1);
assert_eq!(st.partial_count, 1);
assert!(st.composition.is_some());
assert_eq!(st.composition.as_ref().unwrap().unknown_ns, 300);
}
#[test]
fn composition_histogram_buckets_by_duration() {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let mk = |elapsed: u64, on_cpu: u64, wait: u64| ResolvedSpan {
span_uid: [0u8; 16],
span_type_uid: [7u8; 16],
kind: "tracing",
name: "op".to_string(),
target: "t".to_string(),
callsite_file: None,
callsite_line: None,
start_ns: 0,
end_ns: elapsed,
elapsed_ns: elapsed,
active_ns: None,
observed_active_wall_ns: on_cpu + wait,
detail_coverage_ns: on_cpu + wait,
details_complete: false,
concurrent: false,
parent_span_uid: None,
attributes: vec![],
on_cpu_ns_est: Some(on_cpu),
blocked_ns_est: None,
async_wait_ns: Some(wait),
scheduler_delay_ns: None,
unknown_ns: elapsed - on_cpu - wait,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0b1011,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "legacy",
source_key: "k".to_string(),
host: "h".to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
};
let spans = vec![
mk(1000, 1000, 0), mk(1_050_000, 0, 1_050_000), ];
let mut buf = Vec::new();
write_spans(&mut buf, &spans).unwrap();
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(buf).unwrap();
let snap = accum.snapshot();
let st = &snap.span_types[0];
let comp = st.composition.as_ref().unwrap();
assert_eq!(comp.on_cpu_ns, 1000);
assert_eq!(comp.async_wait_ns, 1_050_000);
assert_eq!(comp.instance_count, 2);
assert!((comp.on_cpu_frac_sum - 1.0).abs() < 1e-9);
assert!((comp.async_wait_frac_sum - 1.0).abs() < 1e-9);
let equal_on_cpu = comp.on_cpu_frac_sum / comp.instance_count as f64;
assert!(
(equal_on_cpu - 0.5).abs() < 1e-9,
"equal-weighted on-CPU share is 50%, not the ns-weighted ~0.1%"
);
let short_fracs = st
.composition_histogram
.iter()
.find(|b| b.lo_ns <= 1000 && b.hi_ns > 1000)
.unwrap();
assert_eq!(short_fracs.instance_count, 1);
assert!((short_fracs.on_cpu_frac_sum - 1.0).abs() < 1e-9);
assert_eq!(
st.composition_histogram.len(),
2,
"two duration buckets expected"
);
let short = st
.composition_histogram
.iter()
.find(|b| b.lo_ns <= 1000 && b.hi_ns > 1000)
.expect("short bucket present");
assert_eq!(short.on_cpu_ns, 1000);
assert_eq!(short.async_wait_ns, 0);
let long = st
.composition_histogram
.iter()
.find(|b| b.lo_ns <= 1_050_000 && b.hi_ns > 1_050_000)
.expect("long bucket present");
assert_eq!(long.on_cpu_ns, 0);
assert_eq!(long.async_wait_ns, 1_050_000);
for cb in &st.composition_histogram {
assert!(
st.histogram
.iter()
.any(|hb| hb.lo_ns == cb.lo_ns && hb.hi_ns == cb.hi_ns),
"composition bucket [{}, {}) has no matching count bucket",
cb.lo_ns,
cb.hi_ns
);
}
}
#[test]
fn exemplars_bounded_with_source_coords() {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let count = MAX_EXEMPLARS + 3;
let mut type_uid = [0u8; 16];
type_uid[0] = 1;
let spans: Vec<ResolvedSpan> = (0..count)
.map(|i| ResolvedSpan {
span_uid: {
let mut uid = [0u8; 16];
uid[0..8].copy_from_slice(&(i as u64).to_le_bytes());
uid
},
span_type_uid: type_uid,
kind: "tracing",
name: "same_type".to_string(),
target: "test".to_string(),
callsite_file: Some("src/main.rs".to_string()),
callsite_line: Some((i + 1) as u32),
start_ns: 1000,
end_ns: 1000 + ((i + 1) * 1_000_000) as u64,
elapsed_ns: ((i + 1) * 1_000_000) as u64,
active_ns: None,
observed_active_wall_ns: 0,
detail_coverage_ns: 0,
details_complete: true,
concurrent: false,
parent_span_uid: None,
attributes: Vec::new(),
on_cpu_ns_est: None,
blocked_ns_est: None,
async_wait_ns: None,
scheduler_delay_ns: None,
unknown_ns: ((i + 1) * 1_000_000) as u64,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "metadata",
source_key: "test".to_string(),
host: "h1".to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
})
.collect();
let mut buf = Vec::new();
write_spans(&mut buf, &spans).unwrap();
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(buf).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
let st = &snap.span_types[0];
assert_eq!(st.count, count as u64);
assert_eq!(
st.exemplars.len(),
MAX_EXEMPLARS,
"exemplars must be bounded to MAX_EXEMPLARS"
);
for w in st.exemplars.windows(2) {
assert!(w[0].elapsed_ns >= w[1].elapsed_ns);
}
for ex in &st.exemplars {
assert!(ex.callsite_file.is_some());
assert!(ex.callsite_line.is_some());
}
for ex in &st.exemplars {
assert_eq!(ex.start_ns, 1000, "exemplar start_ns populated");
assert_eq!(
ex.end_ns,
1000 + ex.elapsed_ns,
"exemplar end_ns = start + elapsed"
);
assert_eq!(ex.source_key, "test", "exemplar source_key populated");
}
for ex in &st.exemplars {
let comp = ex
.composition
.as_ref()
.expect("exemplar carries per-instance composition");
assert_eq!(comp.unknown_ns, ex.elapsed_ns);
assert_eq!(comp.on_cpu_ns, 0);
}
}
#[test]
fn exemplars_carry_per_instance_attributes() {
let data = make_spans_parquet_with_attrs(&[(
0,
50,
50,
"A",
"h1",
true,
vec![
("route".to_string(), "/checkout".to_string()),
("status".to_string(), "500".to_string()),
],
)]);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let st = &accum.snapshot().span_types[0];
let ex = &st.exemplars[0];
let attrs: Vec<(&str, &str)> = ex
.attributes
.iter()
.map(|a| (a.key.as_str(), a.value.as_str()))
.collect();
assert!(attrs.contains(&("route", "/checkout")));
assert!(attrs.contains(&("status", "500")));
}
#[test]
fn parse_attr_filters_valid_and_invalid() {
let ok =
parse_attr_filters(&["status_code=500".to_string(), "url=/a?b=c".to_string()]).unwrap();
assert_eq!(
ok,
vec![
AttrFilter {
key: "status_code".to_string(),
value: "500".to_string()
},
AttrFilter {
key: "url".to_string(),
value: "/a?b=c".to_string()
},
]
);
assert_eq!(
parse_attr_filters(&["k=".to_string()]).unwrap(),
vec![AttrFilter {
key: "k".to_string(),
value: String::new()
}]
);
assert!(parse_attr_filters(&["noequals".to_string()]).is_err());
assert!(parse_attr_filters(&["=v".to_string()]).is_err());
}
#[test]
fn exemplar_only_stream_uses_smaller_preview_batches() {
let full = SpanStatsSink {
accum: SpanStatsAccum::new(None, None),
pending_phase_durations: SpanStatsPhaseDurations::default(),
};
let lightweight = SpanStatsSink {
accum: SpanStatsAccum::new(None, None).with_exemplars_only(true),
pending_phase_durations: SpanStatsPhaseDurations::default(),
};
assert_eq!(fold_stream::FoldSink::seed_batch_size(&full, true), 24);
assert_eq!(
fold_stream::FoldSink::seed_batch_size(&lightweight, true),
4
);
assert_eq!(
fold_stream::FoldSink::seed_batch_size(&lightweight, false),
24
);
}
#[test]
fn parse_span_type_uid_validates_hex_and_width() {
assert_eq!(parse_span_type_uid(None).unwrap(), None);
assert_eq!(
parse_span_type_uid(Some("000102030405060708090a0b0c0d0e0f")).unwrap(),
Some([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
);
assert!(parse_span_type_uid(Some("not-hex")).is_err());
assert!(parse_span_type_uid(Some("00")).is_err());
}
#[test]
fn attr_filter_narrows_counts_and_exemplars() {
let s500 = || vec![("status_code".to_string(), "500".to_string())];
let s200 = || vec![("status_code".to_string(), "200".to_string())];
let data = make_spans_parquet_with_attrs(&[
(0, 10, 10, "A", "h1", true, s500()),
(0, 20, 20, "A", "h1", true, s500()),
(0, 30, 30, "A", "h1", true, s200()),
]);
let mut all = SpanStatsAccum::new(None, None);
all.merge_spans_part(data.clone()).unwrap();
assert_eq!(all.snapshot().span_types[0].count, 3);
let mut filtered = SpanStatsAccum::new(None, None).with_attr_filters(vec![AttrFilter {
key: "status_code".to_string(),
value: "500".to_string(),
}]);
filtered.merge_spans_part(data.clone()).unwrap();
let st = &filtered.snapshot().span_types[0];
assert_eq!(st.count, 2, "attr filter narrows total count");
assert!(
st.exemplars.iter().all(|ex| ex
.attributes
.iter()
.any(|a| a.key == "status_code" && a.value == "500")),
"every surviving exemplar matches the filter"
);
let mut none = SpanStatsAccum::new(None, None).with_attr_filters(vec![AttrFilter {
key: "status_code".to_string(),
value: "418".to_string(),
}]);
none.merge_spans_part(data).unwrap();
assert!(none.snapshot().span_types.is_empty());
}
#[test]
fn attr_filters_are_anded() {
let data = make_spans_parquet_with_attrs(&[
(
0,
10,
10,
"A",
"h1",
true,
vec![
("status_code".to_string(), "500".to_string()),
("route".to_string(), "/checkout".to_string()),
],
),
(
0,
20,
20,
"A",
"h1",
true,
vec![
("status_code".to_string(), "500".to_string()),
("route".to_string(), "/cart".to_string()),
],
),
]);
let mut accum = SpanStatsAccum::new(None, None).with_attr_filters(vec![
AttrFilter {
key: "status_code".to_string(),
value: "500".to_string(),
},
AttrFilter {
key: "route".to_string(),
value: "/checkout".to_string(),
},
]);
accum.merge_spans_part(data).unwrap();
let st = &accum.snapshot().span_types[0];
assert_eq!(st.count, 1, "both attr filters must match (AND semantics)");
}
#[test]
fn exemplar_bounds_select_only_matching_instances_without_filtering_stats() {
let data = make_spans_parquet(&[
(0, 10, 10, "A", "h1", true),
(0, 20, 20, "A", "h1", true),
(0, 30, 30, "A", "h1", true),
(0, 40, 40, "A", "h1", true),
(0, 50, 50, "A", "h1", true),
]);
let mut accum = SpanStatsAccum::new(None, None).with_exemplar_bounds(Some(20), Some(40));
accum.merge_spans_part(data).unwrap();
let stats = &accum.snapshot().span_types[0];
assert_eq!(
stats.count, 5,
"duration bounds must not narrow catalog stats"
);
assert_eq!(stats.min_ns, Some(10));
assert_eq!(stats.max_ns, Some(50));
assert_eq!(stats.selected_duration_count, Some(3));
assert_eq!(
stats
.exemplars
.iter()
.map(|exemplar| exemplar.elapsed_ns)
.collect::<Vec<_>>(),
vec![40, 30, 20],
"exemplars must use inclusive selected-band bounds"
);
}
#[test]
fn exemplars_only_preserves_patch_fields_without_catalog_aggregation() {
let data = make_spans_parquet_with_attrs(&[
(
0,
10,
10,
"A",
"h1",
true,
vec![("route".to_string(), "/a".to_string())],
),
(
0,
20,
20,
"A",
"h1",
false,
vec![("route".to_string(), "/b".to_string())],
),
(
0,
30,
30,
"A",
"h1",
true,
vec![("route".to_string(), "/c".to_string())],
),
(
0,
16,
16,
"A",
"h1",
true,
vec![("route".to_string(), "/d".to_string())],
),
(
0,
17,
17,
"A",
"h1",
true,
vec![("route".to_string(), "/e".to_string())],
),
(
0,
18,
18,
"A",
"h1",
true,
vec![("route".to_string(), "/f".to_string())],
),
(
0,
19,
19,
"A",
"h1",
true,
vec![("route".to_string(), "/g".to_string())],
),
(
0,
25,
25,
"B",
"h1",
true,
vec![("large".to_string(), "unrelated".repeat(100))],
),
]);
let mut full = SpanStatsAccum::new(None, None).with_exemplar_bounds(Some(15), Some(30));
full.merge_spans_part(data.clone()).unwrap();
let mut selected_uid = [0_u8; 16];
selected_uid[15] = b'A';
let mut lightweight = SpanStatsAccum::new(None, None)
.with_exemplar_bounds(Some(15), Some(30))
.with_span_type_uid(Some(selected_uid))
.with_exemplars_only(true);
let (phases, result) = lightweight.merge_spans_part_timed(data);
result.unwrap();
let full_snapshot = full.snapshot();
let lightweight_snapshot = lightweight.snapshot();
let full_stats = full_snapshot
.span_types
.iter()
.find(|stats| stats.span_type_uid == hex::encode(selected_uid))
.unwrap();
assert_eq!(lightweight_snapshot.span_types.len(), 1);
assert_eq!(phases.rows_materialized, MAX_EXEMPLARS as u64);
assert_eq!(phases.attribute_entries, MAX_EXEMPLARS as u64);
let lightweight_stats = &lightweight_snapshot.span_types[0];
assert_eq!(lightweight_stats.count, full_stats.count);
assert_eq!(
lightweight_stats.selected_duration_count,
full_stats.selected_duration_count
);
assert_eq!(
lightweight_stats
.exemplars
.iter()
.map(|exemplar| exemplar.elapsed_ns)
.collect::<Vec<_>>(),
full_stats
.exemplars
.iter()
.map(|exemplar| exemplar.elapsed_ns)
.collect::<Vec<_>>()
);
assert!(lightweight_stats.histogram.is_empty());
assert_eq!(lightweight_stats.p50_ns, None);
assert_eq!(lightweight_stats.p95_ns, None);
assert_eq!(lightweight_stats.p99_ns, None);
assert_eq!(lightweight_stats.min_ns, None);
assert_eq!(lightweight_stats.max_ns, None);
assert!(lightweight_stats.composition.is_none());
assert!(lightweight_stats.composition_histogram.is_empty());
assert_eq!(lightweight_stats.details_complete_count, 0);
assert_eq!(lightweight_stats.partial_count, 0);
assert!(lightweight_stats.attribute_facets.is_empty());
assert!(!lightweight_stats.attribute_keys_overflow);
}
#[test]
fn attributes_parsed_and_faceted() {
let data = make_spans_parquet_with_attrs(&[
(
100,
200,
100,
"A",
"h1",
true,
vec![
("method".to_string(), "GET".to_string()),
("status".to_string(), "200".to_string()),
],
),
(
200,
300,
100,
"A",
"h1",
true,
vec![
("method".to_string(), "POST".to_string()),
("status".to_string(), "200".to_string()),
],
),
(
300,
400,
100,
"A",
"h1",
true,
vec![("method".to_string(), "GET".to_string())],
),
]);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
let st = &snap.span_types[0];
assert_eq!(st.count, 3);
let method_facet = st
.attribute_facets
.iter()
.find(|f| f.key == "method")
.expect("method facet should exist");
assert_eq!(method_facet.first_seen_values.len(), 2); assert!(method_facet.first_seen_values.contains(&"GET".to_string()));
assert!(method_facet.first_seen_values.contains(&"POST".to_string()));
assert!(!method_facet.truncated);
assert!(!method_facet.high_cardinality);
let status_facet = st
.attribute_facets
.iter()
.find(|f| f.key == "status")
.expect("status facet should exist");
assert_eq!(status_facet.first_seen_values.len(), 1); assert!(!status_facet.truncated);
}
#[test]
fn attributes_high_cardinality_detection() {
#[allow(clippy::type_complexity)]
let attrs: Vec<(i64, i64, i64, &str, &str, bool, Vec<(String, String)>)> = (0
..MAX_ATTRIBUTE_VALUES + 3)
.map(|i| {
(
100 + i as i64,
200 + i as i64,
100i64,
"A",
"h1",
true,
vec![("user_id".to_string(), format!("user_{i}"))],
)
})
.collect();
let data = make_spans_parquet_with_attrs(&attrs);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
let st = &snap.span_types[0];
let user_facet = st
.attribute_facets
.iter()
.find(|f| f.key == "user_id")
.expect("user_id facet should exist");
assert!(
user_facet.high_cardinality,
"user_id should be marked high_cardinality"
);
assert!(user_facet.truncated);
assert_eq!(
user_facet.first_seen_values.len(),
MAX_ATTRIBUTE_VALUES,
"only the first MAX_ATTRIBUTE_VALUES retained"
);
}
#[test]
fn attribute_keys_overflow_serialized_with_over_50_keys() {
let attrs: Vec<(String, String)> = (0..55)
.map(|i| (format!("key_{i:03}"), format!("val_{i}")))
.collect();
let data = make_spans_parquet_with_attrs(&[(100, 200, 100, "A", "h1", true, attrs)]);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
let st = &snap.span_types[0];
assert_eq!(
st.attribute_facets.len(),
MAX_ATTRIBUTE_KEYS,
"must stop tracking new keys at MAX_ATTRIBUTE_KEYS"
);
assert!(
st.attribute_keys_overflow,
"attribute_keys_overflow must be true when > 50 keys"
);
for facet in &st.attribute_facets {
assert_eq!(facet.occurrences, 1);
assert_eq!(facet.first_seen_values.len(), 1);
}
}
#[test]
fn attributes_parquet_readback() {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let spans = vec![ResolvedSpan {
span_uid: [1u8; 16],
span_type_uid: [2u8; 16],
kind: "tracing",
name: "test_span".to_string(),
target: "my_crate".to_string(),
callsite_file: Some("src/main.rs".to_string()),
callsite_line: Some(42),
start_ns: 1000,
end_ns: 2000,
elapsed_ns: 1000,
active_ns: None,
observed_active_wall_ns: 0,
detail_coverage_ns: 0,
details_complete: true,
concurrent: false,
parent_span_uid: None,
attributes: vec![
("http.method".to_string(), "GET".to_string()),
("http.path".to_string(), "/api/users".to_string()),
],
on_cpu_ns_est: None,
blocked_ns_est: None,
async_wait_ns: None,
scheduler_delay_ns: None,
unknown_ns: 1000,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "metadata",
source_key: "test".to_string(),
host: "h1".to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
}];
let mut buf = Vec::new();
write_spans(&mut buf, &spans).unwrap();
let reader = parquet::arrow::arrow_reader::ParquetRecordBatchReader::try_new(
bytes::Bytes::from(buf.clone()),
1024,
)
.unwrap();
let batches: Vec<_> = reader.into_iter().collect::<Result<_, _>>().unwrap();
assert_eq!(batches.len(), 1);
let batch = &batches[0];
let attr_col = batch
.column_by_name("attributes")
.expect("attributes column must exist");
let map_arr = attr_col
.as_any()
.downcast_ref::<arrow::array::MapArray>()
.expect("attributes must be a MapArray");
assert!(!map_arr.is_null(0));
let attrs = parse_map_column(map_arr, 0);
assert_eq!(attrs.len(), 2);
assert!(attrs.contains(&("http.method".to_string(), "GET".to_string())));
assert!(attrs.contains(&("http.path".to_string(), "/api/users".to_string())));
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(buf).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types[0].attribute_facets.len(), 2);
}
#[test]
fn stream_coverage_with_injected_failures() {
let mut accum = SpanStatsAccum::new(None, None);
let mut errors = FoldErrors::default();
errors.record("file_1.bin", "spans part GET: connection refused");
let result = accum.merge_spans_part(b"not valid parquet data".to_vec());
assert!(result.is_err(), "garbage data should fail to parse");
errors.record(
"file_2.bin",
&format!("decode/merge: {}", result.unwrap_err()),
);
errors.record("file_3.bin", "spans part GET: 503 Service Unavailable");
assert_eq!(errors.count, 3, "three fold errors recorded");
assert!(errors.sample.is_some(), "error sample message present");
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 0, "no span types from failed merges");
let coverage = Coverage {
files_matched: 10,
files_folded: 0, folded_set_id: None,
target_folded_set_id: None,
fold_work_cap: 10,
samples_folded: accum.total_instances(),
total_bytes: 50_000,
hosts_matched: 3,
hosts_folded: 0,
fold_errors: errors.count,
fold_error_sample: errors.sample.clone(),
};
assert_eq!(
coverage.files_folded, 0,
"no files folded → not falsely successful"
);
assert_eq!(coverage.samples_folded, 0, "zero samples folded");
assert_eq!(coverage.fold_errors, 3, "all failures counted");
assert!(
coverage.fold_error_sample.is_some(),
"error sample propagated for UI display"
);
}
#[test]
fn inverted_time_range_rejected_in_accum() {
let data = make_spans_parquet(&[(100, 200, 100, "A", "h1", true)]);
let mut accum = SpanStatsAccum::new(Some(300), Some(100));
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 0, "inverted range matches nothing");
}
#[test]
fn missing_end_ns_column_with_time_filter_is_merge_error() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
]));
let uid_data: Vec<u8> = [0u8; 16].to_vec();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["test"])),
StdArc::new(StringArray::from(vec!["tracing"])),
StdArc::new(Int64Array::from(vec![100i64])),
],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(None, None);
assert!(accum.merge_spans_part(buf.clone()).is_ok());
let mut accum = SpanStatsAccum::new(Some(50), Some(200));
let result = accum.merge_spans_part(buf);
assert!(
result.is_err(),
"missing end_ns column with time filter must be a merge error"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("end_ns"),
"error must mention end_ns: got {msg}"
);
}
#[test]
fn malformed_parquet_is_merge_error() {
let mut accum = SpanStatsAccum::new(None, None);
let result = accum.merge_spans_part(b"not valid parquet data at all".to_vec());
assert!(result.is_err(), "garbage data must fail to parse");
}
#[test]
fn negative_end_ns_rows_skipped() {
use crate::ingest::decode::ResolvedSpan;
use crate::ingest::parquet_writer::write_spans;
let spans = vec![ResolvedSpan {
span_uid: [1u8; 16],
span_type_uid: [2u8; 16],
kind: "tracing",
name: "test".to_string(),
target: "test".to_string(),
callsite_file: None,
callsite_line: None,
start_ns: 10,
end_ns: 50, elapsed_ns: 40,
active_ns: None,
observed_active_wall_ns: 0,
detail_coverage_ns: 0,
details_complete: true,
concurrent: false,
parent_span_uid: None,
attributes: Vec::new(),
on_cpu_ns_est: None,
blocked_ns_est: None,
async_wait_ns: None,
scheduler_delay_ns: None,
unknown_ns: 40,
cpu_sample_count: 0,
sched_sample_count: 0,
attribution_version: 1,
attribution_flags: 0,
unbalanced_exits: 0,
unbalanced_enters: 0,
identity_quality: "metadata",
source_key: "test".to_string(),
host: "h1".to_string(),
service: "svc".to_string(),
date: "2026-07-15".to_string(),
}];
let mut buf = Vec::new();
write_spans(&mut buf, &spans).unwrap();
let mut accum = SpanStatsAccum::new(Some(100), Some(200));
accum.merge_spans_part(buf).unwrap();
let snap = accum.snapshot();
assert_eq!(
snap.span_types.len(),
0,
"span ending before filter window must be excluded"
);
}
#[test]
fn histogram_bucket_hi_gt_lo_invariant() {
let data = make_spans_parquet(&[
(100, 100, 0, "A", "h1", true), (100, 101, 1, "A", "h1", true), (100, 102, 2, "A", "h1", true), (100, 110, 10, "A", "h1", true), (100, 1100, 1000, "A", "h1", true), ]);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
let hist = &snap.span_types[0].histogram;
assert!(!hist.is_empty(), "histogram must not be empty");
for bucket in hist {
assert!(
bucket.hi_ns > bucket.lo_ns,
"bucket [{}, {}) violates hi > lo",
bucket.lo_ns,
bucket.hi_ns
);
}
let zero = hist.iter().find(|b| b.lo_ns == 0).expect("zero bucket");
assert_eq!(zero.lo_ns, 0);
assert_eq!(zero.hi_ns, 1);
let one_ns = hist.iter().find(|b| b.lo_ns == 1).expect("1ns bucket");
assert_eq!(one_ns.lo_ns, 1);
assert_eq!(one_ns.hi_ns, 2, "1ns bucket must be [1, 2) with hi > lo");
}
#[test]
fn wrong_type_end_ns_with_time_filter_is_merge_error() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
Field::new("end_ns", DataType::Utf8, false), ]));
let uid_data: Vec<u8> = [0u8; 16].to_vec();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["test"])),
StdArc::new(StringArray::from(vec!["tracing"])),
StdArc::new(Int64Array::from(vec![100i64])),
StdArc::new(StringArray::from(vec!["not_a_number"])),
],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(Some(50), Some(200));
let result = accum.merge_spans_part(buf);
assert!(
result.is_err(),
"wrong-type end_ns with time filter must be a merge error"
);
}
#[test]
fn null_end_ns_with_time_filter_is_part_error() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
Field::new("end_ns", DataType::Int64, true), ]));
let uid_data: Vec<u8> = [0u8; 16].to_vec();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["test"])),
StdArc::new(StringArray::from(vec!["tracing"])),
StdArc::new(Int64Array::from(vec![100i64])),
StdArc::new(Int64Array::from(vec![None::<i64>])), ],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(None, None);
assert!(accum.merge_spans_part(buf.clone()).is_ok());
let mut accum = SpanStatsAccum::new(Some(50), Some(200));
let result = accum.merge_spans_part(buf);
assert!(
result.is_err(),
"null end_ns with time filter must be a part error"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("null end_ns"),
"error message must mention null end_ns: got {msg}"
);
}
#[test]
fn negative_end_ns_with_time_filter_is_part_error() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
Field::new("end_ns", DataType::Int64, false),
]));
let uid_data: Vec<u8> = [0u8; 16].to_vec();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["test"])),
StdArc::new(StringArray::from(vec!["tracing"])),
StdArc::new(Int64Array::from(vec![100i64])),
StdArc::new(Int64Array::from(vec![-500i64])), ],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(Some(50), Some(200));
let result = accum.merge_spans_part(buf);
assert!(
result.is_err(),
"negative end_ns with time filter must be a part error"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("negative end_ns"),
"error message must mention negative end_ns: got {msg}"
);
}
#[test]
fn absent_span_type_uid_is_ignored_but_wrong_type_is_error() {
use arrow::array::{Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
fn write_part(
schema: StdArc<Schema>,
columns: Vec<StdArc<dyn arrow::array::Array>>,
) -> Vec<u8> {
let batch = RecordBatch::try_new(StdArc::clone(&schema), columns).unwrap();
let mut data = Vec::new();
let mut writer = ArrowWriter::try_new(&mut data, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
data
}
let absent_schema = StdArc::new(Schema::new(vec![
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
]));
let absent = write_part(
absent_schema,
vec![
StdArc::new(StringArray::from(vec!["legacy"])),
StdArc::new(StringArray::from(vec!["tracing"])),
StdArc::new(Int64Array::from(vec![10_i64])),
],
);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(absent).unwrap();
assert!(accum.snapshot().span_types.is_empty());
let wrong_type_schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::Utf8, false),
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
]));
let wrong_type = write_part(
wrong_type_schema,
vec![
StdArc::new(StringArray::from(vec!["not-binary"])),
StdArc::new(StringArray::from(vec!["bad"])),
StdArc::new(StringArray::from(vec!["tracing"])),
StdArc::new(Int64Array::from(vec![10_i64])),
],
);
let error = accum.merge_spans_part(wrong_type).unwrap_err().to_string();
assert!(error.contains("span_type_uid column has wrong type"));
}
#[test]
fn wrong_width_uid_is_merge_error() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(8), false), Field::new("name", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
]));
let uid_data: Vec<u8> = [0u8; 8].to_vec(); let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["test"])),
StdArc::new(Int64Array::from(vec![100i64])),
],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(None, None);
let result = accum.merge_spans_part(buf);
assert!(
result.is_err(),
"wrong-width span_type_uid must be a merge error"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("wrong width"),
"error message must mention wrong width: got {msg}"
);
}
#[test]
fn stream_coverage_seed_failed_parts_excluded_from_folded() {
let mut accum = SpanStatsAccum::new(None, None);
let mut errors = FoldErrors::default();
let valid_data = make_spans_parquet(&[(100, 200, 100, "A", "h1", true)]);
let mut folded = std::collections::HashSet::new();
assert!(accum.merge_spans_part(valid_data).is_ok());
folded.insert("leaf_a".to_string());
errors.record("seed", "leaf_b.bin: connection refused");
let bad_data = b"not valid parquet".to_vec();
let merge_result = accum.merge_spans_part(bad_data);
assert!(merge_result.is_err());
errors.record("seed", &merge_result.unwrap_err().to_string());
assert_eq!(folded.len(), 1);
assert!(folded.contains("leaf_a"));
assert_eq!(errors.count, 2);
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
assert_eq!(snap.span_types[0].count, 1);
}
#[test]
fn stream_coverage_fold_phase_success_only() {
let mut accum = SpanStatsAccum::new(None, None);
let mut errors = FoldErrors::default();
let mut folded = std::collections::HashSet::new();
let valid = make_spans_parquet(&[(100, 200, 100, "B", "h2", true)]);
assert!(accum.merge_spans_part(valid).is_ok());
folded.insert("leaf_good".to_string());
let bad = b"corrupt parquet bytes".to_vec();
let merge_result = accum.merge_spans_part(bad);
assert!(merge_result.is_err());
errors.record(
"leaf_bad",
&format!("decode/merge: {}", merge_result.unwrap_err()),
);
errors.record("leaf_failed", "fold error: access denied");
assert_eq!(folded.len(), 1);
assert!(folded.contains("leaf_good"));
assert_eq!(errors.count, 2);
let coverage = Coverage {
files_matched: 3,
files_folded: 1, folded_set_id: Some("leaf-good".to_string()),
target_folded_set_id: None,
fold_work_cap: 3,
samples_folded: accum.total_instances(),
total_bytes: 30_000,
hosts_matched: 2,
hosts_folded: 1,
fold_errors: errors.count,
fold_error_sample: errors.sample.clone(),
};
assert_eq!(coverage.files_folded, 1);
assert_eq!(coverage.fold_errors, 2);
assert_eq!(coverage.samples_folded, 1);
}
#[test]
fn attribute_keys_overflow_on_wire_json() {
let attrs: Vec<(String, String)> = (0..55)
.map(|i| (format!("key_{i:03}"), format!("val_{i}")))
.collect();
let data = make_spans_parquet_with_attrs(&[(100, 200, 100, "A", "h1", true, attrs)]);
let mut accum = SpanStatsAccum::new(None, None);
accum.merge_spans_part(data).unwrap();
let snap = accum.snapshot();
assert_eq!(snap.span_types.len(), 1);
let st = &snap.span_types[0];
assert!(st.attribute_keys_overflow);
let json = serde_json::to_value(st).expect("serialization must succeed");
let obj = json.as_object().expect("must be JSON object");
let overflow_val = obj
.get("attribute_keys_overflow")
.expect("attribute_keys_overflow must be in JSON");
assert_eq!(
overflow_val,
&serde_json::Value::Bool(true),
"attribute_keys_overflow must be true on wire"
);
let facets = obj
.get("attribute_facets")
.and_then(|v| v.as_array())
.expect("attribute_facets must be a JSON array");
assert_eq!(
facets.len(),
MAX_ATTRIBUTE_KEYS,
"only MAX_ATTRIBUTE_KEYS facets serialized"
);
let resp_json =
serde_json::to_string(&snap).expect("SpanStatsResponse serialization must succeed");
let resp: serde_json::Value =
serde_json::from_str(&resp_json).expect("round-trip must parse");
let types = resp["span_types"].as_array().unwrap();
assert_eq!(types.len(), 1);
assert_eq!(types[0]["attribute_keys_overflow"], true);
}
#[test]
fn max_duration_histogram_bucket_is_valid() {
let mut accum =
SpanTypeAccum::new("tracing".to_string(), "max".to_string(), None, None, None);
accum.record(i64::MAX, None);
let stats = accum.to_stats(&[7; 16]);
assert_eq!(stats.histogram.len(), 1);
let bucket = &stats.histogram[0];
assert_eq!(bucket.hi_ns, i64::MAX);
assert_eq!(bucket.lo_ns, i64::MAX - 1);
assert!(bucket.hi_ns > bucket.lo_ns);
}
#[test]
fn malformed_later_row_does_not_partially_mutate_accumulator() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
Field::new("end_ns", DataType::Int64, true),
]));
let uid_a = [1u8; 16];
let uid_b = [2u8; 16];
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(
vec![uid_a.as_slice(), uid_b.as_slice()].into_iter(),
)
.unwrap(),
),
StdArc::new(StringArray::from(vec!["valid-first", "invalid-second"])),
StdArc::new(StringArray::from(vec!["tracing", "tracing"])),
StdArc::new(Int64Array::from(vec![10i64, 20i64])),
StdArc::new(Int64Array::from(vec![Some(100i64), None])),
],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(Some(0), Some(1_000));
let (phases, result) = accum.merge_spans_part_timed(buf);
assert!(result.is_err());
assert_eq!(phases.rows_materialized, 1);
assert_eq!(phases.attribute_entries, 0);
assert_eq!(
phases.parse,
phases.reader_setup + phases.batch_decode + phases.row_materialize
);
assert_eq!(accum.total_instances(), 0);
assert!(accum.types.is_empty(), "failed part must commit no rows");
}
#[test]
fn missing_kind_column_is_merge_error() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
]));
let uid_data: Vec<u8> = [0u8; 16].to_vec();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["test"])),
StdArc::new(Int64Array::from(vec![100i64])),
],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(None, None);
let result = accum.merge_spans_part(buf);
assert!(result.is_err(), "missing kind column must be a merge error");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("kind"),
"error message must mention kind: got {msg}"
);
assert_eq!(accum.total_instances(), 0);
}
#[test]
fn missing_name_column_is_merge_error() {
use arrow::array::{FixedSizeBinaryArray, Int64Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("kind", DataType::Utf8, false),
Field::new("elapsed_ns", DataType::Int64, false),
]));
let uid_data: Vec<u8> = [0u8; 16].to_vec();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["tracing"])),
StdArc::new(Int64Array::from(vec![100i64])),
],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(None, None);
let result = accum.merge_spans_part(buf);
assert!(result.is_err(), "missing name column must be a merge error");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("name"),
"error message must mention name: got {msg}"
);
assert_eq!(accum.total_instances(), 0);
}
#[test]
fn missing_elapsed_column_is_merge_error() {
use arrow::array::{FixedSizeBinaryArray, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc as StdArc;
let schema = StdArc::new(Schema::new(vec![
Field::new("span_type_uid", DataType::FixedSizeBinary(16), false),
Field::new("name", DataType::Utf8, false),
Field::new("kind", DataType::Utf8, false),
]));
let uid_data: Vec<u8> = [0u8; 16].to_vec();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
StdArc::new(
FixedSizeBinaryArray::try_from_iter(vec![uid_data.as_slice()].into_iter())
.unwrap(),
),
StdArc::new(StringArray::from(vec!["test"])),
StdArc::new(StringArray::from(vec!["tracing"])),
],
)
.unwrap();
let mut buf = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let mut accum = SpanStatsAccum::new(None, None);
let result = accum.merge_spans_part(buf);
assert!(
result.is_err(),
"missing elapsed_ns column must be a merge error"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("elapsed_ns"),
"error message must mention elapsed_ns: got {msg}"
);
assert_eq!(accum.total_instances(), 0);
}
#[test]
fn phase_durations_and_counters_on_successful_parse() {
let data = make_spans_parquet_with_attrs(&[
(
100,
200,
100,
"A",
"h1",
true,
vec![
("key1".to_string(), "v1".to_string()),
("key2".to_string(), "v2".to_string()),
],
),
(300, 400, 100, "B", "h1", true, vec![]),
(
500,
600,
100,
"C",
"h1",
true,
vec![("k".to_string(), "v".to_string())],
),
]);
let data_len = data.len() as u64;
let mut accum = SpanStatsAccum::new(None, None);
let (phases, result) = accum.merge_spans_part_timed(data);
result.unwrap();
let sub_sum = phases.reader_setup + phases.batch_decode + phases.row_materialize;
assert_eq!(phases.parse, sub_sum);
assert_eq!(phases.parquet_bytes, data_len);
assert!(phases.record_batches_decoded >= 1);
assert_eq!(phases.rows_materialized, 3);
assert_eq!(phases.attribute_entries, 3);
}
#[test]
fn phase_durations_recorded_on_parse_failure() {
let garbage = b"definitely not a parquet file".to_vec();
let garbage_len = garbage.len() as u64;
let mut accum = SpanStatsAccum::new(None, None);
let (phases, result) = accum.merge_spans_part_timed(garbage);
assert!(result.is_err());
assert_eq!(phases.parquet_bytes, garbage_len);
assert_eq!(phases.parse, phases.reader_setup);
assert_eq!(phases.record_batches_decoded, 0);
assert_eq!(phases.rows_materialized, 0);
assert_eq!(phases.attribute_entries, 0);
}
#[test]
fn phase_durations_accumulate_across_parts() {
let part_a = make_spans_parquet(&[(100, 200, 100, "A", "h1", true)]);
let part_b = make_spans_parquet(&[
(200, 300, 100, "B", "h1", true),
(300, 400, 100, "C", "h1", true),
]);
let mut accum = SpanStatsAccum::new(None, None);
let (phases_a, _) = accum.merge_spans_part_timed(part_a);
let (phases_b, _) = accum.merge_spans_part_timed(part_b);
let mut total = SpanStatsPhaseDurations::default();
total += phases_a;
total += phases_b;
assert_eq!(total.rows_materialized, 3);
assert!(total.record_batches_decoded >= 2);
let sub_sum = total.reader_setup + total.batch_decode + total.row_materialize;
assert_eq!(total.parse, sub_sum);
assert!(total.parquet_bytes > 0);
}
#[test]
fn time_filtered_rows_excluded_from_counter() {
let data = make_spans_parquet(&[
(10, 50, 40, "A", "h1", true), (10, 150, 140, "A", "h1", true), (10, 250, 240, "A", "h1", true), ]);
let mut accum = SpanStatsAccum::new(Some(100), Some(200));
let (phases, result) = accum.merge_spans_part_timed(data);
result.unwrap();
assert_eq!(
phases.rows_materialized, 1,
"only time-included rows counted"
);
}
}