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