Skip to main content

libdd_trace_stats/
stats_exporter.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    sync::{
6        atomic::{AtomicU64, Ordering},
7        Arc, Mutex,
8    },
9    time,
10};
11
12#[cfg(feature = "stats-obfuscation")]
13use crate::span_concentrator::SharedStatsComputationObfuscationConfig;
14use crate::span_concentrator::{FlushableConcentrator, SpanConcentrator};
15use async_trait::async_trait;
16use libdd_capabilities::{HttpClientCapability, MaybeSend, SleepCapability};
17use libdd_common::Endpoint;
18use libdd_shared_runtime::Worker;
19use libdd_trace_protobuf::pb;
20use libdd_trace_utils::send_with_retry::{send_with_retry, RetryStrategy};
21use libdd_trace_utils::trace_utils::TracerHeaderTags;
22use libdd_trace_utils::tracer_metadata::TracerMetadata;
23use std::fmt::Debug;
24use tracing::error;
25
26pub const STATS_ENDPOINT_PATH: &str = "/v0.6/stats";
27
28/// Metadata needed by the stats exporter to annotate payloads and HTTP requests.
29#[derive(Clone, Default, Debug)]
30pub struct StatsMetadata {
31    pub hostname: String,
32    pub env: String,
33    pub app_version: String,
34    pub runtime_id: String,
35    pub language: String,
36    pub lang_version: String,
37    pub lang_interpreter: String,
38    pub lang_vendor: String,
39    pub tracer_version: String,
40    pub git_commit_sha: String,
41    pub process_tags: String,
42    pub service: String,
43}
44
45impl<'a> From<&'a StatsMetadata> for TracerHeaderTags<'a> {
46    fn from(m: &'a StatsMetadata) -> TracerHeaderTags<'a> {
47        TracerHeaderTags {
48            lang: &m.language,
49            lang_version: &m.lang_version,
50            lang_interpreter: &m.lang_interpreter,
51            lang_vendor: &m.lang_vendor,
52            tracer_version: &m.tracer_version,
53            ..Default::default()
54        }
55    }
56}
57
58impl From<TracerMetadata> for StatsMetadata {
59    fn from(m: TracerMetadata) -> StatsMetadata {
60        StatsMetadata {
61            hostname: m.hostname,
62            env: m.env,
63            app_version: m.app_version,
64            runtime_id: m.runtime_id,
65            language: m.language,
66            lang_version: m.language_version,
67            lang_interpreter: m.language_interpreter,
68            lang_vendor: m.language_interpreter_vendor,
69            tracer_version: m.tracer_version,
70            git_commit_sha: m.git_commit_sha,
71            process_tags: m.process_tags,
72            service: m.service,
73        }
74    }
75}
76
77/// An exporter that concentrates and sends stats to the agent.
78///
79/// `Cap` is the capabilities bundle (HTTP + sleep). Leaf crates pin it to a
80/// concrete type (`NativeCapabilities` or `WasmCapabilities`).
81#[derive(Debug)]
82pub struct StatsExporter<
83    Cap: HttpClientCapability + SleepCapability,
84    Con: FlushableConcentrator = SpanConcentrator,
85> {
86    flush_interval: time::Duration,
87    concentrator: Arc<Mutex<Con>>,
88    endpoint: Endpoint,
89    meta: StatsMetadata,
90    sequence_id: AtomicU64,
91    capabilities: Cap,
92    #[cfg(feature = "stats-obfuscation")]
93    obfuscation_config: SharedStatsComputationObfuscationConfig,
94    #[cfg(feature = "stats-obfuscation")]
95    supported_obfuscation_version: &'static str,
96}
97
98impl<Cap: HttpClientCapability + SleepCapability, Con: FlushableConcentrator>
99    StatsExporter<Cap, Con>
100{
101    /// Return a new StatsExporter
102    ///
103    /// - `flush_interval` the interval on which the concentrator is flushed
104    /// - `concentrator` an impl of `FlushableConcentrator` storing the stats to be sent to the
105    ///   agent
106    /// - `meta` metadata used in ClientStatsPayload and as headers to send stats to the agent
107    /// - `endpoint` the Endpoint used to send stats to the agent
108    pub fn new(
109        flush_interval: time::Duration,
110        concentrator: Arc<Mutex<Con>>,
111        meta: StatsMetadata,
112        endpoint: Endpoint,
113        capabilities: Cap,
114        #[cfg(feature = "stats-obfuscation")]
115        obfuscation_config: SharedStatsComputationObfuscationConfig,
116        #[cfg(feature = "stats-obfuscation")] supported_obfuscation_version: &'static str,
117    ) -> Self {
118        Self {
119            flush_interval,
120            concentrator,
121            endpoint,
122            meta,
123            sequence_id: AtomicU64::new(0),
124            capabilities,
125            #[cfg(feature = "stats-obfuscation")]
126            obfuscation_config,
127            #[cfg(feature = "stats-obfuscation")]
128            supported_obfuscation_version,
129        }
130    }
131
132    /// Flush the stats stored in the concentrator and send them
133    ///
134    /// If the stats flushed from the concentrator contain at least one time bucket the stats are
135    /// sent to `self.endpoint`. The stats are serialized as msgpack.
136    ///
137    /// # Errors
138    /// The function will return an error in the following case:
139    /// - The endpoint failed to build
140    /// - The stats payload cannot be serialized as a valid http body
141    /// - The http client failed while sending the request
142    /// - The http status of the response is not 2xx
143    ///
144    /// # Panic
145    /// Will panic if another thread panicked while holding the concentrator lock in which
146    /// case stats cannot be flushed since the concentrator might be corrupted.
147    /// Returns `Ok(true)` if stats were sent, `Ok(false)` if the concentrator had nothing to send.
148    pub async fn send(&self, force_flush: bool) -> anyhow::Result<bool> {
149        let payload = self.flush(force_flush);
150        if payload.stats.is_empty() {
151            return Ok(false);
152        }
153        let body = rmp_serde::encode::to_vec_named(&payload)?;
154
155        let mut headers: http::HeaderMap = TracerHeaderTags::from(&self.meta).into();
156
157        headers.insert(
158            http::header::CONTENT_TYPE,
159            libdd_common::header::APPLICATION_MSGPACK,
160        );
161
162        #[cfg(feature = "stats-obfuscation")]
163        if self.obfuscation_config.load().enabled {
164            headers.insert(
165                http::HeaderName::from_static("datadog-obfuscation-version"),
166                http::HeaderValue::from_static(self.supported_obfuscation_version),
167            );
168        }
169
170        let result = send_with_retry(
171            &self.capabilities,
172            &self.endpoint,
173            body,
174            &headers,
175            &RetryStrategy::default(),
176        )
177        .await;
178
179        match result {
180            Ok(_) => Ok(true),
181            Err(err) => {
182                error!(?err, "Error with the StateExporter when sending stats");
183                anyhow::bail!("Failed to send stats: {err}");
184            }
185        }
186    }
187
188    /// Flush stats from the concentrator into a payload
189    ///
190    /// # Arguments
191    /// - `force_flush` if true, triggers a force flush on the concentrator causing all buckets to
192    ///   be flushed regardless of their age.
193    ///
194    /// # Panic
195    /// Will panic if another thread panicked while holding the concentrator lock in which
196    /// case stats cannot be flushed since the concentrator might be corrupted.
197    fn flush(&self, force_flush: bool) -> pb::ClientStatsPayload {
198        let sequence = self.sequence_id.fetch_add(1, Ordering::Relaxed);
199        encode_stats_payload(
200            &self.meta,
201            sequence,
202            #[allow(clippy::unwrap_used)]
203            self.concentrator.lock().unwrap().flush_buckets(force_flush),
204        )
205    }
206}
207
208#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
209#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
210impl<
211        Cap: HttpClientCapability + SleepCapability + MaybeSend + Sync + 'static,
212        Con: FlushableConcentrator + Send + Debug,
213    > Worker for StatsExporter<Cap, Con>
214{
215    async fn trigger(&mut self) {
216        self.capabilities.sleep(self.flush_interval).await;
217    }
218
219    /// Flush and send stats on every trigger.
220    async fn run(&mut self) {
221        let _ = self.send(false).await; // bool return ignored by Worker
222    }
223
224    async fn shutdown(&mut self) {
225        let _ = self.send(true).await;
226    }
227}
228
229fn encode_stats_payload(
230    meta: &StatsMetadata,
231    sequence: u64,
232    buckets: Vec<pb::ClientStatsBucket>,
233) -> pb::ClientStatsPayload {
234    pb::ClientStatsPayload {
235        hostname: meta.hostname.clone(),
236        env: if meta.env.is_empty() {
237            "unknown-env".to_string()
238        } else {
239            meta.env.clone()
240        },
241        version: meta.app_version.clone(),
242        runtime_id: meta.runtime_id.clone(),
243        sequence,
244        service: meta.service.clone(),
245        stats: buckets,
246        git_commit_sha: meta.git_commit_sha.clone(),
247        process_tags: meta.process_tags.clone(),
248        // These fields will be set by the Agent
249        container_id: String::new(),
250        tags: Vec::new(),
251        agent_aggregation: String::new(),
252        image_tag: String::new(),
253        process_tags_hash: 0,
254        lang: String::new(),
255        tracer_version: String::new(),
256    }
257}
258
259/// Return the stats endpoint url to send stats to the agent at `agent_url`
260pub fn stats_url_from_agent_url(agent_url: &str) -> anyhow::Result<http::Uri> {
261    let mut parts = agent_url.parse::<http::Uri>()?.into_parts();
262    parts.path_and_query = Some(http::uri::PathAndQuery::from_static(STATS_ENDPOINT_PATH));
263    Ok(http::Uri::from_parts(parts)?)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    #[cfg(feature = "stats-obfuscation")]
270    use crate::span_concentrator::StatsComputationObfuscationConfig;
271    use httpmock::prelude::*;
272    use httpmock::MockServer;
273    use libdd_capabilities_impl::NativeCapabilities;
274    use libdd_shared_runtime::SharedRuntime;
275    use libdd_trace_utils::span::{trace_utils, v04::SpanSlice};
276    use libdd_trace_utils::test_utils::poll_for_mock_hit;
277    use time::Duration;
278    use time::SystemTime;
279
280    fn is_send<T: Send>() {}
281    fn is_sync<T: Sync>() {}
282
283    const BUCKETS_DURATION: Duration = Duration::from_secs(10);
284
285    /// Fails to compile if stats exporter is not Send and Sync
286    #[test]
287    fn test_stats_exporter_sync_send() {
288        let _ = is_send::<StatsExporter<NativeCapabilities>>;
289        let _ = is_sync::<StatsExporter<NativeCapabilities>>;
290    }
291
292    fn get_test_metadata() -> StatsMetadata {
293        StatsMetadata {
294            hostname: "libdatadog-test".into(),
295            env: "test".into(),
296            app_version: "0.0.0".into(),
297            language: "rust".into(),
298            tracer_version: "0.0.0".into(),
299            runtime_id: "e39d6d12-0752-489f-b488-cf80006c0378".into(),
300            process_tags: "key1:value1,key2:value2".into(),
301            ..Default::default()
302        }
303    }
304
305    fn get_test_concentrator() -> SpanConcentrator {
306        let mut concentrator = SpanConcentrator::new(
307            BUCKETS_DURATION,
308            // Make sure the oldest bucket will be flushed on next send
309            SystemTime::now() - BUCKETS_DURATION * 3,
310            vec![],
311            vec![],
312            #[cfg(feature = "stats-obfuscation")]
313            None,
314        );
315        let mut trace = vec![];
316
317        for i in 1..100 {
318            trace.push(SpanSlice {
319                service: "libdatadog-test",
320                duration: i,
321                ..Default::default()
322            })
323        }
324
325        trace_utils::compute_top_level_span(trace.as_mut_slice());
326
327        for span in trace.iter() {
328            concentrator.add_span(span);
329        }
330        concentrator
331    }
332
333    #[cfg_attr(miri, ignore)]
334    #[tokio::test]
335    async fn test_send_stats() {
336        let server = MockServer::start_async().await;
337
338        let mock = server
339            .mock_async(|when, then| {
340                when.method(POST)
341                    .header("Content-type", "application/msgpack")
342                    .path("/v0.6/stats")
343                    .body_includes("libdatadog-test")
344                    .body_includes("key1:value1,key2:value2");
345                then.status(200).body("");
346            })
347            .await;
348
349        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
350            BUCKETS_DURATION,
351            Arc::new(Mutex::new(get_test_concentrator())),
352            get_test_metadata(),
353            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
354            NativeCapabilities::new_client(),
355            #[cfg(feature = "stats-obfuscation")]
356            StatsComputationObfuscationConfig::disabled(),
357            #[cfg(feature = "stats-obfuscation")]
358            "1",
359        );
360
361        let send_status = stats_exporter.send(true).await;
362        send_status.unwrap();
363
364        mock.assert_async().await;
365    }
366
367    #[cfg_attr(miri, ignore)]
368    #[tokio::test]
369    async fn test_send_stats_fail() {
370        let server = MockServer::start_async().await;
371
372        let mut mock = server
373            .mock_async(|_when, then| {
374                then.status(503)
375                    .header("content-type", "application/json")
376                    .body(r#"{"status":"error"}"#);
377            })
378            .await;
379
380        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
381            BUCKETS_DURATION,
382            Arc::new(Mutex::new(get_test_concentrator())),
383            get_test_metadata(),
384            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
385            NativeCapabilities::new_client(),
386            #[cfg(feature = "stats-obfuscation")]
387            StatsComputationObfuscationConfig::disabled(),
388            #[cfg(feature = "stats-obfuscation")]
389            "1",
390        );
391
392        let send_status = stats_exporter.send(true).await;
393        send_status.unwrap_err();
394
395        assert!(
396            poll_for_mock_hit(&mut mock, 10, 100, 6, true).await,
397            "Expected max retry attempts"
398        );
399    }
400
401    #[cfg_attr(miri, ignore)]
402    #[test]
403    fn test_run() {
404        let shared_runtime = SharedRuntime::new().expect("Failed to create runtime");
405
406        let server = MockServer::start();
407
408        let mut mock = server.mock(|when, then| {
409            when.method(POST)
410                .header("Content-type", "application/msgpack")
411                .path("/v0.6/stats")
412                .body_includes("libdatadog-test")
413                .body_includes("key1:value1,key2:value2");
414            then.status(200).body("");
415        });
416
417        let caps = NativeCapabilities::new();
418        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
419            // Use smaller buckets duration to speed up test
420            Duration::from_secs(1),
421            Arc::new(Mutex::new(get_test_concentrator())),
422            get_test_metadata(),
423            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
424            caps.clone(),
425            #[cfg(feature = "stats-obfuscation")]
426            StatsComputationObfuscationConfig::disabled(),
427            #[cfg(feature = "stats-obfuscation")]
428            "1",
429        );
430        let _handle = shared_runtime
431            .spawn_worker(stats_exporter, true)
432            .expect("Failed to spawn worker");
433
434        // Wait for stats to be flushed
435        std::thread::sleep(Duration::from_secs(1));
436
437        assert!(
438            shared_runtime
439                .block_on(poll_for_mock_hit(&mut mock, 10, 100, 1, false))
440                .expect("Failed to use runtime"),
441            "Expected max retry attempts"
442        );
443    }
444
445    #[cfg_attr(miri, ignore)]
446    #[test]
447    fn test_worker_shutdown() {
448        let shared_runtime = SharedRuntime::new().expect("Failed to create runtime");
449
450        let server = MockServer::start();
451
452        let mut mock = server.mock(|when, then| {
453            when.method(POST)
454                .header("Content-type", "application/msgpack")
455                .path("/v0.6/stats")
456                .body_includes("libdatadog-test")
457                .body_includes("key1:value1,key2:value2");
458            then.status(200).body("");
459        });
460
461        let buckets_duration = Duration::from_secs(10);
462
463        let caps = NativeCapabilities::new();
464        let stats_exporter = StatsExporter::<NativeCapabilities>::new(
465            buckets_duration,
466            Arc::new(Mutex::new(get_test_concentrator())),
467            get_test_metadata(),
468            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
469            caps.clone(),
470            #[cfg(feature = "stats-obfuscation")]
471            StatsComputationObfuscationConfig::disabled(),
472            #[cfg(feature = "stats-obfuscation")]
473            "1",
474        );
475
476        let _handle = shared_runtime
477            .spawn_worker(stats_exporter, true)
478            .expect("Failed to spawn worker");
479
480        shared_runtime.shutdown(None).unwrap();
481
482        assert!(
483            shared_runtime
484                .block_on(poll_for_mock_hit(&mut mock, 10, 100, 1, false))
485                .expect("Failed to get runtime"),
486            "Expected max retry attempts"
487        );
488    }
489
490    #[test]
491    fn test_encode_stats_payload_defaults_empty_env() {
492        // Test that empty env defaults to "unknown-env"
493        let mut meta_with_empty_env = get_test_metadata();
494        meta_with_empty_env.env = "".to_string();
495
496        let buckets = vec![];
497        let payload = encode_stats_payload(&meta_with_empty_env, 1, buckets.clone());
498
499        assert_eq!(
500            payload.env, "unknown-env",
501            "Empty env should default to 'unknown-env'"
502        );
503
504        // Test that non-empty env is preserved
505        let meta_with_env = get_test_metadata();
506        let payload_with_env = encode_stats_payload(&meta_with_env, 2, buckets);
507
508        assert_eq!(
509            payload_with_env.env, "test",
510            "Non-empty env should be preserved"
511        );
512    }
513    #[cfg(feature = "stats-obfuscation")]
514    #[cfg_attr(miri, ignore)]
515    #[tokio::test]
516    async fn test_send_stats_with_obfuscation_header() {
517        use arc_swap::ArcSwap;
518
519        let server = MockServer::start_async().await;
520
521        let mock = server
522            .mock_async(|when, then| {
523                when.method(POST)
524                    .header("Content-type", "application/msgpack")
525                    .header("datadog-obfuscation-version", "1")
526                    .path("/v0.6/stats")
527                    .body_includes("libdatadog-test");
528                then.status(200).body("");
529            })
530            .await;
531
532        let stats_exporter = StatsExporter::new(
533            BUCKETS_DURATION,
534            Arc::new(Mutex::new(get_test_concentrator())),
535            get_test_metadata(),
536            Endpoint::from_url(stats_url_from_agent_url(&server.url("/")).unwrap()),
537            NativeCapabilities::new_client(),
538            #[cfg(feature = "stats-obfuscation")]
539            Arc::new(ArcSwap::from_pointee(StatsComputationObfuscationConfig {
540                enabled: true,
541                ..Default::default()
542            })),
543            #[cfg(feature = "stats-obfuscation")]
544            "1",
545        );
546
547        let send_status = stats_exporter.send(true).await;
548        send_status.unwrap();
549
550        mock.assert_async().await;
551    }
552}