1#![allow(dead_code)]
2
3use std::{fmt::Write, time::Duration};
4
5use async_trait::async_trait;
6use datadog_protos::traces::builders::{
7 attribute_any_value::AttributeAnyValueType, attribute_array_value::AttributeArrayValueType, AgentPayloadBuilder,
8 AttributeAnyValueBuilder, AttributeArrayValueBuilder,
9};
10use facet::Facet;
11use http::{uri::PathAndQuery, HeaderName, HeaderValue, Method, Uri};
12use piecemeal::{ScratchBuffer, ScratchWriter};
13use saluki_common::collections::FastHashMap;
14use saluki_common::strings::StringBuilder;
15use saluki_common::task::HandleExt as _;
16use saluki_config::GenericConfiguration;
17use saluki_context::tags::TagSet;
18use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
19use saluki_core::data_model::event::trace::AttributeValue;
20use saluki_core::topology::{EventsBuffer, PayloadsBuffer};
21use saluki_core::{
22 components::{encoders::*, ComponentContext},
23 data_model::{
24 event::{trace::Trace, EventType},
25 payload::{HttpPayload, Payload, PayloadMetadata, PayloadType},
26 },
27 observability::ComponentMetricsExt as _,
28};
29use saluki_env::host::providers::BoxedHostProvider;
30use saluki_env::{EnvironmentProvider, HostProvider};
31use saluki_error::generic_error;
32use saluki_error::{ErrorContext as _, GenericError};
33use saluki_io::compression::CompressionScheme;
34use saluki_metrics::MetricsBuilder;
35use serde::Deserialize;
36use stringtheory::MetaString;
37use tokio::pin;
38use tokio::{
39 select,
40 sync::mpsc::{self, Receiver, Sender},
41 time::sleep,
42};
43use tracing::{debug, error};
44
45use crate::common::datadog::{
46 apm::ApmConfig,
47 io::RB_BUFFER_CHUNK_SIZE,
48 request_builder::{EndpointEncoder, RequestBuilder},
49 resolve_zstd_compressor_level,
50 telemetry::ComponentTelemetry,
51 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT, TAG_DECISION_MAKER,
52};
53use crate::common::otlp::config::TracesConfig;
54use crate::common::otlp::util::{
55 attributes_to_source, extract_container_tags_from_attributes_map, Source as OtlpSource,
56 SourceKind as OtlpSourceKind, KEY_DATADOG_CONTAINER_TAGS,
57};
58
59const CONTAINER_TAGS_META_KEY: &str = "_dd.tags.container";
60const MAX_TRACES_PER_PAYLOAD: usize = 10000;
61static CONTENT_TYPE_PROTOBUF: HeaderValue = HeaderValue::from_static("application/x-protobuf");
62
63const TAG_OTLP_SAMPLING_RATE: &str = "_dd.otlp_sr";
65const DEFAULT_CHUNK_PRIORITY: i32 = 1; fn default_serializer_compressor_kind() -> String {
68 "zstd".to_string()
69}
70
71const fn default_flush_timeout_secs() -> u64 {
72 2
73}
74
75fn default_env() -> String {
76 "none".to_string()
77}
78
79#[derive(Deserialize, Facet)]
85#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
86pub struct DatadogTraceConfiguration {
87 #[serde(
88 rename = "serializer_compressor_kind", default = "default_serializer_compressor_kind"
90 )]
91 compressor_kind: String,
92
93 #[serde(rename = "data_plane_serializer_zstd_compressor_level", default)]
96 data_plane_zstd_compressor_level: Option<i32>,
97
98 #[serde(rename = "serializer_zstd_compressor_level", default)]
101 serializer_zstd_compressor_level: Option<i32>,
102
103 #[serde(default = "default_flush_timeout_secs")]
111 flush_timeout_secs: u64,
112
113 #[serde(skip)]
114 default_hostname: Option<String>,
115
116 #[serde(skip)]
117 version: String,
118
119 #[serde(skip)]
120 #[facet(opaque)]
121 apm_config: ApmConfig,
122
123 #[serde(skip)]
124 #[facet(opaque)]
125 otlp_traces: TracesConfig,
126
127 #[serde(default = "default_env")]
128 env: String,
129}
130
131impl DatadogTraceConfiguration {
132 pub fn from_configuration(config: &GenericConfiguration) -> Result<Self, GenericError> {
134 let mut trace_config: Self = config.as_typed()?;
135
136 let app_details = saluki_metadata::get_app_details();
137 trace_config.version = format!("agent-data-plane/{}", app_details.version().raw());
138
139 trace_config.apm_config = ApmConfig::from_configuration(config)?;
140 trace_config.otlp_traces = config.try_get_typed("otlp_config.traces")?.unwrap_or_default();
141
142 Ok(trace_config)
143 }
144}
145
146impl DatadogTraceConfiguration {
147 pub async fn with_environment_provider<E>(mut self, environment_provider: E) -> Result<Self, GenericError>
149 where
150 E: EnvironmentProvider<Host = BoxedHostProvider>,
151 {
152 let host_provider = environment_provider.host();
153 let hostname = host_provider.get_hostname().await?;
154 self.default_hostname = Some(hostname);
155 Ok(self)
156 }
157}
158
159#[async_trait]
160impl EncoderBuilder for DatadogTraceConfiguration {
161 fn input_event_type(&self) -> EventType {
162 EventType::Trace
163 }
164
165 fn output_payload_type(&self) -> PayloadType {
166 PayloadType::Http
167 }
168
169 async fn build(&self, context: ComponentContext) -> Result<Box<dyn Encoder + Send>, GenericError> {
170 let metrics_builder = MetricsBuilder::from_component_context(&context);
171 let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
172 let zstd_compressor_level = resolve_zstd_compressor_level(
173 self.data_plane_zstd_compressor_level,
174 self.serializer_zstd_compressor_level,
175 );
176 let compression_scheme = CompressionScheme::new(&self.compressor_kind, zstd_compressor_level);
177
178 let default_hostname = self.default_hostname.clone().unwrap_or_default();
179 let default_hostname = MetaString::from(default_hostname);
180
181 let mut trace_rb = RequestBuilder::new(
184 TraceEndpointEncoder::new(
185 default_hostname,
186 self.version.clone(),
187 self.env.clone(),
188 self.apm_config.clone(),
189 self.otlp_traces.clone(),
190 ),
191 compression_scheme,
192 RB_BUFFER_CHUNK_SIZE,
193 )
194 .await?;
195 trace_rb.with_max_inputs_per_payload(MAX_TRACES_PER_PAYLOAD);
196
197 let flush_timeout = match self.flush_timeout_secs {
198 0 => Duration::from_millis(10),
201 secs => Duration::from_secs(secs),
202 };
203
204 Ok(Box::new(DatadogTrace {
205 trace_rb,
206 telemetry,
207 flush_timeout,
208 }))
209 }
210}
211
212impl MemoryBounds for DatadogTraceConfiguration {
213 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
214 builder
216 .minimum()
217 .with_single_value::<DatadogTrace>("component struct")
218 .with_array::<EventsBuffer>("request builder events channel", 8)
219 .with_array::<PayloadsBuffer>("request builder payloads channel", 8);
220
221 builder
222 .firm()
223 .with_array::<Trace>("traces split re-encode buffer", MAX_TRACES_PER_PAYLOAD);
224 }
225}
226
227pub struct DatadogTrace {
228 trace_rb: RequestBuilder<TraceEndpointEncoder>,
229 telemetry: ComponentTelemetry,
230 flush_timeout: Duration,
231}
232
233#[async_trait]
235impl Encoder for DatadogTrace {
236 async fn run(mut self: Box<Self>, mut context: EncoderContext) -> Result<(), GenericError> {
237 let Self {
238 trace_rb,
239 telemetry,
240 flush_timeout,
241 } = *self;
242
243 let mut health = context.take_health_handle();
244
245 let (events_tx, events_rx) = mpsc::channel(8);
248 let (payloads_tx, mut payloads_rx) = mpsc::channel(8);
250 let request_builder_fut = run_request_builder(trace_rb, telemetry, events_rx, payloads_tx, flush_timeout);
251 let request_builder_handle = context
253 .topology_context()
254 .global_thread_pool() .spawn_traced_named("dd-traces-request-builder", request_builder_fut);
256
257 health.mark_ready();
258 debug!("Datadog Trace encoder started.");
259
260 loop {
261 select! {
262 biased; _ = health.live() => continue,
265 maybe_payload = payloads_rx.recv() => match maybe_payload {
266 Some(payload) => {
267 if let Err(e) = context.dispatcher().dispatch(payload).await {
269 error!("Failed to dispatch payload: {}", e);
270 }
271 }
272 None => break,
273 },
274 maybe_event_buffer = context.events().next() => match maybe_event_buffer {
275 Some(event_buffer) => events_tx.send(event_buffer).await
276 .error_context("Failed to send event buffer to request builder task.")?,
277 None => break,
278 },
279 }
280 }
281
282 drop(events_tx);
284
285 while let Some(payload) = payloads_rx.recv().await {
287 if let Err(e) = context.dispatcher().dispatch(payload).await {
288 error!("Failed to dispatch payload: {}", e);
289 }
290 }
291
292 match request_builder_handle.await {
294 Ok(Ok(())) => debug!("Request builder task stopped."),
295 Ok(Err(e)) => error!(error = %e, "Request builder task failed."),
296 Err(e) => error!(error = %e, "Request builder task panicked."),
297 }
298
299 debug!("Datadog Trace encoder stopped.");
300
301 Ok(())
302 }
303}
304
305async fn run_request_builder(
306 mut trace_request_builder: RequestBuilder<TraceEndpointEncoder>, telemetry: ComponentTelemetry,
307 mut events_rx: Receiver<EventsBuffer>, payloads_tx: Sender<PayloadsBuffer>, flush_timeout: std::time::Duration,
308) -> Result<(), GenericError> {
309 let mut pending_flush = false;
310 let pending_flush_timeout = sleep(flush_timeout);
311 pin!(pending_flush_timeout);
312
313 loop {
314 select! {
315 Some(event_buffer) = events_rx.recv() => {
316 for event in event_buffer {
317 let trace = match event.try_into_trace() {
318 Some(trace) => trace,
319 None => continue,
320 };
321 let trace_to_retry = match trace_request_builder.encode(trace).await {
324 Ok(None) => continue,
325 Ok(Some(trace)) => trace,
326 Err(e) => {
327 error!(error = %e, "Failed to encode trace.");
328 telemetry.events_dropped_encoder().increment(1);
329 continue;
330 }
331 };
332
333 let maybe_requests = trace_request_builder.flush().await;
334 if maybe_requests.is_empty() {
335 panic!("builder told us to flush, but gave us nothing");
336 }
337
338 for maybe_request in maybe_requests {
339 match maybe_request {
340 Ok((events, _data_points, request)) => {
341 let payload_meta = PayloadMetadata::from_event_count(events);
342 let http_payload = HttpPayload::new(payload_meta, request);
343 let payload = Payload::Http(http_payload);
344
345 payloads_tx.send(payload).await
346 .map_err(|_| generic_error!("Failed to send payload to encoder."))?;
347 },
348 Err(e) => if e.is_recoverable() {
349 continue;
351 } else {
352 return Err(GenericError::from(e).context("Failed to flush request."));
353 }
354 }
355 }
356
357 if let Err(e) = trace_request_builder.encode(trace_to_retry).await {
359 error!(error = %e, "Failed to encode trace.");
360 telemetry.events_dropped_encoder().increment(1);
361 }
362 }
363
364 debug!("Processed event buffer.");
365
366 if !pending_flush {
368 pending_flush_timeout.as_mut().reset(tokio::time::Instant::now() + flush_timeout);
369 pending_flush = true;
370 }
371 },
372 _ = &mut pending_flush_timeout, if pending_flush => {
373 debug!("Flushing pending request(s).");
374
375 pending_flush = false;
376
377 let maybe_trace_requests = trace_request_builder.flush().await;
380 for maybe_request in maybe_trace_requests {
381 match maybe_request {
382 Ok((events, _data_points, request)) => {
383 let payload_meta = PayloadMetadata::from_event_count(events);
384 let http_payload = HttpPayload::new(payload_meta, request);
385 let payload = Payload::Http(http_payload);
386
387 payloads_tx.send(payload).await
388 .map_err(|_| generic_error!("Failed to send payload to encoder."))?;
389 },
390 Err(e) => if e.is_recoverable() {
391 continue;
392 } else {
393 return Err(GenericError::from(e).context("Failed to flush request."));
394 }
395 }
396 }
397
398 debug!("All flushed requests sent to I/O task. Waiting for next event buffer...");
399 },
400
401 else => break,
403 }
404 }
405
406 Ok(())
407}
408
409#[derive(Debug)]
410struct TraceEndpointEncoder {
411 scratch: ScratchWriter<Vec<u8>>,
412 default_hostname: MetaString,
413 agent_hostname: String,
414 version: String,
415 env: String,
416 apm_config: ApmConfig,
417 otlp_traces: TracesConfig,
418 string_builder: StringBuilder,
419 error_tracking_standalone: bool,
420 extra_headers: Vec<(HeaderName, HeaderValue)>,
421}
422
423impl TraceEndpointEncoder {
424 fn new(
425 default_hostname: MetaString, version: String, env: String, apm_config: ApmConfig, otlp_traces: TracesConfig,
426 ) -> Self {
427 let error_tracking_standalone = apm_config.error_tracking_standalone_enabled();
428 let extra_headers = if error_tracking_standalone {
429 vec![(
430 HeaderName::from_static("x-datadog-error-tracking-standalone"),
431 HeaderValue::from_static("true"),
432 )]
433 } else {
434 Vec::new()
435 };
436 Self {
437 scratch: ScratchWriter::new(Vec::with_capacity(8192)),
438 agent_hostname: default_hostname.as_ref().to_string(),
439 default_hostname,
440 version,
441 env,
442 apm_config,
443 otlp_traces,
444 string_builder: StringBuilder::new(),
445 error_tracking_standalone,
446 extra_headers,
447 }
448 }
449
450 fn encode_tracer_payload(&mut self, trace: &Trace, output_buffer: &mut Vec<u8>) -> std::io::Result<()> {
451 let sampling_rate = self.sampling_rate();
452 let source = attributes_to_source(&trace.attributes);
453
454 let container_id = if !trace.payload.container_id.is_empty() {
456 Some(trace.payload.container_id.as_ref())
457 } else {
458 None
459 };
460 let lang = if !trace.payload.language_name.is_empty() {
461 Some(trace.payload.language_name.as_ref())
462 } else {
463 None
464 };
465 let tracer_version = format!("otlp-{}", trace.payload.tracer_version.as_ref());
466 let container_tags = resolve_container_tags_from_attrs(
467 &trace.attributes,
468 source.as_ref(),
469 self.otlp_traces.ignore_missing_datadog_fields,
470 );
471 let env = if !trace.payload.env.is_empty() {
472 Some(trace.payload.env.as_ref())
473 } else if self.otlp_traces.ignore_missing_datadog_fields {
474 Some("")
475 } else {
476 None
477 };
478 let hostname = resolve_hostname_from_payload(
479 trace.payload.hostname.as_ref(),
480 source.as_ref(),
481 Some(self.default_hostname.as_ref()),
482 self.otlp_traces.ignore_missing_datadog_fields,
483 );
484 let app_version = if !trace.payload.app_version.is_empty() {
485 Some(trace.payload.app_version.as_ref())
486 } else {
487 None
488 };
489
490 let priority = trace.priority.unwrap_or(DEFAULT_CHUNK_PRIORITY);
492 let dropped_trace = trace.dropped_trace;
493 let decision_maker = trace.decision_maker.as_deref();
494 let otlp_sr = trace.otlp_sampling_rate.unwrap_or(sampling_rate);
495
496 let mut ap_builder = AgentPayloadBuilder::new(&mut self.scratch);
498
499 ap_builder
500 .host_name(&self.agent_hostname)?
501 .env(&self.env)?
502 .agent_version(&self.version)?
503 .target_tps(self.apm_config.target_traces_per_second())?
504 .error_tps(self.apm_config.errors_per_second())?;
505
506 ap_builder.add_tracer_payloads(|tp| {
507 if let Some(cid) = container_id {
508 tp.container_id(cid)?;
509 }
510 if let Some(l) = lang {
511 tp.language_name(l)?;
512 }
513 tp.tracer_version(&tracer_version)?;
514
515 tp.add_chunks(|chunk| {
517 chunk.priority(priority)?;
518
519 for span in trace.spans() {
520 chunk.add_spans(|s| {
521 s.service(span.service())?
522 .name(span.name())?
523 .resource(span.resource())?
524 .trace_id(trace.trace_id_low)?
525 .span_id(span.span_id())?
526 .parent_id(span.parent_id())?
527 .start(span.start() as i64)?
528 .duration(span.duration() as i64)?
529 .error(span.error())?;
530
531 {
532 let mut meta = s.meta();
533 for (k, v) in &span.attributes {
534 match v {
535 AttributeValue::String(str_val) => {
536 meta.write_entry(k.as_ref(), str_val.as_ref())?;
537 }
538 AttributeValue::Bool(b) => {
539 meta.write_entry(k.as_ref(), if *b { "true" } else { "false" })?;
540 }
541 _ => {}
542 }
543 }
544 }
545
546 {
547 let mut metrics = s.metrics();
548 for (k, v) in &span.attributes {
549 match v {
550 AttributeValue::Float(f) => metrics.write_entry(k.as_ref(), *f)?,
551 AttributeValue::Int(i) => metrics.write_entry(k.as_ref(), *i as f64)?,
552 _ => {}
553 }
554 }
555 }
556
557 s.type_(span.span_type())?;
558
559 {
560 let mut ms = s.meta_struct();
561 for (k, v) in &span.attributes {
562 if let AttributeValue::Bytes(bytes) = v {
565 ms.write_entry(k.as_ref(), bytes.as_slice())?;
566 }
567 }
568 }
569
570 for link in span.span_links() {
571 s.add_span_links(|sl| {
572 sl.trace_id(link.trace_id())?
573 .trace_id_high(link.trace_id_high())?
574 .span_id(link.span_id())?;
575 {
576 let mut attrs = sl.attributes();
580 for (k, v) in link.attributes() {
581 if let AttributeValue::String(str_val) = v {
582 attrs.write_entry(k.as_ref(), str_val.as_ref())?;
583 }
584 }
585 }
586 let tracestate = link.tracestate().to_string();
587 sl.tracestate(tracestate.as_str())?.flags(link.flags())?;
588 Ok(())
589 })?;
590 }
591
592 for event in span.span_events() {
593 s.add_span_events(|se| {
594 se.time_unix_nano(event.time_unix_nano())?.name(event.name())?;
595 {
596 let mut attrs = se.attributes();
597 for (k, v) in event.attributes() {
598 attrs.write_entry(&**k, |av| encode_attribute_value(av, v))?;
599 }
600 }
601 Ok(())
602 })?;
603 }
604
605 Ok(())
606 })?;
607 }
608
609 {
611 let mut tags = chunk.tags();
612 if let Some(dm) = decision_maker {
613 tags.write_entry(TAG_DECISION_MAKER, dm)?;
614 }
615 if self.error_tracking_standalone {
616 let trace_has_error = trace.spans().iter().any(|span| {
617 span.error() != 0
618 || span
619 .attributes
620 .get("_dd.span_events.has_exception")
621 .and_then(AttributeValue::as_string)
622 .is_some_and(|v| v == "true")
623 });
624 if trace_has_error {
625 tags.write_entry("_dd.error_tracking_standalone.error", "true")?;
626 }
627 }
628
629 self.string_builder.clear();
630 write!(&mut self.string_builder, "{:.2}", otlp_sr)
631 .expect("should never fail to format sampling rate");
632 tags.write_entry(TAG_OTLP_SAMPLING_RATE, self.string_builder.as_str())?;
633 }
634
635 if dropped_trace {
636 chunk.dropped_trace(true)?;
637 }
638
639 Ok(())
640 })?;
641
642 if let Some(ct) = container_tags {
644 let mut tags = tp.tags();
645 tags.write_entry(CONTAINER_TAGS_META_KEY, &*ct)?;
646 }
647
648 if let Some(e) = env {
649 tp.env(e)?;
650 }
651 if let Some(h) = hostname {
652 tp.hostname(h)?;
653 }
654 if let Some(av) = app_version {
655 tp.app_version(av)?;
656 }
657
658 Ok(())
659 })?;
660
661 ap_builder.finish(output_buffer)?;
662
663 Ok(())
664 }
665
666 fn sampling_rate(&self) -> f64 {
667 let rate = self.otlp_traces.probabilistic_sampler.sampling_percentage / 100.0;
668 if rate <= 0.0 || rate >= 1.0 {
669 return 1.0;
670 }
671 rate
672 }
673}
674
675impl EndpointEncoder for TraceEndpointEncoder {
676 type Input = Trace;
677 type EncodeError = std::io::Error;
678 fn encoder_name() -> &'static str {
679 "traces"
680 }
681
682 fn compressed_size_limit(&self) -> usize {
683 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT
684 }
685
686 fn uncompressed_size_limit(&self) -> usize {
687 DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT
688 }
689
690 fn encode(&mut self, trace: &Self::Input, buffer: &mut Vec<u8>) -> Result<(), Self::EncodeError> {
691 self.encode_tracer_payload(trace, buffer)
692 }
693
694 fn endpoint_uri(&self) -> Uri {
695 PathAndQuery::from_static("/api/v0.2/traces").into()
696 }
697
698 fn endpoint_method(&self) -> Method {
699 Method::POST
700 }
701
702 fn content_type(&self) -> HeaderValue {
703 CONTENT_TYPE_PROTOBUF.clone()
704 }
705
706 fn additional_headers(&self) -> &[(HeaderName, HeaderValue)] {
707 &self.extra_headers
708 }
709}
710
711fn encode_attribute_value<S: ScratchBuffer>(
712 builder: &mut AttributeAnyValueBuilder<'_, S>, value: &AttributeValue,
713) -> std::io::Result<()> {
714 match value {
715 AttributeValue::String(v) => {
716 builder.type_(AttributeAnyValueType::STRING_VALUE)?.string_value(v)?;
717 }
718 AttributeValue::Bool(v) => {
719 builder.type_(AttributeAnyValueType::BOOL_VALUE)?.bool_value(*v)?;
720 }
721 AttributeValue::Int(v) => {
722 builder.type_(AttributeAnyValueType::INT_VALUE)?.int_value(*v)?;
723 }
724 AttributeValue::Float(v) => {
725 builder.type_(AttributeAnyValueType::DOUBLE_VALUE)?.double_value(*v)?;
726 }
727 AttributeValue::Bytes(_) => {
728 }
730 AttributeValue::Array(values) => {
731 builder.type_(AttributeAnyValueType::ARRAY_VALUE)?.array_value(|arr| {
732 for val in values {
733 arr.add_values(|av| encode_attribute_array_value(av, val))?;
734 }
735 Ok(())
736 })?;
737 }
738 AttributeValue::KeyValueList(_) => {
739 }
741 }
742 Ok(())
743}
744
745fn encode_attribute_array_value<S: ScratchBuffer>(
746 builder: &mut AttributeArrayValueBuilder<'_, S>, value: &AttributeValue,
747) -> std::io::Result<()> {
748 match value {
749 AttributeValue::String(v) => {
750 builder.type_(AttributeArrayValueType::STRING_VALUE)?.string_value(v)?;
751 }
752 AttributeValue::Bool(v) => {
753 builder.type_(AttributeArrayValueType::BOOL_VALUE)?.bool_value(*v)?;
754 }
755 AttributeValue::Int(v) => {
756 builder.type_(AttributeArrayValueType::INT_VALUE)?.int_value(*v)?;
757 }
758 AttributeValue::Float(v) => {
759 builder.type_(AttributeArrayValueType::DOUBLE_VALUE)?.double_value(*v)?;
760 }
761 AttributeValue::Bytes(_) | AttributeValue::Array(_) | AttributeValue::KeyValueList(_) => {
762 }
764 }
765 Ok(())
766}
767
768fn resolve_hostname_from_payload<'a>(
769 payload_hostname: &'a str, source: Option<&'a OtlpSource>, default_hostname: Option<&'a str>,
770 ignore_missing_fields: bool,
771) -> Option<&'a str> {
772 if !payload_hostname.is_empty() {
773 return Some(payload_hostname);
774 }
775 if ignore_missing_fields {
776 return Some("");
777 }
778 match source {
779 Some(src) => match src.kind {
780 OtlpSourceKind::HostnameKind => Some(src.identifier.as_str()),
781 _ => Some(""),
782 },
783 None => default_hostname,
784 }
785}
786
787fn resolve_container_tags_from_attrs(
788 attributes: &FastHashMap<MetaString, AttributeValue>, source: Option<&OtlpSource>, ignore_missing_fields: bool,
789) -> Option<MetaString> {
790 if let Some(AttributeValue::String(tags)) = attributes.get(KEY_DATADOG_CONTAINER_TAGS) {
791 if !tags.is_empty() {
792 return Some(tags.clone());
793 }
794 }
795
796 if ignore_missing_fields {
797 return None;
798 }
799 let mut container_tags = TagSet::default();
800 extract_container_tags_from_attributes_map(attributes, &mut container_tags);
801 let is_fargate_source = source.is_some_and(|src| src.kind == OtlpSourceKind::AwsEcsFargateKind);
802 if container_tags.is_empty() && !is_fargate_source {
803 return None;
804 }
805
806 let mut flattened = flatten_container_tag(container_tags);
807 if is_fargate_source {
808 if let Some(src) = source {
809 append_tags(&mut flattened, &src.tag());
810 }
811 }
812
813 if flattened.is_empty() {
814 None
815 } else {
816 Some(MetaString::from(flattened))
817 }
818}
819
820fn flatten_container_tag(tags: TagSet) -> String {
821 let mut flattened = String::new();
822 for tag in tags {
823 if !flattened.is_empty() {
824 flattened.push(',');
825 }
826 flattened.push_str(tag.as_str());
827 }
828 flattened
829}
830
831fn append_tags(target: &mut String, tags: &str) {
832 if tags.is_empty() {
833 return;
834 }
835 if !target.is_empty() {
836 target.push(',');
837 }
838 target.push_str(tags);
839}
840
841#[cfg(test)]
842mod tests {
843 use std::collections::BTreeSet;
844
845 use datadog_protos::traces::AgentPayload;
846 use protobuf::Message as _;
847 use saluki_config::ConfigurationLoader;
848 use saluki_context::tags::Tag;
849 use saluki_core::data_model::event::trace::{Span as DdSpan, Trace};
850 use stringtheory::MetaString;
851
852 use super::*;
853 use crate::common::datadog::apm::ApmConfig;
854 use crate::common::otlp::config::TracesConfig;
855 use crate::config::{DatadogRemapper, KEY_ALIASES};
856
857 async fn make_encoder(ets_enabled: bool) -> TraceEndpointEncoder {
858 let env_vars: Vec<(String, String)> = if ets_enabled {
859 vec![("APM_ERROR_TRACKING_STANDALONE_ENABLED".to_string(), "true".to_string())]
860 } else {
861 vec![]
862 };
863 let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
864 None,
865 Some(&env_vars),
866 false,
867 KEY_ALIASES,
868 DatadogRemapper::from_env_vars,
869 )
870 .await;
871 let apm_config = ApmConfig::from_configuration(&cfg).expect("ApmConfig should deserialize");
872 TraceEndpointEncoder::new(
873 MetaString::from("test-host"),
874 "0.0.0".to_string(),
875 "none".to_string(),
876 apm_config,
877 TracesConfig::default(),
878 )
879 }
880
881 fn make_trace() -> Trace {
882 let span = DdSpan::new(
883 MetaString::from("svc"),
884 MetaString::from("op"),
885 MetaString::from("res"),
886 MetaString::from("web"),
887 1, 0, 0, 1000, 0, );
893 let mut trace = Trace::new(vec![span]);
894 trace.priority = Some(1);
895 trace
896 }
897
898 fn make_error_trace() -> Trace {
899 let span = DdSpan::new(
900 MetaString::from("svc"),
901 MetaString::from("op"),
902 MetaString::from("res"),
903 MetaString::from("web"),
904 1, 0, 0, 1000, 1, );
910 let mut trace = Trace::new(vec![span]);
911 trace.priority = Some(1);
912 trace
913 }
914
915 #[tokio::test]
916 async fn ets_header_present_when_enabled() {
917 let encoder = make_encoder(true).await;
918 let headers = encoder.additional_headers();
919 assert_eq!(headers.len(), 1);
920 assert_eq!(headers[0].0.as_str(), "x-datadog-error-tracking-standalone");
921 assert_eq!(headers[0].1, "true");
922 }
923
924 #[tokio::test]
925 async fn ets_header_absent_when_disabled() {
926 let encoder = make_encoder(false).await;
927 assert!(encoder.additional_headers().is_empty());
928 }
929
930 #[tokio::test]
931 async fn ets_chunk_tag_present_for_error_trace() {
932 let mut encoder = make_encoder(true).await;
933 let trace = make_error_trace();
934 let mut buf = Vec::new();
935 encoder.encode(&trace, &mut buf).expect("encode should succeed");
936 let payload = AgentPayload::parse_from_bytes(&buf).expect("should parse AgentPayload");
937 let tag_value = payload
938 .tracerPayloads
939 .iter()
940 .flat_map(|tp| tp.chunks.iter())
941 .find_map(|chunk| {
942 chunk
943 .tags
944 .get("_dd.error_tracking_standalone.error")
945 .map(|v| v.as_str())
946 });
947 assert_eq!(
948 tag_value,
949 Some("true"),
950 "ETS chunk tag should be present for error traces when ETS is enabled"
951 );
952 }
953
954 #[tokio::test]
955 async fn ets_chunk_tag_absent_for_non_error_trace() {
956 let mut encoder = make_encoder(true).await;
957 let trace = make_trace(); let mut buf = Vec::new();
959 encoder.encode(&trace, &mut buf).expect("encode should succeed");
960 let payload = AgentPayload::parse_from_bytes(&buf).expect("should parse AgentPayload");
961 let has_tag = payload
962 .tracerPayloads
963 .iter()
964 .flat_map(|tp| tp.chunks.iter())
965 .any(|chunk| chunk.tags.contains_key("_dd.error_tracking_standalone.error"));
966 assert!(!has_tag, "ETS chunk tag should be absent for non-error traces");
967 }
968
969 #[tokio::test]
970 async fn ets_chunk_tag_absent_when_disabled() {
971 let mut encoder = make_encoder(false).await;
972 let trace = make_trace();
973 let mut buf = Vec::new();
974 encoder.encode(&trace, &mut buf).expect("encode should succeed");
975 let payload = AgentPayload::parse_from_bytes(&buf).expect("should parse AgentPayload");
976 let has_tag = payload
977 .tracerPayloads
978 .iter()
979 .flat_map(|tp| tp.chunks.iter())
980 .any(|chunk| chunk.tags.contains_key("_dd.error_tracking_standalone.error"));
981 assert!(!has_tag, "ETS chunk tag should be absent when ETS is disabled");
982 }
983
984 #[tokio::test]
985 async fn sampling_rate_clamps_percentage_to_unit_interval() {
986 let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
989 None,
990 None,
991 false,
992 KEY_ALIASES,
993 DatadogRemapper::from_env_vars,
994 )
995 .await;
996 let apm_config = ApmConfig::from_configuration(&cfg).expect("ApmConfig should deserialize");
997
998 let cases = [
999 (25.0, 0.25),
1000 (50.0, 0.5),
1001 (0.0, 1.0),
1002 (-10.0, 1.0),
1003 (100.0, 1.0),
1004 (150.0, 1.0),
1005 ];
1006 for (percentage, expected) in cases {
1007 let mut traces_config = TracesConfig::default();
1008 traces_config.probabilistic_sampler.sampling_percentage = percentage;
1009 let encoder = TraceEndpointEncoder::new(
1010 MetaString::from("test-host"),
1011 "0.0.0".to_string(),
1012 "none".to_string(),
1013 apm_config.clone(),
1014 traces_config,
1015 );
1016 assert_eq!(expected, encoder.sampling_rate(), "sampling_rate for {percentage}%");
1017 }
1018 }
1019
1020 #[test]
1021 fn resolve_hostname_from_payload_prefers_payload_then_source_then_default() {
1022 let host_source = OtlpSource {
1023 kind: OtlpSourceKind::HostnameKind,
1024 identifier: "resolved-host".to_string(),
1025 };
1026 let fargate_source = OtlpSource {
1027 kind: OtlpSourceKind::AwsEcsFargateKind,
1028 identifier: "task-arn".to_string(),
1029 };
1030
1031 assert_eq!(
1033 Some("payload-host"),
1034 resolve_hostname_from_payload("payload-host", Some(&host_source), Some("default"), false)
1035 );
1036 assert_eq!(
1038 Some(""),
1039 resolve_hostname_from_payload("", Some(&host_source), Some("default"), true)
1040 );
1041 assert_eq!(
1043 Some("resolved-host"),
1044 resolve_hostname_from_payload("", Some(&host_source), Some("default"), false)
1045 );
1046 assert_eq!(
1048 Some(""),
1049 resolve_hostname_from_payload("", Some(&fargate_source), Some("default"), false)
1050 );
1051 assert_eq!(
1053 Some("default"),
1054 resolve_hostname_from_payload("", None, Some("default"), false)
1055 );
1056 assert_eq!(None, resolve_hostname_from_payload("", None, None, false));
1057 }
1058
1059 #[test]
1060 fn append_tags_joins_non_empty_segments_with_commas() {
1061 let mut target = String::new();
1062
1063 append_tags(&mut target, "");
1065 assert_eq!("", target);
1066
1067 append_tags(&mut target, "a:1");
1069 assert_eq!("a:1", target);
1070
1071 append_tags(&mut target, "b:2");
1073 assert_eq!("a:1,b:2", target);
1074
1075 append_tags(&mut target, "");
1077 assert_eq!("a:1,b:2", target);
1078 }
1079
1080 #[test]
1081 fn flatten_container_tag_comma_joins_the_tag_set() {
1082 assert_eq!("", flatten_container_tag(TagSet::default()));
1083
1084 let single: TagSet = std::iter::once(Tag::from_static("image_name:web")).collect();
1085 assert_eq!("image_name:web", flatten_container_tag(single));
1086
1087 let multiple: TagSet = ["image_name:web", "runtime:docker"]
1088 .into_iter()
1089 .map(Tag::from_static)
1090 .collect();
1091 let flattened = flatten_container_tag(multiple);
1092 assert_eq!(
1093 BTreeSet::from(["image_name:web", "runtime:docker"]),
1094 flattened.split(',').collect::<BTreeSet<_>>()
1095 );
1096 }
1097
1098 #[test]
1099 fn resolve_container_tags_prefers_explicit_container_tags_attribute() {
1100 let mut attributes = FastHashMap::default();
1102 attributes.insert(
1103 MetaString::from(KEY_DATADOG_CONTAINER_TAGS),
1104 AttributeValue::String(MetaString::from("region:us,team:core")),
1105 );
1106 assert_eq!(
1107 Some(MetaString::from("region:us,team:core")),
1108 resolve_container_tags_from_attrs(&attributes, None, false)
1109 );
1110 }
1111
1112 #[test]
1113 fn resolve_container_tags_returns_none_without_container_attributes() {
1114 let attributes = FastHashMap::default();
1115 assert_eq!(None, resolve_container_tags_from_attrs(&attributes, None, true));
1117 assert_eq!(None, resolve_container_tags_from_attrs(&attributes, None, false));
1119 }
1120
1121 #[tokio::test]
1122 async fn encode_prefixes_tracer_version_and_writes_otlp_sampling_rate() {
1123 let mut encoder = make_encoder(false).await;
1124 let mut trace = make_trace();
1125 trace.payload.tracer_version = MetaString::from("1.2.3");
1126 trace.otlp_sampling_rate = Some(0.5);
1127
1128 let mut buf = Vec::new();
1129 encoder.encode(&trace, &mut buf).expect("encode should succeed");
1130 let payload = AgentPayload::parse_from_bytes(&buf).expect("should parse AgentPayload");
1131
1132 let tracer_payload = payload
1133 .tracerPayloads
1134 .first()
1135 .expect("a tracer payload should be encoded");
1136 assert_eq!("otlp-1.2.3", tracer_payload.tracerVersion());
1138
1139 let otlp_sr = tracer_payload
1141 .chunks
1142 .iter()
1143 .find_map(|chunk| chunk.tags.get("_dd.otlp_sr"))
1144 .expect("chunk should carry the _dd.otlp_sr tag");
1145 assert_eq!("0.50", otlp_sr.as_str());
1146 }
1147}
1148
1149#[cfg(test)]
1150mod config_smoke {
1151 use datadog_agent_config_testing::config_registry::structs;
1152 use datadog_agent_config_testing::run_config_smoke_tests;
1153 use serde_json::json;
1154
1155 use super::DatadogTraceConfiguration;
1156 use crate::config::{DatadogRemapper, KEY_ALIASES};
1157
1158 #[tokio::test]
1159 async fn smoke_test() {
1160 run_config_smoke_tests(
1161 structs::DATADOG_TRACE_CONFIGURATION,
1162 &[],
1163 json!({}),
1164 |cfg| {
1165 cfg.as_typed::<DatadogTraceConfiguration>()
1166 .expect("DatadogTraceConfiguration should deserialize")
1167 },
1168 KEY_ALIASES,
1169 DatadogRemapper::from_env_vars,
1170 )
1171 .await
1172 }
1173}