From 6290ef5647572456f3bfa22710c515f313d634e1 Mon Sep 17 00:00:00 2001 From: Lucas Pimentel Date: Thu, 2 Jul 2026 18:04:45 -0400 Subject: [PATCH 1/2] fix(traces): avoid panic on empty /v0.6/stats payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty msgpack map request body deserializes to a stats payload with no buckets, so indexing stats[0] to stamp the start time panicked with "index out of bounds". Guard the empty case and return 202 Accepted as a no-op instead. Adds a regression test. 🤖 --- bottlecap/src/traces/stats_processor.rs | 39 ++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/bottlecap/src/traces/stats_processor.rs b/bottlecap/src/traces/stats_processor.rs index 32bd76792..60f7eb26d 100644 --- a/bottlecap/src/traces/stats_processor.rs +++ b/bottlecap/src/traces/stats_processor.rs @@ -78,12 +78,20 @@ impl StatsProcessor for ServerlessStatsProcessor { } }; + // An empty stats payload (e.g. an empty msgpack map) has nothing to buffer, so + // treat it as a harmless no-op instead of indexing into an empty vec (which panics). + let Some(first_bucket) = stats.stats.first_mut() else { + debug!("Received empty trace stats payload; nothing to aggregate."); + return Ok((StatusCode::ACCEPTED, "Empty stats payload; nothing to aggregate.") + .into_response()); + }; + let start = SystemTime::now(); let timestamp = start .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); - stats.stats[0].start = if let Ok(result) = u64::try_from(timestamp) { + first_bucket.start = if let Ok(result) = u64::try_from(timestamp) { result } else { let error_msg = "Error converting timestamp to u64"; @@ -109,3 +117,32 @@ impl StatsProcessor for ServerlessStatsProcessor { } } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use tokio::sync::mpsc; + + #[tokio::test] + async fn empty_stats_payload_does_not_panic() { + // An empty msgpack map (0x80) deserializes to a ClientStatsPayload with an empty + // `stats` vec. Indexing `stats.stats[0]` used to panic here; assert we return 202 + // Accepted instead and buffer nothing. + let req = Request::builder() + .body(Body::from(vec![0x80u8])) + .expect("failed to build request"); + + let (tx, mut rx) = mpsc::channel(1); + let response = ServerlessStatsProcessor {} + .process_stats(req, tx) + .await + .expect("process_stats returned an error"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + rx.try_recv().is_err(), + "no stats payload should be buffered for an empty request" + ); + } +} From 4ff0d099d7637e7784e700c7aa751c18cfecdf9b Mon Sep 17 00:00:00 2001 From: Lucas Pimentel Date: Thu, 2 Jul 2026 18:04:47 -0400 Subject: [PATCH 2/2] style: apply cargo fmt to stats_processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 --- bottlecap/src/traces/stats_processor.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bottlecap/src/traces/stats_processor.rs b/bottlecap/src/traces/stats_processor.rs index 60f7eb26d..91bb9e37a 100644 --- a/bottlecap/src/traces/stats_processor.rs +++ b/bottlecap/src/traces/stats_processor.rs @@ -82,7 +82,10 @@ impl StatsProcessor for ServerlessStatsProcessor { // treat it as a harmless no-op instead of indexing into an empty vec (which panics). let Some(first_bucket) = stats.stats.first_mut() else { debug!("Received empty trace stats payload; nothing to aggregate."); - return Ok((StatusCode::ACCEPTED, "Empty stats payload; nothing to aggregate.") + return Ok(( + StatusCode::ACCEPTED, + "Empty stats payload; nothing to aggregate.", + ) .into_response()); };