1use std::{borrow::Cow, fmt::Write, time::Duration};
2
3use agent_data_plane_config::{domains, SharedConfiguration};
4use async_trait::async_trait;
5use datadog_protos::traces::builders::{idx::SpanKind, AgentPayloadBuilder};
6use http::{uri::PathAndQuery, HeaderName, HeaderValue, Method, Uri};
7use piecemeal::{ScratchBuffer, ScratchWriter};
8use saluki_common::collections::{FastHashMap, FastIndexSet};
9use saluki_common::strings::StringBuilder;
10use saluki_core::{
11 accounting::{MemoryBounds, MemoryBoundsBuilder},
12 components::{encoders::*, BuildContext},
13 data_model::{
14 event::{
15 trace::{AttributeValue, Trace, TraceIngestInfo},
16 EventType,
17 },
18 payload::{HttpPayload, Payload, PayloadMetadata, PayloadType},
19 tags::TagSet,
20 },
21 observability::ComponentMetricsExt as _,
22 runtime,
23 topology::{EventsBuffer, PayloadsBuffer},
24};
25use saluki_env::host::providers::BoxedHostProvider;
26use saluki_env::{EnvironmentProvider, HostProvider};
27use saluki_error::generic_error;
28use saluki_error::{ErrorContext as _, GenericError};
29use saluki_io::compression::CompressionScheme;
30use saluki_metrics::MetricsBuilder;
31use stringtheory::MetaString;
32use tokio::pin;
33use tokio::{
34 select,
35 sync::mpsc::{self, Receiver, Sender},
36 time::sleep,
37};
38use tracing::{debug, error};
39
40use crate::common::datadog::{
41 io::RB_BUFFER_CHUNK_SIZE,
42 request_builder::{EndpointEncoder, RequestBuilder},
43 telemetry::ComponentTelemetry,
44 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT, TAG_DECISION_MAKER,
45};
46use crate::common::otlp::util::{
47 attributes_to_source, extract_container_tags_from_attributes_map, Source as OtlpSource,
48 SourceKind as OtlpSourceKind, KEY_DATADOG_CONTAINER_TAGS,
49};
50use crate::config::AgentEnv;
51
52const CONTAINER_TAGS_META_KEY: &str = "_dd.tags.container";
53const MAX_TRACES_PER_PAYLOAD: usize = 10000;
54static CONTENT_TYPE_PROTOBUF: HeaderValue = HeaderValue::from_static("application/x-protobuf");
55
56const TAG_OTLP_SAMPLING_RATE: &str = "_dd.otlp_sr";
58const DEFAULT_CHUNK_PRIORITY: i32 = 1; const TAG_ETS_STANDALONE_ERROR_KEY: &str = "_dd.error_tracking_standalone.error";
62const TAG_ETS_STANDALONE_ERROR_VALUE: &str = "true";
63
64#[derive(Debug)]
68struct StringTable {
69 indices: FastIndexSet<MetaString>,
70}
71
72impl Default for StringTable {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78impl StringTable {
79 fn new() -> Self {
80 let mut t = Self {
81 indices: FastIndexSet::default(),
82 };
83 t.intern("");
84 t
85 }
86
87 fn clear(&mut self) {
88 self.indices.clear();
89 self.intern("");
90 }
91
92 fn intern(&mut self, s: &str) -> u32 {
94 if let Some(idx) = self.indices.get_index_of(s) {
96 idx as u32
97 } else {
98 let (idx, _) = self.indices.insert_full(MetaString::from(s));
99 idx as u32
100 }
101 }
102}
103
104pub struct DatadogTraceConfiguration {
110 compressor_kind: String,
112
113 zstd_compressor_level: i32,
115
116 flush_timeout: Duration,
120
121 agent_env: AgentEnv,
123
124 target_traces_per_second: f64,
126
127 errors_per_second: f64,
129
130 error_tracking_standalone: bool,
132
133 ignore_missing_datadog_fields: bool,
135
136 default_hostname: Option<String>,
138
139 version: String,
141}
142
143impl DatadogTraceConfiguration {
144 pub fn from_configuration(
149 traces: &domains::traces::Domain, otlp_traces: &domains::otlp::Traces, shared: &SharedConfiguration,
150 agent_env: AgentEnv,
151 ) -> Self {
152 let app_details = saluki_metadata::get_app_details();
153 let version = format!("agent-data-plane/{}", app_details.version().raw());
154
155 let compression = &shared.endpoints.compression;
156
157 Self {
158 compressor_kind: compression.compressor_kind.clone(),
159 zstd_compressor_level: compression.effective_zstd_level(),
160 flush_timeout: shared.metrics_encoding.flush_timeout,
161 agent_env,
162 target_traces_per_second: traces.target_traces_per_second,
163 errors_per_second: traces.errors_per_second,
164 error_tracking_standalone: traces.error_tracking_standalone_enabled,
165 ignore_missing_datadog_fields: otlp_traces.ignore_missing_datadog_fields,
166 default_hostname: None,
167 version,
168 }
169 }
170
171 pub async fn with_environment_provider<E>(mut self, environment_provider: E) -> Result<Self, GenericError>
173 where
174 E: EnvironmentProvider<Host = BoxedHostProvider>,
175 {
176 let host_provider = environment_provider.host();
177 let hostname = host_provider.get_hostname().await?;
178 self.default_hostname = Some(hostname);
179 Ok(self)
180 }
181
182 fn endpoint_encoder(&self) -> TraceEndpointEncoder {
183 TraceEndpointEncoder::new(
184 MetaString::from(self.default_hostname.clone().unwrap_or_default()),
185 self.version.clone(),
186 self.agent_env.as_str().to_owned(),
187 self.target_traces_per_second,
188 self.errors_per_second,
189 self.error_tracking_standalone,
190 self.ignore_missing_datadog_fields,
191 )
192 }
193}
194
195#[async_trait]
196impl EncoderBuilder for DatadogTraceConfiguration {
197 fn input_event_type(&self) -> EventType {
198 EventType::Trace
199 }
200
201 fn output_payload_type(&self) -> PayloadType {
202 PayloadType::Http
203 }
204
205 async fn build(&self, context: BuildContext) -> Result<Box<dyn Encoder + Send>, GenericError> {
206 let metrics_builder = MetricsBuilder::from_component_context(context.component_context());
207 let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
208 let compression_scheme = CompressionScheme::new(&self.compressor_kind, self.zstd_compressor_level);
209
210 let mut trace_rb =
212 RequestBuilder::new(self.endpoint_encoder(), compression_scheme, RB_BUFFER_CHUNK_SIZE).await?;
213 trace_rb.with_max_inputs_per_payload(MAX_TRACES_PER_PAYLOAD);
214
215 let flush_timeout = if self.flush_timeout.is_zero() {
216 Duration::from_millis(10)
219 } else {
220 self.flush_timeout
221 };
222
223 Ok(Box::new(DatadogTrace {
224 trace_rb,
225 telemetry,
226 flush_timeout,
227 }))
228 }
229}
230
231impl MemoryBounds for DatadogTraceConfiguration {
232 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
233 builder
235 .minimum()
236 .with_single_value::<DatadogTrace>("component struct")
237 .with_array::<EventsBuffer>("request builder events channel", 8)
238 .with_array::<PayloadsBuffer>("request builder payloads channel", 8);
239
240 builder
241 .firm()
242 .with_array::<Trace>("traces split re-encode buffer", MAX_TRACES_PER_PAYLOAD);
243 }
244}
245
246pub struct DatadogTrace {
247 trace_rb: RequestBuilder<TraceEndpointEncoder>,
248 telemetry: ComponentTelemetry,
249 flush_timeout: Duration,
250}
251
252#[async_trait]
254impl Encoder for DatadogTrace {
255 async fn run(mut self: Box<Self>, mut context: EncoderContext) -> Result<(), GenericError> {
256 let Self {
257 trace_rb,
258 telemetry,
259 flush_timeout,
260 } = *self;
261
262 let mut health = context.take_health_handle();
263
264 let (events_tx, events_rx) = mpsc::channel(8);
265 let (payloads_tx, mut payloads_rx) = mpsc::channel(8);
266
267 let request_builder_fut = run_request_builder(trace_rb, telemetry, events_rx, payloads_tx, flush_timeout);
272 runtime::worker("request_builder", request_builder_fut)
273 .on_runtime(context.topology_context().global_thread_pool().clone())
274 .needed()
275 .spawn();
276
277 health.mark_ready();
278 debug!("Datadog Trace encoder started.");
279
280 loop {
281 select! {
282 biased; _ = health.live() => continue,
285 maybe_payload = payloads_rx.recv() => match maybe_payload {
286 Some(payload) => {
287 if let Err(e) = context.dispatcher().dispatch(payload).await {
289 error!("Failed to dispatch payload: {}", e);
290 }
291 }
292 None => break,
293 },
294 maybe_event_buffer = context.events().next() => match maybe_event_buffer {
295 Some(event_buffer) => events_tx.send(event_buffer).await
296 .error_context("Failed to send event buffer to request builder task.")?,
297 None => break,
298 },
299 }
300 }
301
302 drop(events_tx);
304
305 while let Some(payload) = payloads_rx.recv().await {
307 if let Err(e) = context.dispatcher().dispatch(payload).await {
308 error!("Failed to dispatch payload: {}", e);
309 }
310 }
311
312 debug!("Datadog Trace encoder stopped.");
315
316 Ok(())
317 }
318}
319
320async fn run_request_builder(
321 mut trace_request_builder: RequestBuilder<TraceEndpointEncoder>, telemetry: ComponentTelemetry,
322 mut events_rx: Receiver<EventsBuffer>, payloads_tx: Sender<PayloadsBuffer>, flush_timeout: std::time::Duration,
323) -> Result<(), GenericError> {
324 let mut pending_flush = false;
325 let pending_flush_timeout = sleep(flush_timeout);
326 pin!(pending_flush_timeout);
327
328 loop {
329 select! {
330 Some(event_buffer) = events_rx.recv() => {
331 for event in event_buffer {
332 let trace = match event.try_into_trace() {
333 Some(trace) => trace,
334 None => continue,
335 };
336 let trace_to_retry = match trace_request_builder.encode(trace).await {
339 Ok(None) => continue,
340 Ok(Some(trace)) => trace,
341 Err(e) => {
342 error!(error = %e, "Failed to encode trace.");
343 telemetry.events_dropped_encoder().increment(1);
344 continue;
345 }
346 };
347
348 let maybe_requests = trace_request_builder.flush().await;
349 if maybe_requests.is_empty() {
350 panic!("builder told us to flush, but gave us nothing");
351 }
352
353 for maybe_request in maybe_requests {
354 match maybe_request {
355 Ok((events, _data_points, request)) => {
356 let payload_meta = PayloadMetadata::from_event_count(events);
357 let http_payload = HttpPayload::new(payload_meta, request);
358 let payload = Payload::Http(http_payload);
359
360 payloads_tx.send(payload).await
361 .map_err(|_| generic_error!("Failed to send payload to encoder."))?;
362 },
363 Err(e) => if e.is_recoverable() {
364 continue;
366 } else {
367 return Err(GenericError::from(e).context("Failed to flush request."));
368 }
369 }
370 }
371
372 if let Err(e) = trace_request_builder.encode(trace_to_retry).await {
374 error!(error = %e, "Failed to encode trace.");
375 telemetry.events_dropped_encoder().increment(1);
376 }
377 }
378
379 debug!("Processed event buffer.");
380
381 if !pending_flush {
383 pending_flush_timeout.as_mut().reset(tokio::time::Instant::now() + flush_timeout);
384 pending_flush = true;
385 }
386 },
387 _ = &mut pending_flush_timeout, if pending_flush => {
388 debug!("Flushing pending request(s).");
389
390 pending_flush = false;
391
392 let maybe_trace_requests = trace_request_builder.flush().await;
395 for maybe_request in maybe_trace_requests {
396 match maybe_request {
397 Ok((events, _data_points, request)) => {
398 let payload_meta = PayloadMetadata::from_event_count(events);
399 let http_payload = HttpPayload::new(payload_meta, request);
400 let payload = Payload::Http(http_payload);
401
402 payloads_tx.send(payload).await
403 .map_err(|_| generic_error!("Failed to send payload to encoder."))?;
404 },
405 Err(e) => if e.is_recoverable() {
406 continue;
407 } else {
408 return Err(GenericError::from(e).context("Failed to flush request."));
409 }
410 }
411 }
412
413 debug!("All flushed requests sent to I/O task. Waiting for next event buffer...");
414 },
415
416 else => break,
418 }
419 }
420
421 Ok(())
422}
423
424#[derive(Debug)]
425struct TraceEndpointEncoder {
426 scratch: ScratchWriter<Vec<u8>>,
427 default_hostname: MetaString,
428 agent_hostname: String,
429 version: String,
430 env: String,
431 target_traces_per_second: f64,
432 errors_per_second: f64,
433 ignore_missing_datadog_fields: bool,
434 string_builder: StringBuilder,
435 string_table: StringTable,
436 error_tracking_standalone: bool,
437 extra_headers: Vec<(HeaderName, HeaderValue)>,
438}
439
440impl TraceEndpointEncoder {
441 fn new(
442 default_hostname: MetaString, version: String, env: String, target_traces_per_second: f64,
443 errors_per_second: f64, error_tracking_standalone: bool, ignore_missing_datadog_fields: bool,
444 ) -> Self {
445 let extra_headers = if error_tracking_standalone {
446 vec![(
447 HeaderName::from_static("x-datadog-error-tracking-standalone"),
448 HeaderValue::from_static("true"),
449 )]
450 } else {
451 Vec::new()
452 };
453 Self {
454 scratch: ScratchWriter::new(Vec::with_capacity(8192)),
455 agent_hostname: default_hostname.as_ref().to_string(),
456 default_hostname,
457 version,
458 env,
459 target_traces_per_second,
460 errors_per_second,
461 ignore_missing_datadog_fields,
462 string_builder: StringBuilder::new(),
463 string_table: StringTable::new(),
464 error_tracking_standalone,
465 extra_headers,
466 }
467 }
468
469 fn encode_tracer_payload(&mut self, trace: &Trace, output_buffer: &mut Vec<u8>) -> std::io::Result<()> {
470 let source = attributes_to_source(&trace.attributes);
471
472 let (tracer_version, otlp_sampling_rate) = match trace.ingest_info() {
475 TraceIngestInfo::Datadog => (Cow::Borrowed(trace.payload.tracer_version.as_ref()), None),
476 TraceIngestInfo::Otlp { receiver_sampling_rate } => {
477 self.string_builder.clear();
478 write!(&mut self.string_builder, "{:.2}", receiver_sampling_rate)
479 .expect("should never fail to format sampling rate");
480 (
481 Cow::Owned(format!("otlp-{}", trace.payload.tracer_version)),
482 Some(self.string_builder.as_str()),
483 )
484 }
485 };
486
487 let container_tags =
489 resolve_container_tags_from_attrs(&trace.attributes, source.as_ref(), self.ignore_missing_datadog_fields);
490 let env_str: Option<&str> = if !trace.payload.env.is_empty() {
491 Some(&trace.payload.env)
492 } else if self.ignore_missing_datadog_fields {
493 Some("")
494 } else {
495 None
496 };
497 let hostname_str: Option<&str> = resolve_hostname_from_payload(
498 &trace.payload.hostname,
499 source.as_ref(),
500 Some(self.default_hostname.as_ref()),
501 self.ignore_missing_datadog_fields,
502 );
503 let decision_maker = trace.decision_maker.as_deref();
504 let priority = trace.priority.unwrap_or(DEFAULT_CHUNK_PRIORITY);
505 let dropped_trace = trace.dropped_trace;
506
507 let mut trace_id_bytes = [0u8; 16];
509 trace_id_bytes[..8].copy_from_slice(&trace.trace_id_high.to_be_bytes());
510 trace_id_bytes[8..].copy_from_slice(&trace.trace_id_low.to_be_bytes());
511
512 self.string_table.clear();
514
515 let mut ap_builder = AgentPayloadBuilder::new(&mut self.scratch);
516
517 ap_builder
518 .host_name(&self.agent_hostname)?
519 .env(&self.env)?
520 .agent_version(&self.version)?
521 .target_tps(self.target_traces_per_second)?
522 .error_tps(self.errors_per_second)?;
523
524 ap_builder.add_idx_tracer_payloads(|tp| {
525 if !trace.payload.container_id.is_empty() {
529 tp.container_id_ref(self.string_table.intern(&trace.payload.container_id))?;
530 }
531 if !trace.payload.language_name.is_empty() {
532 tp.language_name_ref(self.string_table.intern(&trace.payload.language_name))?;
533 }
534 if !trace.payload.language_version.is_empty() {
535 tp.language_version_ref(self.string_table.intern(&trace.payload.language_version))?;
536 }
537 if !tracer_version.is_empty() {
538 tp.tracer_version_ref(self.string_table.intern(&tracer_version))?;
539 }
540 if !trace.payload.runtime_id.is_empty() {
541 tp.runtime_id_ref(self.string_table.intern(&trace.payload.runtime_id))?;
542 }
543 if let Some(e) = env_str {
544 tp.env_ref(self.string_table.intern(e))?;
545 }
546 if let Some(h) = hostname_str {
547 tp.hostname_ref(self.string_table.intern(h))?;
548 }
549 if !trace.payload.app_version.is_empty() {
550 tp.app_version_ref(self.string_table.intern(&trace.payload.app_version))?;
551 }
552
553 if let Some(ct) = &container_tags {
555 let k_ref = self.string_table.intern(CONTAINER_TAGS_META_KEY);
556 let v_ref = self.string_table.intern(ct);
557 tp.attributes().write_entry(k_ref, |av: &mut _| {
558 av.value(|vo| vo.string_value_ref(v_ref)).map(|_| ())
559 })?;
560 }
561
562 tp.add_chunks(|chunk| {
564 chunk.priority(priority)?;
565
566 if !trace.origin.is_empty() {
567 chunk.origin_ref(self.string_table.intern(&trace.origin))?;
568 }
569
570 chunk.trace_id(&trace_id_bytes)?;
572
573 if trace.sampling_mechanism != 0 {
575 chunk.sampling_mechanism(trace.sampling_mechanism)?;
576 }
577
578 for span in trace.spans() {
580 let service_ref = self.string_table.intern(span.service());
581 let name_ref = self.string_table.intern(span.name());
582 let resource_ref = self.string_table.intern(span.resource());
583 let type_ref = self.string_table.intern(span.span_type());
584 let env_ref = (!span.env.is_empty()).then(|| self.string_table.intern(&span.env));
585 let version_ref = (!span.version.is_empty()).then(|| self.string_table.intern(&span.version));
586 let component_ref = (!span.component.is_empty()).then(|| self.string_table.intern(&span.component));
587
588 chunk.add_spans(|s| {
589 s.service_ref(service_ref)?
590 .name_ref(name_ref)?
591 .resource_ref(resource_ref)?
592 .span_id(span.span_id())?
593 .parent_id(span.parent_id())?
594 .start(span.start())?
595 .duration(span.duration())?
596 .error(span.error() != 0)?;
597
598 {
600 let mut attrs = s.attributes();
601 for (k, v) in &span.attributes {
602 let k_ref = self.string_table.intern(k);
603 attrs.write_entry(k_ref, |av: &mut _| {
604 encode_etp_attribute_value(av, v, &mut self.string_table)
605 })?;
606 }
607 }
608
609 s.type_ref(type_ref)?;
610
611 if let Some(er) = env_ref {
612 s.env_ref(er)?;
613 }
614 if let Some(vr) = version_ref {
615 s.version_ref(vr)?;
616 }
617 if let Some(cr) = component_ref {
618 s.component_ref(cr)?;
619 }
620 if span.kind != 0 {
621 s.kind(SpanKind::from(span.kind as i32))?;
622 }
623
624 for link in span.span_links() {
626 let mut link_trace_id_bytes = [0u8; 16];
627 link_trace_id_bytes[..8].copy_from_slice(&link.trace_id_high().to_be_bytes());
628 link_trace_id_bytes[8..].copy_from_slice(&link.trace_id().to_be_bytes());
629 let tracestate_ref = self.string_table.intern(link.tracestate());
630
631 s.add_links(|sl| {
632 sl.trace_id(&link_trace_id_bytes)?.span_id(link.span_id())?;
633 {
634 let mut lattrs = sl.attributes();
635 for (k, v) in link.attributes() {
636 let k_ref = self.string_table.intern(k);
637 lattrs.write_entry(k_ref, |av: &mut _| {
638 encode_etp_attribute_value(av, v, &mut self.string_table)
639 })?;
640 }
641 }
642 sl.tracestate_ref(tracestate_ref)?.flags(link.flags())?;
643 Ok(())
644 })?;
645 }
646
647 for event in span.span_events() {
649 let name_ref = self.string_table.intern(event.name());
650 s.add_events(|se| {
651 se.time(event.time_unix_nano())?.name_ref(name_ref)?;
652 {
653 let mut eattrs = se.attributes();
654 for (k, v) in event.attributes() {
655 let k_ref = self.string_table.intern(k);
656 eattrs.write_entry(k_ref, |av: &mut _| {
657 encode_etp_attribute_value(av, v, &mut self.string_table)
658 })?;
659 }
660 }
661 Ok(())
662 })?;
663 }
664
665 Ok(())
666 })?;
667 }
668
669 {
671 let mut cattrs = chunk.attributes();
672 if let Some(dm) = decision_maker {
673 let k_ref = self.string_table.intern(TAG_DECISION_MAKER);
674 let v_ref = self.string_table.intern(dm);
675 cattrs.write_entry(k_ref, |av: &mut _| {
676 av.value(|vo| vo.string_value_ref(v_ref)).map(|_| ())
677 })?;
678 }
679 if self.error_tracking_standalone {
680 let trace_has_error = trace.spans().iter().any(|span| {
681 span.error() != 0
682 || span
683 .attributes
684 .get("_dd.span_events.has_exception")
685 .and_then(AttributeValue::as_string)
686 .is_some_and(|v| v == "true")
687 });
688 if trace_has_error {
689 let k_ref = self.string_table.intern(TAG_ETS_STANDALONE_ERROR_KEY);
690 let v_ref = self.string_table.intern(TAG_ETS_STANDALONE_ERROR_VALUE);
691 cattrs.write_entry(k_ref, |av: &mut _| {
692 av.value(|vo| vo.string_value_ref(v_ref)).map(|_| ())
693 })?;
694 }
695 }
696 if let Some(rate) = otlp_sampling_rate {
697 let k_ref = self.string_table.intern(TAG_OTLP_SAMPLING_RATE);
698 let v_ref = self.string_table.intern(rate);
699 cattrs.write_entry(k_ref, |av: &mut _| {
700 av.value(|vo| vo.string_value_ref(v_ref)).map(|_| ())
701 })?;
702 }
703 }
704
705 if dropped_trace {
706 chunk.dropped_trace(true)?;
707 }
708
709 Ok(())
710 })?;
711
712 tp.strings(|sb| sb.add_many_mapped(&self.string_table.indices, |s| &**s))?;
716
717 Ok(())
718 })?;
719
720 ap_builder.finish(output_buffer)?;
721
722 Ok(())
723 }
724}
725
726impl EndpointEncoder for TraceEndpointEncoder {
727 type Input = Trace;
728 type EncodeError = std::io::Error;
729 fn encoder_name() -> &'static str {
730 "traces"
731 }
732
733 fn compressed_size_limit(&self) -> usize {
734 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT
735 }
736
737 fn uncompressed_size_limit(&self) -> usize {
738 DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT
739 }
740
741 fn encode(&mut self, trace: &Self::Input, buffer: &mut Vec<u8>) -> Result<(), Self::EncodeError> {
742 self.encode_tracer_payload(trace, buffer)
743 }
744
745 fn endpoint_uri(&self) -> Uri {
746 PathAndQuery::from_static("/api/v0.2/traces").into()
747 }
748
749 fn endpoint_method(&self) -> Method {
750 Method::POST
751 }
752
753 fn content_type(&self) -> HeaderValue {
754 CONTENT_TYPE_PROTOBUF.clone()
755 }
756
757 fn additional_headers(&self) -> &[(HeaderName, HeaderValue)] {
758 &self.extra_headers
759 }
760}
761
762fn encode_etp_attribute_value<S: ScratchBuffer>(
765 builder: &mut datadog_protos::traces::builders::idx::AnyValueBuilder<'_, S>, value: &AttributeValue,
766 st: &mut StringTable,
767) -> std::io::Result<()> {
768 builder
769 .value(|vo| match value {
770 AttributeValue::String(s) => vo.string_value_ref(st.intern(s)),
771 AttributeValue::Bool(b) => vo.bool_value(*b),
772 AttributeValue::Int(i) => vo.int_value(*i),
773 AttributeValue::Float(f) => vo.double_value(*f),
774 AttributeValue::Bytes(b) => vo.bytes_value(b),
775 AttributeValue::Array(values) => vo.array_value(|arr| {
776 for v in values {
777 arr.add_values(|av| encode_etp_attribute_value(av, v, st))?;
778 }
779 Ok(())
780 }),
781 AttributeValue::KeyValueList(kvs) => vo.key_value_list(|kvl| {
782 for (k, v) in kvs {
783 kvl.add_key_values(|kv| {
784 kv.key(st.intern(k))?
785 .value(|av| encode_etp_attribute_value(av, v, st))?;
786 Ok(())
787 })?;
788 }
789 Ok(())
790 }),
791 })
792 .map(|_| ())
793}
794
795fn resolve_hostname_from_payload<'a>(
796 payload_hostname: &'a str, source: Option<&'a OtlpSource>, default_hostname: Option<&'a str>,
797 ignore_missing_fields: bool,
798) -> Option<&'a str> {
799 if !payload_hostname.is_empty() {
800 return Some(payload_hostname);
801 }
802 if ignore_missing_fields {
803 return Some("");
804 }
805 match source {
806 Some(src) => match src.kind {
807 OtlpSourceKind::HostnameKind => Some(src.identifier.as_str()),
808 _ => Some(""),
809 },
810 None => default_hostname,
811 }
812}
813
814fn resolve_container_tags_from_attrs(
815 attributes: &FastHashMap<MetaString, AttributeValue>, source: Option<&OtlpSource>, ignore_missing_fields: bool,
816) -> Option<MetaString> {
817 if let Some(AttributeValue::String(tags)) = attributes.get(KEY_DATADOG_CONTAINER_TAGS) {
818 if !tags.is_empty() {
819 return Some(tags.clone());
820 }
821 }
822
823 if ignore_missing_fields {
824 return None;
825 }
826 let mut container_tags = TagSet::default();
827 extract_container_tags_from_attributes_map(attributes, &mut container_tags);
828 let is_fargate_source = source.is_some_and(|src| src.kind == OtlpSourceKind::AwsEcsFargateKind);
829 if container_tags.is_empty() && !is_fargate_source {
830 return None;
831 }
832
833 let mut flattened = flatten_container_tag(container_tags);
834 if is_fargate_source {
835 if let Some(src) = source {
836 append_tags(&mut flattened, &src.tag());
837 }
838 }
839
840 if flattened.is_empty() {
841 None
842 } else {
843 Some(MetaString::from(flattened))
844 }
845}
846
847fn flatten_container_tag(tags: TagSet) -> String {
848 let mut flattened = String::new();
849 for tag in tags {
850 if !flattened.is_empty() {
851 flattened.push(',');
852 }
853 flattened.push_str(tag.as_str());
854 }
855 flattened
856}
857
858fn append_tags(target: &mut String, tags: &str) {
859 if tags.is_empty() {
860 return;
861 }
862 if !target.is_empty() {
863 target.push(',');
864 }
865 target.push_str(tags);
866}
867
868#[cfg(test)]
869mod sampling_round_trip;
870
871#[cfg(test)]
872mod tests {
873 use std::collections::{BTreeSet, HashMap};
874 use std::sync::Arc;
875
876 use datadog_protos::traces::{idx, AgentPayload};
877 use protobuf::Message as _;
878 use saluki_core::data_model::{
879 event::trace::{Span as DdSpan, Trace, TraceIngestInfo},
880 tags::Tag,
881 };
882 use stringtheory::MetaString;
883
884 use super::*;
885
886 fn resolve_ref(strings: &[String], string_ref: u32) -> &str {
900 strings.get(string_ref as usize).map(String::as_str).unwrap_or_default()
901 }
902
903 fn string_attrs(attrs: &HashMap<u32, idx::AnyValue>, strings: &[String]) -> HashMap<String, String> {
908 let mut out = HashMap::new();
909 for (k_ref, value) in attrs {
910 if let Some(idx::any_value::Value::StringValueRef(v_ref)) = &value.value {
911 let key = resolve_ref(strings, *k_ref);
912 if !key.is_empty() {
913 out.insert(key.to_string(), resolve_ref(strings, *v_ref).to_string());
914 }
915 }
916 }
917 out
918 }
919
920 fn decode_etp_chunk_attributes(buf: &[u8]) -> Vec<HashMap<String, String>> {
923 let payload = AgentPayload::parse_from_bytes(buf).expect("AgentPayload should decode");
924 payload
925 .idxTracerPayloads
926 .iter()
927 .flat_map(|tp| {
928 let strings = tp.strings();
929 tp.chunks()
930 .iter()
931 .map(move |chunk| string_attrs(chunk.attributes(), strings))
932 })
933 .collect()
934 }
935
936 fn decode_etp_span_attributes(buf: &[u8]) -> Vec<HashMap<String, String>> {
939 let payload = AgentPayload::parse_from_bytes(buf).expect("AgentPayload should decode");
940 payload
941 .idxTracerPayloads
942 .iter()
943 .flat_map(|tp| {
944 let strings = tp.strings();
945 tp.chunks()
946 .iter()
947 .flat_map(|chunk| chunk.spans())
948 .map(move |span| string_attrs(span.attributes(), strings))
949 })
950 .collect()
951 }
952
953 fn decode_etp_tracer_versions(buf: &[u8]) -> Vec<String> {
955 let payload = AgentPayload::parse_from_bytes(buf).expect("AgentPayload should decode");
956 payload
957 .idxTracerPayloads
958 .iter()
959 .map(|tp| resolve_ref(tp.strings(), tp.tracerVersionRef()).to_string())
960 .collect()
961 }
962
963 const DEFAULT_TARGET_TPS: f64 = 10.0;
965 const DEFAULT_ERRORS_PER_SECOND: f64 = 10.0;
966
967 fn make_encoder(ets_enabled: bool) -> TraceEndpointEncoder {
968 TraceEndpointEncoder::new(
969 MetaString::from("test-host"),
970 "0.0.0".to_string(),
971 "none".to_string(),
972 DEFAULT_TARGET_TPS,
973 DEFAULT_ERRORS_PER_SECOND,
974 ets_enabled,
975 false,
976 )
977 }
978
979 fn make_trace(ingest_info: TraceIngestInfo) -> Trace {
980 let span = DdSpan::new(
981 MetaString::from("svc"),
982 MetaString::from("op"),
983 MetaString::from("res"),
984 MetaString::from("web"),
985 1, 0, 0, 1000, 0, );
991 let mut trace = Trace::new(vec![span], ingest_info);
992 trace.priority = Some(1);
993 trace
994 }
995
996 fn make_error_trace() -> Trace {
997 let span = DdSpan::new(
998 MetaString::from("svc"),
999 MetaString::from("op"),
1000 MetaString::from("res"),
1001 MetaString::from("web"),
1002 1, 0, 0, 1000, 1, );
1008 let mut trace = Trace::new(vec![span], TraceIngestInfo::Datadog);
1009 trace.priority = Some(1);
1010 trace
1011 }
1012
1013 #[test]
1014 fn ets_header_present_when_enabled() {
1015 let encoder = make_encoder(true);
1016 let headers = encoder.additional_headers();
1017 assert_eq!(headers.len(), 1);
1018 assert_eq!(headers[0].0.as_str(), "x-datadog-error-tracking-standalone");
1019 assert_eq!(headers[0].1, "true");
1020 }
1021
1022 #[test]
1023 fn ets_header_absent_when_disabled() {
1024 let encoder = make_encoder(false);
1025 assert!(encoder.additional_headers().is_empty());
1026 }
1027
1028 #[test]
1029 fn ets_chunk_tag_present_for_error_trace() {
1030 let mut encoder = make_encoder(true);
1031 let trace = make_error_trace();
1032 let mut buf = Vec::new();
1033 encoder.encode(&trace, &mut buf).expect("encode should succeed");
1034 let chunk_attrs = decode_etp_chunk_attributes(&buf);
1035 let tag_value = chunk_attrs
1036 .iter()
1037 .find_map(|attrs| attrs.get("_dd.error_tracking_standalone.error").map(|v| v.as_str()));
1038 assert_eq!(
1039 tag_value,
1040 Some("true"),
1041 "ETS chunk tag should be present for error traces when ETS is enabled"
1042 );
1043 }
1044
1045 #[test]
1046 fn ets_chunk_tag_absent_for_non_error_trace() {
1047 let mut encoder = make_encoder(true);
1048 let trace = make_trace(TraceIngestInfo::Datadog); let mut buf = Vec::new();
1050 encoder.encode(&trace, &mut buf).expect("encode should succeed");
1051 let chunk_attrs = decode_etp_chunk_attributes(&buf);
1052 let has_tag = chunk_attrs
1053 .iter()
1054 .any(|attrs| attrs.contains_key("_dd.error_tracking_standalone.error"));
1055 assert!(!has_tag, "ETS chunk tag should be absent for non-error traces");
1056 }
1057
1058 #[test]
1059 fn ets_chunk_tag_absent_when_disabled() {
1060 let mut encoder = make_encoder(false);
1061 let trace = make_trace(TraceIngestInfo::Datadog);
1062 let mut buf = Vec::new();
1063 encoder.encode(&trace, &mut buf).expect("encode should succeed");
1064 let chunk_attrs = decode_etp_chunk_attributes(&buf);
1065 let has_tag = chunk_attrs
1066 .iter()
1067 .any(|attrs| attrs.contains_key("_dd.error_tracking_standalone.error"));
1068 assert!(!has_tag, "ETS chunk tag should be absent when ETS is disabled");
1069 }
1070
1071 #[test]
1072 fn resolve_hostname_from_payload_prefers_payload_then_source_then_default() {
1073 let host_source = OtlpSource {
1074 kind: OtlpSourceKind::HostnameKind,
1075 identifier: "resolved-host".to_string(),
1076 };
1077 let fargate_source = OtlpSource {
1078 kind: OtlpSourceKind::AwsEcsFargateKind,
1079 identifier: "task-arn".to_string(),
1080 };
1081
1082 assert_eq!(
1084 Some("payload-host"),
1085 resolve_hostname_from_payload("payload-host", Some(&host_source), Some("default"), false)
1086 );
1087 assert_eq!(
1089 Some(""),
1090 resolve_hostname_from_payload("", Some(&host_source), Some("default"), true)
1091 );
1092 assert_eq!(
1094 Some("resolved-host"),
1095 resolve_hostname_from_payload("", Some(&host_source), Some("default"), false)
1096 );
1097 assert_eq!(
1099 Some(""),
1100 resolve_hostname_from_payload("", Some(&fargate_source), Some("default"), false)
1101 );
1102 assert_eq!(
1104 Some("default"),
1105 resolve_hostname_from_payload("", None, Some("default"), false)
1106 );
1107 assert_eq!(None, resolve_hostname_from_payload("", None, None, false));
1108 }
1109
1110 #[test]
1111 fn append_tags_joins_non_empty_segments_with_commas() {
1112 let mut target = String::new();
1113
1114 append_tags(&mut target, "");
1116 assert_eq!("", target);
1117
1118 append_tags(&mut target, "a:1");
1120 assert_eq!("a:1", target);
1121
1122 append_tags(&mut target, "b:2");
1124 assert_eq!("a:1,b:2", target);
1125
1126 append_tags(&mut target, "");
1128 assert_eq!("a:1,b:2", target);
1129 }
1130
1131 #[test]
1132 fn flatten_container_tag_comma_joins_the_tag_set() {
1133 assert_eq!("", flatten_container_tag(TagSet::default()));
1134
1135 let single: TagSet = std::iter::once(Tag::from_static("image_name:web")).collect();
1136 assert_eq!("image_name:web", flatten_container_tag(single));
1137
1138 let multiple: TagSet = ["image_name:web", "runtime:docker"]
1139 .into_iter()
1140 .map(Tag::from_static)
1141 .collect();
1142 let flattened = flatten_container_tag(multiple);
1143 assert_eq!(
1144 BTreeSet::from(["image_name:web", "runtime:docker"]),
1145 flattened.split(',').collect::<BTreeSet<_>>()
1146 );
1147 }
1148
1149 #[test]
1150 fn resolve_container_tags_prefers_explicit_container_tags_attribute() {
1151 let mut attributes = FastHashMap::default();
1153 attributes.insert(
1154 MetaString::from(KEY_DATADOG_CONTAINER_TAGS),
1155 AttributeValue::String(MetaString::from("region:us,team:core")),
1156 );
1157 assert_eq!(
1158 Some(MetaString::from("region:us,team:core")),
1159 resolve_container_tags_from_attrs(&attributes, None, false)
1160 );
1161 }
1162
1163 #[test]
1164 fn resolve_container_tags_returns_none_without_container_attributes() {
1165 let attributes = FastHashMap::default();
1166 assert_eq!(None, resolve_container_tags_from_attrs(&attributes, None, true));
1168 assert_eq!(None, resolve_container_tags_from_attrs(&attributes, None, false));
1170 }
1171
1172 #[test]
1173 fn agent_payload_env_is_the_agent_env() {
1174 let cases = [
1177 (AgentEnv::resolve(None, Some("Prod"), &[], &[]), "prod"),
1178 (AgentEnv::resolve(Some("!!!"), None, &[], &[]), ""),
1179 ];
1180 for (agent_env, expected) in cases {
1181 let config = DatadogTraceConfiguration::from_configuration(
1182 &domains::traces::Domain::default(),
1183 &domains::otlp::Traces::default(),
1184 &SharedConfiguration::default(),
1185 agent_env,
1186 );
1187 let mut buf = Vec::new();
1188 config
1189 .endpoint_encoder()
1190 .encode(&make_trace(TraceIngestInfo::Datadog), &mut buf)
1191 .expect("encode should succeed");
1192
1193 let payload = AgentPayload::parse_from_bytes(&buf).expect("AgentPayload should decode");
1194 assert_eq!(payload.env, expected);
1195 }
1196 }
1197
1198 #[test]
1203 fn encode_marks_only_otlp_traces_with_tracer_version_prefix_and_sampling_rate() {
1204 let otlp = |receiver_sampling_rate| TraceIngestInfo::Otlp { receiver_sampling_rate };
1205 let cases = [
1206 (otlp(0.5), "1.2.3", "otlp-1.2.3", Some("0.50")),
1207 (otlp(1.0), "", "otlp-", Some("1.00")),
1208 (otlp(0.125), "1.2.3", "otlp-1.2.3", Some("0.12")),
1209 (otlp(0.375), "1.2.3", "otlp-1.2.3", Some("0.38")),
1210 (otlp(1.0 / 3.0), "1.2.3", "otlp-1.2.3", Some("0.33")),
1211 (otlp(0.001), "1.2.3", "otlp-1.2.3", Some("0.00")),
1212 (TraceIngestInfo::Datadog, "v2.1.0", "v2.1.0", None),
1213 (TraceIngestInfo::Datadog, "", "", None),
1214 ];
1215 for (ingest_info, tracer_version, want_tracer_version, want_rate) in cases {
1216 let mut trace = make_trace(ingest_info);
1217 trace.payload.tracer_version = MetaString::from(tracer_version);
1218
1219 let mut buf = Vec::new();
1220 make_encoder(false)
1221 .encode(&trace, &mut buf)
1222 .expect("encode should succeed");
1223
1224 let ctx = format!("{ingest_info:?}, tracer version {tracer_version:?}");
1225 assert_eq!(decode_etp_tracer_versions(&buf), [want_tracer_version], "{ctx}");
1226 let chunk_attrs = decode_etp_chunk_attributes(&buf);
1227 assert_eq!(chunk_attrs.len(), 1, "{ctx}");
1228 assert_eq!(
1229 chunk_attrs[0].get(TAG_OTLP_SAMPLING_RATE).map(String::as_str),
1230 want_rate,
1231 "{ctx}"
1232 );
1233 }
1234 }
1235
1236 #[test]
1240 fn encode_otlp_sampling_rate_is_independent_of_same_named_attributes() {
1241 let conflicting = |ingest_info| {
1242 let mut trace = make_trace(ingest_info);
1243 trace.attributes = Arc::new(FastHashMap::from_iter([(
1244 MetaString::from(TAG_OTLP_SAMPLING_RATE),
1245 AttributeValue::String(MetaString::from("0.33")),
1246 )]));
1247 trace.spans_mut()[0].attributes.insert(
1248 MetaString::from(TAG_OTLP_SAMPLING_RATE),
1249 AttributeValue::String(MetaString::from("0.77")),
1250 );
1251 trace
1252 };
1253 let encode = |trace: &Trace| {
1254 let mut buf = Vec::new();
1255 make_encoder(false)
1256 .encode(trace, &mut buf)
1257 .expect("encode should succeed");
1258 buf
1259 };
1260
1261 let buf = encode(&conflicting(TraceIngestInfo::Otlp {
1262 receiver_sampling_rate: 0.5,
1263 }));
1264 assert_eq!(decode_etp_chunk_attributes(&buf)[0][TAG_OTLP_SAMPLING_RATE], "0.50");
1265 assert_eq!(decode_etp_span_attributes(&buf)[0][TAG_OTLP_SAMPLING_RATE], "0.77");
1266
1267 let buf = encode(&conflicting(TraceIngestInfo::Datadog));
1269 let native_chunk_rate = decode_etp_chunk_attributes(&buf)[0]
1270 .get(TAG_OTLP_SAMPLING_RATE)
1271 .cloned();
1272 assert!(
1273 matches!(native_chunk_rate.as_deref(), None | Some("0.33")),
1274 "unexpected native chunk rate {native_chunk_rate:?}"
1275 );
1276 assert_eq!(decode_etp_span_attributes(&buf)[0][TAG_OTLP_SAMPLING_RATE], "0.77");
1277 }
1278}