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::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
34/// A fully-built HTTP request ready to be sent by the [`StatsExporter`].
35///
36/// The exporter is agnostic to how the body was produced: the agent and
37/// agentless destinations each build their own [`StatsRequest`] with the
38/// appropriate encoding, headers, compression, and retry strategy.
39pub 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    /// Send `ClientStatsPayload` as msgpack to the Agent's `/v0.6/stats`.
50    Agent { endpoint: Endpoint },
51    /// Send the top-level `StatsPayload` as zstd-compressed msgpack directly to
52    /// the intake (`/api/v0.2/stats`), authenticated.
53    Agentless(AgentlessStatsTarget),
54}
55
56/// Parameters needed to send stats directly to the Datadog intake.
57///
58/// This type is intentionally free of any Agent-specific concepts. `hostname`
59/// and `env` for the `StatsPayload` are taken from the exporter's
60/// [`StatsMetadata`]; only intake-specific values live here.
61#[derive(Debug)]
62pub struct AgentlessStatsTarget {
63    /// Full intake endpoint (e.g. `https://trace.agent.<site>/api/v0.2/stats`),
64    /// carrying the Datadog API key. `send_with_retry` derives the `dd-api-key`
65    /// header from [`Endpoint::api_key`].
66    pub endpoint: Endpoint,
67    /// `agent_version` field of the `StatsPayload`: the library version suffixed
68    /// with the language, so the backend can tell libdatadog tracers from the Agent.
69    pub version: String,
70}
71
72/// Health metric name for the number of spans collapsed.
73pub const COLLAPSED_SPANS_HEALTH_METRIC: &str = "datadog.tracer.stats.collapsed_spans";
74
75/// Telemetry metric name for the number of spans collapsed.
76pub const COLLAPSED_SPANS_TELEMETRY_METRIC: &str = "tracers.stats_collapsed_spans";
77
78/// Metadata needed by the stats exporter to annotate payloads and HTTP requests.
79#[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/// An exporter that concentrates and sends stats to the agent.
130///
131/// `Cap` is the capabilities bundle (HTTP + sleep). Leaf crates pin it to a
132/// concrete type (`NativeCapabilities` or `WasmCapabilities`).
133#[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    /// Optional telemetry handle and context key.
147    #[cfg(feature = "telemetry")]
148    telemetry: Option<(
149        libdd_telemetry::worker::TelemetryWorkerHandle<Cap>,
150        libdd_telemetry::metrics::ContextKey,
151    )>,
152    /// Optional DogStatsD client.
153    #[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    /// Return a new StatsExporter targeting the Datadog Agent's `/v0.6/stats`.
163    ///
164    /// - `flush_interval` the interval on which the concentrator is flushed
165    /// - `concentrator` an impl of `FlushableConcentrator` storing the stats to be sent to the
166    ///   agent
167    /// - `meta` metadata used in ClientStatsPayload and as headers to send stats to the agent
168    /// - `endpoint` the Endpoint used to send stats to the agent
169    #[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    /// Return a new StatsExporter that sends the top-level `StatsPayload`
198    /// directly to the Datadog intake (agentless).
199    ///
200    /// This path is fully decoupled from the Agent transport: it wraps the
201    /// flushed buckets in a `StatsPayload`, msgpack-encodes it, and posts it to
202    /// `target.endpoint` with `dd-api-key` auth and zstd compression.
203    #[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    /// Shared constructor for both the Agent and agentless destinations.
231    #[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    /// Flush the stats stored in the concentrator and send them
272    ///
273    /// If the stats flushed from the concentrator contain at least one time bucket the stats are
274    /// sent to `self.endpoint`. The stats are serialized as msgpack.
275    ///
276    /// # Errors
277    /// The function will return an error in the following case:
278    /// - The endpoint failed to build
279    /// - The stats payload cannot be serialized as a valid http body
280    /// - The http client failed while sending the request
281    /// - The http status of the response is not 2xx
282    ///
283    /// # Panic
284    /// Will panic if another thread panicked while holding the concentrator lock in which
285    /// case stats cannot be flushed since the concentrator might be corrupted.
286    /// Returns `Ok(true)` if stats were sent, `Ok(false)` if the concentrator had nothing to send.
287    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    /// Encode the buckets into stats payloads and send them.
339    ///
340    /// Buckets over [`MAX_GROUPED_STATS_PER_PAYLOAD`] are split into several payloads. Like the
341    /// Agent, all fragments of one flush share a sequence id, are flagged `split_payload`, and are
342    /// sent in separate requests.
343    ///
344    /// `obfuscated` adds the `datadog-obfuscation-version` header.
345    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        // All fragments of one flush share a single sequence id, as the Agent does.
353        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    /// Encode a single (already split) group of buckets into a stats payload and send it.
383    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    /// Build the request for the Agent `/v0.6/stats` destination: a
419    /// `ClientStatsPayload` serialized as msgpack, uncompressed.
420    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
453/// Number of retries used by the agentless intake stats sender.
454const AGENTLESS_STATS_MAX_RETRIES: u32 = 2;
455/// Initial retry delay (ms) used by the agentless intake stats sender.
456const AGENTLESS_STATS_RETRY_DELAY_MS: u64 = 1000;
457
458/// Build the request for the agentless intake `/api/v0.2/stats` destination.
459///
460/// The buckets are wrapped in a `ClientStatsPayload`, then a `StatsPayload`,
461/// serialized as msgpack and compressed with zstd (`Content-Encoding: zstd` set
462/// by `send_with_retry`). Auth uses the `dd-api-key` header. Shares no logic
463/// with the Agent transport.
464///
465/// `split` marks this payload as one fragment of a split flush (see `send_payload`).
466fn 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    // Unlike the Agent path, no Agent enriches the payload downstream, so populate the
475    // informative `lang`/`tracer_version`/`container_id` fields ourselves.
476    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    // `dd-api-key` and entity headers are set automatically by `send_with_retry` from the
489    // endpoint (`Endpoint::api_key`), so only the payload-specific headers are added here.
490    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    /// Flush and send stats on every trigger.
527    async fn run(&mut self) {
528        let _ = self.send(false).await; // bool return ignored by Worker
529    }
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        // These fields will be set by the Agent
561        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
571/// Return the stats endpoint url to send stats to the agent at `agent_url`
572pub 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    /// Fails to compile if stats exporter is not Send and Sync
597    #[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            // Make sure the oldest bucket will be flushed on next send
631            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                // The agentless intake compresses with zstd when the feature is enabled;
708                // otherwise the raw msgpack body is sent and contains the service name.
709                #[cfg(feature = "compression")]
710                let w = w.header("Content-Encoding", "zstd");
711                #[cfg(not(feature = "compression"))]
712                // `libdatadog-test` is the service; `rust` is the lang field that must now be
713                // populated by us (the Agent no longer sets it in agentless mode).
714                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    /// The agentless intake retries on server errors (unlike the agent path, which
747    /// does not retry). A `503` response must trigger `AGENTLESS_STATS_MAX_RETRIES`
748    /// additional attempts (3 total: 1 initial + 2 retries) before `send` returns
749    /// an error.
750    #[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        // 1 initial attempt + AGENTLESS_STATS_MAX_RETRIES (2) retries = 3 hits. The
793        // exponential backoff starts at 1s, so allow a generous poll window.
794        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            // Use smaller buckets duration to speed up test
862            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        // Wait for stats to be flushed
879        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        // Test that empty env defaults to "unknown-env"
939        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        // Test that non-empty env is preserved
951        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    /// Build a concentrator with `max_entries_per_bucket = 1` pre-seeded with four distinct spans
1007    /// so that three spans are collapsed into the overflow bucket.
1008    ///
1009    /// per_key_collapsed: enable small per-key limits to also collapse on `resource` and
1010    /// `http_endpoint`
1011    #[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, // max 2 distinct key → third distinct span collapses
1021            ..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            // only resource get collapsed if per-key limits are enabled
1047            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            // both resource and http_endpoint get collapsed if per-key limits are enabled
1055            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            // both resource and http_endpoint get collapsed if per-key limits are enabled
1066            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    /// Verify that when `collapsed_spans == 0` the DogStatsD socket receives nothing.
1081    #[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        // Bind a UDP socket so we can detect whether anything arrives.
1095        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        // get_test_concentrator() has no cardinality collapse: collapsed_spans will be 0.
1106        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        // The socket must not have received any datagram.
1122        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    /// Verify that `COLLAPSED_SPANS_METRIC` is emitted to DogStatsD when spans are collapsed.
1132    #[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    /// Verify that `COLLAPSED_SPANS_METRIC` is emitted to DogStatsD when spans are collapsed by
1182    /// per-key limits.
1183    #[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        // Whole-key metric emitted first
1224        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        // Then comes the per-key collapse telemetry
1234        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    /// Verify that `COLLAPSED_SPANS_METRIC` is enqueued to the telemetry worker when spans
1253    /// are collapsed. This does not verify the actual value of the metric.
1254    #[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        // metric_contexts == 1 verifies that exactly one metric name was registered
1300        // (i.e. COLLAPSED_SPANS_METRIC and nothing else).
1301        // metric_buckets.buckets == 1 verifies that a data point was recorded for it.
1302        // However it does not check the value of the data point.
1303        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}