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