Skip to main content

libdd_trace_stats/
stats_exporter.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    sync::{
6        atomic::{AtomicU64, Ordering},
7        Arc, Mutex,
8    },
9    time,
10};
11
12use crate::span_concentrator::{FlushableConcentrator, SpanConcentrator};
13use async_trait::async_trait;
14use futures::stream::FuturesUnordered;
15use futures::StreamExt as _;
16use libdd_capabilities::{HttpClientCapability, MaybeSend, SleepCapability};
17use libdd_common::{Endpoint, MutexExt};
18use libdd_shared_runtime::Worker;
19use libdd_trace_protobuf::pb;
20use libdd_trace_utils::send_with_retry::{
21    send_with_retry, CompressionStrategy, RetryBackoffType, RetryStrategy,
22};
23use libdd_trace_utils::trace_utils::TracerHeaderTags;
24use libdd_trace_utils::tracer_metadata::TracerMetadata;
25use std::fmt::Debug;
26use tracing::error;
27
28pub const STATS_ENDPOINT_PATH: &str = "/v0.6/stats";
29
30/// Health metric name for the number of spans collapsed.
31pub const COLLAPSED_SPANS_HEALTH_METRIC: &str = "datadog.tracer.stats.collapsed_spans";
32
33/// Telemetry metric name for the number of spans collapsed.
34pub const COLLAPSED_SPANS_TELEMETRY_METRIC: &str = "tracers.stats_collapsed_spans";
35
36/// Metadata needed by the stats exporter to annotate payloads and HTTP requests.
37#[derive(Clone, Default, Debug)]
38pub struct StatsMetadata {
39    pub hostname: String,
40    pub env: String,
41    pub app_version: String,
42    pub runtime_id: String,
43    pub language: String,
44    pub lang_version: String,
45    pub lang_interpreter: String,
46    pub lang_vendor: String,
47    pub tracer_version: String,
48    pub git_commit_sha: String,
49    pub process_tags: String,
50    pub service: String,
51}
52
53impl<'a> From<&'a StatsMetadata> for TracerHeaderTags<'a> {
54    fn from(m: &'a StatsMetadata) -> TracerHeaderTags<'a> {
55        TracerHeaderTags {
56            lang: &m.language,
57            lang_version: &m.lang_version,
58            lang_interpreter: &m.lang_interpreter,
59            lang_vendor: &m.lang_vendor,
60            tracer_version: &m.tracer_version,
61            ..Default::default()
62        }
63    }
64}
65
66impl From<TracerMetadata> for StatsMetadata {
67    fn from(m: TracerMetadata) -> StatsMetadata {
68        StatsMetadata {
69            hostname: m.hostname,
70            env: m.env,
71            app_version: m.app_version,
72            runtime_id: m.runtime_id,
73            language: m.language,
74            lang_version: m.language_version,
75            lang_interpreter: m.language_interpreter,
76            lang_vendor: m.language_interpreter_vendor,
77            tracer_version: m.tracer_version,
78            git_commit_sha: m.git_commit_sha,
79            process_tags: m.process_tags,
80            service: m.service,
81        }
82    }
83}
84
85/// An exporter that concentrates and sends stats to the agent.
86///
87/// `Cap` is the capabilities bundle (HTTP + sleep). Leaf crates pin it to a
88/// concrete type (`NativeCapabilities` or `WasmCapabilities`).
89#[derive(Debug)]
90pub struct StatsExporter<
91    Cap: HttpClientCapability + SleepCapability + MaybeSend + Sync + 'static,
92    Con: FlushableConcentrator = SpanConcentrator,
93> {
94    flush_interval: time::Duration,
95    concentrator: Arc<Mutex<Con>>,
96    endpoint: Endpoint,
97    meta: StatsMetadata,
98    sequence_id: AtomicU64,
99    capabilities: Cap,
100    #[cfg(feature = "stats-obfuscation")]
101    supported_obfuscation_version: &'static str,
102    /// Optional telemetry handle and context key.
103    #[cfg(feature = "telemetry")]
104    telemetry: Option<(
105        libdd_telemetry::worker::TelemetryWorkerHandle<Cap>,
106        libdd_telemetry::metrics::ContextKey,
107    )>,
108    /// Optional DogStatsD client.
109    #[cfg(feature = "dogstatsd")]
110    dogstatsd: Option<libdd_dogstatsd_client::DogStatsDClient>,
111}
112
113impl<
114        Cap: HttpClientCapability + SleepCapability + MaybeSend + Sync + 'static,
115        Con: FlushableConcentrator,
116    > StatsExporter<Cap, Con>
117{
118    /// Return a new StatsExporter
119    ///
120    /// - `flush_interval` the interval on which the concentrator is flushed
121    /// - `concentrator` an impl of `FlushableConcentrator` storing the stats to be sent to the
122    ///   agent
123    /// - `meta` metadata used in ClientStatsPayload and as headers to send stats to the agent
124    /// - `endpoint` the Endpoint used to send stats to the agent
125    #[allow(clippy::too_many_arguments)]
126    pub fn new(
127        flush_interval: time::Duration,
128        concentrator: Arc<Mutex<Con>>,
129        meta: StatsMetadata,
130        endpoint: Endpoint,
131        capabilities: Cap,
132        #[cfg(feature = "stats-obfuscation")] supported_obfuscation_version: &'static str,
133        #[cfg(feature = "telemetry")] telemetry: Option<
134            libdd_telemetry::worker::TelemetryWorkerHandle<Cap>,
135        >,
136        #[cfg(feature = "dogstatsd")] dogstatsd: Option<libdd_dogstatsd_client::DogStatsDClient>,
137    ) -> Self {
138        #[cfg(feature = "telemetry")]
139        let telemetry = telemetry.map(|handle| {
140            let key = handle.register_metric_context(
141                COLLAPSED_SPANS_TELEMETRY_METRIC.to_string(),
142                vec![],
143                libdd_telemetry::data::metrics::MetricType::Count,
144                true,
145                libdd_telemetry::data::metrics::MetricNamespace::Tracers,
146            );
147            (handle, key)
148        });
149        Self {
150            flush_interval,
151            concentrator,
152            endpoint,
153            meta,
154            sequence_id: AtomicU64::new(0),
155            capabilities,
156            #[cfg(feature = "stats-obfuscation")]
157            supported_obfuscation_version,
158            #[cfg(feature = "telemetry")]
159            telemetry,
160            #[cfg(feature = "dogstatsd")]
161            dogstatsd,
162        }
163    }
164
165    /// Flush the stats stored in the concentrator and send them
166    ///
167    /// If the stats flushed from the concentrator contain at least one time bucket the stats are
168    /// sent to `self.endpoint`. The stats are serialized as msgpack.
169    ///
170    /// # Errors
171    /// The function will return an error in the following case:
172    /// - The endpoint failed to build
173    /// - The stats payload cannot be serialized as a valid http body
174    /// - The http client failed while sending the request
175    /// - The http status of the response is not 2xx
176    ///
177    /// # Panic
178    /// Will panic if another thread panicked while holding the concentrator lock in which
179    /// case stats cannot be flushed since the concentrator might be corrupted.
180    /// Returns `Ok(true)` if stats were sent, `Ok(false)` if the concentrator had nothing to send.
181    pub async fn send(&self, force_flush: bool) -> anyhow::Result<bool> {
182        let flush = {
183            let mut concentrator = self.concentrator.lock_or_panic();
184            concentrator.flush_buckets(force_flush)
185        };
186
187        #[cfg(feature = "telemetry")]
188        if let Some((handle, key)) = &self.telemetry {
189            if flush.collapsed_spans > 0 {
190                let _ = handle.add_point(
191                    flush.collapsed_spans as f64,
192                    key,
193                    vec![libdd_common::tag!("collapsed_spans", "whole_key")],
194                );
195            }
196            flush.collapsed_fields_metrics.emit_telemetry(handle, key);
197        }
198
199        #[cfg(feature = "dogstatsd")]
200        if let Some(client) = &self.dogstatsd {
201            if flush.collapsed_spans > 0 {
202                client.send(vec![libdd_dogstatsd_client::DogStatsDAction::Count(
203                    COLLAPSED_SPANS_HEALTH_METRIC,
204                    flush.collapsed_spans as i64,
205                    [libdd_common::tag!("collapsed_spans", "whole_key")].iter(),
206                )]);
207            }
208            flush.collapsed_fields_metrics.emit_dogstatsd(client);
209        }
210
211        let futures = FuturesUnordered::new();
212
213        if !flush.obfuscated_buckets.is_empty() {
214            futures.push(self.send_payload(flush.obfuscated_buckets, true));
215        }
216
217        if !flush.unobfuscated_buckets.is_empty() {
218            futures.push(self.send_payload(flush.unobfuscated_buckets, false));
219        }
220
221        let sent_stats = !futures.is_empty();
222
223        futures
224            .collect::<Vec<anyhow::Result<()>>>()
225            .await
226            .into_iter()
227            .collect::<anyhow::Result<()>>()?;
228
229        Ok(sent_stats)
230    }
231
232    /// Encode the given buckets into a stats payload and send it to the agent.
233    ///
234    /// `obfuscated` indicates whether the buckets were obfuscated client-side, in which case the
235    /// `datadog-obfuscation-version` header is added.
236    async fn send_payload(
237        &self,
238        buckets: Vec<pb::ClientStatsBucket>,
239        #[cfg_attr(not(feature = "stats-obfuscation"), allow(unused))] obfuscated: bool,
240    ) -> anyhow::Result<()> {
241        let sequence = self.sequence_id.fetch_add(1, Ordering::Relaxed);
242        let payload = encode_stats_payload(&self.meta, sequence, buckets);
243        let body = rmp_serde::encode::to_vec_named(&payload)?;
244
245        let mut headers: http::HeaderMap = TracerHeaderTags::from(&self.meta).into();
246        headers.insert(
247            http::header::CONTENT_TYPE,
248            libdd_common::header::APPLICATION_MSGPACK,
249        );
250        #[cfg(feature = "stats-obfuscation")]
251        if obfuscated {
252            headers.insert(
253                http::HeaderName::from_static("datadog-obfuscation-version"),
254                http::HeaderValue::from_static(self.supported_obfuscation_version),
255            );
256        }
257
258        let result = send_with_retry(
259            &self.capabilities,
260            &self.endpoint,
261            body,
262            &headers,
263            &RetryStrategy::new(0, 0, RetryBackoffType::Constant, None),
264            CompressionStrategy::None,
265        )
266        .await;
267
268        match result {
269            Ok(_) => Ok(()),
270            Err(err) => {
271                error!(?err, "Error with the StatsExporter when sending stats");
272                anyhow::bail!("Failed to send stats: {err}");
273            }
274        }
275    }
276}
277
278#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
279#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
280impl<
281        Cap: HttpClientCapability + SleepCapability + MaybeSend + Sync + 'static,
282        Con: FlushableConcentrator + Send + Debug,
283    > Worker for StatsExporter<Cap, Con>
284{
285    async fn trigger(&mut self) {
286        self.capabilities.sleep(self.flush_interval).await;
287    }
288
289    /// Flush and send stats on every trigger.
290    async fn run(&mut self) {
291        let _ = self.send(false).await; // bool return ignored by Worker
292    }
293
294    fn reset(&mut self) {
295        let _ = self.concentrator.lock_or_panic().flush_buckets(true);
296        self.sequence_id.store(0, Ordering::Relaxed);
297    }
298
299    async fn shutdown(&mut self) {
300        let _ = self.send(true).await;
301    }
302}
303
304fn encode_stats_payload(
305    meta: &StatsMetadata,
306    sequence: u64,
307    buckets: Vec<pb::ClientStatsBucket>,
308) -> pb::ClientStatsPayload {
309    pb::ClientStatsPayload {
310        hostname: meta.hostname.clone(),
311        env: if meta.env.is_empty() {
312            "unknown-env".to_string()
313        } else {
314            meta.env.clone()
315        },
316        version: meta.app_version.clone(),
317        runtime_id: meta.runtime_id.clone(),
318        sequence,
319        service: meta.service.clone(),
320        stats: buckets,
321        git_commit_sha: meta.git_commit_sha.clone(),
322        process_tags: meta.process_tags.clone(),
323        // These fields will be set by the Agent
324        container_id: String::new(),
325        tags: Vec::new(),
326        agent_aggregation: String::new(),
327        image_tag: String::new(),
328        process_tags_hash: 0,
329        lang: String::new(),
330        tracer_version: String::new(),
331    }
332}
333
334/// Return the stats endpoint url to send stats to the agent at `agent_url`
335pub fn stats_url_from_agent_url(agent_url: &str) -> anyhow::Result<http::Uri> {
336    let mut parts = agent_url.parse::<http::Uri>()?.into_parts();
337    parts.path_and_query = Some(http::uri::PathAndQuery::from_static(STATS_ENDPOINT_PATH));
338    Ok(http::Uri::from_parts(parts)?)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use httpmock::prelude::*;
345    use httpmock::MockServer;
346    use libdd_capabilities_impl::NativeCapabilities;
347    use libdd_shared_runtime::{BlockingRuntime, ForkSafeRuntime, SharedRuntime};
348    use libdd_trace_utils::span::{trace_utils, v04::SpanSlice};
349    use libdd_trace_utils::test_utils::poll_for_mock_hit;
350    use time::Duration;
351    use time::SystemTime;
352
353    fn is_send<T: Send>() {}
354    fn is_sync<T: Sync>() {}
355
356    const BUCKETS_DURATION: Duration = Duration::from_secs(10);
357
358    /// Fails to compile if stats exporter is not Send and Sync
359    #[test]
360    fn test_stats_exporter_sync_send() {
361        let _ = is_send::<StatsExporter<NativeCapabilities>>;
362        let _ = is_sync::<StatsExporter<NativeCapabilities>>;
363    }
364
365    fn get_test_metadata() -> StatsMetadata {
366        StatsMetadata {
367            hostname: "libdatadog-test".into(),
368            env: "test".into(),
369            app_version: "0.0.0".into(),
370            language: "rust".into(),
371            tracer_version: "0.0.0".into(),
372            runtime_id: "e39d6d12-0752-489f-b488-cf80006c0378".into(),
373            process_tags: "key1:value1,key2:value2".into(),
374            ..Default::default()
375        }
376    }
377
378    fn get_test_concentrator() -> SpanConcentrator {
379        get_test_concentrator_with_obfuscation_config(
380            #[cfg(feature = "stats-obfuscation")]
381            None,
382        )
383    }
384
385    fn get_test_concentrator_with_obfuscation_config(
386        #[cfg(feature = "stats-obfuscation")] obfuscation_config: Option<
387            crate::span_concentrator::SharedStatsComputationObfuscationConfig,
388        >,
389    ) -> SpanConcentrator {
390        let mut concentrator = SpanConcentrator::new(
391            BUCKETS_DURATION,
392            // Make sure the oldest bucket will be flushed on next send
393            SystemTime::now() - BUCKETS_DURATION * 3,
394            vec![],
395            vec![],
396            None,
397            vec![],
398            #[cfg(feature = "stats-obfuscation")]
399            obfuscation_config,
400        );
401        let mut trace = vec![];
402
403        for i in 1..100 {
404            trace.push(SpanSlice {
405                service: "libdatadog-test",
406                duration: i,
407                ..Default::default()
408            })
409        }
410
411        trace_utils::compute_top_level_span(trace.as_mut_slice());
412
413        for span in trace.iter() {
414            concentrator.add_span(span);
415        }
416        concentrator
417    }
418
419    #[cfg_attr(miri, ignore)]
420    #[tokio::test]
421    async fn test_send_stats() {
422        let server = MockServer::start_async().await;
423
424        let mock = server
425            .mock_async(|when, then| {
426                when.method(POST)
427                    .header("Content-type", "application/msgpack")
428                    .path("/v0.6/stats")
429                    .body_includes("libdatadog-test")
430                    .body_includes("key1:value1,key2:value2");
431                then.status(200).body("");
432            })
433            .await;
434
435        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
436            BUCKETS_DURATION,
437            Arc::new(Mutex::new(get_test_concentrator())),
438            get_test_metadata(),
439            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
440            NativeCapabilities::new_client(),
441            #[cfg(feature = "stats-obfuscation")]
442            "1",
443            #[cfg(feature = "telemetry")]
444            None,
445            #[cfg(feature = "dogstatsd")]
446            None,
447        );
448
449        let send_status = stats_exporter.send(true).await;
450        send_status.unwrap();
451
452        mock.assert_async().await;
453    }
454
455    #[cfg_attr(miri, ignore)]
456    #[tokio::test]
457    async fn test_send_stats_fail() {
458        let server = MockServer::start_async().await;
459
460        let mut mock = server
461            .mock_async(|_when, then| {
462                then.status(503)
463                    .header("content-type", "application/json")
464                    .body(r#"{"status":"error"}"#);
465            })
466            .await;
467
468        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
469            BUCKETS_DURATION,
470            Arc::new(Mutex::new(get_test_concentrator())),
471            get_test_metadata(),
472            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
473            NativeCapabilities::new_client(),
474            #[cfg(feature = "stats-obfuscation")]
475            "1",
476            #[cfg(feature = "telemetry")]
477            None,
478            #[cfg(feature = "dogstatsd")]
479            None,
480        );
481
482        let send_status = stats_exporter.send(true).await;
483        send_status.unwrap_err();
484
485        assert!(
486            poll_for_mock_hit(&mut mock, 10, 100, 1, true).await,
487            "Expected a single attempt with no retries"
488        );
489    }
490
491    #[cfg_attr(miri, ignore)]
492    #[test]
493    fn test_run() {
494        let shared_runtime = ForkSafeRuntime::new().expect("Failed to create runtime");
495
496        let server = MockServer::start();
497
498        let mut mock = server.mock(|when, then| {
499            when.method(POST)
500                .header("Content-type", "application/msgpack")
501                .path("/v0.6/stats")
502                .body_includes("libdatadog-test")
503                .body_includes("key1:value1,key2:value2");
504            then.status(200).body("");
505        });
506
507        let caps = NativeCapabilities::new();
508        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
509            // Use smaller buckets duration to speed up test
510            Duration::from_secs(1),
511            Arc::new(Mutex::new(get_test_concentrator())),
512            get_test_metadata(),
513            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
514            caps.clone(),
515            #[cfg(feature = "stats-obfuscation")]
516            "1",
517            #[cfg(feature = "telemetry")]
518            None,
519            #[cfg(feature = "dogstatsd")]
520            None,
521        );
522        let _handle = shared_runtime
523            .spawn_worker(stats_exporter, true)
524            .expect("Failed to spawn worker");
525
526        // Wait for stats to be flushed
527        std::thread::sleep(Duration::from_secs(1));
528
529        assert!(
530            shared_runtime
531                .block_on(poll_for_mock_hit(&mut mock, 10, 100, 1, false))
532                .expect("Failed to use runtime"),
533            "Expected max retry attempts"
534        );
535    }
536
537    #[cfg_attr(miri, ignore)]
538    #[test]
539    fn test_worker_shutdown() {
540        let shared_runtime = ForkSafeRuntime::new().expect("Failed to create runtime");
541
542        let server = MockServer::start();
543
544        let mut mock = server.mock(|when, then| {
545            when.method(POST)
546                .header("Content-type", "application/msgpack")
547                .path("/v0.6/stats")
548                .body_includes("libdatadog-test")
549                .body_includes("key1:value1,key2:value2");
550            then.status(200).body("");
551        });
552
553        let buckets_duration = Duration::from_secs(10);
554
555        let caps = NativeCapabilities::new();
556        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
557            buckets_duration,
558            Arc::new(Mutex::new(get_test_concentrator())),
559            get_test_metadata(),
560            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
561            caps.clone(),
562            #[cfg(feature = "stats-obfuscation")]
563            "1",
564            #[cfg(feature = "telemetry")]
565            None,
566            #[cfg(feature = "dogstatsd")]
567            None,
568        );
569
570        let _handle = shared_runtime
571            .spawn_worker(stats_exporter, true)
572            .expect("Failed to spawn worker");
573
574        shared_runtime.shutdown(None).unwrap();
575
576        assert!(
577            shared_runtime
578                .block_on(poll_for_mock_hit(&mut mock, 10, 100, 1, false))
579                .expect("Failed to get runtime"),
580            "Expected max retry attempts"
581        );
582    }
583
584    #[test]
585    fn test_encode_stats_payload_defaults_empty_env() {
586        // Test that empty env defaults to "unknown-env"
587        let mut meta_with_empty_env = get_test_metadata();
588        meta_with_empty_env.env = "".to_string();
589
590        let buckets = vec![];
591        let payload = encode_stats_payload(&meta_with_empty_env, 1, buckets.clone());
592
593        assert_eq!(
594            payload.env, "unknown-env",
595            "Empty env should default to 'unknown-env'"
596        );
597
598        // Test that non-empty env is preserved
599        let meta_with_env = get_test_metadata();
600        let payload_with_env = encode_stats_payload(&meta_with_env, 2, buckets);
601
602        assert_eq!(
603            payload_with_env.env, "test",
604            "Non-empty env should be preserved"
605        );
606    }
607    #[cfg(feature = "stats-obfuscation")]
608    #[cfg_attr(miri, ignore)]
609    #[tokio::test]
610    async fn test_send_stats_with_obfuscation_header() {
611        use crate::span_concentrator::StatsComputationObfuscationConfig;
612        use arc_swap::ArcSwap;
613
614        let server = MockServer::start_async().await;
615
616        let mock = server
617            .mock_async(|when, then| {
618                when.method(POST)
619                    .header("Content-type", "application/msgpack")
620                    .header("datadog-obfuscation-version", "1")
621                    .path("/v0.6/stats")
622                    .body_includes("libdatadog-test");
623                then.status(200).body("");
624            })
625            .await;
626
627        let concentrator = get_test_concentrator_with_obfuscation_config(Some(Arc::new(
628            ArcSwap::from_pointee(StatsComputationObfuscationConfig {
629                enabled: true,
630                ..Default::default()
631            }),
632        )));
633
634        let stats_exporter = StatsExporter::new(
635            BUCKETS_DURATION,
636            Arc::new(Mutex::new(concentrator)),
637            get_test_metadata(),
638            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
639            NativeCapabilities::new_client(),
640            #[cfg(feature = "stats-obfuscation")]
641            "1",
642            #[cfg(feature = "telemetry")]
643            None,
644            #[cfg(feature = "dogstatsd")]
645            None,
646        );
647
648        let send_status = stats_exporter.send(true).await;
649        send_status.unwrap();
650
651        mock.assert_async().await;
652    }
653
654    /// Build a concentrator with `max_entries_per_bucket = 1` pre-seeded with four distinct spans
655    /// so that three spans are collapsed into the overflow bucket.
656    ///
657    /// per_key_collapsed: enable small per-key limits to also collapse on `resource` and
658    /// `http_endpoint`
659    #[cfg(any(feature = "telemetry", feature = "dogstatsd"))]
660    fn get_collapsed_concentrator(per_key_collapsed: bool) -> SpanConcentrator {
661        use crate::span_concentrator::CardinalityLimitConfig;
662        use libdd_trace_utils::span::{
663            trace_utils,
664            v04::{SpanSlice, VecMap},
665        };
666
667        let mut cardinality_limit_config = CardinalityLimitConfig {
668            whole_key_limit: 2, // max 2 distinct key → third distinct span collapses
669            ..Default::default()
670        };
671        if per_key_collapsed {
672            cardinality_limit_config.resource_limit = 1;
673            cardinality_limit_config.http_endpoint_limit = 1;
674        }
675        let mut concentrator = SpanConcentrator::new(
676            BUCKETS_DURATION,
677            SystemTime::now(),
678            vec![],
679            vec![],
680            Some(cardinality_limit_config),
681            vec![],
682            #[cfg(feature = "stats-obfuscation")]
683            None,
684        );
685
686        let mut trace = vec![
687            SpanSlice {
688                service: "svc-a",
689                resource: "resource-a",
690                duration: 10,
691                meta: VecMap::from_iter([("http.endpoint", "/")]),
692                ..Default::default()
693            },
694            // only resource get collapsed if per-key limits are enabled
695            SpanSlice {
696                service: "svc-a",
697                resource: "resource-b",
698                duration: 20,
699                meta: VecMap::from_iter([("http.endpoint", "/")]),
700                ..Default::default()
701            },
702            // both resource and http_endpoint get collapsed if per-key limits are enabled
703            SpanSlice {
704                service: "svc-b",
705                resource: "resource-c",
706                duration: 20,
707                meta: VecMap::from_iter([("http.endpoint", "/hello.txt")]),
708                ..Default::default()
709            },
710            // both resource and http_endpoint get collapsed if per-key limits are enabled
711            SpanSlice {
712                service: "svc-b",
713                resource: "resource-b",
714                duration: 20,
715                ..Default::default()
716            },
717        ];
718        trace_utils::compute_top_level_span(trace.as_mut_slice());
719        for span in &trace {
720            concentrator.add_span(span);
721        }
722        concentrator
723    }
724
725    /// Verify that when `collapsed_spans == 0` the DogStatsD socket receives nothing.
726    #[cfg(feature = "dogstatsd")]
727    #[cfg_attr(miri, ignore)]
728    #[tokio::test]
729    async fn test_no_emission_when_zero() {
730        use std::net;
731
732        let server = MockServer::start_async().await;
733        server
734            .mock_async(|_when, then| {
735                then.status(200).body("");
736            })
737            .await;
738
739        // Bind a UDP socket so we can detect whether anything arrives.
740        let socket = net::UdpSocket::bind("127.0.0.1:0").expect("failed to bind UDP socket");
741        socket
742            .set_read_timeout(Some(std::time::Duration::from_millis(200)))
743            .unwrap();
744        let addr = socket.local_addr().unwrap().to_string();
745
746        let dogstatsd_client =
747            libdd_dogstatsd_client::DogStatsDClient::new(libdd_common::Endpoint::from_slice(&addr))
748                .expect("failed to create dogstatsd client");
749
750        // get_test_concentrator() has no cardinality collapse: collapsed_spans will be 0.
751        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
752            BUCKETS_DURATION,
753            Arc::new(Mutex::new(get_test_concentrator())),
754            get_test_metadata(),
755            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
756            NativeCapabilities::new_client(),
757            #[cfg(feature = "stats-obfuscation")]
758            "1",
759            #[cfg(feature = "telemetry")]
760            None,
761            Some(dogstatsd_client),
762        );
763
764        stats_exporter.send(true).await.unwrap();
765
766        // The socket must not have received any datagram.
767        let mut buf = [0u8; 256];
768        let result = socket.recv(&mut buf);
769        assert!(
770            result.is_err(),
771            "No DogStatsD datagram expected when collapsed_spans == 0. Got {}",
772            std::str::from_utf8(&buf[..result.unwrap()]).unwrap()
773        );
774    }
775
776    /// Verify that `COLLAPSED_SPANS_METRIC` is emitted to DogStatsD when spans are collapsed.
777    #[cfg(feature = "dogstatsd")]
778    #[cfg_attr(miri, ignore)]
779    #[tokio::test]
780    async fn test_collapsed_spans_dogstatsd() {
781        use std::net;
782
783        let server = MockServer::start_async().await;
784        server
785            .mock_async(|_when, then| {
786                then.status(200).body("");
787            })
788            .await;
789
790        let socket = net::UdpSocket::bind("127.0.0.1:0").expect("failed to bind UDP socket");
791        socket
792            .set_read_timeout(Some(std::time::Duration::from_millis(500)))
793            .unwrap();
794        let addr = socket.local_addr().unwrap().to_string();
795
796        let dogstatsd_client =
797            libdd_dogstatsd_client::DogStatsDClient::new(libdd_common::Endpoint::from_slice(&addr))
798                .expect("failed to create dogstatsd client");
799
800        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
801            BUCKETS_DURATION,
802            Arc::new(Mutex::new(get_collapsed_concentrator(false))),
803            get_test_metadata(),
804            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
805            NativeCapabilities::new_client(),
806            #[cfg(feature = "stats-obfuscation")]
807            "1",
808            #[cfg(feature = "telemetry")]
809            None,
810            Some(dogstatsd_client),
811        );
812
813        stats_exporter.send(true).await.unwrap();
814
815        let mut buf = [0u8; 256];
816        let n = socket
817            .recv(&mut buf)
818            .expect("expected a DogStatsD datagram");
819        let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
820        assert_eq!(
821            datagram, "datadog.tracer.stats.collapsed_spans:2|c|#collapsed_spans:whole_key",
822            "DogStatsD datagram must match the expected format"
823        );
824    }
825
826    /// Verify that `COLLAPSED_SPANS_METRIC` is emitted to DogStatsD when spans are collapsed by
827    /// per-key limits.
828    #[cfg(feature = "dogstatsd")]
829    #[cfg_attr(miri, ignore)]
830    #[tokio::test]
831    async fn test_collapsed_spans_per_key_dogstatsd() {
832        use std::net;
833
834        let server = MockServer::start_async().await;
835        server
836            .mock_async(|_when, then| {
837                then.status(200).body("");
838            })
839            .await;
840
841        let socket = net::UdpSocket::bind("127.0.0.1:0").expect("failed to bind UDP socket");
842        socket
843            .set_read_timeout(Some(std::time::Duration::from_millis(500)))
844            .unwrap();
845        let addr = socket.local_addr().unwrap().to_string();
846
847        let dogstatsd_client =
848            libdd_dogstatsd_client::DogStatsDClient::new(libdd_common::Endpoint::from_slice(&addr))
849                .expect("failed to create dogstatsd client");
850
851        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
852            BUCKETS_DURATION,
853            Arc::new(Mutex::new(get_collapsed_concentrator(true))),
854            get_test_metadata(),
855            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
856            NativeCapabilities::new_client(),
857            #[cfg(feature = "stats-obfuscation")]
858            "1",
859            #[cfg(feature = "telemetry")]
860            None,
861            Some(dogstatsd_client),
862        );
863
864        stats_exporter.send(true).await.unwrap();
865
866        let mut buf = [0u8; 256];
867
868        // Whole-key metric emitted first
869        let n = socket
870            .recv(&mut buf)
871            .expect("expected a DogStatsD datagram");
872        let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
873        assert_eq!(
874            datagram, "datadog.tracer.stats.collapsed_spans:2|c|#collapsed_spans:whole_key",
875            "DogStatsD datagram must match the expected format"
876        );
877
878        // Then comes the per-key collapse telemetry
879        let n = socket
880            .recv(&mut buf)
881            .expect("expected a DogStatsD datagram");
882        let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
883        assert_eq!(
884            datagram, "datadog.tracer.stats.collapsed_spans:1|c|#collapsed_spans:resource",
885            "DogStatsD datagram must match the expected format"
886        );
887        let n = socket
888            .recv(&mut buf)
889            .expect("expected a DogStatsD datagram");
890        let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
891        assert_eq!(
892            datagram, "datadog.tracer.stats.collapsed_spans:2|c|#collapsed_spans:resource,collapsed_spans:http_endpoint",
893            "DogStatsD datagram must match the expected format"
894        );
895    }
896
897    /// Verify that `COLLAPSED_SPANS_METRIC` is enqueued to the telemetry worker when spans
898    /// are collapsed. This does not verify the actual value of the metric.
899    #[cfg(feature = "telemetry")]
900    #[cfg_attr(miri, ignore)]
901    #[tokio::test]
902    async fn test_collapsed_spans_telemetry() {
903        use libdd_telemetry::worker::TelemetryWorkerBuilder;
904
905        let server = MockServer::start_async().await;
906        server
907            .mock_async(|_when, then| {
908                then.status(200).body("");
909            })
910            .await;
911
912        let (handle, _join_handle) = TelemetryWorkerBuilder::new(
913            "test-host".to_string(),
914            "test-service".to_string(),
915            "rust".to_string(),
916            "1.0".to_string(),
917            "0.0.0".to_string(),
918        )
919        .spawn();
920
921        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
922            BUCKETS_DURATION,
923            Arc::new(Mutex::new(get_collapsed_concentrator(true))),
924            get_test_metadata(),
925            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
926            NativeCapabilities::new_client(),
927            #[cfg(feature = "stats-obfuscation")]
928            "1",
929            #[cfg(feature = "telemetry")]
930            Some(handle),
931            #[cfg(feature = "dogstatsd")]
932            None,
933        );
934
935        stats_exporter.send(true).await.unwrap();
936
937        let stats_exporter_ref = &stats_exporter;
938        let (handle_ref, _key) = stats_exporter_ref
939            .telemetry
940            .as_ref()
941            .expect("telemetry must be set");
942        let receiver = handle_ref.stats().expect("failed to request stats");
943        let stats = receiver.await.expect("failed to receive stats");
944        // metric_contexts == 1 verifies that exactly one metric name was registered
945        // (i.e. COLLAPSED_SPANS_METRIC and nothing else).
946        // metric_buckets.buckets == 1 verifies that a data point was recorded for it.
947        // However it does not check the value of the data point.
948        assert_eq!(
949            stats.metric_contexts, 1,
950            "exactly one metric context (COLLAPSED_SPANS_METRIC) should be registered"
951        );
952        assert_eq!(
953            stats.metric_buckets.buckets, 3,
954            "exactly 3 metric bucket expected after one whole-key collapsed-spans, one resource key collapsed-span and one resource key+http_endpoint key emissions"
955        );
956    }
957}