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 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#[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#[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 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 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 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 async fn run(&mut self) {
221 let _ = self.send(false).await; }
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 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
259pub 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 #[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 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".into(),
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, 5, 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 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 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 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 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}