1use 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
28pub const COLLAPSED_SPANS_HEALTH_METRIC: &str = "datadog.tracer.stats.collapsed_spans";
30
31pub const COLLAPSED_SPANS_TELEMETRY_METRIC: &str = "tracers.stats_collapsed_spans";
33
34#[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#[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 #[cfg(feature = "telemetry")]
104 telemetry: Option<(
105 libdd_telemetry::worker::TelemetryWorkerHandle,
106 libdd_telemetry::metrics::ContextKey,
107 )>,
108 #[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 #[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 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 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 async fn run(&mut self) {
271 let _ = self.send(false).await; }
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 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
309pub 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 #[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 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 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 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 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 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 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), #[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 #[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 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 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 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 #[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 #[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 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}