saluki_components/encoders/datadog/traces/
mod.rs

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
63// Sampling metadata keys / values.
64const TAG_OTLP_SAMPLING_RATE: &str = "_dd.otlp_sr";
65const DEFAULT_CHUNK_PRIORITY: i32 = 1; // PRIORITY_AUTO_KEEP
66
67fn 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/// Configuration for the Datadog Traces encoder.
80///
81/// This encoder converts trace events into Datadog's TracerPayload protobuf format and sends them
82/// to the Datadog traces intake endpoint (`/api/v0.2/traces`). It handles batching, compression,
83/// and enrichment with metadata such as hostname, environment, and container tags.
84#[derive(Deserialize, Facet)]
85#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
86pub struct DatadogTraceConfiguration {
87    #[serde(
88        rename = "serializer_compressor_kind",  // renames the field in the user_configuration from "serializer_compressor_kind" to "compressor_kind".
89        default = "default_serializer_compressor_kind"
90    )]
91    compressor_kind: String,
92
93    /// ADP-specific zstd compression level, taking precedence over `serializer_zstd_compressor_level`.
94    /// See [`resolve_zstd_compressor_level`] for how the effective level is determined.
95    #[serde(rename = "data_plane_serializer_zstd_compressor_level", default)]
96    data_plane_zstd_compressor_level: Option<i32>,
97
98    /// The Core Agent's zstd compression level, used only when set to a non-default value (not 1).
99    /// See [`resolve_zstd_compressor_level`] for how the effective level is determined.
100    #[serde(rename = "serializer_zstd_compressor_level", default)]
101    serializer_zstd_compressor_level: Option<i32>,
102
103    /// Flush timeout for pending requests, in seconds.
104    ///
105    /// When the encoder has written traces to the in-flight request payload, but it hasn't yet reached the
106    /// payload size limits that would force the payload to be flushed, the encoder will wait for a period of time
107    /// before flushing the in-flight request payload.
108    ///
109    /// Defaults to 2 seconds.
110    #[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    /// Creates a new `DatadogTraceConfiguration` from the given configuration.
133    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    /// Sets the `default_hostname` using the environment provider
148    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        // Create request builder for traces which is used to generate HTTP requests.
182
183        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            // We always give ourselves a minimum flush timeout of 10ms to allow for some very minimal amount of
199            // batching, while still practically flushing things almost immediately.
200            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        // TODO: How do we properly represent the requests we can generate that may be sitting around in-flight?
215        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// Encodes Trace events to TracerPayloads.
234#[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        // The encoder runs two async loops, the main encoder loop and the request builder loop,
246        // this channel is used to send events from the main encoder loop to the request builder loop safely.
247        let (events_tx, events_rx) = mpsc::channel(8);
248        // adds a channel to send payloads to the dispatcher and a channel to receive them.
249        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        // Spawn the request builder task on the global thread pool, this task is responsible for encoding traces and flushing requests.
252        let request_builder_handle = context
253            .topology_context()
254            .global_thread_pool() // Use the shared Tokio runtime thread pool.
255            .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; // makes the branches of the select statement be evaluated in order.
263
264                _ = health.live() => continue,
265                maybe_payload = payloads_rx.recv() => match maybe_payload {
266                    Some(payload) => {
267                        // Dispatch an HTTP payload to the dispatcher.
268                        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 the events sender, which signals the request builder task to stop.
283        drop(events_tx);
284
285        // Continue draining the payloads receiver until it is closed.
286        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        // Request build task should now be stopped.
293        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                    // Encode the trace. If we get it back, that means the current request is full, and we need to
322                    // flush it before we can try to encode the trace again.
323                    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                                // If the error is recoverable, we'll hold on to the trace to retry it later.
350                                continue;
351                            } else {
352                                return Err(GenericError::from(e).context("Failed to flush request."));
353                            }
354                        }
355                    }
356
357                    // Now try to encode the trace again.
358                    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 we're not already pending a flush, we'll start the countdown.
367                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                // Once we've encoded and written all traces, we flush the request builders to generate a request with
378                // anything left over. Again, we'll enqueue those requests to be sent immediately.
379                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            // Event buffers channel has been closed, and we have no pending flushing, so we're all done.
402            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        // Resolve metadata from payload fields and attributes.
455        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        // Resolve sampling metadata from flat trace fields.
491        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        // Now incrementally build the payload.
497        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            // Encode the single TraceChunk containing all spans.
516            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                                // TODO: Array and KeyValueList could be JSON-serialized into meta_struct;
563                                // skipped until a caller needs them for span-level attributes.
564                                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                                    // TODO: investigate whether non-String attribute values can be
577                                    // serialized directly into the link attributes proto field rather
578                                    // than being silently dropped here.
579                                    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                // Chunk tags.
610                {
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            // Tracer payload tags.
643            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            // Bytes are not directly representable in OTLP AnyValue; skip.
729        }
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            // KVList encoding not needed for this encoder path; skip.
740        }
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            // Nested complex values not representable in OTLP array; skip.
763        }
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,    // span_id
888            0,    // parent_id
889            0,    // start
890            1000, // duration
891            0,    // error
892        );
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,    // span_id
905            0,    // parent_id
906            0,    // start
907            1000, // duration
908            1,    // error
909        );
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(); // no error
958        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        // `sampling_percentage` is a 0..100 percentage; only strictly in-range values map to a fractional rate, and
987        // anything <= 0 or >= 100 collapses to 1.0 (sample everything).
988        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        // A non-empty payload hostname always wins.
1032        assert_eq!(
1033            Some("payload-host"),
1034            resolve_hostname_from_payload("payload-host", Some(&host_source), Some("default"), false)
1035        );
1036        // An empty payload plus `ignore_missing_fields` short-circuits to an empty hostname.
1037        assert_eq!(
1038            Some(""),
1039            resolve_hostname_from_payload("", Some(&host_source), Some("default"), true)
1040        );
1041        // Honoring fields, a hostname-kind source supplies its identifier.
1042        assert_eq!(
1043            Some("resolved-host"),
1044            resolve_hostname_from_payload("", Some(&host_source), Some("default"), false)
1045        );
1046        // A non-hostname (Fargate) source resolves to an empty hostname.
1047        assert_eq!(
1048            Some(""),
1049            resolve_hostname_from_payload("", Some(&fargate_source), Some("default"), false)
1050        );
1051        // With no source, it falls back to the default hostname (which may itself be absent).
1052        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        // Appending an empty segment is a no-op.
1064        append_tags(&mut target, "");
1065        assert_eq!("", target);
1066
1067        // The first non-empty append does not prepend a separator.
1068        append_tags(&mut target, "a:1");
1069        assert_eq!("a:1", target);
1070
1071        // Subsequent non-empty appends are comma-separated.
1072        append_tags(&mut target, "b:2");
1073        assert_eq!("a:1,b:2", target);
1074
1075        // An empty segment remains a no-op even once the target is non-empty.
1076        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        // An explicit, non-empty `datadog.container_tags` attribute is used verbatim.
1101        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        // `ignore_missing_fields` skips the extraction path entirely.
1116        assert_eq!(None, resolve_container_tags_from_attrs(&attributes, None, true));
1117        // Honoring fields but with no container attributes and no Fargate source still yields nothing.
1118        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        // The tracer version is prefixed with `otlp-` to mark the OTLP ingestion path.
1137        assert_eq!("otlp-1.2.3", tracer_payload.tracerVersion());
1138
1139        // The OTLP sampling rate is written to each chunk formatted to two decimal places.
1140        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}