1use 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::stats_payload_encoder::{
24 build_stats_payload, encode_stats_payload_msgpack, split_stats_buckets,
25 MAX_GROUPED_STATS_PER_PAYLOAD,
26};
27use libdd_trace_utils::trace_utils::TracerHeaderTags;
28use libdd_trace_utils::tracer_metadata::TracerMetadata;
29use std::fmt::Debug;
30use tracing::error;
31
32pub const STATS_ENDPOINT_PATH: &str = "/v0.6/stats";
33
34pub struct StatsRequest {
40 pub body: Vec<u8>,
41 pub headers: http::HeaderMap,
42 pub compression: CompressionStrategy,
43 pub endpoint: Endpoint,
44 pub retry: RetryStrategy,
45}
46
47#[derive(Debug)]
48pub enum StatsDestination {
49 Agent { endpoint: Endpoint },
51 Agentless(AgentlessStatsTarget),
54}
55
56#[derive(Debug)]
62pub struct AgentlessStatsTarget {
63 pub endpoint: Endpoint,
67 pub version: String,
70}
71
72pub const COLLAPSED_SPANS_HEALTH_METRIC: &str = "datadog.tracer.stats.collapsed_spans";
74
75pub const COLLAPSED_SPANS_TELEMETRY_METRIC: &str = "tracers.stats_collapsed_spans";
77
78#[derive(Clone, Default, Debug)]
80pub struct StatsMetadata {
81 pub hostname: String,
82 pub env: String,
83 pub app_version: String,
84 pub runtime_id: String,
85 pub language: String,
86 pub lang_version: String,
87 pub lang_interpreter: String,
88 pub lang_vendor: String,
89 pub tracer_version: String,
90 pub git_commit_sha: String,
91 pub process_tags: String,
92 pub service: String,
93 pub container_id: String,
94}
95
96impl<'a> From<&'a StatsMetadata> for TracerHeaderTags<'a> {
97 fn from(m: &'a StatsMetadata) -> TracerHeaderTags<'a> {
98 TracerHeaderTags {
99 lang: &m.language,
100 lang_version: &m.lang_version,
101 lang_interpreter: &m.lang_interpreter,
102 lang_vendor: &m.lang_vendor,
103 tracer_version: &m.tracer_version,
104 ..Default::default()
105 }
106 }
107}
108
109impl From<TracerMetadata> for StatsMetadata {
110 fn from(m: TracerMetadata) -> StatsMetadata {
111 StatsMetadata {
112 hostname: m.hostname,
113 env: m.env,
114 app_version: m.app_version,
115 runtime_id: m.runtime_id,
116 language: m.language,
117 lang_version: m.language_version,
118 lang_interpreter: m.language_interpreter,
119 lang_vendor: m.language_interpreter_vendor,
120 tracer_version: m.tracer_version,
121 git_commit_sha: m.git_commit_sha,
122 process_tags: m.process_tags,
123 service: m.service,
124 container_id: String::new(),
125 }
126 }
127}
128
129#[derive(Debug)]
134pub struct StatsExporter<
135 Cap: HttpClientCapability + SleepCapability + MaybeSend + Sync + 'static,
136 Con: FlushableConcentrator = SpanConcentrator,
137> {
138 flush_interval: time::Duration,
139 concentrator: Arc<Mutex<Con>>,
140 destination: StatsDestination,
141 meta: StatsMetadata,
142 sequence_id: AtomicU64,
143 capabilities: Cap,
144 #[cfg(feature = "stats-obfuscation")]
145 supported_obfuscation_version: &'static str,
146 #[cfg(feature = "telemetry")]
148 telemetry: Option<(
149 libdd_telemetry::worker::TelemetryWorkerHandle<Cap>,
150 libdd_telemetry::metrics::ContextKey,
151 )>,
152 #[cfg(feature = "dogstatsd")]
154 dogstatsd: Option<libdd_dogstatsd_client::DogStatsDClient>,
155}
156
157impl<
158 Cap: HttpClientCapability + SleepCapability + MaybeSend + Sync + 'static,
159 Con: FlushableConcentrator,
160 > StatsExporter<Cap, Con>
161{
162 #[allow(clippy::too_many_arguments)]
170 pub fn new(
171 flush_interval: time::Duration,
172 concentrator: Arc<Mutex<Con>>,
173 meta: StatsMetadata,
174 endpoint: Endpoint,
175 capabilities: Cap,
176 #[cfg(feature = "stats-obfuscation")] supported_obfuscation_version: &'static str,
177 #[cfg(feature = "telemetry")] telemetry: Option<
178 libdd_telemetry::worker::TelemetryWorkerHandle<Cap>,
179 >,
180 #[cfg(feature = "dogstatsd")] dogstatsd: Option<libdd_dogstatsd_client::DogStatsDClient>,
181 ) -> Self {
182 Self::from_parts(
183 flush_interval,
184 concentrator,
185 meta,
186 StatsDestination::Agent { endpoint },
187 capabilities,
188 #[cfg(feature = "stats-obfuscation")]
189 supported_obfuscation_version,
190 #[cfg(feature = "telemetry")]
191 telemetry,
192 #[cfg(feature = "dogstatsd")]
193 dogstatsd,
194 )
195 }
196
197 #[allow(clippy::too_many_arguments)]
204 pub fn new_agentless(
205 flush_interval: time::Duration,
206 concentrator: Arc<Mutex<Con>>,
207 meta: StatsMetadata,
208 target: AgentlessStatsTarget,
209 capabilities: Cap,
210 #[cfg(feature = "telemetry")] telemetry: Option<
211 libdd_telemetry::worker::TelemetryWorkerHandle<Cap>,
212 >,
213 #[cfg(feature = "dogstatsd")] dogstatsd: Option<libdd_dogstatsd_client::DogStatsDClient>,
214 ) -> Self {
215 Self::from_parts(
216 flush_interval,
217 concentrator,
218 meta,
219 StatsDestination::Agentless(target),
220 capabilities,
221 #[cfg(feature = "stats-obfuscation")]
222 "1",
223 #[cfg(feature = "telemetry")]
224 telemetry,
225 #[cfg(feature = "dogstatsd")]
226 dogstatsd,
227 )
228 }
229
230 #[allow(clippy::too_many_arguments)]
232 fn from_parts(
233 flush_interval: time::Duration,
234 concentrator: Arc<Mutex<Con>>,
235 meta: StatsMetadata,
236 destination: StatsDestination,
237 capabilities: Cap,
238 #[cfg(feature = "stats-obfuscation")] supported_obfuscation_version: &'static str,
239 #[cfg(feature = "telemetry")] telemetry: Option<
240 libdd_telemetry::worker::TelemetryWorkerHandle<Cap>,
241 >,
242 #[cfg(feature = "dogstatsd")] dogstatsd: Option<libdd_dogstatsd_client::DogStatsDClient>,
243 ) -> Self {
244 #[cfg(feature = "telemetry")]
245 let telemetry = telemetry.map(|handle| {
246 let key = handle.register_metric_context(
247 COLLAPSED_SPANS_TELEMETRY_METRIC.to_string(),
248 vec![],
249 libdd_telemetry::data::metrics::MetricType::Count,
250 true,
251 libdd_telemetry::data::metrics::MetricNamespace::Tracers,
252 );
253 (handle, key)
254 });
255 Self {
256 flush_interval,
257 concentrator,
258 destination,
259 meta,
260 sequence_id: AtomicU64::new(0),
261 capabilities,
262 #[cfg(feature = "stats-obfuscation")]
263 supported_obfuscation_version,
264 #[cfg(feature = "telemetry")]
265 telemetry,
266 #[cfg(feature = "dogstatsd")]
267 dogstatsd,
268 }
269 }
270
271 pub async fn send(&self, force_flush: bool) -> anyhow::Result<bool> {
288 let flush = {
289 let mut concentrator = self.concentrator.lock_or_panic();
290 concentrator.flush_buckets(force_flush)
291 };
292
293 #[cfg(feature = "telemetry")]
294 if let Some((handle, key)) = &self.telemetry {
295 if flush.collapsed_spans > 0 {
296 let _ = handle.add_point(
297 flush.collapsed_spans as f64,
298 key,
299 vec![libdd_common::tag!("collapsed_spans", "whole_key")],
300 );
301 }
302 flush.collapsed_fields_metrics.emit_telemetry(handle, key);
303 }
304
305 #[cfg(feature = "dogstatsd")]
306 if let Some(client) = &self.dogstatsd {
307 if flush.collapsed_spans > 0 {
308 client.send(vec![libdd_dogstatsd_client::DogStatsDAction::Count(
309 COLLAPSED_SPANS_HEALTH_METRIC,
310 flush.collapsed_spans as i64,
311 [libdd_common::tag!("collapsed_spans", "whole_key")].iter(),
312 )]);
313 }
314 flush.collapsed_fields_metrics.emit_dogstatsd(client);
315 }
316
317 let futures = FuturesUnordered::new();
318
319 if !flush.obfuscated_buckets.is_empty() {
320 futures.push(self.send_payload(flush.obfuscated_buckets, true));
321 }
322
323 if !flush.unobfuscated_buckets.is_empty() {
324 futures.push(self.send_payload(flush.unobfuscated_buckets, false));
325 }
326
327 let sent_stats = !futures.is_empty();
328
329 futures
330 .collect::<Vec<anyhow::Result<()>>>()
331 .await
332 .into_iter()
333 .collect::<anyhow::Result<()>>()?;
334
335 Ok(sent_stats)
336 }
337
338 async fn send_payload(
346 &self,
347 buckets: Vec<pb::ClientStatsBucket>,
348 obfuscated: bool,
349 ) -> anyhow::Result<()> {
350 let groups = split_stats_buckets(buckets, MAX_GROUPED_STATS_PER_PAYLOAD);
351 let split = groups.len() > 1;
352 let sequence = self.sequence_id.fetch_add(1, Ordering::Relaxed);
354 let mut errors = Vec::new();
355 for group in groups {
356 if let Err(e) = self
357 .send_single_payload(group, obfuscated, split, sequence)
358 .await
359 {
360 errors.push(e);
361 }
362 }
363 if let Some(last_err) = errors.pop() {
364 if !errors.is_empty() {
365 struct AdditionalErrors(Vec<anyhow::Error>);
366 impl std::fmt::Display for AdditionalErrors {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 writeln!(f, "with {} additional errors:", self.0.len())?;
369 for e in &self.0 {
370 writeln!(f, "{e}")?;
371 }
372 Ok(())
373 }
374 }
375 return Err(last_err.context(AdditionalErrors(errors)));
376 }
377 return Err(last_err);
378 }
379 Ok(())
380 }
381
382 async fn send_single_payload(
384 &self,
385 buckets: Vec<pb::ClientStatsBucket>,
386 obfuscated: bool,
387 split: bool,
388 sequence: u64,
389 ) -> anyhow::Result<()> {
390 let request = match &self.destination {
391 StatsDestination::Agent { endpoint } => {
392 self.build_agent_request(endpoint.clone(), sequence, buckets, obfuscated)?
393 }
394 StatsDestination::Agentless(target) => {
395 build_agentless_request(&self.meta, sequence, buckets, target, split)?
396 }
397 };
398
399 let result = send_with_retry(
400 &self.capabilities,
401 &request.endpoint,
402 request.body,
403 &request.headers,
404 &request.retry,
405 request.compression,
406 )
407 .await;
408
409 match result {
410 Ok(_) => Ok(()),
411 Err(err) => {
412 error!(?err, "Error with the StatsExporter when sending stats");
413 anyhow::bail!("Failed to send stats: {err}");
414 }
415 }
416 }
417
418 fn build_agent_request(
421 &self,
422 endpoint: Endpoint,
423 sequence: u64,
424 buckets: Vec<pb::ClientStatsBucket>,
425 #[cfg_attr(not(feature = "stats-obfuscation"), allow(unused))] obfuscated: bool,
426 ) -> anyhow::Result<StatsRequest> {
427 let payload = encode_stats_payload(&self.meta, sequence, buckets);
428 let body = rmp_serde::encode::to_vec_named(&payload)?;
429
430 let mut headers: http::HeaderMap = TracerHeaderTags::from(&self.meta).into();
431 headers.insert(
432 http::header::CONTENT_TYPE,
433 libdd_common::header::APPLICATION_MSGPACK,
434 );
435 #[cfg(feature = "stats-obfuscation")]
436 if obfuscated {
437 headers.insert(
438 http::HeaderName::from_static("datadog-obfuscation-version"),
439 http::HeaderValue::from_static(self.supported_obfuscation_version),
440 );
441 }
442
443 Ok(StatsRequest {
444 body,
445 headers,
446 compression: CompressionStrategy::None,
447 endpoint,
448 retry: RetryStrategy::new(0, 0, RetryBackoffType::Constant, None),
449 })
450 }
451}
452
453const AGENTLESS_STATS_MAX_RETRIES: u32 = 2;
455const AGENTLESS_STATS_RETRY_DELAY_MS: u64 = 1000;
457
458fn build_agentless_request(
467 meta: &StatsMetadata,
468 sequence: u64,
469 buckets: Vec<pb::ClientStatsBucket>,
470 target: &AgentlessStatsTarget,
471 split: bool,
472) -> anyhow::Result<StatsRequest> {
473 let mut client_payload = encode_stats_payload(meta, sequence, buckets);
474 client_payload.lang = meta.language.clone();
477 client_payload.tracer_version = meta.tracer_version.clone();
478 client_payload.container_id = meta.container_id.clone();
479 let payload = build_stats_payload(
480 client_payload,
481 meta.hostname.clone(),
482 meta.env.clone(),
483 target.version.clone(),
484 split,
485 );
486 let body = encode_stats_payload_msgpack(&payload)?;
487
488 let mut headers: http::HeaderMap = TracerHeaderTags::from(meta).into();
491 headers.insert(
492 http::header::CONTENT_TYPE,
493 libdd_common::header::APPLICATION_MSGPACK,
494 );
495
496 #[cfg(feature = "compression")]
497 let compression = CompressionStrategy::Zstd { level: 1 };
498 #[cfg(not(feature = "compression"))]
499 let compression = CompressionStrategy::None;
500
501 Ok(StatsRequest {
502 body,
503 headers,
504 compression,
505 endpoint: target.endpoint.clone(),
506 retry: RetryStrategy::new(
507 AGENTLESS_STATS_MAX_RETRIES,
508 AGENTLESS_STATS_RETRY_DELAY_MS,
509 RetryBackoffType::Exponential,
510 None,
511 ),
512 })
513}
514
515#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
516#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
517impl<
518 Cap: HttpClientCapability + SleepCapability + MaybeSend + Sync + 'static,
519 Con: FlushableConcentrator + Send + Debug,
520 > Worker for StatsExporter<Cap, Con>
521{
522 async fn trigger(&mut self) {
523 self.capabilities.sleep(self.flush_interval).await;
524 }
525
526 async fn run(&mut self) {
528 let _ = self.send(false).await; }
530
531 fn reset(&mut self) {
532 let _ = self.concentrator.lock_or_panic().flush_buckets(true);
533 self.sequence_id.store(0, Ordering::Relaxed);
534 }
535
536 async fn shutdown(&mut self) {
537 let _ = self.send(true).await;
538 }
539}
540
541fn encode_stats_payload(
542 meta: &StatsMetadata,
543 sequence: u64,
544 buckets: Vec<pb::ClientStatsBucket>,
545) -> pb::ClientStatsPayload {
546 pb::ClientStatsPayload {
547 hostname: meta.hostname.clone(),
548 env: if meta.env.is_empty() {
549 "unknown-env".to_string()
550 } else {
551 meta.env.clone()
552 },
553 version: meta.app_version.clone(),
554 runtime_id: meta.runtime_id.clone(),
555 sequence,
556 service: meta.service.clone(),
557 stats: buckets,
558 git_commit_sha: meta.git_commit_sha.clone(),
559 process_tags: meta.process_tags.clone(),
560 container_id: String::new(),
562 tags: Vec::new(),
563 agent_aggregation: String::new(),
564 image_tag: String::new(),
565 process_tags_hash: 0,
566 lang: String::new(),
567 tracer_version: String::new(),
568 }
569}
570
571pub fn stats_url_from_agent_url(agent_url: &str) -> anyhow::Result<http::Uri> {
573 let mut parts = agent_url.parse::<http::Uri>()?.into_parts();
574 parts.path_and_query = Some(http::uri::PathAndQuery::from_static(STATS_ENDPOINT_PATH));
575 Ok(http::Uri::from_parts(parts)?)
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use httpmock::prelude::*;
582 use httpmock::MockServer;
583 use libdd_capabilities_impl::NativeCapabilities;
584 use libdd_shared_runtime::{BlockingRuntime, ForkSafeRuntime, SharedRuntime};
585 use libdd_trace_utils::span::{trace_utils, v04::SpanSlice};
586 use libdd_trace_utils::test_utils::{poll_for_mock_hit, poll_for_mock_hits};
587 use std::borrow::Cow;
588 use time::Duration;
589 use time::SystemTime;
590
591 fn is_send<T: Send>() {}
592 fn is_sync<T: Sync>() {}
593
594 const BUCKETS_DURATION: Duration = Duration::from_secs(10);
595
596 #[test]
598 fn test_stats_exporter_sync_send() {
599 let _ = is_send::<StatsExporter<NativeCapabilities>>;
600 let _ = is_sync::<StatsExporter<NativeCapabilities>>;
601 }
602
603 fn get_test_metadata() -> StatsMetadata {
604 StatsMetadata {
605 hostname: "libdatadog-test".into(),
606 env: "test".into(),
607 app_version: "0.0.0".into(),
608 language: "rust".into(),
609 tracer_version: "0.0.0".into(),
610 runtime_id: "e39d6d12-0752-489f-b488-cf80006c0378".into(),
611 process_tags: "key1:value1,key2:value2".into(),
612 ..Default::default()
613 }
614 }
615
616 fn get_test_concentrator() -> SpanConcentrator {
617 get_test_concentrator_with_obfuscation_config(
618 #[cfg(feature = "stats-obfuscation")]
619 None,
620 )
621 }
622
623 fn get_test_concentrator_with_obfuscation_config(
624 #[cfg(feature = "stats-obfuscation")] obfuscation_config: Option<
625 crate::span_concentrator::SharedStatsComputationObfuscationConfig,
626 >,
627 ) -> SpanConcentrator {
628 let mut concentrator = SpanConcentrator::new(
629 BUCKETS_DURATION,
630 SystemTime::now() - BUCKETS_DURATION * 3,
632 vec![],
633 vec![],
634 None,
635 vec![],
636 #[cfg(feature = "stats-obfuscation")]
637 obfuscation_config,
638 );
639 let mut trace = vec![];
640
641 for i in 1..100 {
642 trace.push(SpanSlice {
643 service: Cow::Borrowed("libdatadog-test"),
644 duration: i,
645 ..Default::default()
646 })
647 }
648
649 trace_utils::compute_top_level_span(trace.as_mut_slice());
650
651 for span in trace.iter() {
652 concentrator.add_span(span);
653 }
654 concentrator
655 }
656
657 #[cfg_attr(miri, ignore)]
658 #[tokio::test]
659 async fn test_send_stats() {
660 let server = MockServer::start_async().await;
661
662 let mock = server
663 .mock_async(|when, then| {
664 when.method(POST)
665 .header("Content-type", "application/msgpack")
666 .path("/v0.6/stats")
667 .body_includes("libdatadog-test")
668 .body_includes("key1:value1,key2:value2");
669 then.status(200).body("");
670 })
671 .await;
672
673 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
674 BUCKETS_DURATION,
675 Arc::new(Mutex::new(get_test_concentrator())),
676 get_test_metadata(),
677 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
678 NativeCapabilities::new_client(),
679 #[cfg(feature = "stats-obfuscation")]
680 "1",
681 #[cfg(feature = "telemetry")]
682 None,
683 #[cfg(feature = "dogstatsd")]
684 None,
685 );
686
687 let send_status = stats_exporter.send(true).await;
688 send_status.unwrap();
689
690 mock.assert_async().await;
691 }
692
693 #[cfg_attr(miri, ignore)]
694 #[tokio::test]
695 async fn test_send_agentless_stats() {
696 use super::AgentlessStatsTarget;
697
698 let server = MockServer::start_async().await;
699
700 let mock = server
701 .mock_async(|when, then| {
702 let w = when
703 .method(POST)
704 .header("Content-type", "application/msgpack")
705 .header("dd-api-key", "test-api-key")
706 .path("/api/v0.2/stats");
707 #[cfg(feature = "compression")]
710 let w = w.header("Content-Encoding", "zstd");
711 #[cfg(not(feature = "compression"))]
712 let w = w.body_includes("libdatadog-test").body_includes("rust");
715 let _ = w;
716 then.status(202).body("");
717 })
718 .await;
719
720 let target = AgentlessStatsTarget {
721 endpoint: Endpoint {
722 api_key: Some("test-api-key".into()),
723 ..Endpoint::from_slice(&server.url("/api/v0.2/stats"))
724 },
725 version: "1.2.3-libdatadog".to_string(),
726 };
727
728 let stats_exporter = StatsExporter::<NativeCapabilities>::new_agentless(
729 BUCKETS_DURATION,
730 Arc::new(Mutex::new(get_test_concentrator())),
731 get_test_metadata(),
732 target,
733 NativeCapabilities::new_client(),
734 #[cfg(feature = "telemetry")]
735 None,
736 #[cfg(feature = "dogstatsd")]
737 None,
738 );
739
740 let send_status = stats_exporter.send(true).await;
741 send_status.unwrap();
742
743 mock.assert_async().await;
744 }
745
746 #[cfg_attr(miri, ignore)]
751 #[tokio::test]
752 async fn test_send_agentless_stats_fail_retries() {
753 use super::AgentlessStatsTarget;
754
755 let server = MockServer::start_async().await;
756
757 let mut mock = server
758 .mock_async(|when, then| {
759 when.method(POST)
760 .header("Content-type", "application/msgpack")
761 .header("dd-api-key", "test-api-key")
762 .path("/api/v0.2/stats");
763 then.status(503)
764 .header("content-type", "application/json")
765 .body(r#"{"status":"error"}"#);
766 })
767 .await;
768
769 let target = AgentlessStatsTarget {
770 endpoint: Endpoint {
771 api_key: Some("test-api-key".into()),
772 ..Endpoint::from_slice(&server.url("/api/v0.2/stats"))
773 },
774 version: "1.2.3-libdatadog".to_string(),
775 };
776
777 let stats_exporter = StatsExporter::<NativeCapabilities>::new_agentless(
778 BUCKETS_DURATION,
779 Arc::new(Mutex::new(get_test_concentrator())),
780 get_test_metadata(),
781 target,
782 NativeCapabilities::new_client(),
783 #[cfg(feature = "telemetry")]
784 None,
785 #[cfg(feature = "dogstatsd")]
786 None,
787 );
788
789 let send_status = stats_exporter.send(true).await;
790 send_status.expect_err("agentless stats send should fail after exhausting retries");
791
792 assert!(
795 poll_for_mock_hits(
796 &mut mock,
797 80,
798 100,
799 (AGENTLESS_STATS_MAX_RETRIES + 1) as usize
800 )
801 .await,
802 "Expected {} attempts (initial + retries) for the agentless intake",
803 AGENTLESS_STATS_MAX_RETRIES + 1
804 );
805 }
806
807 #[cfg_attr(miri, ignore)]
808 #[tokio::test]
809 async fn test_send_stats_fail() {
810 let server = MockServer::start_async().await;
811
812 let mut mock = server
813 .mock_async(|_when, then| {
814 then.status(503)
815 .header("content-type", "application/json")
816 .body(r#"{"status":"error"}"#);
817 })
818 .await;
819
820 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
821 BUCKETS_DURATION,
822 Arc::new(Mutex::new(get_test_concentrator())),
823 get_test_metadata(),
824 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
825 NativeCapabilities::new_client(),
826 #[cfg(feature = "stats-obfuscation")]
827 "1",
828 #[cfg(feature = "telemetry")]
829 None,
830 #[cfg(feature = "dogstatsd")]
831 None,
832 );
833
834 let send_status = stats_exporter.send(true).await;
835 send_status.unwrap_err();
836
837 assert!(
838 poll_for_mock_hit(&mut mock, 10, 100, 1, true).await,
839 "Expected a single attempt with no retries"
840 );
841 }
842
843 #[cfg_attr(miri, ignore)]
844 #[test]
845 fn test_run() {
846 let shared_runtime = ForkSafeRuntime::new().expect("Failed to create runtime");
847
848 let server = MockServer::start();
849
850 let mut mock = server.mock(|when, then| {
851 when.method(POST)
852 .header("Content-type", "application/msgpack")
853 .path("/v0.6/stats")
854 .body_includes("libdatadog-test")
855 .body_includes("key1:value1,key2:value2");
856 then.status(200).body("");
857 });
858
859 let caps = NativeCapabilities::new();
860 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
861 Duration::from_secs(1),
863 Arc::new(Mutex::new(get_test_concentrator())),
864 get_test_metadata(),
865 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
866 caps.clone(),
867 #[cfg(feature = "stats-obfuscation")]
868 "1",
869 #[cfg(feature = "telemetry")]
870 None,
871 #[cfg(feature = "dogstatsd")]
872 None,
873 );
874 let _handle = shared_runtime
875 .spawn_worker(stats_exporter, true)
876 .expect("Failed to spawn worker");
877
878 std::thread::sleep(Duration::from_secs(1));
880
881 assert!(
882 shared_runtime
883 .block_on(poll_for_mock_hit(&mut mock, 10, 100, 1, false))
884 .expect("Failed to use runtime"),
885 "Expected max retry attempts"
886 );
887 }
888
889 #[cfg_attr(miri, ignore)]
890 #[test]
891 fn test_worker_shutdown() {
892 let shared_runtime = ForkSafeRuntime::new().expect("Failed to create runtime");
893
894 let server = MockServer::start();
895
896 let mut mock = server.mock(|when, then| {
897 when.method(POST)
898 .header("Content-type", "application/msgpack")
899 .path("/v0.6/stats")
900 .body_includes("libdatadog-test")
901 .body_includes("key1:value1,key2:value2");
902 then.status(200).body("");
903 });
904
905 let buckets_duration = Duration::from_secs(10);
906
907 let caps = NativeCapabilities::new();
908 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
909 buckets_duration,
910 Arc::new(Mutex::new(get_test_concentrator())),
911 get_test_metadata(),
912 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
913 caps.clone(),
914 #[cfg(feature = "stats-obfuscation")]
915 "1",
916 #[cfg(feature = "telemetry")]
917 None,
918 #[cfg(feature = "dogstatsd")]
919 None,
920 );
921
922 let _handle = shared_runtime
923 .spawn_worker(stats_exporter, true)
924 .expect("Failed to spawn worker");
925
926 shared_runtime.shutdown(None).unwrap();
927
928 assert!(
929 shared_runtime
930 .block_on(poll_for_mock_hit(&mut mock, 10, 100, 1, false))
931 .expect("Failed to get runtime"),
932 "Expected max retry attempts"
933 );
934 }
935
936 #[test]
937 fn test_encode_stats_payload_defaults_empty_env() {
938 let mut meta_with_empty_env = get_test_metadata();
940 meta_with_empty_env.env = "".to_string();
941
942 let buckets = vec![];
943 let payload = encode_stats_payload(&meta_with_empty_env, 1, buckets.clone());
944
945 assert_eq!(
946 payload.env, "unknown-env",
947 "Empty env should default to 'unknown-env'"
948 );
949
950 let meta_with_env = get_test_metadata();
952 let payload_with_env = encode_stats_payload(&meta_with_env, 2, buckets);
953
954 assert_eq!(
955 payload_with_env.env, "test",
956 "Non-empty env should be preserved"
957 );
958 }
959 #[cfg(feature = "stats-obfuscation")]
960 #[cfg_attr(miri, ignore)]
961 #[tokio::test]
962 async fn test_send_stats_with_obfuscation_header() {
963 use crate::span_concentrator::StatsComputationObfuscationConfig;
964 use arc_swap::ArcSwap;
965
966 let server = MockServer::start_async().await;
967
968 let mock = server
969 .mock_async(|when, then| {
970 when.method(POST)
971 .header("Content-type", "application/msgpack")
972 .header("datadog-obfuscation-version", "1")
973 .path("/v0.6/stats")
974 .body_includes("libdatadog-test");
975 then.status(200).body("");
976 })
977 .await;
978
979 let concentrator = get_test_concentrator_with_obfuscation_config(Some(Arc::new(
980 ArcSwap::from_pointee(StatsComputationObfuscationConfig {
981 enabled: true,
982 ..Default::default()
983 }),
984 )));
985
986 let stats_exporter = StatsExporter::new(
987 BUCKETS_DURATION,
988 Arc::new(Mutex::new(concentrator)),
989 get_test_metadata(),
990 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
991 NativeCapabilities::new_client(),
992 #[cfg(feature = "stats-obfuscation")]
993 "1",
994 #[cfg(feature = "telemetry")]
995 None,
996 #[cfg(feature = "dogstatsd")]
997 None,
998 );
999
1000 let send_status = stats_exporter.send(true).await;
1001 send_status.unwrap();
1002
1003 mock.assert_async().await;
1004 }
1005
1006 #[cfg(any(feature = "telemetry", feature = "dogstatsd"))]
1012 fn get_collapsed_concentrator(per_key_collapsed: bool) -> SpanConcentrator {
1013 use crate::span_concentrator::CardinalityLimitConfig;
1014 use libdd_trace_utils::span::{
1015 trace_utils,
1016 v04::{SpanSlice, VecMap},
1017 };
1018
1019 let mut cardinality_limit_config = CardinalityLimitConfig {
1020 whole_key_limit: 2, ..Default::default()
1022 };
1023 if per_key_collapsed {
1024 cardinality_limit_config.resource_limit = 1;
1025 cardinality_limit_config.http_endpoint_limit = 1;
1026 }
1027 let mut concentrator = SpanConcentrator::new(
1028 BUCKETS_DURATION,
1029 SystemTime::now(),
1030 vec![],
1031 vec![],
1032 Some(cardinality_limit_config),
1033 vec![],
1034 #[cfg(feature = "stats-obfuscation")]
1035 None,
1036 );
1037
1038 let mut trace = vec![
1039 SpanSlice {
1040 service: Cow::Borrowed("svc-a"),
1041 resource: Cow::Borrowed("resource-a"),
1042 duration: 10,
1043 meta: VecMap::from_iter([(Cow::Borrowed("http.endpoint"), Cow::Borrowed("/"))]),
1044 ..Default::default()
1045 },
1046 SpanSlice {
1048 service: Cow::Borrowed("svc-a"),
1049 resource: Cow::Borrowed("resource-b"),
1050 duration: 20,
1051 meta: VecMap::from_iter([(Cow::Borrowed("http.endpoint"), Cow::Borrowed("/"))]),
1052 ..Default::default()
1053 },
1054 SpanSlice {
1056 service: Cow::Borrowed("svc-b"),
1057 resource: Cow::Borrowed("resource-c"),
1058 duration: 20,
1059 meta: VecMap::from_iter([(
1060 Cow::Borrowed("http.endpoint"),
1061 Cow::Borrowed("/hello.txt"),
1062 )]),
1063 ..Default::default()
1064 },
1065 SpanSlice {
1067 service: Cow::Borrowed("svc-b"),
1068 resource: Cow::Borrowed("resource-b"),
1069 duration: 20,
1070 ..Default::default()
1071 },
1072 ];
1073 trace_utils::compute_top_level_span(trace.as_mut_slice());
1074 for span in &trace {
1075 concentrator.add_span(span);
1076 }
1077 concentrator
1078 }
1079
1080 #[cfg(feature = "dogstatsd")]
1082 #[cfg_attr(miri, ignore)]
1083 #[tokio::test]
1084 async fn test_no_emission_when_zero() {
1085 use std::net;
1086
1087 let server = MockServer::start_async().await;
1088 server
1089 .mock_async(|_when, then| {
1090 then.status(200).body("");
1091 })
1092 .await;
1093
1094 let socket = net::UdpSocket::bind("127.0.0.1:0").expect("failed to bind UDP socket");
1096 socket
1097 .set_read_timeout(Some(std::time::Duration::from_millis(200)))
1098 .unwrap();
1099 let addr = socket.local_addr().unwrap().to_string();
1100
1101 let dogstatsd_client =
1102 libdd_dogstatsd_client::DogStatsDClient::new(libdd_common::Endpoint::from_slice(&addr))
1103 .expect("failed to create dogstatsd client");
1104
1105 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
1107 BUCKETS_DURATION,
1108 Arc::new(Mutex::new(get_test_concentrator())),
1109 get_test_metadata(),
1110 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
1111 NativeCapabilities::new_client(),
1112 #[cfg(feature = "stats-obfuscation")]
1113 "1",
1114 #[cfg(feature = "telemetry")]
1115 None,
1116 Some(dogstatsd_client),
1117 );
1118
1119 stats_exporter.send(true).await.unwrap();
1120
1121 let mut buf = [0u8; 256];
1123 let result = socket.recv(&mut buf);
1124 assert!(
1125 result.is_err(),
1126 "No DogStatsD datagram expected when collapsed_spans == 0. Got {}",
1127 std::str::from_utf8(&buf[..result.unwrap()]).unwrap()
1128 );
1129 }
1130
1131 #[cfg(feature = "dogstatsd")]
1133 #[cfg_attr(miri, ignore)]
1134 #[tokio::test]
1135 async fn test_collapsed_spans_dogstatsd() {
1136 use std::net;
1137
1138 let server = MockServer::start_async().await;
1139 server
1140 .mock_async(|_when, then| {
1141 then.status(200).body("");
1142 })
1143 .await;
1144
1145 let socket = net::UdpSocket::bind("127.0.0.1:0").expect("failed to bind UDP socket");
1146 socket
1147 .set_read_timeout(Some(std::time::Duration::from_millis(500)))
1148 .unwrap();
1149 let addr = socket.local_addr().unwrap().to_string();
1150
1151 let dogstatsd_client =
1152 libdd_dogstatsd_client::DogStatsDClient::new(libdd_common::Endpoint::from_slice(&addr))
1153 .expect("failed to create dogstatsd client");
1154
1155 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
1156 BUCKETS_DURATION,
1157 Arc::new(Mutex::new(get_collapsed_concentrator(false))),
1158 get_test_metadata(),
1159 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
1160 NativeCapabilities::new_client(),
1161 #[cfg(feature = "stats-obfuscation")]
1162 "1",
1163 #[cfg(feature = "telemetry")]
1164 None,
1165 Some(dogstatsd_client),
1166 );
1167
1168 stats_exporter.send(true).await.unwrap();
1169
1170 let mut buf = [0u8; 256];
1171 let n = socket
1172 .recv(&mut buf)
1173 .expect("expected a DogStatsD datagram");
1174 let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
1175 assert_eq!(
1176 datagram, "datadog.tracer.stats.collapsed_spans:2|c|#collapsed_spans:whole_key",
1177 "DogStatsD datagram must match the expected format"
1178 );
1179 }
1180
1181 #[cfg(feature = "dogstatsd")]
1184 #[cfg_attr(miri, ignore)]
1185 #[tokio::test]
1186 async fn test_collapsed_spans_per_key_dogstatsd() {
1187 use std::net;
1188
1189 let server = MockServer::start_async().await;
1190 server
1191 .mock_async(|_when, then| {
1192 then.status(200).body("");
1193 })
1194 .await;
1195
1196 let socket = net::UdpSocket::bind("127.0.0.1:0").expect("failed to bind UDP socket");
1197 socket
1198 .set_read_timeout(Some(std::time::Duration::from_millis(500)))
1199 .unwrap();
1200 let addr = socket.local_addr().unwrap().to_string();
1201
1202 let dogstatsd_client =
1203 libdd_dogstatsd_client::DogStatsDClient::new(libdd_common::Endpoint::from_slice(&addr))
1204 .expect("failed to create dogstatsd client");
1205
1206 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
1207 BUCKETS_DURATION,
1208 Arc::new(Mutex::new(get_collapsed_concentrator(true))),
1209 get_test_metadata(),
1210 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
1211 NativeCapabilities::new_client(),
1212 #[cfg(feature = "stats-obfuscation")]
1213 "1",
1214 #[cfg(feature = "telemetry")]
1215 None,
1216 Some(dogstatsd_client),
1217 );
1218
1219 stats_exporter.send(true).await.unwrap();
1220
1221 let mut buf = [0u8; 256];
1222
1223 let n = socket
1225 .recv(&mut buf)
1226 .expect("expected a DogStatsD datagram");
1227 let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
1228 assert_eq!(
1229 datagram, "datadog.tracer.stats.collapsed_spans:2|c|#collapsed_spans:whole_key",
1230 "DogStatsD datagram must match the expected format"
1231 );
1232
1233 let n = socket
1235 .recv(&mut buf)
1236 .expect("expected a DogStatsD datagram");
1237 let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
1238 assert_eq!(
1239 datagram, "datadog.tracer.stats.collapsed_spans:1|c|#collapsed_spans:resource",
1240 "DogStatsD datagram must match the expected format"
1241 );
1242 let n = socket
1243 .recv(&mut buf)
1244 .expect("expected a DogStatsD datagram");
1245 let datagram = std::str::from_utf8(&buf[..n]).expect("valid utf-8");
1246 assert_eq!(
1247 datagram, "datadog.tracer.stats.collapsed_spans:2|c|#collapsed_spans:resource,collapsed_spans:http_endpoint",
1248 "DogStatsD datagram must match the expected format"
1249 );
1250 }
1251
1252 #[cfg(feature = "telemetry")]
1255 #[cfg_attr(miri, ignore)]
1256 #[tokio::test]
1257 async fn test_collapsed_spans_telemetry() {
1258 use libdd_telemetry::worker::TelemetryWorkerBuilder;
1259
1260 let server = MockServer::start_async().await;
1261 server
1262 .mock_async(|_when, then| {
1263 then.status(200).body("");
1264 })
1265 .await;
1266
1267 let (handle, _join_handle) = TelemetryWorkerBuilder::new(
1268 "test-host".to_string(),
1269 "test-service".to_string(),
1270 "rust".to_string(),
1271 "1.0".to_string(),
1272 "0.0.0".to_string(),
1273 )
1274 .spawn();
1275
1276 let stats_exporter = StatsExporter::<NativeCapabilities>::new(
1277 BUCKETS_DURATION,
1278 Arc::new(Mutex::new(get_collapsed_concentrator(true))),
1279 get_test_metadata(),
1280 Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
1281 NativeCapabilities::new_client(),
1282 #[cfg(feature = "stats-obfuscation")]
1283 "1",
1284 #[cfg(feature = "telemetry")]
1285 Some(handle),
1286 #[cfg(feature = "dogstatsd")]
1287 None,
1288 );
1289
1290 stats_exporter.send(true).await.unwrap();
1291
1292 let stats_exporter_ref = &stats_exporter;
1293 let (handle_ref, _key) = stats_exporter_ref
1294 .telemetry
1295 .as_ref()
1296 .expect("telemetry must be set");
1297 let receiver = handle_ref.stats().expect("failed to request stats");
1298 let stats = receiver.await.expect("failed to receive stats");
1299 assert_eq!(
1304 stats.metric_contexts, 1,
1305 "exactly one metric context (COLLAPSED_SPANS_METRIC) should be registered"
1306 );
1307 assert_eq!(
1308 stats.metric_buckets.buckets, 3,
1309 "exactly 3 metric bucket expected after one whole-key collapsed-spans, one resource key collapsed-span and one resource key+http_endpoint key emissions"
1310 );
1311 }
1312}