Skip to main content

saluki_components/transforms/apm_stats/
mod.rs

1//! APM Stats transform.
2//!
3//! Aggregates traces into time-bucketed statistics, producing `TraceStats` events.
4
5use 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
53/// Default flush interval for the APM stats transform.
54const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(10);
55
56/// Tag key for process tags in span meta.
57const TAG_PROCESS_TAGS: &str = "_dd.tags.process";
58
59/// Maximum number of `ClientGroupedStats` entries per `TraceStats` event.
60const MAX_STATS_GROUPS_PER_EVENT: usize = 4000;
61
62/// APM Stats transform configuration.
63///
64/// Aggregates incoming `Trace` events into time-bucketed statistics, emitting
65/// `TraceStats` events.
66pub 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    /// Creates a new `ApmStatsTransformConfiguration` from the resolved traces configuration.
78    ///
79    /// Traces whose spans and payload carry no environment are aggregated under `agent_env`.
80    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    /// Sets the default hostname using the environment provider.
93    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    /// Sets the workload provider.
103    ///
104    /// Defaults to unset.
105    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    /// Sets the provider of semantic mappings used to choose peer tag names for stats grouping.
114    ///
115    /// Peer tags describe the remote service or resource a span calls. The transform combines their names from the
116    /// registry with configured custom peer tags. With a subscribed provider, it checks for changed mappings before
117    /// each input event buffer; all spans in that buffer use the same key set.
118    ///
119    /// Defaults to embedded mappings without remote updates.
120    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        // TODO: Think about everything we need to account for here.
161    }
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        // env fallback order mirrors get_trace_env (Go agent pkg/trace/traceutil/trace.go:GetEnv):
232        // root span attrs → all span attrs → trace.payload.env (ADP extension) → agent_env.
233        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        // Version resolution mirrors Go agent pkg/trace/version/version.go:GetAppVersionFromTrace:
275        // root span meta["version"] (set by otel_span_to_dd_span with span-over-resource precedence)
276        // takes priority, falling back to the resource-level payload.app_version.
277        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
338/// Splits stats payloads into multiple `TraceStats` events, each containing at most `max_entries_per_event` grouped
339/// entries.
340///
341/// This function won't attempt to collapse/pack payloads to optimize the number of events generated: there will always
342/// be at least as many output client payloads as there are input client payloads.
343fn 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    // If the total number of grouped stats entries is below the specified threshold, then we don't do any splitting or
349    // collapsing or anything.
350    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        // If the current payload can fit entirely in the current event, then collect it as-is.
364        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        // Consume all the stats buckets from the current client payload, and set ourselves up to go through each
372        // bucket, splitting them as necessary.
373        //
374        // We basically iterate until we find a bucket where adding its entries would cause us to exceed our threshold,
375        // and then we split that bucket, creating a new client payload in the process, and keep repeating that until
376        // we've exhausted all buckets for our starting client payload.
377        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 this bucket can fit in the current event, then collect it as-is.
381            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            // We have to split this bucket. We take the grouped entries it has, and we subdivide those into a new
388            // client bucket, or buckets in order to ensure we don't exceed our threshold.
389            let mut bucket_entries = client_stats_bucket.take_stats();
390            while current_event_len + bucket_entries.len() > max_entries_per_event {
391                // We calculate the split point this way because the returned vector from `split_off` is referenced
392                // against the end of the vector, so if we have 100 items, and we only want 20, we need to split at
393                // index 80 (100 - 20 == 80).
394                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                // Create a new "split" bucket based on the current bucket (`client_stats_bucket`) but containing only
399                // the split-off entries. This will feed into the current client payload (`client_payload`) which we'll
400                // finalize and add to the current event before starting a new event.
401                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 we have any leftover entries from the bucket after splitting it up, put them back in the current bucket
414            // and add that bucket to the current client payload.
415            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 we have buckets for this client payload (whether we split or not), add them back to the current client payload.
422        if !current_client_stats_buckets.is_empty() {
423            current_client_payloads.push(client_payload.with_stats(current_client_stats_buckets));
424        }
425    }
426
427    // Stick the remaining entries into a final `TraceStats` event, if any.
428    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                            // Use one peer tag key set for the whole buffer.
476                            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                            // We've reached the end of our input stream, so mark ourselves for a final flush and reset the
485                            // interval so it ticks immediately on the next loop iteration.
486                            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
500/// Returns the current time as nanoseconds since Unix epoch.
501fn now_nanos() -> u64 {
502    SystemTime::now()
503        .duration_since(UNIX_EPOCH)
504        .unwrap_or_default()
505        .as_nanos() as u64
506}
507
508/// Extracts process tags from trace, checking both span and trace attributes.
509fn 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    /// Helper to align timestamp to bucket boundary
549    fn align_ts(ts: u64, bsize: u64) -> u64 {
550        ts - ts % bsize
551    }
552
553    /// Creates a test span with the given parameters.
554    #[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    /// Creates a simple measured span for basic tests
577    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    /// Creates a top-level span (`parent_id` = 0, has `_top_level` metric)
584    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        // The buffer refresh must see a registry published after construction.
623        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        // Closing the input makes the run loop flush and stop.
650        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        // Shutting down flushes every bucket.
688        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        // Flush and verify we got stats
719        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        // Create a span with 0.5 sample rate (weight = 2.0)
737        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        // The hits should be weighted (approximately 2 due to 0.5 sample rate)
761        let bucket = &stats[0].stats()[0];
762        let grouped = &bucket.stats()[0];
763        // With stochastic rounding, hits could be 1 or 2, but with weight 2.0 it should round to 2
764        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        // Add a span
775        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        // ts=0 so that flush always considers buckets not old enough
792        let ts: u64 = 0;
793
794        // Without force flush, should skip the bucket
795        let stats = concentrator.flush(ts, false);
796        assert!(stats.is_empty(), "Non-force flush should return empty");
797
798        // With force flush, should flush buckets regardless of age
799        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        // Create a partial span (has _dd.partial_version metric)
812        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        // Partial spans should be ignored
833        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        // Set oldestTs to allow old buckets
843        let oldest_ts = aligned_now - 2 * BUCKET_DURATION_NS;
844        let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), oldest_ts);
845
846        // Build spans spread over time windows
847        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        // Flush all and collect totals
869        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        // Root span (parent_id = 0, top_level)
901        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        // Non-root but top level span (has _top_level but parent_id != 0)
917        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        // Client span (non-root, non-top level, but has span.kind = client)
933        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        // Count grouped stats - should be split by IsTraceRoot
955        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        // We expect 3 grouped stats:
973        // 1. Root span (is_trace_root = true)
974        // 2. Non-root top-level span (is_trace_root = false)
975        // 3. Client span (is_trace_root = false, span.kind = client)
976        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        // Test with compute_stats_by_span_kind DISABLED
986        {
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            // Client span with span.kind=client but no _top_level or _dd.measured
1004            // Should NOT produce stats when compute_stats_by_span_kind is disabled
1005            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            // When disabled, only top_level span gets stats (client span has no top_level/measured)
1027            assert_eq!(count, 1, "Expected 1 stat when span kind check disabled");
1028        }
1029
1030        // Test with compute_stats_by_span_kind ENABLED
1031        {
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            // Client span with span.kind=client
1049            // SHOULD produce stats when compute_stats_by_span_kind is enabled
1050            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            // When enabled, both spans get stats
1072            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        // Test without peer tags aggregation enabled
1081        {
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            // Without peer tags aggregation, peer_tags should be empty
1114            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        // Test with peer tags aggregation enabled
1127        {
1128            // Note: BASE_PEER_TAGS already includes db.instance and db.system
1129            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            // With peer tags aggregation, client span should have peer_tags
1161            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                            // Check that peer tags contain db.instance and db.system
1168                            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        // Test "cold" scenario - all spans in the past should end up in current bucket
1195        {
1196            // Start concentrator at current time (cold start)
1197            let mut concentrator = SpanConcentrator::new(true, true, &[], Default::default(), now);
1198
1199            // Build spans spread over many time windows (all in the past)
1200            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            // Flush multiple times without force
1222            let mut flush_time = now;
1223            let buffer_len = 2; // DEFAULT_BUFFER_LEN
1224
1225            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            // After buffer_len flushes, should get aggregated stats
1232            let stats = concentrator.flush(flush_time, false);
1233            assert!(!stats.is_empty(), "Should flush after buffer fills");
1234
1235            // All spans should be aggregated into one bucket (oldest bucket aggregates old data)
1236            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        // Valid span kinds (case insensitive)
1261        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        // Uppercase
1267        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        // Mixed case
1273        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        // Invalid span kinds
1279        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        // Test with no process tags
1288        {
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        // Test with process tags in first span meta
1296        {
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        // Test with empty process tags
1309        {
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        // Test with empty trace
1325        {
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        // Empty string should return 0
1337        assert_eq!(process_tags_hash(""), 0);
1338
1339        // Same tags should produce same hash
1340        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        // Different tags should produce different hash
1345        let hash3 = process_tags_hash("a:1,b:2");
1346        assert_ne!(hash1, hash3);
1347    }
1348
1349    // Helper to create a ClientGroupedStats for testing
1350    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    // Helper to create a ClientStatsBucket with N stats
1357    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    // Helper to create a ClientStatsPayload with specified buckets
1365    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    // Helper to count total ClientGroupedStats across all payloads in a TraceStats
1373    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        // 50 stats with max 100 - no split needed
1391        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        // Exactly 100 stats with max 100 - no split needed
1403        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        // 250 stats in one bucket with max 100 - should split into 3 TraceStats
1415        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        // Total should be preserved
1426        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        // Two payloads with 60 stats each, max 100 - should combine into 2 TraceStats
1433        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        // First TraceStats: 60 from payload1 + 40 from payload2 = 100
1440        assert_eq!(count_grouped_stats(&result[0]), 100);
1441        // Second TraceStats: remaining 20 from payload2
1442        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        // One payload with two buckets (70 + 80 = 150 stats), max 100
1448        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        // All split payloads should have the same metadata
1474        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        // Create a bucket with specific start/duration/time_shift
1492        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        // All split buckets should have the same start/duration/time_shift
1501        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        // 10,000 stats with max 4000 - should produce 3 TraceStats
1526        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    // Property test strategies
1538
1539    /// Strategy to generate arbitrary ClientGroupedStats.
1540    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    /// Strategy to generate a bucket with 0..=max_stats_per_bucket grouped stats.
1549    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    /// Strategy to generate a payload with 1..=max_buckets buckets.
1555    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    /// Strategy to generate test inputs for the split function.
1561    ///
1562    /// Parameters:
1563    /// - `num_payloads`: 1..=10
1564    /// - `num_buckets_per_payload`: 1..=5
1565    /// - `num_stats_per_bucket`: 0..=500
1566    /// - `max_entries_per_event`: 1..=1000 (never zero)
1567    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    // Mirrors Go agent behavior: for OTLP traces the version on the root span's meta["version"]
1575    // (set with span-over-resource precedence by otel_span_to_dd_span) should win over the
1576    // resource-level version carried in payload.app_version.
1577    // See: pkg/trace/version/version.go:GetAppVersionFromTrace and pkg/trace/api/otlp.go.
1578    #[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        // Root span carries a span-level version attribute (set by otel_span_to_dd_span with
1591        // span-over-resource precedence).
1592        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        // Simulate OTLP resource extraction: payload.app_version comes from resource attrs only.
1601        trace.payload.app_version = MetaString::from("resource-v1");
1602
1603        let key = transform.build_payload_key(&trace, "");
1604
1605        // The Go agent resolves version from root_span.meta["version"] first, which carries
1606        // span-over-resource precedence. The span attribute ("span-v2") must win.
1607        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            // Property 1: No TraceStats should exceed max_entries_per_event
1623            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            // Property 2: Total stats should be preserved
1634            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}