Skip to main content

libdd_trace_utils/otlp_encoder/
mapper.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! Maps Datadog trace/spans to OTLP ExportTraceServiceRequest.
5
6use super::json_types::{
7    self, AnyValue, ExportTraceServiceRequest, InstrumentationScope, KeyValue, OtlpSpan,
8    OtlpSpanEvent, OtlpSpanLink, Resource, ResourceSpans, ScopeSpans, Status,
9};
10use super::OtlpResourceInfo;
11use crate::span::v04::{Span, SpanEvent, SpanLink};
12use crate::span::TraceData;
13use std::borrow::Borrow;
14
15/// Maximum number of attributes per span; excess are dropped and counted.
16const MAX_ATTRIBUTES_PER_SPAN: usize = 128;
17
18/// Maps Datadog trace chunks and resource info to an OTLP ExportTraceServiceRequest.
19///
20/// Resource: SDK-level attributes (service.name, deployment.environment.name, telemetry.sdk.*,
21/// runtime-id). InstrumentationScope: present but empty (DD SDKs don't have a scope concept).
22/// All analogous DD span fields are mapped; meta→attributes (string), metrics→attributes
23/// (int/double), links and events mapped to OTLP links and events. Status from span.error and
24/// meta["error.msg"].
25///
26/// The high 64 bits of a 128-bit trace ID are carried in the trace_id field itself or (if not
27/// present) as the `_dd.p.tid` meta tag, which per RFC #85 is set on the chunk root only.
28/// We resolve it once per chunk and apply it to every span so OTLP receivers see the full 128-bit
29/// trace_id on every span in the trace.
30pub fn map_traces_to_otlp<T: TraceData>(
31    trace_chunks: Vec<Vec<Span<T>>>,
32    resource_info: &OtlpResourceInfo,
33) -> ExportTraceServiceRequest {
34    let resource = build_resource(resource_info);
35    let mut all_spans: Vec<OtlpSpan> = Vec::new();
36    for chunk in &trace_chunks {
37        // Resolve the high 64 bits of the 128-bit trace ID once per chunk. For each span,
38        // prefer the native u128 `trace_id` field (e.g. Python's native spans hold the full
39        // 128-bit ID there) and fall back to its RFC #85 `_dd.p.tid` meta tag.
40        let chunk_trace_id_high: u64 = chunk
41            .iter()
42            .find_map(|s| {
43                let high = (s.trace_id >> 64) as u64;
44                if high != 0 {
45                    return Some(high);
46                }
47                s.meta
48                    .get("_dd.p.tid")
49                    .and_then(|v| u64::from_str_radix(v.borrow(), 16).ok())
50            })
51            .unwrap_or(0);
52        for span in chunk {
53            all_spans.push(map_span(span, &resource_info.service, chunk_trace_id_high));
54        }
55    }
56    let scope_spans = ScopeSpans {
57        scope: Some(InstrumentationScope::default()),
58        spans: all_spans,
59        schema_url: None,
60    };
61    let resource_spans = ResourceSpans {
62        resource: Some(resource),
63        scope_spans: vec![scope_spans],
64    };
65    ExportTraceServiceRequest {
66        resource_spans: vec![resource_spans],
67    }
68}
69
70fn build_resource(resource_info: &OtlpResourceInfo) -> Resource {
71    let mut attributes: Vec<KeyValue> = Vec::new();
72    if !resource_info.service.is_empty() {
73        attributes.push(KeyValue {
74            key: "service.name".to_string(),
75            value: AnyValue::StringValue(resource_info.service.clone()),
76        });
77    }
78    if !resource_info.env.is_empty() {
79        attributes.push(KeyValue {
80            key: "deployment.environment.name".to_string(),
81            value: AnyValue::StringValue(resource_info.env.clone()),
82        });
83    }
84    if !resource_info.app_version.is_empty() {
85        attributes.push(KeyValue {
86            key: "service.version".to_string(),
87            value: AnyValue::StringValue(resource_info.app_version.clone()),
88        });
89    }
90    attributes.push(KeyValue {
91        key: "telemetry.sdk.name".to_string(),
92        value: AnyValue::StringValue("datadog".to_string()),
93    });
94    if !resource_info.language.is_empty() {
95        attributes.push(KeyValue {
96            key: "telemetry.sdk.language".to_string(),
97            value: AnyValue::StringValue(resource_info.language.clone()),
98        });
99    }
100    if !resource_info.tracer_version.is_empty() {
101        attributes.push(KeyValue {
102            key: "telemetry.sdk.version".to_string(),
103            value: AnyValue::StringValue(resource_info.tracer_version.clone()),
104        });
105    }
106    if !resource_info.runtime_id.is_empty() {
107        attributes.push(KeyValue {
108            key: "runtime-id".to_string(),
109            value: AnyValue::StringValue(resource_info.runtime_id.clone()),
110        });
111    }
112    Resource { attributes }
113}
114
115fn map_span<T: TraceData>(
116    span: &Span<T>,
117    resource_service: &str,
118    chunk_trace_id_high: u64,
119) -> OtlpSpan {
120    // Reconstruct the full 128-bit trace ID. The caller resolves the high 64 bits once per
121    // chunk (from either the native u128 `trace_id` field or the "_dd.p.tid" meta tag).
122    // All spans in a chunk share the same trace ID.
123    let trace_id_128 = ((chunk_trace_id_high as u128) << 64) | (span.trace_id as u64 as u128);
124    let trace_id_hex = format!("{:032x}", trace_id_128);
125    let span_id_hex = format!("{:016x}", span.span_id);
126    let parent_span_id = if span.parent_id != 0 {
127        Some(format!("{:016x}", span.parent_id))
128    } else {
129        None
130    };
131    let start_nano = span.start;
132    let end_nano = span.start + span.duration;
133    let start_time_unix_nano = start_nano.to_string();
134    let end_time_unix_nano = end_nano.to_string();
135    // Prefer explicit "span.kind" tag (set by OTEL-instrumented tracers); fall back to
136    // the Datadog span type field for DD-instrumented spans.
137    let kind = span
138        .meta
139        .get("span.kind")
140        .map(|v| tag_to_otlp_kind(v.borrow()))
141        .unwrap_or_else(|| dd_type_to_otlp_kind(span.r#type.borrow()));
142    let (attributes, dropped_attributes_count) = map_attributes(span, resource_service);
143    let error_msg = span.meta.get("error.msg").map(|v| v.borrow().to_string());
144    let status = if span.error != 0 {
145        Status {
146            message: error_msg,
147            code: json_types::status_code::ERROR,
148        }
149    } else {
150        Status {
151            message: None,
152            code: json_types::status_code::UNSET,
153        }
154    };
155    // Set flags from sampling priority: 1 = sampled/keep, 0 = dropped.
156    let flags = span
157        .metrics
158        .get("_sampling_priority_v1")
159        .map(|p| if *p >= 1.0 { 1u32 } else { 0u32 });
160    let trace_state = span
161        .meta
162        .get("tracestate")
163        .map(|v| v.borrow().to_string())
164        .filter(|s| !s.is_empty());
165    let links = span.span_links.iter().map(map_span_link).collect();
166    let (events, dropped_events_count) = map_span_events(&span.span_events);
167    OtlpSpan {
168        trace_id: trace_id_hex,
169        span_id: span_id_hex,
170        parent_span_id,
171        trace_state,
172        name: span.resource.borrow().to_string(),
173        kind,
174        start_time_unix_nano,
175        end_time_unix_nano,
176        attributes,
177        status,
178        links,
179        events,
180        dropped_attributes_count: if dropped_attributes_count > 0 {
181            Some(dropped_attributes_count as u32)
182        } else {
183            None
184        },
185        dropped_events_count: if dropped_events_count > 0 {
186            Some(dropped_events_count as u32)
187        } else {
188            None
189        },
190        flags,
191    }
192}
193
194fn map_span_link<T: TraceData>(link: &SpanLink<T>) -> OtlpSpanLink {
195    let trace_id_128 = ((link.trace_id_high as u128) << 64) | (link.trace_id as u128);
196    let trace_id_hex = format!("{:032x}", trace_id_128);
197    let span_id_hex = format!("{:016x}", link.span_id);
198    let trace_state = if link.tracestate.borrow().is_empty() {
199        None
200    } else {
201        Some(link.tracestate.borrow().to_string())
202    };
203    let attributes: Vec<KeyValue> = link
204        .attributes
205        .iter()
206        .map(|(k, v)| KeyValue {
207            key: k.borrow().to_string(),
208            value: AnyValue::StringValue(v.borrow().to_string()),
209        })
210        .collect();
211    OtlpSpanLink {
212        trace_id: trace_id_hex,
213        span_id: span_id_hex,
214        trace_state,
215        attributes,
216        dropped_attributes_count: None,
217    }
218}
219
220fn map_span_events<T: TraceData>(events: &[SpanEvent<T>]) -> (Vec<OtlpSpanEvent>, usize) {
221    const MAX_EVENTS_PER_SPAN: usize = 128;
222    let mut otlp_events = Vec::with_capacity(events.len().min(MAX_EVENTS_PER_SPAN));
223    for ev in events.iter().take(MAX_EVENTS_PER_SPAN) {
224        let attributes: Vec<KeyValue> = ev
225            .attributes
226            .iter()
227            .map(|(k, v)| event_attr_to_key_value(k, v))
228            .collect();
229        otlp_events.push(OtlpSpanEvent {
230            time_unix_nano: ev.time_unix_nano.to_string(),
231            name: ev.name.borrow().to_string(),
232            attributes,
233            dropped_attributes_count: None,
234        });
235    }
236    let dropped = events.len().saturating_sub(otlp_events.len());
237    (otlp_events, dropped)
238}
239
240fn event_attr_to_key_value<T: TraceData>(
241    k: &T::Text,
242    v: &crate::span::v04::AttributeAnyValue<T>,
243) -> KeyValue {
244    use crate::span::v04::AttributeArrayValue;
245    let value = match v {
246        crate::span::v04::AttributeAnyValue::SingleValue(av) => match av {
247            AttributeArrayValue::String(s) => AnyValue::StringValue(s.borrow().to_string()),
248            AttributeArrayValue::Boolean(b) => AnyValue::BoolValue(*b),
249            AttributeArrayValue::Integer(i) => AnyValue::IntValue(*i),
250            AttributeArrayValue::Double(d) => AnyValue::DoubleValue(*d),
251        },
252        crate::span::v04::AttributeAnyValue::Array(items) => {
253            let values = items
254                .iter()
255                .map(|item| match item {
256                    AttributeArrayValue::String(s) => AnyValue::StringValue(s.borrow().to_string()),
257                    AttributeArrayValue::Boolean(b) => AnyValue::BoolValue(*b),
258                    AttributeArrayValue::Integer(i) => AnyValue::IntValue(*i),
259                    AttributeArrayValue::Double(d) => AnyValue::DoubleValue(*d),
260                })
261                .collect();
262            AnyValue::ArrayValue(crate::otlp_encoder::json_types::ArrayValue { values })
263        }
264    };
265    KeyValue {
266        key: k.borrow().to_string(),
267        value,
268    }
269}
270
271/// Maps the explicit "span.kind" meta tag (set by OTEL-instrumented tracers) to an OTLP SpanKind.
272fn tag_to_otlp_kind(t: &str) -> i32 {
273    match t.to_lowercase().as_str() {
274        "server" => json_types::span_kind::SERVER,
275        "client" => json_types::span_kind::CLIENT,
276        "producer" => json_types::span_kind::PRODUCER,
277        "consumer" => json_types::span_kind::CONSUMER,
278        "internal" => json_types::span_kind::INTERNAL,
279        _ => json_types::span_kind::UNSPECIFIED,
280    }
281}
282
283/// Maps the Datadog span type field (set by DD-instrumented tracers) to an OTLP SpanKind.
284fn dd_type_to_otlp_kind(t: &str) -> i32 {
285    match t.to_lowercase().as_str() {
286        "server" | "web" | "http" => json_types::span_kind::SERVER,
287        "client" => json_types::span_kind::CLIENT,
288        "producer" => json_types::span_kind::PRODUCER,
289        "consumer" => json_types::span_kind::CONSUMER,
290        _ => json_types::span_kind::INTERNAL,
291    }
292}
293
294fn map_attributes<T: TraceData>(span: &Span<T>, resource_service: &str) -> (Vec<KeyValue>, usize) {
295    let mut attrs: Vec<KeyValue> = Vec::new();
296    // Add service.name when the span's service differs from the resource-level service.
297    let span_service = span.service.borrow();
298    let has_per_span_service = !span_service.is_empty() && span_service != resource_service;
299    if has_per_span_service {
300        attrs.push(KeyValue {
301            key: "service.name".to_string(),
302            value: AnyValue::StringValue(span_service.to_string()),
303        });
304    }
305    let operation_name = span.name.borrow();
306    let has_operation_name = !operation_name.is_empty();
307    if has_operation_name {
308        attrs.push(KeyValue {
309            key: "operation.name".to_string(),
310            value: AnyValue::StringValue(operation_name.to_string()),
311        });
312    }
313    let span_type = span.r#type.borrow();
314    let has_span_type = !span_type.is_empty();
315    if has_span_type {
316        attrs.push(KeyValue {
317            key: "span.type".to_string(),
318            value: AnyValue::StringValue(span_type.to_string()),
319        });
320    }
321    let resource_name = span.resource.borrow();
322    let has_resource_name = !resource_name.is_empty();
323    if has_resource_name {
324        attrs.push(KeyValue {
325            key: "resource.name".to_string(),
326            value: AnyValue::StringValue(resource_name.to_string()),
327        });
328    }
329    for (k, v) in span.meta.iter() {
330        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
331            break;
332        }
333        attrs.push(KeyValue {
334            key: k.borrow().to_string(),
335            value: AnyValue::StringValue(v.borrow().to_string()),
336        });
337    }
338    for (k, v) in span.metrics.iter() {
339        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
340            break;
341        }
342        let value = if v.fract() == 0.0 && (*v >= i64::MIN as f64 && *v <= i64::MAX as f64) {
343            AnyValue::IntValue(*v as i64)
344        } else {
345            AnyValue::DoubleValue(*v)
346        };
347        attrs.push(KeyValue {
348            key: k.borrow().to_string(),
349            value,
350        });
351    }
352    for (k, v) in span.meta_struct.iter() {
353        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
354            break;
355        }
356        attrs.push(KeyValue {
357            key: k.borrow().to_string(),
358            value: AnyValue::BytesValue(v.borrow().to_vec()),
359        });
360    }
361    let total = (if has_per_span_service { 1 } else { 0 })
362        + (if has_operation_name { 1 } else { 0 })
363        + (if has_span_type { 1 } else { 0 })
364        + (if has_resource_name { 1 } else { 0 })
365        + span.meta.len()
366        + span.metrics.len()
367        + span.meta_struct.len();
368    let dropped = total.saturating_sub(attrs.len());
369    (attrs, dropped)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::otlp_encoder::OtlpResourceInfo;
376    use crate::span::BytesData;
377
378    #[test]
379    fn test_trace_id_span_id_format() {
380        let resource_info = OtlpResourceInfo::default();
381        let span: Span<BytesData> = Span {
382            trace_id: 0xD269B633813FC60C_u128, // low 64 bits only (v04 wire format)
383            span_id: 0xEEE19B7EC3C1B174,
384            parent_id: 0xEEE19B7EC3C1B173,
385            name: libdd_tinybytes::BytesString::from_static("test"),
386            service: libdd_tinybytes::BytesString::from_static("svc"),
387            resource: libdd_tinybytes::BytesString::from_static("res"),
388            r#type: libdd_tinybytes::BytesString::from_static("web"),
389            start: 1544712660000000000,
390            duration: 1000000000,
391            error: 0,
392            ..Default::default()
393        };
394        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
395        let rs = &req.resource_spans[0];
396        let otlp_span = &rs.scope_spans[0].spans[0];
397        assert_eq!(otlp_span.trace_id, "0000000000000000d269b633813fc60c");
398        assert_eq!(otlp_span.span_id, "eee19b7ec3c1b174");
399        assert_eq!(
400            otlp_span.parent_span_id.as_deref(),
401            Some("eee19b7ec3c1b173")
402        );
403        assert_eq!(otlp_span.kind, json_types::span_kind::SERVER);
404        assert_eq!(otlp_span.start_time_unix_nano, "1544712660000000000");
405        assert_eq!(otlp_span.end_time_unix_nano, "1544712661000000000");
406        assert_eq!(rs.scope_spans[0].scope.as_ref().unwrap().name, None);
407    }
408
409    #[test]
410    fn test_status_error_message_from_meta() {
411        let resource_info = OtlpResourceInfo::default();
412        let mut span: Span<BytesData> = Span {
413            trace_id: 1,
414            span_id: 2,
415            name: libdd_tinybytes::BytesString::from_static("err_span"),
416            start: 0,
417            duration: 1,
418            error: 1,
419            ..Default::default()
420        };
421        span.meta.insert(
422            libdd_tinybytes::BytesString::from_static("error.msg"),
423            libdd_tinybytes::BytesString::from_static("something broke"),
424        );
425        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
426        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
427        let status = &otlp_span.status;
428        assert_eq!(status.code, json_types::status_code::ERROR);
429        assert_eq!(status.message.as_deref(), Some("something broke"));
430    }
431
432    #[test]
433    fn test_metrics_as_int_or_double() {
434        let resource_info = OtlpResourceInfo::default();
435        let mut span: Span<BytesData> = Span {
436            trace_id: 1,
437            span_id: 2,
438            name: libdd_tinybytes::BytesString::from_static("m"),
439            start: 0,
440            duration: 1,
441            ..Default::default()
442        };
443        span.metrics
444            .insert(libdd_tinybytes::BytesString::from_static("count"), 42.0);
445        span.metrics.insert(
446            libdd_tinybytes::BytesString::from_static("rate"),
447            std::f64::consts::PI,
448        );
449        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
450        let json = serde_json::to_value(&req).unwrap();
451        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
452        let count_kv = attrs
453            .as_array()
454            .unwrap()
455            .iter()
456            .find(|a| a["key"] == "count")
457            .unwrap();
458        assert_eq!(count_kv["value"]["intValue"], "42");
459        let rate_kv = attrs
460            .as_array()
461            .unwrap()
462            .iter()
463            .find(|a| a["key"] == "rate")
464            .unwrap();
465        let rate = rate_kv["value"]["doubleValue"].as_f64().unwrap();
466        assert!((rate - std::f64::consts::PI).abs() < 1e-9);
467    }
468
469    #[test]
470    fn test_128bit_trace_id_from_dd_p_tid() {
471        // When "_dd.p.tid" is present it supplies the high 64 bits of the trace ID.
472        // Low 64 bits come from span.trace_id; the two are concatenated to form a 128-bit hex ID.
473        let resource_info = OtlpResourceInfo::default();
474        let mut span: Span<BytesData> = Span {
475            trace_id: 0xD269B633813FC60C_u128, // low 64 bits
476            span_id: 1,
477            name: libdd_tinybytes::BytesString::from_static("s"),
478            start: 0,
479            duration: 1,
480            ..Default::default()
481        };
482        span.meta.insert(
483            "_dd.p.tid".into(),
484            libdd_tinybytes::BytesString::from_static("5b8efff798038103"),
485        );
486        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
487        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
488        assert_eq!(otlp_span.trace_id, "5b8efff798038103d269b633813fc60c");
489    }
490
491    #[test]
492    fn test_128bit_trace_id_from_native_span_field() {
493        // When the span's u128 `trace_id` field already carries the full 128-bit ID (e.g.
494        // tracers with native spans like Python), the chunk-root meta lookup is skipped and
495        // the field's high 64 bits are propagated to every span in the chunk.
496        let resource_info = OtlpResourceInfo::default();
497        let full: u128 = 0x5b8efff798038103_d269b633813fc60c_u128;
498        let root: Span<BytesData> = Span {
499            trace_id: full,
500            span_id: 1,
501            name: libdd_tinybytes::BytesString::from_static("root"),
502            start: 0,
503            duration: 1,
504            ..Default::default()
505        };
506        // Child carries only the low 64 bits; it should still inherit the chunk's high bits.
507        let child: Span<BytesData> = Span {
508            trace_id: 0xD269B633813FC60C_u128,
509            span_id: 2,
510            parent_id: 1,
511            name: libdd_tinybytes::BytesString::from_static("child"),
512            start: 0,
513            duration: 1,
514            ..Default::default()
515        };
516        let req = map_traces_to_otlp(vec![vec![root, child]], &resource_info);
517        let spans = &req.resource_spans[0].scope_spans[0].spans;
518        let expected = "5b8efff798038103d269b633813fc60c";
519        assert_eq!(spans[0].trace_id, expected);
520        assert_eq!(spans[1].trace_id, expected);
521    }
522
523    #[test]
524    fn test_128bit_trace_id_without_dd_p_tid() {
525        // When the entire chunk has no "_dd.p.tid" the high 64 bits default to zero
526        // (legacy 64-bit-only trace IDs).
527        let resource_info = OtlpResourceInfo::default();
528        let span: Span<BytesData> = Span {
529            trace_id: 0xD269B633813FC60C_u128,
530            span_id: 1,
531            name: libdd_tinybytes::BytesString::from_static("s"),
532            start: 0,
533            duration: 1,
534            ..Default::default()
535        };
536        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
537        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
538        assert_eq!(otlp_span.trace_id, "0000000000000000d269b633813fc60c");
539    }
540
541    #[test]
542    fn test_128bit_trace_id_propagated_to_chunk_children() {
543        // Per RFC #85 dd-trace tracers set "_dd.p.tid" only on the chunk root.
544        // The OTLP mapper must apply that high-bits value to every span in the chunk
545        // so receivers see the full 128-bit trace_id on every span.
546        let resource_info = OtlpResourceInfo::default();
547        let low: u128 = 0xD269B633813FC60C_u128;
548        let mut root: Span<BytesData> = Span {
549            trace_id: low,
550            span_id: 1,
551            name: libdd_tinybytes::BytesString::from_static("root"),
552            start: 0,
553            duration: 1,
554            ..Default::default()
555        };
556        root.meta.insert(
557            "_dd.p.tid".into(),
558            libdd_tinybytes::BytesString::from_static("5b8efff798038103"),
559        );
560        let child_a: Span<BytesData> = Span {
561            trace_id: low,
562            span_id: 2,
563            parent_id: 1,
564            name: libdd_tinybytes::BytesString::from_static("child_a"),
565            start: 0,
566            duration: 1,
567            ..Default::default()
568        };
569        let child_b: Span<BytesData> = Span {
570            trace_id: low,
571            span_id: 3,
572            parent_id: 1,
573            name: libdd_tinybytes::BytesString::from_static("child_b"),
574            start: 0,
575            duration: 1,
576            ..Default::default()
577        };
578        let req = map_traces_to_otlp(vec![vec![root, child_a, child_b]], &resource_info);
579        let spans = &req.resource_spans[0].scope_spans[0].spans;
580        assert_eq!(spans.len(), 3);
581        let expected = "5b8efff798038103d269b633813fc60c";
582        for s in spans {
583            assert_eq!(s.trace_id, expected, "span {} mismatched", s.span_id);
584        }
585    }
586
587    #[test]
588    fn test_128bit_trace_id_isolation_across_chunks() {
589        // The chunk-level high bits must not leak across chunks. Each chunk's spans
590        // get only their own chunk root's "_dd.p.tid".
591        let resource_info = OtlpResourceInfo::default();
592        let low_a: u128 = 0x1111111111111111_u128;
593        let low_b: u128 = 0x2222222222222222_u128;
594        let mut root_a: Span<BytesData> = Span {
595            trace_id: low_a,
596            span_id: 1,
597            name: libdd_tinybytes::BytesString::from_static("root_a"),
598            start: 0,
599            duration: 1,
600            ..Default::default()
601        };
602        root_a.meta.insert(
603            "_dd.p.tid".into(),
604            libdd_tinybytes::BytesString::from_static("aaaaaaaaaaaaaaaa"),
605        );
606        let child_a: Span<BytesData> = Span {
607            trace_id: low_a,
608            span_id: 2,
609            parent_id: 1,
610            name: libdd_tinybytes::BytesString::from_static("child_a"),
611            start: 0,
612            duration: 1,
613            ..Default::default()
614        };
615        let mut root_b: Span<BytesData> = Span {
616            trace_id: low_b,
617            span_id: 3,
618            name: libdd_tinybytes::BytesString::from_static("root_b"),
619            start: 0,
620            duration: 1,
621            ..Default::default()
622        };
623        root_b.meta.insert(
624            "_dd.p.tid".into(),
625            libdd_tinybytes::BytesString::from_static("bbbbbbbbbbbbbbbb"),
626        );
627        let child_b: Span<BytesData> = Span {
628            trace_id: low_b,
629            span_id: 4,
630            parent_id: 3,
631            name: libdd_tinybytes::BytesString::from_static("child_b"),
632            start: 0,
633            duration: 1,
634            ..Default::default()
635        };
636        let req = map_traces_to_otlp(
637            vec![vec![root_a, child_a], vec![root_b, child_b]],
638            &resource_info,
639        );
640        let spans = &req.resource_spans[0].scope_spans[0].spans;
641        assert_eq!(spans.len(), 4);
642        // Spans 1, 2 belong to chunk A; spans 3, 4 to chunk B.
643        let expect_a = "aaaaaaaaaaaaaaaa1111111111111111";
644        let expect_b = "bbbbbbbbbbbbbbbb2222222222222222";
645        assert_eq!(spans[0].trace_id, expect_a);
646        assert_eq!(spans[1].trace_id, expect_a);
647        assert_eq!(spans[2].trace_id, expect_b);
648        assert_eq!(spans[3].trace_id, expect_b);
649    }
650
651    #[test]
652    fn test_chunk_with_malformed_dd_p_tid_on_root_falls_back() {
653        // If the chunk root's "_dd.p.tid" fails to parse, the scan continues looking for
654        // any other parseable value in the chunk before giving up. This keeps a malformed
655        // tag on one span from poisoning the rest of the trace.
656        let resource_info = OtlpResourceInfo::default();
657        let low: u128 = 0xD269B633813FC60C_u128;
658        let mut root: Span<BytesData> = Span {
659            trace_id: low,
660            span_id: 1,
661            name: libdd_tinybytes::BytesString::from_static("root"),
662            start: 0,
663            duration: 1,
664            ..Default::default()
665        };
666        root.meta.insert(
667            "_dd.p.tid".into(),
668            libdd_tinybytes::BytesString::from_static("not-hex"),
669        );
670        let child_no_tag: Span<BytesData> = Span {
671            trace_id: low,
672            span_id: 2,
673            parent_id: 1,
674            name: libdd_tinybytes::BytesString::from_static("child_no_tag"),
675            start: 0,
676            duration: 1,
677            ..Default::default()
678        };
679        let mut child_valid: Span<BytesData> = Span {
680            trace_id: low,
681            span_id: 3,
682            parent_id: 1,
683            name: libdd_tinybytes::BytesString::from_static("child_valid"),
684            start: 0,
685            duration: 1,
686            ..Default::default()
687        };
688        child_valid.meta.insert(
689            "_dd.p.tid".into(),
690            libdd_tinybytes::BytesString::from_static("dddddddddddddddd"),
691        );
692        let req = map_traces_to_otlp(vec![vec![root, child_no_tag, child_valid]], &resource_info);
693        let spans = &req.resource_spans[0].scope_spans[0].spans;
694        // The chunk-level scan skips the malformed root and picks up child_valid's tag,
695        // which is then applied to every span in the chunk.
696        let expected = "ddddddddddddddddd269b633813fc60c";
697        assert_eq!(spans[0].trace_id, expected);
698        assert_eq!(spans[1].trace_id, expected);
699        assert_eq!(spans[2].trace_id, expected);
700    }
701
702    #[test]
703    fn test_empty_chunk_does_not_panic() {
704        // Defensive: an empty chunk should produce no spans and not panic.
705        let resource_info = OtlpResourceInfo::default();
706        let empty: Vec<Vec<Span<BytesData>>> = vec![vec![]];
707        let req = map_traces_to_otlp(empty, &resource_info);
708        let spans = &req.resource_spans[0].scope_spans[0].spans;
709        assert!(spans.is_empty());
710    }
711
712    #[test]
713    fn test_tracestate_from_meta() {
714        let resource_info = OtlpResourceInfo::default();
715        let mut span: Span<BytesData> = Span {
716            trace_id: 1,
717            span_id: 2,
718            name: libdd_tinybytes::BytesString::from_static("s"),
719            start: 0,
720            duration: 1,
721            ..Default::default()
722        };
723        span.meta.insert(
724            "tracestate".into(),
725            libdd_tinybytes::BytesString::from_static("vendor1=abc,rojo=00f067"),
726        );
727        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
728        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
729        assert_eq!(
730            otlp_span.trace_state.as_deref(),
731            Some("vendor1=abc,rojo=00f067")
732        );
733    }
734
735    #[test]
736    fn test_meta_struct_as_bytes_value() {
737        use libdd_tinybytes::Bytes;
738        let resource_info = OtlpResourceInfo::default();
739        let mut span: Span<BytesData> = Span {
740            trace_id: 1,
741            span_id: 2,
742            name: libdd_tinybytes::BytesString::from_static("s"),
743            start: 0,
744            duration: 1,
745            ..Default::default()
746        };
747        span.meta_struct
748            .insert("my_key".into(), Bytes::from(vec![1u8, 2, 3]));
749        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
750        let json = serde_json::to_value(&req).unwrap();
751        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
752        let kv = attrs
753            .as_array()
754            .unwrap()
755            .iter()
756            .find(|a| a["key"] == "my_key")
757            .expect("my_key attribute not found");
758        // Per the protobuf JSON mapping, bytes are base64-encoded.
759        assert_eq!(kv["value"]["bytesValue"], "AQID");
760    }
761
762    #[test]
763    fn test_operation_name_attribute() {
764        let resource_info = OtlpResourceInfo::default();
765        let span: Span<BytesData> = Span {
766            trace_id: 1,
767            span_id: 2,
768            name: libdd_tinybytes::BytesString::from_static("my.operation"),
769            start: 0,
770            duration: 1,
771            ..Default::default()
772        };
773        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
774        let json = serde_json::to_value(&req).unwrap();
775        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
776        let kv = attrs
777            .as_array()
778            .unwrap()
779            .iter()
780            .find(|a| a["key"] == "operation.name")
781            .expect("operation.name attribute not found");
782        assert_eq!(kv["value"]["stringValue"], "my.operation");
783    }
784
785    #[test]
786    fn test_span_type_attribute() {
787        let resource_info = OtlpResourceInfo::default();
788        let span: Span<BytesData> = Span {
789            trace_id: 1,
790            span_id: 2,
791            name: libdd_tinybytes::BytesString::from_static("s"),
792            r#type: libdd_tinybytes::BytesString::from_static("grpc"),
793            start: 0,
794            duration: 1,
795            ..Default::default()
796        };
797        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
798        let json = serde_json::to_value(&req).unwrap();
799        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
800        let kv = attrs
801            .as_array()
802            .unwrap()
803            .iter()
804            .find(|a| a["key"] == "span.type")
805            .expect("span.type attribute not found");
806        assert_eq!(kv["value"]["stringValue"], "grpc");
807    }
808
809    #[test]
810    fn test_resource_name_attribute() {
811        let resource_info = OtlpResourceInfo::default();
812        let span: Span<BytesData> = Span {
813            trace_id: 1,
814            span_id: 2,
815            name: libdd_tinybytes::BytesString::from_static("s"),
816            resource: libdd_tinybytes::BytesString::from_static("GET /api/users"),
817            start: 0,
818            duration: 1,
819            ..Default::default()
820        };
821        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
822        let json = serde_json::to_value(&req).unwrap();
823        let otlp_span = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
824        // resource maps to the OTLP span name
825        assert_eq!(otlp_span["name"], "GET /api/users");
826        // resource also maps to the resource.name attribute
827        let kv = otlp_span["attributes"]
828            .as_array()
829            .unwrap()
830            .iter()
831            .find(|a| a["key"] == "resource.name")
832            .expect("resource.name attribute not found");
833        assert_eq!(kv["value"]["stringValue"], "GET /api/users");
834    }
835
836    #[test]
837    fn test_empty_resource_name_not_emitted() {
838        // A span with no resource set should not emit a resource.name attribute.
839        // In practice DD spans always have a resource, but the mapper is defensive about
840        // empty fields from the wire.
841        let resource_info = OtlpResourceInfo::default();
842        let span: Span<BytesData> = Span {
843            trace_id: 1,
844            span_id: 2,
845            name: libdd_tinybytes::BytesString::from_static("s"),
846            // resource is empty (default)
847            start: 0,
848            duration: 1,
849            ..Default::default()
850        };
851        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
852        let json = serde_json::to_value(&req).unwrap();
853        let attrs = json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]
854            .as_array()
855            .unwrap();
856        assert!(
857            !attrs.iter().any(|a| a["key"] == "resource.name"),
858            "resource.name should not be emitted when resource is empty"
859        );
860    }
861
862    #[test]
863    fn test_per_span_service_name_attribute() {
864        // When span.service differs from the resource-level service, service.name is emitted
865        // as a per-span attribute so the receiver can distinguish between services in a trace.
866        let resource_info = OtlpResourceInfo {
867            service: "resource-svc".to_string(),
868            ..Default::default()
869        };
870        let span: Span<BytesData> = Span {
871            trace_id: 1,
872            span_id: 2,
873            name: libdd_tinybytes::BytesString::from_static("s"),
874            service: libdd_tinybytes::BytesString::from_static("span-svc"),
875            start: 0,
876            duration: 1,
877            ..Default::default()
878        };
879        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
880        let json = serde_json::to_value(&req).unwrap();
881        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
882        let kv = attrs
883            .as_array()
884            .unwrap()
885            .iter()
886            .find(|a| a["key"] == "service.name")
887            .expect("service.name attribute not found");
888        assert_eq!(kv["value"]["stringValue"], "span-svc");
889    }
890
891    #[test]
892    fn test_unsampled_span_flags_zero() {
893        // _sampling_priority_v1 = 0 means explicitly dropped; flags field must be 0.
894        let resource_info = OtlpResourceInfo::default();
895        let mut span: Span<BytesData> = Span {
896            trace_id: 1,
897            span_id: 2,
898            name: libdd_tinybytes::BytesString::from_static("s"),
899            start: 0,
900            duration: 1,
901            ..Default::default()
902        };
903        span.metrics.insert("_sampling_priority_v1".into(), 0.0);
904        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
905        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
906        assert_eq!(otlp_span.flags, Some(0));
907    }
908}