1use std::{
5 sync::{
6 atomic::{AtomicU64, Ordering},
7 Arc, Mutex,
8 },
9 time,
10};
11
12#[cfg(feature = "stats-obfuscation")]
13use crate::span_concentrator::SharedStatsComputationObfuscationConfig;
14use crate::span_concentrator::{FlushableConcentrator, SpanConcentrator};
15use async_trait::async_trait;
16use libdd_capabilities::{HttpClientCapability, MaybeSend, SleepCapability};
17use libdd_common::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#[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#[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 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 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 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 async fn run(&mut self) {
201 let _ = self.send(false).await; }
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 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
239pub 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 #[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 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 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 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 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 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}