Skip to main content

saluki_components/encoders/datadog/traces/
mod.rs

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
56// Sampling metadata keys / values.
57const TAG_OTLP_SAMPLING_RATE: &str = "_dd.otlp_sr";
58const DEFAULT_CHUNK_PRIORITY: i32 = 1; // PRIORITY_AUTO_KEEP
59
60// ETS chunk-level attribute keys / values.
61const TAG_ETS_STANDALONE_ERROR_KEY: &str = "_dd.error_tracking_standalone.error";
62const TAG_ETS_STANDALONE_ERROR_VALUE: &str = "true";
63
64/// String interning table for the ETP (Efficient Trace Payload) tracer payload format.
65///
66/// Index 0 is always the empty string. All other strings are assigned indices in insertion order.
67#[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    /// Interns `s`, returning its index. If `s` was already interned, returns the existing index.
93    fn intern(&mut self, s: &str) -> u32 {
94        // We use get_index_of to check if the string is already interned to avoid reconstructing a new meta string if it's already interned.
95        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
104/// Configuration for the Datadog Traces encoder.
105///
106/// This encoder converts trace events into Datadog's TracerPayload protobuf format and sends them
107/// to the Datadog traces intake endpoint (`/api/v0.2/traces`). It handles batching, compression,
108/// and enrichment with metadata such as hostname, environment, and container tags.
109pub struct DatadogTraceConfiguration {
110    /// Compression algorithm applied to outgoing payloads.
111    compressor_kind: String,
112
113    /// Effective zstd compression level, resolved by the configuration layer.
114    zstd_compressor_level: i32,
115
116    /// How long the encoder waits before flushing a partially filled payload.
117    ///
118    /// A zero duration is treated as "flush almost immediately" during `build`.
119    flush_timeout: Duration,
120
121    /// Agent environment of emitted payloads.
122    agent_env: AgentEnv,
123
124    /// Target sampled traces per second, forwarded to the intake as `target_tps`.
125    target_traces_per_second: f64,
126
127    /// Target sampled error traces per second, forwarded to the intake as `error_tps`.
128    errors_per_second: f64,
129
130    /// Whether Error Tracking standalone mode is enabled.
131    error_tracking_standalone: bool,
132
133    /// Whether spans missing intake-required fields are ingested rather than rejected.
134    ignore_missing_datadog_fields: bool,
135
136    /// Default hostname, resolved from the environment provider.
137    default_hostname: Option<String>,
138
139    /// ADP version string embedded in emitted payloads.
140    version: String,
141}
142
143impl DatadogTraceConfiguration {
144    /// Creates a new `DatadogTraceConfiguration` from the resolved traces and shared configuration.
145    ///
146    /// The OTLP trace settings live in their own domain, so they arrive as a separate slice rather
147    /// than through the traces domain. Each payload carries `agent_env` as its agent environment.
148    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    /// Sets the `default_hostname` using the environment provider
172    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        // Create request builder for traces which is used to generate HTTP requests.
211        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            // We always give ourselves a minimum flush timeout of 10ms to allow for some very minimal amount of
217            // batching, while still practically flushing things almost immediately.
218            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        // TODO: How do we properly represent the requests we can generate that may be sitting around in-flight?
234        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// Encodes Trace events to TracerPayloads.
253#[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        // Run our request builder task on the worker pool.
268        //
269        // The request builder task ignores the shutdown signal on purpose: it drains its incoming event buffer channel
270        // until the channel closes, which is what guarantees every buffered metric is encoded and dispatched.
271        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; // makes the branches of the select statement be evaluated in order.
283
284                _ = health.live() => continue,
285                maybe_payload = payloads_rx.recv() => match maybe_payload {
286                    Some(payload) => {
287                        // Dispatch an HTTP payload to the dispatcher.
288                        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 the events sender, which signals the request builder task to stop.
303        drop(events_tx);
304
305        // Continue draining the payloads receiver until it is closed.
306        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        // Draining `payloads_rx` to completion already implies the request builder finished: it owns the only sender,
313        // so the channel only closes once that child's future has run to completion (or been dropped).
314        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                    // Encode the trace. If we get it back, that means the current request is full, and we need to
337                    // flush it before we can try to encode the trace again.
338                    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                                // If the error is recoverable, we'll hold on to the trace to retry it later.
365                                continue;
366                            } else {
367                                return Err(GenericError::from(e).context("Failed to flush request."));
368                            }
369                        }
370                    }
371
372                    // Now try to encode the trace again.
373                    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 we're not already pending a flush, we'll start the countdown.
382                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                // Once we've encoded and written all traces, we flush the request builders to generate a request with
393                // anything left over. Again, we'll enqueue those requests to be sent immediately.
394                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            // Event buffers channel has been closed, and we have no pending flushing, so we're all done.
417            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        // OTLP traces report the receiver sampling rate and mark the tracer version as OTLP. Native traces keep
473        // their tracer version as sent.
474        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        // Resolve computed metadata strings (may produce strings not directly present on trace fields).
488        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        // Build 128-bit big-endian trace ID bytes for the chunk.
508        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        // Reset the string table; strings are interned on the fly during encoding below.
513        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            // Tracer payload metadata refs (skip default/empty values).
526            // Strings are interned on the fly; the string table is written at the end of this
527            // closure so it is complete by the time tp.strings() is called.
528            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            // Container tags go in the payload-level attributes map.
554            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            // Single TraceChunk containing all spans.
563            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                // Write 128-bit trace ID.
571                chunk.trace_id(&trace_id_bytes)?;
572
573                // Sampling mechanism (only write when non-zero).
574                if trace.sampling_mechanism != 0 {
575                    chunk.sampling_mechanism(trace.sampling_mechanism)?;
576                }
577
578                // Spans.
579                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                        // Unified attribute map (replaces separate meta/metrics/meta_struct).
599                        {
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                        // Span links.
625                        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                        // Span events.
648                        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                // Chunk attributes: decision maker, ETS tag, OTLP sampling rate.
670                {
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            // Write the string table after all refs so the table is complete.
713            // Protobuf allows fields in any order; decoders that do a full parse before
714            // resolving refs handle strings-after-refs correctly.
715            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
762/// Encodes an [`AttributeValue`] into an ETP-format `AnyValue` builder, interning any
763/// string values into `st` on the fly.
764fn 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    // ---------------------------------------------------------------------------
887    // Decode helpers for assertions on the encoded ETP `AgentPayload`.
888    //
889    // The encoder emits the wire format via `piecemeal` builders; here we decode it back
890    // with the independently code-generated `rust-protobuf` types (`idx::TracerPayload`
891    // and friends). A full-message parse resolves protobuf field ordering for us, so the
892    // fact that the encoder writes string refs before the string table is a non-issue.
893    //
894    // The only ETP-specific work left is resolving `u32` string-table refs and unwrapping
895    // the `AnyValue` oneof, which the two helpers below handle.
896    // ---------------------------------------------------------------------------
897
898    /// Resolves a string-table ref against a tracer payload's string table.
899    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    /// Resolves the string-valued entries of an ETP attribute map into a readable
904    /// `key -> value` map, keeping only entries whose `AnyValue` is a string ref and
905    /// whose key resolves to a non-empty string. Keys and values are resolved through
906    /// the payload string table.
907    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    /// Collects the resolved string-valued attributes of every chunk across all ETP
921    /// tracer payloads in an encoded `AgentPayload`, one map per chunk.
922    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    /// Collects the resolved string-valued attributes of every span across all ETP tracer payloads in an encoded
937    /// `AgentPayload`, one map per span.
938    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    /// Resolves the `tracerVersionRef` of every ETP tracer payload in an encoded `AgentPayload`.
954    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    // The APM sampler defaults: 10 target and error traces per second.
964    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,    // span_id
986            0,    // parent_id
987            0,    // start
988            1000, // duration
989            0,    // error
990        );
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,    // span_id
1003            0,    // parent_id
1004            0,    // start
1005            1000, // duration
1006            1,    // error
1007        );
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); // no error
1049        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        // A non-empty payload hostname always wins.
1083        assert_eq!(
1084            Some("payload-host"),
1085            resolve_hostname_from_payload("payload-host", Some(&host_source), Some("default"), false)
1086        );
1087        // An empty payload plus `ignore_missing_fields` short-circuits to an empty hostname.
1088        assert_eq!(
1089            Some(""),
1090            resolve_hostname_from_payload("", Some(&host_source), Some("default"), true)
1091        );
1092        // Honoring fields, a hostname-kind source supplies its identifier.
1093        assert_eq!(
1094            Some("resolved-host"),
1095            resolve_hostname_from_payload("", Some(&host_source), Some("default"), false)
1096        );
1097        // A non-hostname (Fargate) source resolves to an empty hostname.
1098        assert_eq!(
1099            Some(""),
1100            resolve_hostname_from_payload("", Some(&fargate_source), Some("default"), false)
1101        );
1102        // With no source, it falls back to the default hostname (which may itself be absent).
1103        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        // Appending an empty segment is a no-op.
1115        append_tags(&mut target, "");
1116        assert_eq!("", target);
1117
1118        // The first non-empty append does not prepend a separator.
1119        append_tags(&mut target, "a:1");
1120        assert_eq!("a:1", target);
1121
1122        // Subsequent non-empty appends are comma-separated.
1123        append_tags(&mut target, "b:2");
1124        assert_eq!("a:1,b:2", target);
1125
1126        // An empty segment remains a no-op even once the target is non-empty.
1127        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        // An explicit, non-empty `datadog.container_tags` attribute is used verbatim.
1152        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        // `ignore_missing_fields` skips the extraction path entirely.
1167        assert_eq!(None, resolve_container_tags_from_attrs(&attributes, None, true));
1168        // Honoring fields but with no container attributes and no Fargate source still yields nothing.
1169        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        // The encoder sends the resolved agent environment unchanged: one that normalizes to empty does not become
1175        // `none`.
1176        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    /// Only OTLP traces get the `otlp-` tracer version prefix and `_dd.otlp_sr`, as in the Go trace agent's OTLP
1199    /// receiver; native tracer versions pass through unchanged. The rate is formatted like Go's
1200    /// `strconv.FormatFloat(rate, 'f', 2, 64)`, which rounds exact ties to even:
1201    /// https://github.com/DataDog/datadog-agent/blob/c6f7e0bc2d5744601a10f231751948ac7a4ee781/pkg/trace/api/otlp.go#L581-L590
1202    #[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    /// The chunk's `_dd.otlp_sr` comes only from the ingest information: a trace attribute of the same name, such as
1237    /// an OTLP resource attribute, doesn't replace it. A span attribute of the same name is span data and passes
1238    /// through.
1239    #[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        // The encoder never synthesizes a native chunk rate. A native trace's own attribute may be dropped or kept.
1268        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}