1use std::{
6 sync::Arc,
7 time::{Duration, SystemTime, UNIX_EPOCH},
8};
9
10use agent_data_plane_config::domains;
11use async_trait::async_trait;
12use datadog_agent_apm::semantics::SemanticRegistryProvider;
13use saluki_core::{
14 accounting::{MemoryBounds, MemoryBoundsBuilder},
15 components::{transforms::*, BuildContext},
16 data_model::{
17 event::{
18 trace::{AttributeValue, Trace},
19 trace_stats::{ClientStatsPayload, TraceStats},
20 Event, EventType,
21 },
22 origin::OriginTagCardinality,
23 tags::TagSet,
24 },
25 topology::OutputDefinition,
26};
27use saluki_env::{
28 host::providers::BoxedHostProvider, workload::EntityId, EnvironmentProvider, HostProvider, WorkloadProvider,
29};
30use saluki_error::{ErrorContext as _, GenericError};
31use stringtheory::MetaString;
32use tokio::{select, time::interval};
33use tracing::{debug, error};
34
35use crate::common::otlp::util::extract_container_tags_from_attributes_map;
36use crate::config::AgentEnv;
37
38mod aggregation;
39pub(crate) use self::aggregation::{process_tags_hash, PayloadAggregationKey};
40
41mod peer_ip_quantize;
42
43mod peer_tags;
44
45mod span_concentrator;
46pub(crate) use self::span_concentrator::{InfraTags, SpanConcentrator};
47
48mod statsraw;
49
50mod weight;
51use self::weight::weight;
52
53const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(10);
55
56const TAG_PROCESS_TAGS: &str = "_dd.tags.process";
58
59const MAX_STATS_GROUPS_PER_EVENT: usize = 4000;
61
62pub struct ApmStatsTransformConfiguration {
67 compute_stats_by_span_kind: bool,
68 peer_tags_aggregation: bool,
69 peer_tags: Vec<MetaString>,
70 agent_env: AgentEnv,
71 default_hostname: Option<String>,
72 workload_provider: Option<Arc<dyn WorkloadProvider + Send + Sync>>,
73 semantic_registry: SemanticRegistryProvider,
74}
75
76impl ApmStatsTransformConfiguration {
77 pub fn from_configuration(config: &domains::traces::Domain, agent_env: AgentEnv) -> Self {
81 Self {
82 compute_stats_by_span_kind: config.compute_stats_by_span_kind,
83 peer_tags_aggregation: config.peer_tags_aggregation,
84 peer_tags: config.peer_tags.iter().cloned().map(MetaString::from).collect(),
85 agent_env,
86 default_hostname: None,
87 workload_provider: None,
88 semantic_registry: SemanticRegistryProvider::default(),
89 }
90 }
91
92 pub async fn with_environment_provider<E>(mut self, env_provider: E) -> Result<Self, GenericError>
94 where
95 E: EnvironmentProvider<Host = BoxedHostProvider>,
96 {
97 let hostname = env_provider.host().get_hostname().await?;
98 self.default_hostname = Some(hostname);
99 Ok(self)
100 }
101
102 pub fn with_workload_provider<W>(mut self, workload_provider: W) -> Self
106 where
107 W: WorkloadProvider + Send + Sync + 'static,
108 {
109 self.workload_provider = Some(Arc::new(workload_provider));
110 self
111 }
112
113 pub fn with_semantic_registry(mut self, semantic_registry: SemanticRegistryProvider) -> Self {
121 self.semantic_registry = semantic_registry;
122 self
123 }
124}
125
126#[async_trait]
127impl TransformBuilder for ApmStatsTransformConfiguration {
128 async fn build(&self, _context: BuildContext) -> Result<Box<dyn Transform + Send>, GenericError> {
129 let agent_hostname = MetaString::from(self.default_hostname.clone().unwrap_or_default());
130 let concentrator = SpanConcentrator::new(
131 self.compute_stats_by_span_kind,
132 self.peer_tags_aggregation,
133 &self.peer_tags,
134 self.semantic_registry.clone(),
135 now_nanos(),
136 );
137
138 Ok(Box::new(ApmStats {
139 concentrator,
140 flush_interval: DEFAULT_FLUSH_INTERVAL,
141 agent_env: self.agent_env.clone().into(),
142 agent_hostname,
143 workload_provider: self.workload_provider.clone(),
144 }))
145 }
146
147 fn input_event_type(&self) -> EventType {
148 EventType::Trace
149 }
150
151 fn outputs(&self) -> &[OutputDefinition<EventType>] {
152 static OUTPUTS: &[OutputDefinition<EventType>] = &[OutputDefinition::default_output(EventType::TraceStats)];
153 OUTPUTS
154 }
155}
156
157impl MemoryBounds for ApmStatsTransformConfiguration {
158 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
159 builder.minimum().with_single_value::<ApmStats>("component struct");
160 }
162}
163
164struct ApmStats {
165 concentrator: SpanConcentrator,
166 flush_interval: Duration,
167 agent_env: MetaString,
168 agent_hostname: MetaString,
169 workload_provider: Option<Arc<dyn WorkloadProvider + Send + Sync>>,
170}
171
172impl ApmStats {
173 fn process_trace(&mut self, trace: &Trace) {
174 let root_span = trace
175 .spans()
176 .iter()
177 .find(|s| s.parent_id() == 0)
178 .or_else(|| trace.spans().first());
179
180 let trace_weight = root_span.map(weight).unwrap_or(1.0);
181
182 let process_tags = extract_process_tags(trace);
183
184 let payload_key = self.build_payload_key(trace, &process_tags);
185 let infra_tags = self.build_infra_tags(trace, &process_tags);
186
187 let origin = trace
188 .spans()
189 .first()
190 .and_then(|s| s.attributes.get("_dd.origin").and_then(AttributeValue::as_string))
191 .map(|s| s.as_ref())
192 .unwrap_or("");
193
194 for span in trace.spans() {
195 if let Some(stat_span) = self.concentrator.new_stat_span_from_span(span) {
196 self.concentrator
197 .add_span(&stat_span, trace_weight, &payload_key, &infra_tags, origin);
198 }
199 }
200 }
201
202 fn build_infra_tags(&self, trace: &Trace, process_tags: &str) -> InfraTags {
203 let container_id = trace.payload.container_id.clone();
204 let mut container_tags = if container_id.is_empty() {
205 TagSet::default()
206 } else {
207 let mut tags = TagSet::default();
208 extract_container_tags_from_attributes_map(&trace.attributes, &mut tags);
209 tags
210 };
211
212 if !container_id.is_empty() {
213 if let Some(workload_provider) = &self.workload_provider {
214 let entity_id = EntityId::Container(container_id.clone());
215 if let Some(tags) = workload_provider.get_tags_for_entity(&entity_id, OriginTagCardinality::Low) {
216 container_tags.merge_shared(&tags);
217 }
218 }
219 }
220
221 InfraTags::new(container_id, container_tags, process_tags)
222 }
223
224 fn build_payload_key(&self, trace: &Trace, process_tags: &str) -> PayloadAggregationKey {
225 let root_span = trace
226 .spans()
227 .iter()
228 .find(|s| s.parent_id() == 0)
229 .or_else(|| trace.spans().first());
230
231 let env = root_span
234 .and_then(|s| {
235 s.attributes
236 .get("env")
237 .and_then(AttributeValue::as_string)
238 .filter(|s| !s.is_empty())
239 })
240 .cloned()
241 .or_else(|| {
242 trace.spans().iter().find_map(|s| {
243 s.attributes
244 .get("env")
245 .and_then(AttributeValue::as_string)
246 .filter(|s| !s.is_empty())
247 .cloned()
248 })
249 })
250 .unwrap_or_else(|| {
251 if !trace.payload.env.is_empty() {
252 trace.payload.env.clone()
253 } else {
254 self.agent_env.clone()
255 }
256 });
257
258 let hostname = root_span
259 .and_then(|s| {
260 s.attributes
261 .get("_dd.hostname")
262 .and_then(AttributeValue::as_string)
263 .filter(|s| !s.is_empty())
264 })
265 .cloned()
266 .unwrap_or_else(|| {
267 if !trace.payload.hostname.is_empty() {
268 trace.payload.hostname.clone()
269 } else {
270 self.agent_hostname.clone()
271 }
272 });
273
274 let version = root_span
278 .and_then(|s| {
279 s.attributes
280 .get("version")
281 .and_then(AttributeValue::as_string)
282 .filter(|s| !s.is_empty())
283 })
284 .cloned()
285 .unwrap_or_else(|| trace.payload.app_version.clone());
286
287 let container_id = if !trace.payload.container_id.is_empty() {
288 trace.payload.container_id.clone()
289 } else {
290 root_span
291 .and_then(|s| s.attributes.get("_dd.container_id").and_then(AttributeValue::as_string))
292 .cloned()
293 .unwrap_or_default()
294 };
295
296 let git_commit_sha = root_span
297 .and_then(|s| {
298 s.attributes
299 .get("_dd.git.commit.sha")
300 .and_then(AttributeValue::as_string)
301 .filter(|s| !s.is_empty())
302 })
303 .cloned()
304 .unwrap_or_default();
305
306 let image_tag = root_span
307 .and_then(|s| {
308 s.attributes
309 .get("_dd.image_tag")
310 .and_then(AttributeValue::as_string)
311 .filter(|s| !s.is_empty())
312 })
313 .cloned()
314 .unwrap_or_default();
315
316 let lang = if !trace.payload.language_name.is_empty() {
317 trace.payload.language_name.clone()
318 } else {
319 root_span
320 .and_then(|s| s.attributes.get("language").and_then(AttributeValue::as_string))
321 .cloned()
322 .unwrap_or_default()
323 };
324
325 PayloadAggregationKey {
326 env,
327 hostname,
328 version,
329 container_id,
330 git_commit_sha,
331 image_tag,
332 lang,
333 process_tags_hash: process_tags_hash(process_tags),
334 }
335 }
336}
337
338fn split_into_trace_stats(client_payloads: Vec<ClientStatsPayload>, max_entries_per_event: usize) -> Vec<TraceStats> {
344 if client_payloads.is_empty() {
345 return Vec::new();
346 }
347
348 let total_grouped_entries = client_payloads
351 .iter()
352 .map(|p| p.stats().iter().map(|b| b.stats().len()).sum::<usize>())
353 .sum::<usize>();
354 if total_grouped_entries <= max_entries_per_event {
355 return vec![TraceStats::new(client_payloads)];
356 }
357
358 let mut events = Vec::new();
359 let mut current_client_payloads = Vec::new();
360 let mut current_event_len = 0;
361
362 for mut client_payload in client_payloads {
363 let client_payload_len = client_payload.stats().iter().map(|b| b.stats().len()).sum::<usize>();
365 if current_event_len + client_payload_len <= max_entries_per_event {
366 current_client_payloads.push(client_payload);
367 current_event_len += client_payload_len;
368 continue;
369 }
370
371 let mut current_client_stats_buckets = Vec::new();
378 for mut client_stats_bucket in client_payload.take_stats() {
379 let bucket_len = client_stats_bucket.stats().len();
380 if current_event_len + bucket_len <= max_entries_per_event {
382 current_client_stats_buckets.push(client_stats_bucket);
383 current_event_len += bucket_len;
384 continue;
385 }
386
387 let mut bucket_entries = client_stats_bucket.take_stats();
390 while current_event_len + bucket_entries.len() > max_entries_per_event {
391 let split_amount = max_entries_per_event - current_event_len;
395 let split_point = bucket_entries.len() - split_amount;
396 let split_entries = bucket_entries.split_off(split_point);
397
398 let split_bucket = client_stats_bucket.clone().with_stats(split_entries);
402 current_client_stats_buckets.push(split_bucket);
403
404 let split_client_payload = client_payload
405 .clone()
406 .with_stats(std::mem::take(&mut current_client_stats_buckets));
407 current_client_payloads.push(split_client_payload);
408
409 events.push(TraceStats::new(std::mem::take(&mut current_client_payloads)));
410 current_event_len = 0;
411 }
412
413 if !bucket_entries.is_empty() {
416 current_event_len += bucket_entries.len();
417 current_client_stats_buckets.push(client_stats_bucket.with_stats(bucket_entries));
418 }
419 }
420
421 if !current_client_stats_buckets.is_empty() {
423 current_client_payloads.push(client_payload.with_stats(current_client_stats_buckets));
424 }
425 }
426
427 if !current_client_payloads.is_empty() {
429 events.push(TraceStats::new(current_client_payloads));
430 }
431
432 events
433}
434
435#[async_trait]
436impl Transform for ApmStats {
437 async fn run(mut self: Box<Self>, mut context: TransformContext) -> Result<(), GenericError> {
438 let mut health = context.take_health_handle();
439
440 let mut flush_ticker = interval(self.flush_interval);
441 flush_ticker.tick().await;
442
443 let mut final_flush = false;
444
445 health.mark_ready();
446 debug!("APM Stats transform started.");
447
448 loop {
449 select! {
450 _ = health.live() => continue,
451
452 _ = flush_ticker.tick() => {
453 let stats_payloads = self.concentrator.flush(now_nanos(), final_flush);
454 if !stats_payloads.is_empty() {
455 debug!(stats_payloads = stats_payloads.len(), "Flushing APM stats.");
456
457 let events = split_into_trace_stats(stats_payloads, MAX_STATS_GROUPS_PER_EVENT);
458 let dispatcher = context.dispatcher().buffered()
459 .error_context("Default output should be available.")?;
460
461 if let Err(e) = dispatcher.send_all(events.into_iter().map(Event::TraceStats)).await {
462 error!(error = %e, "Failed to dispatch events.");
463 }
464 }
465
466 if final_flush {
467 debug!("Final APM stats flush complete.");
468 break;
469 }
470 },
471
472 maybe_events = context.events().next(), if !final_flush => {
473 match maybe_events {
474 Some(events) => {
475 self.concentrator.refresh_registry();
477 for event in events {
478 if let Event::Trace(trace) = event {
479 self.process_trace(&trace);
480 }
481 }
482 },
483 None => {
484 final_flush = true;
487 flush_ticker.reset_immediately();
488 debug!("APM Stats transform stopping, triggering final flush...");
489 }
490 }
491 },
492 }
493 }
494
495 debug!("APM Stats transform stopped.");
496 Ok(())
497 }
498}
499
500fn now_nanos() -> u64 {
502 SystemTime::now()
503 .duration_since(UNIX_EPOCH)
504 .unwrap_or_default()
505 .as_nanos() as u64
506}
507
508fn extract_process_tags(trace: &Trace) -> MetaString {
510 let root_span = trace
511 .spans()
512 .iter()
513 .find(|s| s.parent_id() == 0)
514 .or_else(|| trace.spans().first());
515 if let Some(span) = root_span {
516 if let Some(tags) = span
517 .attributes
518 .get(TAG_PROCESS_TAGS)
519 .and_then(AttributeValue::as_string)
520 .filter(|s| !s.is_empty())
521 {
522 return tags.clone();
523 }
524 }
525 if let Some(AttributeValue::String(tags)) = trace.attributes.get(TAG_PROCESS_TAGS) {
526 if !tags.is_empty() {
527 return tags.clone();
528 }
529 }
530 MetaString::empty()
531}
532
533#[cfg(test)]
534mod tests {
535 use datadog_agent_apm::semantics::test_util::TestRegistryPublisher;
536 use datadog_agent_apm::semantics::{test_util::registry_json, Registry};
537 use proptest::prelude::*;
538 use saluki_common::collections::FastHashMap;
539 use saluki_core::components::test_util::TestComponentDriver;
540 use saluki_core::data_model::event::trace::{AttributeValue, Span, TraceIngestInfo};
541 use saluki_core::data_model::event::trace_stats::ClientGroupedStats;
542 use saluki_core::data_model::event::trace_stats::ClientStatsBucket;
543
544 use super::aggregation::BUCKET_DURATION_NS;
545 use super::span_concentrator::METRIC_PARTIAL_VERSION;
546 use super::*;
547
548 fn align_ts(ts: u64, bsize: u64) -> u64 {
550 ts - ts % bsize
551 }
552
553 #[allow(clippy::too_many_arguments)]
555 fn test_span(
556 aligned_now: u64, span_id: u64, parent_id: u64, duration: u64, bucket_offset: u64, service: &str,
557 resource: &str, error: i32, meta: Option<FastHashMap<MetaString, MetaString>>,
558 metrics: Option<FastHashMap<MetaString, f64>>,
559 ) -> Span {
560 let bucket_start = aligned_now - bucket_offset * BUCKET_DURATION_NS;
561 let start = bucket_start - duration;
562
563 let mut attrs: FastHashMap<MetaString, AttributeValue> = FastHashMap::default();
564 if let Some(m) = meta {
565 attrs.extend(m.into_iter().map(|(k, v)| (k, AttributeValue::String(v))));
566 }
567 if let Some(m) = metrics {
568 attrs.extend(m.into_iter().map(|(k, v)| (k, AttributeValue::Float(v))));
569 }
570 Span::new(
571 service, "query", resource, "db", span_id, parent_id, start, duration, error,
572 )
573 .with_attributes(attrs)
574 }
575
576 fn make_test_span(service: &str, name: &str, resource: &str) -> Span {
578 let mut attrs = FastHashMap::default();
579 attrs.insert(MetaString::from("_dd.measured"), AttributeValue::Float(1.0));
580 Span::new(service, name, resource, "web", 1, 0, 1000000000, 100000000, 0).with_attributes(attrs)
581 }
582
583 fn make_top_level_span(
585 aligned_now: u64, span_id: u64, duration: u64, bucket_offset: u64, service: &str, resource: &str, error: i32,
586 meta: Option<FastHashMap<MetaString, MetaString>>,
587 ) -> Span {
588 let mut metrics = FastHashMap::default();
589 metrics.insert(MetaString::from("_top_level"), 1.0);
590 test_span(
591 aligned_now,
592 span_id,
593 0,
594 duration,
595 bucket_offset,
596 service,
597 resource,
598 error,
599 meta,
600 Some(metrics),
601 )
602 }
603
604 #[tokio::test]
605 async fn run_refreshes_peer_tag_keys_before_each_buffer() {
606 use saluki_core::components::test_util::TestComponentDriver;
607
608 let now = now_nanos();
609 let (publisher, provider) = TestRegistryPublisher::new();
610 let config = domains::traces::Domain {
611 compute_stats_by_span_kind: true,
612 peer_tags_aggregation: true,
613 ..Default::default()
614 };
615 let builder =
616 ApmStatsTransformConfiguration::from_configuration(&config, AgentEnv::resolve(None, None, &[], &[]))
617 .with_semantic_registry(provider);
618 let (control, mut outputs) = TestComponentDriver::transform(builder)
619 .await
620 .expect("transform should build");
621
622 let remapped = registry_json(
624 r#"{"peer.service": {"fallbacks": [
625 {"name": "custom.remote.service", "provider": "otel", "type": "string"}]}}"#,
626 );
627 publisher.accept_registry(
628 Registry::from_json(&remapped).expect("remapped registry should parse"),
629 "remapped",
630 );
631
632 let mut meta = FastHashMap::default();
633 meta.insert(MetaString::from("span.kind"), MetaString::from("client"));
634 meta.insert(MetaString::from("custom.remote.service"), MetaString::from("billing"));
635 let span = make_top_level_span(
636 align_ts(now, BUCKET_DURATION_NS),
637 1,
638 100,
639 0,
640 "svc",
641 "resource",
642 0,
643 Some(meta),
644 );
645 control
646 .send_event(Event::Trace(Trace::new(vec![span], TraceIngestInfo::Datadog)))
647 .await;
648
649 control.shutdown().await.expect("run should succeed");
651
652 let mut peer_tags = Vec::new();
653 for event in outputs.default_output().collect_events().await {
654 let Event::TraceStats(stats) = event else { continue };
655 for payload in stats.stats() {
656 for bucket in payload.stats() {
657 for grouped in bucket.stats() {
658 peer_tags.extend(grouped.peer_tags().iter().map(|tag| tag.to_string()));
659 }
660 }
661 }
662 }
663 assert_eq!(peer_tags, ["custom.remote.service:billing"]);
664 }
665
666 #[tokio::test]
667 async fn traces_without_an_env_aggregate_under_the_agent_env() {
668 let agent_env = AgentEnv::resolve(None, Some("Prod"), &[], &[]);
669 let config = ApmStatsTransformConfiguration::from_configuration(&domains::traces::Domain::default(), agent_env);
670 let (control, mut outputs) = TestComponentDriver::transform(config)
671 .await
672 .expect("the transform should start");
673
674 let span = make_top_level_span(
675 align_ts(now_nanos(), BUCKET_DURATION_NS),
676 1,
677 100,
678 0,
679 "svc",
680 "resource",
681 0,
682 None,
683 );
684 control
685 .send_event(Event::Trace(Trace::new(vec![span], TraceIngestInfo::Datadog)))
686 .await;
687 control.shutdown().await.expect("the transform should stop cleanly");
689
690 let mut envs = Vec::new();
691 for event in outputs.default_output().collect_events().await {
692 let Event::TraceStats(stats) = event else {
693 panic!("unexpected event: {event:?}");
694 };
695 envs.extend(stats.stats().iter().map(|payload| payload.env().to_string()));
696 }
697 assert_eq!(envs, ["prod"]);
698 }
699
700 #[test]
701 fn test_process_trace_creates_stats() {
702 let now = now_nanos();
703
704 let concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
705 let mut transform = ApmStats {
706 concentrator,
707 flush_interval: DEFAULT_FLUSH_INTERVAL,
708 agent_env: MetaString::from("none"),
709 agent_hostname: MetaString::default(),
710 workload_provider: None,
711 };
712
713 let span = make_test_span("test-service", "test-operation", "test-resource");
714 let trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
715
716 transform.process_trace(&trace);
717
718 let stats = transform.concentrator.flush(now + BUCKET_DURATION_NS * 2, true);
720 assert!(!stats.is_empty(), "Expected stats to be produced");
721 }
722
723 #[test]
724 fn test_weight_applied_to_stats() {
725 let now = now_nanos();
726
727 let concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
728 let mut transform = ApmStats {
729 concentrator,
730 flush_interval: DEFAULT_FLUSH_INTERVAL,
731 agent_env: MetaString::from("none"),
732 agent_hostname: MetaString::default(),
733 workload_provider: None,
734 };
735
736 let mut attrs = FastHashMap::default();
738 attrs.insert(MetaString::from("_dd.measured"), AttributeValue::Float(1.0));
739 attrs.insert(MetaString::from("_sample_rate"), AttributeValue::Float(0.5));
740
741 let span = Span::new(
742 "test-service",
743 "test-op",
744 "test-resource",
745 "web",
746 1,
747 0,
748 now,
749 100000000,
750 0,
751 )
752 .with_attributes(attrs);
753
754 let trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
755 transform.process_trace(&trace);
756
757 let stats = transform.concentrator.flush(now + BUCKET_DURATION_NS * 2, true);
758 assert!(!stats.is_empty());
759
760 let bucket = &stats[0].stats()[0];
762 let grouped = &bucket.stats()[0];
763 assert!(grouped.hits() >= 1, "Expected weighted hits");
765 }
766
767 #[test]
768 fn test_force_flush() {
769 let now = now_nanos();
770 let aligned_now = align_ts(now, BUCKET_DURATION_NS);
771
772 let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
773
774 let span = make_top_level_span(aligned_now, 1, 50, 5, "A1", "resource1", 0, None);
776 let trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
777
778 let payload_key = PayloadAggregationKey {
779 env: MetaString::from("test"),
780 hostname: MetaString::from("host"),
781 ..Default::default()
782 };
783 let infra_tags = InfraTags::default();
784
785 for span in trace.spans() {
786 if let Some(stat_span) = concentrator.new_stat_span_from_span(span) {
787 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
788 }
789 }
790
791 let ts: u64 = 0;
793
794 let stats = concentrator.flush(ts, false);
796 assert!(stats.is_empty(), "Non-force flush should return empty");
797
798 let stats = concentrator.flush(ts, true);
800 assert!(!stats.is_empty(), "Force flush should return stats");
801 assert_eq!(stats[0].stats().len(), 1, "Should have 1 bucket");
802 }
803
804 #[test]
805 fn test_ignores_partial_spans() {
806 let now = now_nanos();
807 let aligned_now = align_ts(now, BUCKET_DURATION_NS);
808
809 let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
810
811 let mut metrics = FastHashMap::default();
813 metrics.insert(MetaString::from("_top_level"), 1.0);
814 metrics.insert(MetaString::from(METRIC_PARTIAL_VERSION), 830604.0);
815
816 let span = test_span(aligned_now, 1, 0, 50, 5, "A1", "resource1", 0, None, Some(metrics));
817 let trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
818
819 let payload_key = PayloadAggregationKey {
820 env: MetaString::from("test"),
821 hostname: MetaString::from("tracer-hostname"),
822 ..Default::default()
823 };
824 let infra_tags = InfraTags::default();
825
826 for span in trace.spans() {
827 if let Some(stat_span) = concentrator.new_stat_span_from_span(span) {
828 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
829 }
830 }
831
832 let stats = concentrator.flush(now + BUCKET_DURATION_NS * 3, true);
834 assert!(stats.is_empty(), "Partial spans should be ignored");
835 }
836
837 #[test]
838 fn test_concentrator_stats_totals() {
839 let now = now_nanos();
840 let aligned_now = align_ts(now, BUCKET_DURATION_NS);
841
842 let oldest_ts = aligned_now - 2 * BUCKET_DURATION_NS;
844 let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), oldest_ts);
845
846 let spans = vec![
848 make_top_level_span(aligned_now, 1, 50, 5, "A1", "resource1", 0, None),
849 make_top_level_span(aligned_now, 2, 40, 4, "A1", "resource1", 0, None),
850 make_top_level_span(aligned_now, 3, 30, 3, "A1", "resource1", 0, None),
851 make_top_level_span(aligned_now, 4, 20, 2, "A1", "resource1", 0, None),
852 make_top_level_span(aligned_now, 5, 10, 1, "A1", "resource1", 0, None),
853 make_top_level_span(aligned_now, 6, 1, 0, "A1", "resource1", 0, None),
854 ];
855
856 let payload_key = PayloadAggregationKey {
857 env: MetaString::from("none"),
858 ..Default::default()
859 };
860 let infra_tags = InfraTags::default();
861
862 for span in &spans {
863 if let Some(stat_span) = concentrator.new_stat_span_from_span(span) {
864 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
865 }
866 }
867
868 let all_stats = concentrator.flush(now + BUCKET_DURATION_NS * 10, true);
870
871 let mut total_duration: u64 = 0;
872 let mut total_hits: u64 = 0;
873 let mut total_errors: u64 = 0;
874 let mut total_top_level_hits: u64 = 0;
875
876 for payload in &all_stats {
877 for bucket in payload.stats() {
878 for grouped in bucket.stats() {
879 total_duration += grouped.duration();
880 total_hits += grouped.hits();
881 total_errors += grouped.errors();
882 total_top_level_hits += grouped.top_level_hits();
883 }
884 }
885 }
886
887 assert_eq!(total_duration, 50 + 40 + 30 + 20 + 10 + 1, "Wrong total duration");
888 assert_eq!(total_hits, 6, "Wrong total hits");
889 assert_eq!(total_top_level_hits, 6, "Wrong total top level hits");
890 assert_eq!(total_errors, 0, "Wrong total errors");
891 }
892
893 #[test]
894 fn test_root_tag() {
895 let now = now_nanos();
896 let aligned_now = align_ts(now, BUCKET_DURATION_NS);
897
898 let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
899
900 let mut root_metrics = FastHashMap::default();
902 root_metrics.insert(MetaString::from("_top_level"), 1.0);
903 let root_span = test_span(
904 aligned_now,
905 1,
906 0,
907 40,
908 10,
909 "A1",
910 "resource1",
911 0,
912 None,
913 Some(root_metrics),
914 );
915
916 let mut top_level_metrics = FastHashMap::default();
918 top_level_metrics.insert(MetaString::from("_top_level"), 1.0);
919 let top_level_span = test_span(
920 aligned_now,
921 4,
922 1000,
923 10,
924 10,
925 "A1",
926 "resource1",
927 0,
928 None,
929 Some(top_level_metrics),
930 );
931
932 let mut client_meta = FastHashMap::default();
934 client_meta.insert(MetaString::from("span.kind"), MetaString::from("client"));
935 let client_span = test_span(aligned_now, 3, 2, 20, 10, "A1", "resource1", 0, Some(client_meta), None);
936
937 let spans = vec![root_span, top_level_span, client_span];
938
939 let payload_key = PayloadAggregationKey {
940 env: MetaString::from("none"),
941 ..Default::default()
942 };
943 let infra_tags = InfraTags::default();
944
945 for span in &spans {
946 if let Some(stat_span) = concentrator.new_stat_span_from_span(span) {
947 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
948 }
949 }
950
951 let stats = concentrator.flush(now + BUCKET_DURATION_NS * 20, true);
952 assert!(!stats.is_empty(), "Should have stats");
953
954 let mut total_grouped = 0;
956 let mut root_count = 0;
957 let mut non_root_count = 0;
958
959 for payload in &stats {
960 for bucket in payload.stats() {
961 for grouped in bucket.stats() {
962 total_grouped += 1;
963 match grouped.is_trace_root() {
964 Some(true) => root_count += 1,
965 Some(false) => non_root_count += 1,
966 None => {}
967 }
968 }
969 }
970 }
971
972 assert_eq!(total_grouped, 3, "Expected 3 grouped stats");
977 assert_eq!(root_count, 1, "Expected 1 root span");
978 assert_eq!(non_root_count, 2, "Expected 2 non-root spans");
979 }
980
981 #[test]
982 fn test_compute_stats_through_span_kind_check() {
983 let now = now_nanos();
984
985 {
987 let mut concentrator = SpanConcentrator::new(false, true, &[], Default::default(), now);
988
989 let mut attrs = FastHashMap::default();
990 attrs.insert(MetaString::from("_top_level"), AttributeValue::Float(1.0));
991 let span = Span::new("myservice", "query", "GET /users", "web", 1, 0, now, 500, 0).with_attributes(attrs);
992
993 let payload_key = PayloadAggregationKey {
994 env: MetaString::from("test"),
995 ..Default::default()
996 };
997 let infra_tags = InfraTags::default();
998
999 if let Some(stat_span) = concentrator.new_stat_span_from_span(&span) {
1000 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
1001 }
1002
1003 let mut client_attrs = FastHashMap::default();
1006 client_attrs.insert(
1007 MetaString::from("span.kind"),
1008 AttributeValue::String(MetaString::from("client")),
1009 );
1010 let client_span = Span::new("myservice", "postgres.query", "SELECT ...", "db", 2, 1, now, 75, 0)
1011 .with_attributes(client_attrs);
1012
1013 if let Some(stat_span) = concentrator.new_stat_span_from_span(&client_span) {
1014 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
1015 }
1016
1017 let stats = concentrator.flush(now + BUCKET_DURATION_NS * 3, true);
1018
1019 let mut count = 0;
1020 for payload in &stats {
1021 for bucket in payload.stats() {
1022 count += bucket.stats().len();
1023 }
1024 }
1025
1026 assert_eq!(count, 1, "Expected 1 stat when span kind check disabled");
1028 }
1029
1030 {
1032 let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
1033
1034 let mut attrs = FastHashMap::default();
1035 attrs.insert(MetaString::from("_top_level"), AttributeValue::Float(1.0));
1036 let span = Span::new("myservice", "query", "GET /users", "web", 1, 0, now, 500, 0).with_attributes(attrs);
1037
1038 let payload_key = PayloadAggregationKey {
1039 env: MetaString::from("test"),
1040 ..Default::default()
1041 };
1042 let infra_tags = InfraTags::default();
1043
1044 if let Some(stat_span) = concentrator.new_stat_span_from_span(&span) {
1045 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
1046 }
1047
1048 let mut client_attrs = FastHashMap::default();
1051 client_attrs.insert(
1052 MetaString::from("span.kind"),
1053 AttributeValue::String(MetaString::from("client")),
1054 );
1055 let client_span = Span::new("myservice", "postgres.query", "SELECT ...", "db", 2, 1, now, 75, 0)
1056 .with_attributes(client_attrs);
1057
1058 if let Some(stat_span) = concentrator.new_stat_span_from_span(&client_span) {
1059 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
1060 }
1061
1062 let stats = concentrator.flush(now + BUCKET_DURATION_NS * 3, true);
1063
1064 let mut count = 0;
1065 for payload in &stats {
1066 for bucket in payload.stats() {
1067 count += bucket.stats().len();
1068 }
1069 }
1070
1071 assert_eq!(count, 2, "Expected 2 stats when span kind check enabled");
1073 }
1074 }
1075
1076 #[test]
1077 fn test_peer_tags() {
1078 let now = now_nanos();
1079
1080 {
1082 let mut concentrator = SpanConcentrator::new(true, false, &[], Default::default(), now);
1083
1084 let mut attrs = FastHashMap::default();
1085 attrs.insert(
1086 MetaString::from("span.kind"),
1087 AttributeValue::String(MetaString::from("client")),
1088 );
1089 attrs.insert(
1090 MetaString::from("db.instance"),
1091 AttributeValue::String(MetaString::from("i-1234")),
1092 );
1093 attrs.insert(
1094 MetaString::from("db.system"),
1095 AttributeValue::String(MetaString::from("postgres")),
1096 );
1097 attrs.insert(MetaString::from("_dd.measured"), AttributeValue::Float(1.0));
1098 let client_span =
1099 Span::new("myservice", "postgres.query", "SELECT ...", "db", 2, 1, now, 75, 0).with_attributes(attrs);
1100
1101 let payload_key = PayloadAggregationKey {
1102 env: MetaString::from("test"),
1103 ..Default::default()
1104 };
1105 let infra_tags = InfraTags::default();
1106
1107 if let Some(stat_span) = concentrator.new_stat_span_from_span(&client_span) {
1108 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
1109 }
1110
1111 let stats = concentrator.flush(now + BUCKET_DURATION_NS * 3, true);
1112
1113 for payload in &stats {
1115 for bucket in payload.stats() {
1116 for grouped in bucket.stats() {
1117 assert!(
1118 grouped.peer_tags().is_empty(),
1119 "Peer tags should be empty when peer_tags_aggregation is false"
1120 );
1121 }
1122 }
1123 }
1124 }
1125
1126 {
1128 let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
1130
1131 let mut attrs = FastHashMap::default();
1132 attrs.insert(
1133 MetaString::from("span.kind"),
1134 AttributeValue::String(MetaString::from("client")),
1135 );
1136 attrs.insert(
1137 MetaString::from("db.instance"),
1138 AttributeValue::String(MetaString::from("i-1234")),
1139 );
1140 attrs.insert(
1141 MetaString::from("db.system"),
1142 AttributeValue::String(MetaString::from("postgres")),
1143 );
1144 attrs.insert(MetaString::from("_dd.measured"), AttributeValue::Float(1.0));
1145 let client_span =
1146 Span::new("myservice", "postgres.query", "SELECT ...", "db", 2, 1, now, 75, 0).with_attributes(attrs);
1147
1148 let payload_key = PayloadAggregationKey {
1149 env: MetaString::from("test"),
1150 ..Default::default()
1151 };
1152 let infra_tags = InfraTags::default();
1153
1154 if let Some(stat_span) = concentrator.new_stat_span_from_span(&client_span) {
1155 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
1156 }
1157
1158 let stats = concentrator.flush(now + BUCKET_DURATION_NS * 3, true);
1159
1160 let mut found_client_with_peer_tags = false;
1162 for payload in &stats {
1163 for bucket in payload.stats() {
1164 for grouped in bucket.stats() {
1165 if grouped.resource() == "SELECT ..." {
1166 assert!(!grouped.peer_tags().is_empty(), "Client span should have peer tags");
1167 let peer_tags: Vec<&str> = grouped.peer_tags().iter().map(|s| s.as_ref()).collect();
1169 assert!(
1170 peer_tags.iter().any(|t| t.starts_with("db.instance:")),
1171 "Should have db.instance peer tag"
1172 );
1173 assert!(
1174 peer_tags.iter().any(|t| t.starts_with("db.system:")),
1175 "Should have db.system peer tag"
1176 );
1177 found_client_with_peer_tags = true;
1178 }
1179 }
1180 }
1181 }
1182 assert!(
1183 found_client_with_peer_tags,
1184 "Should have found client span with peer tags"
1185 );
1186 }
1187 }
1188
1189 #[test]
1190 fn test_concentrator_oldest_ts() {
1191 let now = now_nanos();
1192 let aligned_now = align_ts(now, BUCKET_DURATION_NS);
1193
1194 {
1196 let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
1198
1199 let spans = vec![
1201 make_top_level_span(aligned_now, 1, 50, 5, "A1", "resource1", 0, None),
1202 make_top_level_span(aligned_now, 2, 40, 4, "A1", "resource1", 0, None),
1203 make_top_level_span(aligned_now, 3, 30, 3, "A1", "resource1", 0, None),
1204 make_top_level_span(aligned_now, 4, 20, 2, "A1", "resource1", 0, None),
1205 make_top_level_span(aligned_now, 5, 10, 1, "A1", "resource1", 0, None),
1206 make_top_level_span(aligned_now, 6, 1, 0, "A1", "resource1", 0, None),
1207 ];
1208
1209 let payload_key = PayloadAggregationKey {
1210 env: MetaString::from("none"),
1211 ..Default::default()
1212 };
1213 let infra_tags = InfraTags::default();
1214
1215 for span in &spans {
1216 if let Some(stat_span) = concentrator.new_stat_span_from_span(span) {
1217 concentrator.add_span(&stat_span, 1.0, &payload_key, &infra_tags, "");
1218 }
1219 }
1220
1221 let mut flush_time = now;
1223 let buffer_len = 2; for _ in 0..buffer_len {
1226 let stats = concentrator.flush(flush_time, false);
1227 assert!(stats.is_empty(), "Should not flush before buffer fills");
1228 flush_time += BUCKET_DURATION_NS;
1229 }
1230
1231 let stats = concentrator.flush(flush_time, false);
1233 assert!(!stats.is_empty(), "Should flush after buffer fills");
1234
1235 let mut total_hits: u64 = 0;
1237 let mut total_duration: u64 = 0;
1238 for payload in &stats {
1239 for bucket in payload.stats() {
1240 for grouped in bucket.stats() {
1241 total_hits += grouped.hits();
1242 total_duration += grouped.duration();
1243 }
1244 }
1245 }
1246
1247 assert_eq!(total_hits, 6, "All 6 spans should be counted");
1248 assert_eq!(
1249 total_duration,
1250 50 + 40 + 30 + 20 + 10 + 1,
1251 "Total duration should match"
1252 );
1253 }
1254 }
1255
1256 #[test]
1257 fn test_compute_stats_for_span_kind() {
1258 use super::span_concentrator::compute_stats_for_span_kind;
1259
1260 assert!(compute_stats_for_span_kind("server"));
1262 assert!(compute_stats_for_span_kind("consumer"));
1263 assert!(compute_stats_for_span_kind("client"));
1264 assert!(compute_stats_for_span_kind("producer"));
1265
1266 assert!(compute_stats_for_span_kind("SERVER"));
1268 assert!(compute_stats_for_span_kind("CONSUMER"));
1269 assert!(compute_stats_for_span_kind("CLIENT"));
1270 assert!(compute_stats_for_span_kind("PRODUCER"));
1271
1272 assert!(compute_stats_for_span_kind("SErVER"));
1274 assert!(compute_stats_for_span_kind("COnSUMER"));
1275 assert!(compute_stats_for_span_kind("CLiENT"));
1276 assert!(compute_stats_for_span_kind("PRoDUCER"));
1277
1278 assert!(!compute_stats_for_span_kind("internal"));
1280 assert!(!compute_stats_for_span_kind("INTERNAL"));
1281 assert!(!compute_stats_for_span_kind("INtERNAL"));
1282 assert!(!compute_stats_for_span_kind(""));
1283 }
1284
1285 #[test]
1286 fn test_extract_process_tags() {
1287 {
1289 let span = Span::default();
1290 let trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
1291 let process_tags = extract_process_tags(&trace);
1292 assert!(process_tags.is_empty(), "Should be empty when no _dd.tags.process");
1293 }
1294
1295 {
1297 let mut attrs = FastHashMap::default();
1298 attrs.insert(
1299 MetaString::from(TAG_PROCESS_TAGS),
1300 AttributeValue::String(MetaString::from("a:1,b:2,c:3")),
1301 );
1302 let span = Span::default().with_attributes(attrs);
1303 let trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
1304 let process_tags = extract_process_tags(&trace);
1305 assert_eq!(process_tags, "a:1,b:2,c:3");
1306 }
1307
1308 {
1310 let mut attrs = FastHashMap::default();
1311 attrs.insert(
1312 MetaString::from(TAG_PROCESS_TAGS),
1313 AttributeValue::String(MetaString::from("")),
1314 );
1315 let span = Span::default().with_attributes(attrs);
1316 let trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
1317 let process_tags = extract_process_tags(&trace);
1318 assert!(
1319 process_tags.is_empty(),
1320 "Should be empty when _dd.tags.process is empty string"
1321 );
1322 }
1323
1324 {
1326 let trace = Trace::new(vec![], TraceIngestInfo::Datadog);
1327 let process_tags = extract_process_tags(&trace);
1328 assert!(process_tags.is_empty(), "Should be empty when trace has no spans");
1329 }
1330 }
1331
1332 #[test]
1333 fn test_process_tags_hash_computation() {
1334 use super::aggregation::process_tags_hash;
1335
1336 assert_eq!(process_tags_hash(""), 0);
1338
1339 let hash1 = process_tags_hash("a:1,b:2,c:3");
1341 let hash2 = process_tags_hash("a:1,b:2,c:3");
1342 assert_eq!(hash1, hash2);
1343
1344 let hash3 = process_tags_hash("a:1,b:2");
1346 assert_ne!(hash1, hash3);
1347 }
1348
1349 fn make_grouped_stats(service: &str, resource: &str) -> ClientGroupedStats {
1351 ClientGroupedStats::new(service, "operation", resource)
1352 .with_hits(1)
1353 .with_duration(100)
1354 }
1355
1356 fn make_bucket_with_stats(n: usize) -> ClientStatsBucket {
1358 let stats: Vec<ClientGroupedStats> = (0..n)
1359 .map(|i| make_grouped_stats("service", &format!("resource-{}", i)))
1360 .collect();
1361 ClientStatsBucket::new(1000, 10_000_000_000, stats)
1362 }
1363
1364 fn make_payload_with_buckets(hostname: &str, buckets: Vec<ClientStatsBucket>) -> ClientStatsPayload {
1366 ClientStatsPayload::new(hostname, "test-env", "1.0.0")
1367 .with_stats(buckets)
1368 .with_container_id("container-123")
1369 .with_lang("rust")
1370 }
1371
1372 fn count_grouped_stats(trace_stats: &TraceStats) -> usize {
1374 trace_stats
1375 .stats()
1376 .iter()
1377 .flat_map(|p| p.stats())
1378 .map(|b| b.stats().len())
1379 .sum()
1380 }
1381
1382 #[test]
1383 fn test_split_into_trace_stats_empty_input() {
1384 let result = split_into_trace_stats(vec![], 100);
1385 assert!(result.is_empty());
1386 }
1387
1388 #[test]
1389 fn test_split_into_trace_stats_no_split_needed() {
1390 let bucket = make_bucket_with_stats(50);
1392 let payload = make_payload_with_buckets("host1", vec![bucket]);
1393
1394 let result = split_into_trace_stats(vec![payload], 100);
1395
1396 assert_eq!(result.len(), 1);
1397 assert_eq!(count_grouped_stats(&result[0]), 50);
1398 }
1399
1400 #[test]
1401 fn test_split_into_trace_stats_exact_threshold() {
1402 let bucket = make_bucket_with_stats(100);
1404 let payload = make_payload_with_buckets("host1", vec![bucket]);
1405
1406 let result = split_into_trace_stats(vec![payload], 100);
1407
1408 assert_eq!(result.len(), 1);
1409 assert_eq!(count_grouped_stats(&result[0]), 100);
1410 }
1411
1412 #[test]
1413 fn test_split_into_trace_stats_splits_single_bucket() {
1414 let bucket = make_bucket_with_stats(250);
1416 let payload = make_payload_with_buckets("host1", vec![bucket]);
1417
1418 let result = split_into_trace_stats(vec![payload], 100);
1419
1420 assert_eq!(result.len(), 3);
1421 assert_eq!(count_grouped_stats(&result[0]), 100);
1422 assert_eq!(count_grouped_stats(&result[1]), 100);
1423 assert_eq!(count_grouped_stats(&result[2]), 50);
1424
1425 let total: usize = result.iter().map(count_grouped_stats).sum();
1427 assert_eq!(total, 250);
1428 }
1429
1430 #[test]
1431 fn test_split_into_trace_stats_splits_across_payloads() {
1432 let payload1 = make_payload_with_buckets("host1", vec![make_bucket_with_stats(60)]);
1434 let payload2 = make_payload_with_buckets("host2", vec![make_bucket_with_stats(60)]);
1435
1436 let result = split_into_trace_stats(vec![payload1, payload2], 100);
1437
1438 assert_eq!(result.len(), 2);
1439 assert_eq!(count_grouped_stats(&result[0]), 100);
1441 assert_eq!(count_grouped_stats(&result[1]), 20);
1443 }
1444
1445 #[test]
1446 fn test_split_into_trace_stats_splits_single_payload_multiple_buckets() {
1447 let bucket1 = make_bucket_with_stats(70);
1449 let bucket2 = make_bucket_with_stats(80);
1450 let payload = make_payload_with_buckets("host1", vec![bucket1, bucket2]);
1451
1452 let result = split_into_trace_stats(vec![payload], 100);
1453
1454 assert_eq!(result.len(), 2);
1455 let total: usize = result.iter().map(count_grouped_stats).sum();
1456 assert_eq!(total, 150);
1457 }
1458
1459 #[test]
1460 fn test_split_into_trace_stats_preserves_metadata() {
1461 let bucket = make_bucket_with_stats(250);
1462 let payload = ClientStatsPayload::new("test-host", "prod", "2.0.0")
1463 .with_stats(vec![bucket])
1464 .with_container_id("container-abc")
1465 .with_lang("go")
1466 .with_git_commit_sha("abc123")
1467 .with_image_tag("v1.2.3")
1468 .with_process_tags_hash(12345)
1469 .with_process_tags("tag1,tag2");
1470
1471 let result = split_into_trace_stats(vec![payload], 100);
1472
1473 for trace_stats in &result {
1475 for p in trace_stats.stats() {
1476 assert_eq!(p.hostname(), "test-host");
1477 assert_eq!(p.env(), "prod");
1478 assert_eq!(p.version(), "2.0.0");
1479 assert_eq!(p.container_id(), "container-abc");
1480 assert_eq!(p.lang(), "go");
1481 assert_eq!(p.git_commit_sha(), "abc123");
1482 assert_eq!(p.image_tag(), "v1.2.3");
1483 assert_eq!(p.process_tags_hash(), 12345);
1484 assert_eq!(p.process_tags(), "tag1,tag2");
1485 }
1486 }
1487 }
1488
1489 #[test]
1490 fn test_split_into_trace_stats_preserves_bucket_metadata() {
1491 let stats: Vec<ClientGroupedStats> = (0..150)
1493 .map(|i| make_grouped_stats("svc", &format!("res-{}", i)))
1494 .collect();
1495 let bucket = ClientStatsBucket::new(999_000_000, 10_000_000_000, stats).with_agent_time_shift(42);
1496 let payload = make_payload_with_buckets("host1", vec![bucket]);
1497
1498 let result = split_into_trace_stats(vec![payload], 100);
1499
1500 for trace_stats in &result {
1502 for p in trace_stats.stats() {
1503 for b in p.stats() {
1504 assert_eq!(b.start(), 999_000_000);
1505 assert_eq!(b.duration(), 10_000_000_000);
1506 assert_eq!(b.agent_time_shift(), 42);
1507 }
1508 }
1509 }
1510 }
1511
1512 #[test]
1513 fn test_split_into_trace_stats_handles_empty_bucket() {
1514 let empty_bucket = ClientStatsBucket::new(1000, 10_000_000_000, vec![]);
1515 let payload = make_payload_with_buckets("host1", vec![empty_bucket]);
1516
1517 let result = split_into_trace_stats(vec![payload], 100);
1518
1519 assert_eq!(result.len(), 1);
1520 assert_eq!(count_grouped_stats(&result[0]), 0);
1521 }
1522
1523 #[test]
1524 fn test_split_into_trace_stats_large_split() {
1525 let bucket = make_bucket_with_stats(10_000);
1527 let payload = make_payload_with_buckets("host1", vec![bucket]);
1528
1529 let result = split_into_trace_stats(vec![payload], 4000);
1530
1531 assert_eq!(result.len(), 3);
1532 assert_eq!(count_grouped_stats(&result[0]), 4000);
1533 assert_eq!(count_grouped_stats(&result[1]), 4000);
1534 assert_eq!(count_grouped_stats(&result[2]), 2000);
1535 }
1536
1537 fn arb_grouped_stats() -> impl Strategy<Value = ClientGroupedStats> {
1541 (0..100u64, 0..1000u64).prop_map(|(hits, duration)| {
1542 ClientGroupedStats::new("service", "operation", "resource")
1543 .with_hits(hits)
1544 .with_duration(duration)
1545 })
1546 }
1547
1548 fn arb_bucket(max_stats_per_bucket: usize) -> impl Strategy<Value = ClientStatsBucket> {
1550 proptest::collection::vec(arb_grouped_stats(), 0..=max_stats_per_bucket)
1551 .prop_map(|stats| ClientStatsBucket::new(1000, 10_000_000_000, stats))
1552 }
1553
1554 fn arb_payload(max_buckets: usize, max_stats_per_bucket: usize) -> impl Strategy<Value = ClientStatsPayload> {
1556 proptest::collection::vec(arb_bucket(max_stats_per_bucket), 1..=max_buckets)
1557 .prop_map(|buckets| ClientStatsPayload::new("host", "env", "1.0.0").with_stats(buckets))
1558 }
1559
1560 fn arb_split_inputs() -> impl Strategy<Value = (Vec<ClientStatsPayload>, usize)> {
1568 let payloads_strategy = proptest::collection::vec(arb_payload(5, 500), 1..=10);
1569 let max_entries_strategy = 1..=1000usize;
1570
1571 (payloads_strategy, max_entries_strategy)
1572 }
1573
1574 #[test]
1579 fn test_version_span_beats_resource_for_otlp() {
1580 let now = now_nanos();
1581 let concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
1582 let transform = ApmStats {
1583 concentrator,
1584 flush_interval: DEFAULT_FLUSH_INTERVAL,
1585 agent_env: MetaString::default(),
1586 agent_hostname: MetaString::default(),
1587 workload_provider: None,
1588 };
1589
1590 let mut attrs = FastHashMap::default();
1593 attrs.insert(
1594 MetaString::from("version"),
1595 AttributeValue::String(MetaString::from("span-v2")),
1596 );
1597 let root_span = Span::new("svc", "op", "res", "web", 1, 0, now, 1_000_000, 0).with_attributes(attrs);
1598
1599 let mut trace = Trace::new(vec![root_span], TraceIngestInfo::Datadog);
1600 trace.payload.app_version = MetaString::from("resource-v1");
1602
1603 let key = transform.build_payload_key(&trace, "");
1604
1605 assert_eq!(
1608 key.version.as_ref(),
1609 "span-v2",
1610 "span-level version must take precedence over resource-level payload.app_version for OTLP traces"
1611 );
1612 }
1613
1614 proptest! {
1615 #[test]
1616 #[cfg_attr(miri, ignore)]
1617 fn property_test_split_respects_max_entries((payloads, max_entries_per_event) in arb_split_inputs()) {
1618 let input_total: usize = payloads.iter().flat_map(|p| p.stats()).map(|b| b.stats().len()).sum();
1619
1620 let result = split_into_trace_stats(payloads, max_entries_per_event);
1621
1622 for trace_stats in &result {
1624 let count = count_grouped_stats(trace_stats);
1625 prop_assert!(
1626 count <= max_entries_per_event,
1627 "TraceStats has {} grouped stats, exceeds max of {}",
1628 count,
1629 max_entries_per_event
1630 );
1631 }
1632
1633 let output_total: usize = result.iter().map(count_grouped_stats).sum();
1635 prop_assert_eq!(input_total, output_total, "Total stats count should be preserved");
1636 }
1637 }
1638}