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 directly to the generated prost OTLP types (the IR).
5//!
6//! The prost `ExportTraceServiceRequest` is the single OTLP representation: from it the
7//! HTTP/protobuf wire format is produced by prost encoding and the HTTP/JSON wire format by the
8//! serde serializer in `json_serializer`. Attributes are built straight into prost
9//! `KeyValue`/`AnyValue` in one pass — there is no intermediate value type to keep the two
10//! encoders in sync because there is only one IR.
11
12use super::OtlpResourceInfo;
13use crate::span::v04::{Span, SpanEvent, SpanLink};
14use crate::span::{TraceData, SPAN_LINK_FLAGS_SET_SENTINEL};
15use std::borrow::Borrow;
16
17use libdd_trace_protobuf::opentelemetry::proto::collector::trace::v1::ExportTraceServiceRequest as ProtoReq;
18use libdd_trace_protobuf::opentelemetry::proto::common::v1::{
19    any_value::Value as ProtoValue, AnyValue as ProtoAnyValue, ArrayValue as ProtoArrayValue,
20    InstrumentationScope as ProtoScope, KeyValue as ProtoKeyValue,
21};
22use libdd_trace_protobuf::opentelemetry::proto::resource::v1::Resource as ProtoResource;
23use libdd_trace_protobuf::opentelemetry::proto::trace::v1::{
24    span::{Event as ProtoEvent, Link as ProtoLink},
25    ResourceSpans as ProtoResourceSpans, ScopeSpans as ProtoScopeSpans, Span as ProtoSpan,
26    Status as ProtoStatus,
27};
28
29/// Maximum number of attributes per span; excess are dropped and counted.
30pub(crate) const MAX_ATTRIBUTES_PER_SPAN: usize = 128;
31
32/// OTLP SpanKind enum values.
33mod span_kind {
34    pub const UNSPECIFIED: i32 = 0;
35    pub const INTERNAL: i32 = 1;
36    pub const SERVER: i32 = 2;
37    pub const CLIENT: i32 = 3;
38    pub const PRODUCER: i32 = 4;
39    pub const CONSUMER: i32 = 5;
40}
41
42/// OTLP StatusCode enum values. Public because the OTLP metrics exporter
43/// (`libdd-data-pipeline`) reuses these constants.
44pub mod status_code {
45    pub const UNSET: i32 = 0;
46    pub const ERROR: i32 = 2;
47}
48
49// ─── Scalar mapping helpers ──────────────────────────────────────────────────
50
51/// OTLP status (code, optional message) for a span. ERROR with the error message when
52/// `span.error != 0`, otherwise UNSET. A span carries at most one of `error.msg` / `error.message`
53/// (`error.message` is used by all SDKs except .NET, which uses `error.msg`), so promote whichever
54/// is present — under OTel-semantics `collect_span_attributes` drops both compat tags since the
55/// message now lives in `Status`.
56fn span_status<T: TraceData>(span: &Span<T>) -> (i32, Option<String>) {
57    if span.error != 0 {
58        let message = span
59            .meta
60            .get("error.msg")
61            .or_else(|| span.meta.get("error.message"))
62            .map(|v| v.borrow().to_string());
63        (status_code::ERROR, message)
64    } else {
65        (status_code::UNSET, None)
66    }
67}
68
69/// OTLP SpanKind for a span: prefer the explicit `span.kind` meta tag, else the DD span type.
70fn span_kind<T: TraceData>(span: &Span<T>) -> i32 {
71    span.meta
72        .get("span.kind")
73        .map(|v| tag_to_otlp_kind(v.borrow()))
74        .unwrap_or_else(|| dd_type_to_otlp_kind(span.r#type.borrow()))
75}
76
77/// Resolve the high 64 bits of the chunk's 128-bit trace id (native field or `_dd.p.tid`).
78fn chunk_trace_id_high<T: TraceData>(chunk: &[Span<T>]) -> u64 {
79    chunk
80        .iter()
81        .find_map(|s| {
82            let high = (s.trace_id >> 64) as u64;
83            if high != 0 {
84                return Some(high);
85            }
86            s.meta
87                .get("_dd.p.tid")
88                .and_then(|v| u64::from_str_radix(v.borrow(), 16).ok())
89        })
90        .unwrap_or(0)
91}
92
93fn span_sampling_flags<T: TraceData>(span: &Span<T>) -> u32 {
94    span.metrics
95        .get("_sampling_priority_v1")
96        .is_some_and(|priority| *priority >= 1.0)
97        .into()
98}
99
100/// Resolve an explicit trace-level sampling decision from the first span carrying a priority.
101///
102/// A missing priority leaves the OTLP sampled flag unset. Chunk retention and an explicit
103/// sampling decision are separate contracts.
104fn chunk_sampling_flags<T: TraceData>(chunk: &[Span<T>]) -> u32 {
105    chunk
106        .iter()
107        .find_map(|span| span.metrics.get("_sampling_priority_v1"))
108        .is_some_and(|priority| *priority > 0.0)
109        .into()
110}
111
112/// Maps the explicit "span.kind" meta tag (set by OTEL-instrumented tracers) to an OTLP SpanKind.
113fn tag_to_otlp_kind(t: &str) -> i32 {
114    // Case-insensitive match without allocating: these are ASCII keywords, so
115    // `eq_ignore_ascii_case` avoids the per-span `to_lowercase()` String on the encode hot
116    // path.
117    if t.eq_ignore_ascii_case("server") {
118        span_kind::SERVER
119    } else if t.eq_ignore_ascii_case("client") {
120        span_kind::CLIENT
121    } else if t.eq_ignore_ascii_case("producer") {
122        span_kind::PRODUCER
123    } else if t.eq_ignore_ascii_case("consumer") {
124        span_kind::CONSUMER
125    } else if t.eq_ignore_ascii_case("internal") {
126        span_kind::INTERNAL
127    } else {
128        span_kind::UNSPECIFIED
129    }
130}
131
132/// Maps the Datadog span type field (set by DD-instrumented tracers) to an OTLP SpanKind.
133fn dd_type_to_otlp_kind(t: &str) -> i32 {
134    // Case-insensitive match without allocating (see `tag_to_otlp_kind`).
135    if t.eq_ignore_ascii_case("server")
136        || t.eq_ignore_ascii_case("web")
137        || t.eq_ignore_ascii_case("http")
138    {
139        span_kind::SERVER
140    } else if t.eq_ignore_ascii_case("client") {
141        span_kind::CLIENT
142    } else if t.eq_ignore_ascii_case("producer") {
143        span_kind::PRODUCER
144    } else if t.eq_ignore_ascii_case("consumer") {
145        span_kind::CONSUMER
146    } else {
147        span_kind::INTERNAL
148    }
149}
150
151// ─── Attribute builders (straight into prost) ─────────────────────────────────
152
153/// Wrap a prost attribute value as a `KeyValue`. `key_ref` is a profiling-signal field, set to
154/// its zero default explicitly (no `..Default::default()`).
155fn proto_kv(key: String, value: ProtoValue) -> ProtoKeyValue {
156    ProtoKeyValue {
157        key,
158        value: Some(ProtoAnyValue { value: Some(value) }),
159        key_ref: 0,
160    }
161}
162
163/// Collect a span's OTLP attributes directly as prost `KeyValue`s plus the dropped count.
164/// Per-span service.name (only when it differs from the resource service), operation.name,
165/// span.type, resource.name, then meta (string), metrics (int when integral and in i64 range
166/// else double), meta_struct (bytes), capped at `MAX_ATTRIBUTES_PER_SPAN`.
167fn collect_span_attributes<T: TraceData>(
168    span: &Span<T>,
169    resource_service: &str,
170    otel_trace_semantics_enabled: bool,
171) -> (Vec<ProtoKeyValue>, usize) {
172    // Pre-size to avoid reallocations as attributes accumulate. Upper bound is the 4 synthetic
173    // attrs plus every meta/metrics/meta_struct entry, clamped to the per-span cap.
174    let capacity = (4 + span.meta.len() + span.metrics.len() + span.meta_struct.len())
175        .min(MAX_ATTRIBUTES_PER_SPAN);
176    let mut attrs: Vec<ProtoKeyValue> = Vec::with_capacity(capacity);
177    // With OTel-semantics enabled the DD-specific attributes are omitted: the four promoted tags
178    // below, and the `error.*`/`span.kind` meta tags (that information lives in the OTLP Status
179    // and Span.kind fields instead).
180    let span_service = span.service.borrow();
181    let has_per_span_service = !span_service.is_empty() && span_service != resource_service;
182    if has_per_span_service && !otel_trace_semantics_enabled {
183        attrs.push(proto_kv(
184            "service.name".to_string(),
185            ProtoValue::StringValue(span_service.to_string()),
186        ));
187    }
188    let operation_name = span.name.borrow();
189    let has_operation_name = !operation_name.is_empty();
190    if has_operation_name && !otel_trace_semantics_enabled {
191        attrs.push(proto_kv(
192            "operation.name".to_string(),
193            ProtoValue::StringValue(operation_name.to_string()),
194        ));
195    }
196    let span_type = span.r#type.borrow();
197    let has_span_type = !span_type.is_empty();
198    if has_span_type && !otel_trace_semantics_enabled {
199        attrs.push(proto_kv(
200            "span.type".to_string(),
201            ProtoValue::StringValue(span_type.to_string()),
202        ));
203    }
204    let resource_name = span.resource.borrow();
205    let has_resource_name = !resource_name.is_empty();
206    if has_resource_name && !otel_trace_semantics_enabled {
207        attrs.push(proto_kv(
208            "resource.name".to_string(),
209            ProtoValue::StringValue(resource_name.to_string()),
210        ));
211    }
212    for (k, v) in span.meta.iter() {
213        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
214            break;
215        }
216        let key = k.borrow();
217        if otel_trace_semantics_enabled
218            && (key == "error.msg" || key == "error.message" || key == "span.kind")
219        {
220            continue;
221        }
222        attrs.push(proto_kv(
223            key.to_string(),
224            ProtoValue::StringValue(v.borrow().to_string()),
225        ));
226    }
227    for (k, v) in span.metrics.iter() {
228        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
229            break;
230        }
231        let value = if v.fract() == 0.0 && (*v >= i64::MIN as f64 && *v <= i64::MAX as f64) {
232            ProtoValue::IntValue(*v as i64)
233        } else {
234            ProtoValue::DoubleValue(*v)
235        };
236        attrs.push(proto_kv(k.borrow().to_string(), value));
237    }
238    for (k, v) in span.meta_struct.iter() {
239        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
240            break;
241        }
242        attrs.push(proto_kv(
243            k.borrow().to_string(),
244            ProtoValue::BytesValue(v.borrow().to_vec()),
245        ));
246    }
247    // Dropped-count accounting must mirror what was actually emitted: with OTel-semantics on, the
248    // promoted tags aren't added and the excluded `error.*`/`span.kind` meta tags drop out of the
249    // meta total.
250    let excluded_compat_tags = if otel_trace_semantics_enabled {
251        span.meta.contains_key("error.msg") as usize
252            + span.meta.contains_key("error.message") as usize
253            + span.meta.contains_key("span.kind") as usize
254    } else {
255        0
256    };
257    let promoted = if otel_trace_semantics_enabled {
258        0
259    } else {
260        (has_per_span_service as usize)
261            + (has_operation_name as usize)
262            + (has_span_type as usize)
263            + (has_resource_name as usize)
264    };
265    let total = promoted
266        + (span.meta.len() - excluded_compat_tags)
267        + span.metrics.len()
268        + span.meta_struct.len();
269    let dropped = total.saturating_sub(attrs.len());
270    (attrs, dropped)
271}
272
273/// A single event/link attribute value → prost (events carry typed single/array values).
274fn event_attr_value<T: TraceData>(av: &crate::span::v04::AttributeArrayValue<T>) -> ProtoValue {
275    use crate::span::v04::AttributeArrayValue;
276    match av {
277        AttributeArrayValue::String(s) => ProtoValue::StringValue(s.borrow().to_string()),
278        AttributeArrayValue::Boolean(b) => ProtoValue::BoolValue(*b),
279        AttributeArrayValue::Integer(i) => ProtoValue::IntValue(*i),
280        AttributeArrayValue::Double(d) => ProtoValue::DoubleValue(*d),
281    }
282}
283
284fn collect_event_attributes<T: TraceData>(ev: &SpanEvent<T>) -> Vec<ProtoKeyValue> {
285    use crate::span::v04::AttributeAnyValue;
286    ev.attributes
287        .iter()
288        .map(|(k, v)| {
289            let value = match v {
290                AttributeAnyValue::SingleValue(av) => event_attr_value(av),
291                AttributeAnyValue::Array(items) => ProtoValue::ArrayValue(ProtoArrayValue {
292                    values: items
293                        .iter()
294                        .map(|it| ProtoAnyValue {
295                            value: Some(event_attr_value(it)),
296                        })
297                        .collect(),
298                }),
299            };
300            proto_kv(k.borrow().to_string(), value)
301        })
302        .collect()
303}
304
305// ─── Public mapper ────────────────────────────────────────────────────────────
306
307/// Maps Datadog trace chunks and resource info to a prost OTLP `ExportTraceServiceRequest`, built
308/// directly from the native span fields (no hex/decimal round trip — the prost types are the IR).
309///
310/// Resource: SDK-level attributes (service.name, deployment.environment.name, telemetry.sdk.*,
311/// runtime-id). InstrumentationScope: optional tracer scope name/version.
312/// All analogous DD span fields are mapped; meta→attributes (string), metrics→attributes
313/// (int/double), links and events mapped to OTLP links and events. Status from span.error and
314/// meta["error.msg"] or meta["error.message"].
315///
316/// The high 64 bits of a 128-bit trace ID are carried in the trace_id field itself or (if not
317/// present) as the `_dd.p.tid` meta tag, which per RFC #85 is set on the chunk root only.
318/// We resolve it once per chunk and apply it to every span so OTLP receivers see the full 128-bit
319/// trace_id on every span in the trace.
320pub fn map_traces_to_otlp<T: TraceData>(
321    trace_chunks: Vec<Vec<Span<T>>>,
322    resource_info: &OtlpResourceInfo,
323    otel_trace_semantics_enabled: bool,
324) -> ProtoReq {
325    let resource = build_resource(resource_info);
326    // Pre-size to the total span count so the per-span push loop never reallocates.
327    let total_spans: usize = trace_chunks.iter().map(|chunk| chunk.len()).sum();
328    let mut all_spans: Vec<ProtoSpan> = Vec::with_capacity(total_spans);
329    for chunk in &trace_chunks {
330        // Resolve the high 64 bits of the 128-bit trace ID once per chunk. For each span,
331        // prefer the native u128 `trace_id` field (e.g. Python's native spans hold the full
332        // 128-bit ID there) and fall back to its RFC #85 `_dd.p.tid` meta tag.
333        let high = chunk_trace_id_high(chunk);
334        // OTel semantics requires one explicit trace-level decision on every span. Outside that
335        // opt-in mode, preserve the existing per-span conversion behavior.
336        let chunk_flags = otel_trace_semantics_enabled.then(|| chunk_sampling_flags(chunk));
337        for span in chunk {
338            let flags = chunk_flags.unwrap_or_else(|| span_sampling_flags(span));
339            all_spans.push(map_span(
340                span,
341                &resource_info.service,
342                high,
343                flags,
344                otel_trace_semantics_enabled,
345            ));
346        }
347    }
348    ProtoReq {
349        resource_spans: vec![ProtoResourceSpans {
350            resource: Some(resource),
351            scope_spans: vec![ProtoScopeSpans {
352                scope: Some(ProtoScope {
353                    name: resource_info.instrumentation_scope_name.clone(),
354                    version: resource_info.instrumentation_scope_version.clone(),
355                    attributes: Vec::new(),
356                    dropped_attributes_count: 0,
357                }),
358                spans: all_spans,
359                schema_url: String::new(),
360            }],
361            schema_url: String::new(),
362        }],
363    }
364}
365
366fn build_resource(resource_info: &OtlpResourceInfo) -> ProtoResource {
367    fn push_str_attr(attrs: &mut Vec<ProtoKeyValue>, k: &str, v: &str) {
368        if !v.is_empty() {
369            attrs.push(proto_kv(
370                k.to_string(),
371                ProtoValue::StringValue(v.to_string()),
372            ));
373        }
374    }
375    let mut attributes = Vec::new();
376    push_str_attr(&mut attributes, "service.name", &resource_info.service);
377    push_str_attr(
378        &mut attributes,
379        "deployment.environment.name",
380        &resource_info.env,
381    );
382    push_str_attr(
383        &mut attributes,
384        "service.version",
385        &resource_info.app_version,
386    );
387    attributes.push(proto_kv(
388        "telemetry.sdk.name".to_string(),
389        ProtoValue::StringValue("datadog".to_string()),
390    ));
391    push_str_attr(
392        &mut attributes,
393        "telemetry.sdk.language",
394        &resource_info.language,
395    );
396    push_str_attr(
397        &mut attributes,
398        "telemetry.sdk.version",
399        &resource_info.tracer_version,
400    );
401    push_str_attr(&mut attributes, "runtime-id", &resource_info.runtime_id);
402    // Tells Datadog Agent OTLP receivers to skip their concentrator; prevents double-counted
403    // APM metrics.
404    if resource_info.client_computed_stats {
405        push_str_attr(&mut attributes, "_dd.stats_computed", "true");
406    }
407    // `entity_refs` is a profiling-signal-only field; explicit default.
408    ProtoResource {
409        attributes,
410        dropped_attributes_count: 0,
411        entity_refs: Vec::new(),
412    }
413}
414
415fn map_span<T: TraceData>(
416    span: &Span<T>,
417    resource_service: &str,
418    chunk_trace_id_high: u64,
419    flags: u32,
420    otel_trace_semantics_enabled: bool,
421) -> ProtoSpan {
422    // Reconstruct the full 128-bit trace ID. The caller resolves the high 64 bits once per
423    // chunk (from either the native u128 `trace_id` field or the "_dd.p.tid" meta tag).
424    // All spans in a chunk share the same trace ID.
425    let trace_id_128 = ((chunk_trace_id_high as u128) << 64) | (span.trace_id as u64 as u128);
426    let parent_span_id = if span.parent_id != 0 {
427        span.parent_id.to_be_bytes().to_vec()
428    } else {
429        Vec::new()
430    };
431    let (attributes, dropped_attributes_count) =
432        collect_span_attributes(span, resource_service, otel_trace_semantics_enabled);
433    let (code, message) = span_status(span);
434    let trace_state = span
435        .meta
436        .get("tracestate")
437        .map(|v| v.borrow().to_string())
438        .filter(|s| !s.is_empty())
439        .unwrap_or_default();
440    let links = span.span_links.iter().map(map_span_link).collect();
441    let (events, dropped_events_count) = map_span_events(&span.span_events);
442    ProtoSpan {
443        trace_id: trace_id_128.to_be_bytes().to_vec(),
444        span_id: span.span_id.to_be_bytes().to_vec(),
445        trace_state,
446        parent_span_id,
447        flags,
448        name: span.resource.borrow().to_string(),
449        kind: span_kind(span),
450        // OTLP timestamps are unsigned; clamp negatives to 0 so the `as u64` cast can't wrap.
451        start_time_unix_nano: span.start.max(0) as u64,
452        end_time_unix_nano: (span.start + span.duration).max(0) as u64,
453        attributes,
454        dropped_attributes_count: dropped_attributes_count as u32,
455        events,
456        dropped_events_count: dropped_events_count as u32,
457        links,
458        // The mapper enforces no link cap, so dropped links is always 0.
459        dropped_links_count: 0,
460        status: Some(ProtoStatus {
461            message: message.unwrap_or_default(),
462            code,
463        }),
464    }
465}
466
467fn map_span_link<T: TraceData>(link: &SpanLink<T>) -> ProtoLink {
468    let trace_id_128 = ((link.trace_id_high as u128) << 64) | (link.trace_id as u128);
469    ProtoLink {
470        trace_id: trace_id_128.to_be_bytes().to_vec(),
471        span_id: link.span_id.to_be_bytes().to_vec(),
472        trace_state: {
473            let ts = link.tracestate.borrow();
474            if ts.is_empty() {
475                String::new()
476            } else {
477                ts.to_string()
478            }
479        },
480        attributes: link
481            .attributes
482            .iter()
483            .map(|(k, v)| {
484                proto_kv(
485                    k.borrow().to_string(),
486                    ProtoValue::StringValue(v.borrow().to_string()),
487                )
488            })
489            .collect(),
490        dropped_attributes_count: 0,
491        // W3C trace flags of the linked context (sampled bit, etc.); carry them through so OTLP
492        // consumers see the same link metadata the tracer recorded. Bit 31 is an internal
493        // "explicitly set" sentinel that must not leak into OTLP's flags field.
494        flags: link.flags & !SPAN_LINK_FLAGS_SET_SENTINEL,
495    }
496}
497
498fn map_span_events<T: TraceData>(events: &[SpanEvent<T>]) -> (Vec<ProtoEvent>, usize) {
499    const MAX_EVENTS_PER_SPAN: usize = 128;
500    let mut out = Vec::with_capacity(events.len().min(MAX_EVENTS_PER_SPAN));
501    for ev in events.iter().take(MAX_EVENTS_PER_SPAN) {
502        out.push(ProtoEvent {
503            time_unix_nano: ev.time_unix_nano,
504            name: ev.name.borrow().to_string(),
505            attributes: collect_event_attributes(ev),
506            dropped_attributes_count: 0,
507        });
508    }
509    let dropped = events.len().saturating_sub(out.len());
510    (out, dropped)
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::otlp_encoder::OtlpResourceInfo;
517    use crate::span::BytesData;
518
519    #[test]
520    fn maps_native_span_to_prost_ir() {
521        use libdd_trace_protobuf::opentelemetry::proto::common::v1::any_value::Value as PV;
522        let resource_info = OtlpResourceInfo::default();
523        let mut span: Span<BytesData> = Span {
524            trace_id: 0xD269B633813FC60C_u128,
525            span_id: 0xEEE19B7EC3C1B174,
526            parent_id: 0xEEE19B7EC3C1B173,
527            name: libdd_tinybytes::BytesString::from_static("op"),
528            resource: libdd_tinybytes::BytesString::from_static("res"),
529            r#type: libdd_tinybytes::BytesString::from_static("web"),
530            start: 1544712660000000000,
531            duration: 1000000000,
532            error: 1,
533            ..Default::default()
534        };
535        span.meta.insert(
536            "error.msg".into(),
537            libdd_tinybytes::BytesString::from_static("boom"),
538        );
539        span.metrics
540            .insert(libdd_tinybytes::BytesString::from_static("count"), 42.0);
541        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
542        let s = &req.resource_spans[0].scope_spans[0].spans[0];
543        assert_eq!(s.trace_id, 0xD269B633813FC60C_u128.to_be_bytes().to_vec());
544        assert_eq!(s.span_id, 0xEEE19B7EC3C1B174u64.to_be_bytes().to_vec());
545        assert_eq!(
546            s.parent_span_id,
547            0xEEE19B7EC3C1B173u64.to_be_bytes().to_vec()
548        );
549        assert_eq!(s.name, "res");
550        assert_eq!(s.kind, 2); // SERVER (from dd type "web")
551        assert_eq!(s.start_time_unix_nano, 1544712660000000000);
552        assert_eq!(s.end_time_unix_nano, 1544712661000000000);
553        let st = s.status.as_ref().unwrap();
554        assert_eq!(st.code, 2);
555        assert_eq!(st.message, "boom");
556        let count = s.attributes.iter().find(|a| a.key == "count").unwrap();
557        assert!(matches!(
558            count.value.as_ref().unwrap().value,
559            Some(PV::IntValue(42))
560        ));
561    }
562
563    #[test]
564    fn instrumentation_scope_from_resource_info() {
565        let resource_info = OtlpResourceInfo {
566            instrumentation_scope_name: "dd-trace-js".to_string(),
567            instrumentation_scope_version: "7.0.0-pre".to_string(),
568            ..Default::default()
569        };
570        let span: Span<BytesData> = Span {
571            trace_id: 1,
572            span_id: 2,
573            name: libdd_tinybytes::BytesString::from_static("s"),
574            start: 0,
575            duration: 1,
576            ..Default::default()
577        };
578
579        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
580        let scope = req.resource_spans[0].scope_spans[0].scope.as_ref().unwrap();
581        assert_eq!(scope.name, "dd-trace-js");
582        assert_eq!(scope.version, "7.0.0-pre");
583    }
584
585    #[test]
586    fn proto_span_uses_raw_id_bytes_and_native_timestamps() {
587        let resource_info = OtlpResourceInfo {
588            service: "svc".to_string(),
589            ..Default::default()
590        };
591        let span: Span<BytesData> = Span {
592            trace_id: 0x5b8efff798038103_d269b633813fc60c_u128,
593            span_id: 0xEEE19B7EC3C1B174,
594            parent_id: 0xEEE19B7EC3C1B173,
595            name: libdd_tinybytes::BytesString::from_static("op"),
596            resource: libdd_tinybytes::BytesString::from_static("res"),
597            r#type: libdd_tinybytes::BytesString::from_static("web"),
598            start: 1544712660000000000,
599            duration: 1000000000,
600            ..Default::default()
601        };
602        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
603        let s = &req.resource_spans[0].scope_spans[0].spans[0];
604        assert_eq!(
605            s.trace_id,
606            0x5b8efff798038103_d269b633813fc60c_u128
607                .to_be_bytes()
608                .to_vec()
609        );
610        assert_eq!(s.span_id, 0xEEE19B7EC3C1B174u64.to_be_bytes().to_vec());
611        assert_eq!(
612            s.parent_span_id,
613            0xEEE19B7EC3C1B173u64.to_be_bytes().to_vec()
614        );
615        assert_eq!(s.start_time_unix_nano, 1544712660000000000);
616        assert_eq!(s.end_time_unix_nano, 1544712661000000000);
617        assert_eq!(s.name, "res");
618        assert_eq!(s.kind, span_kind::SERVER);
619    }
620
621    #[test]
622    fn negative_start_clamps_to_zero() {
623        // Regression test: a span with negative start (malformed input) must map to
624        // start_time_unix_nano == 0 (and not wrap to u64::MAX), matching the old parse_u64
625        // behavior.
626        let resource_info = OtlpResourceInfo {
627            service: "svc".to_string(),
628            ..Default::default()
629        };
630        let span: Span<BytesData> = Span {
631            trace_id: 1,
632            span_id: 1,
633            start: -1,
634            duration: 0,
635            ..Default::default()
636        };
637        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
638        let s = &req.resource_spans[0].scope_spans[0].spans[0];
639        assert_eq!(
640            s.start_time_unix_nano, 0,
641            "negative start must clamp to 0, not wrap"
642        );
643        assert_eq!(
644            s.end_time_unix_nano, 0,
645            "negative start+duration must clamp to 0, not wrap"
646        );
647    }
648
649    #[test]
650    fn status_error_message_from_meta() {
651        let resource_info = OtlpResourceInfo::default();
652        let mut span: Span<BytesData> = Span {
653            trace_id: 1,
654            span_id: 2,
655            name: libdd_tinybytes::BytesString::from_static("err_span"),
656            start: 0,
657            duration: 1,
658            error: 1,
659            ..Default::default()
660        };
661        span.meta.insert(
662            libdd_tinybytes::BytesString::from_static("error.msg"),
663            libdd_tinybytes::BytesString::from_static("something broke"),
664        );
665        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
666        let s = &req.resource_spans[0].scope_spans[0].spans[0];
667        let status = s.status.as_ref().unwrap();
668        assert_eq!(status.code, status_code::ERROR);
669        assert_eq!(status.message, "something broke");
670    }
671
672    #[test]
673    fn metrics_as_int_or_double() {
674        use libdd_trace_protobuf::opentelemetry::proto::common::v1::any_value::Value as PV;
675        let resource_info = OtlpResourceInfo::default();
676        let mut span: Span<BytesData> = Span {
677            trace_id: 1,
678            span_id: 2,
679            name: libdd_tinybytes::BytesString::from_static("m"),
680            start: 0,
681            duration: 1,
682            ..Default::default()
683        };
684        span.metrics
685            .insert(libdd_tinybytes::BytesString::from_static("count"), 42.0);
686        span.metrics.insert(
687            libdd_tinybytes::BytesString::from_static("rate"),
688            std::f64::consts::PI,
689        );
690        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
691        let s = &req.resource_spans[0].scope_spans[0].spans[0];
692        let count = s.attributes.iter().find(|a| a.key == "count").unwrap();
693        assert!(matches!(
694            count.value.as_ref().unwrap().value,
695            Some(PV::IntValue(42))
696        ));
697        let rate = s.attributes.iter().find(|a| a.key == "rate").unwrap();
698        match rate.value.as_ref().unwrap().value {
699            Some(PV::DoubleValue(d)) => assert!((d - std::f64::consts::PI).abs() < 1e-9),
700            ref other => panic!("expected double, got {other:?}"),
701        }
702    }
703
704    #[test]
705    fn trace_id_128_from_dd_p_tid() {
706        // When "_dd.p.tid" is present it supplies the high 64 bits of the trace ID.
707        // Low 64 bits come from span.trace_id; the two are concatenated to form a 128-bit ID.
708        let resource_info = OtlpResourceInfo::default();
709        let mut span: Span<BytesData> = Span {
710            trace_id: 0xD269B633813FC60C_u128, // low 64 bits
711            span_id: 1,
712            name: libdd_tinybytes::BytesString::from_static("s"),
713            start: 0,
714            duration: 1,
715            ..Default::default()
716        };
717        span.meta.insert(
718            "_dd.p.tid".into(),
719            libdd_tinybytes::BytesString::from_static("5b8efff798038103"),
720        );
721        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
722        let s = &req.resource_spans[0].scope_spans[0].spans[0];
723        assert_eq!(
724            s.trace_id,
725            0x5b8efff798038103_d269b633813fc60c_u128
726                .to_be_bytes()
727                .to_vec()
728        );
729    }
730
731    #[test]
732    fn trace_id_128_from_native_span_field() {
733        // When the span's u128 `trace_id` field already carries the full 128-bit ID (e.g.
734        // tracers with native spans like Python), the chunk-root meta lookup is skipped and
735        // the field's high 64 bits are propagated to every span in the chunk.
736        let resource_info = OtlpResourceInfo::default();
737        let full: u128 = 0x5b8efff798038103_d269b633813fc60c_u128;
738        let root: Span<BytesData> = Span {
739            trace_id: full,
740            span_id: 1,
741            name: libdd_tinybytes::BytesString::from_static("root"),
742            start: 0,
743            duration: 1,
744            ..Default::default()
745        };
746        // Child carries only the low 64 bits; it should still inherit the chunk's high bits.
747        let child: Span<BytesData> = Span {
748            trace_id: 0xD269B633813FC60C_u128,
749            span_id: 2,
750            parent_id: 1,
751            name: libdd_tinybytes::BytesString::from_static("child"),
752            start: 0,
753            duration: 1,
754            ..Default::default()
755        };
756        let req = map_traces_to_otlp(vec![vec![root, child]], &resource_info, false);
757        let spans = &req.resource_spans[0].scope_spans[0].spans;
758        let expected = full.to_be_bytes().to_vec();
759        assert_eq!(spans[0].trace_id, expected);
760        assert_eq!(spans[1].trace_id, expected);
761    }
762
763    #[test]
764    fn trace_id_128_without_dd_p_tid_defaults_high_to_zero() {
765        // When the entire chunk has no "_dd.p.tid" the high 64 bits default to zero
766        // (legacy 64-bit-only trace IDs).
767        let resource_info = OtlpResourceInfo::default();
768        let span: Span<BytesData> = Span {
769            trace_id: 0xD269B633813FC60C_u128,
770            span_id: 1,
771            name: libdd_tinybytes::BytesString::from_static("s"),
772            start: 0,
773            duration: 1,
774            ..Default::default()
775        };
776        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
777        let s = &req.resource_spans[0].scope_spans[0].spans[0];
778        assert_eq!(s.trace_id, 0xD269B633813FC60C_u128.to_be_bytes().to_vec());
779    }
780
781    #[test]
782    fn trace_id_128_propagated_to_chunk_children() {
783        // Per RFC #85 dd-trace tracers set "_dd.p.tid" only on the chunk root.
784        // The OTLP mapper must apply that high-bits value to every span in the chunk
785        // so receivers see the full 128-bit trace_id on every span.
786        let resource_info = OtlpResourceInfo::default();
787        let low: u128 = 0xD269B633813FC60C_u128;
788        let mut root: Span<BytesData> = Span {
789            trace_id: low,
790            span_id: 1,
791            name: libdd_tinybytes::BytesString::from_static("root"),
792            start: 0,
793            duration: 1,
794            ..Default::default()
795        };
796        root.meta.insert(
797            "_dd.p.tid".into(),
798            libdd_tinybytes::BytesString::from_static("5b8efff798038103"),
799        );
800        let child_a: Span<BytesData> = Span {
801            trace_id: low,
802            span_id: 2,
803            parent_id: 1,
804            name: libdd_tinybytes::BytesString::from_static("child_a"),
805            start: 0,
806            duration: 1,
807            ..Default::default()
808        };
809        let child_b: Span<BytesData> = Span {
810            trace_id: low,
811            span_id: 3,
812            parent_id: 1,
813            name: libdd_tinybytes::BytesString::from_static("child_b"),
814            start: 0,
815            duration: 1,
816            ..Default::default()
817        };
818        let req = map_traces_to_otlp(vec![vec![root, child_a, child_b]], &resource_info, false);
819        let spans = &req.resource_spans[0].scope_spans[0].spans;
820        assert_eq!(spans.len(), 3);
821        let expected = 0x5b8efff798038103_d269b633813fc60c_u128
822            .to_be_bytes()
823            .to_vec();
824        for s in spans {
825            assert_eq!(s.trace_id, expected);
826        }
827    }
828
829    #[test]
830    fn trace_id_128_isolation_across_chunks() {
831        // The chunk-level high bits must not leak across chunks. Each chunk's spans
832        // get only their own chunk root's "_dd.p.tid".
833        let resource_info = OtlpResourceInfo::default();
834        let low_a: u128 = 0x1111111111111111_u128;
835        let low_b: u128 = 0x2222222222222222_u128;
836        let mut root_a: Span<BytesData> = Span {
837            trace_id: low_a,
838            span_id: 1,
839            name: libdd_tinybytes::BytesString::from_static("root_a"),
840            start: 0,
841            duration: 1,
842            ..Default::default()
843        };
844        root_a.meta.insert(
845            "_dd.p.tid".into(),
846            libdd_tinybytes::BytesString::from_static("aaaaaaaaaaaaaaaa"),
847        );
848        let child_a: Span<BytesData> = Span {
849            trace_id: low_a,
850            span_id: 2,
851            parent_id: 1,
852            name: libdd_tinybytes::BytesString::from_static("child_a"),
853            start: 0,
854            duration: 1,
855            ..Default::default()
856        };
857        let mut root_b: Span<BytesData> = Span {
858            trace_id: low_b,
859            span_id: 3,
860            name: libdd_tinybytes::BytesString::from_static("root_b"),
861            start: 0,
862            duration: 1,
863            ..Default::default()
864        };
865        root_b.meta.insert(
866            "_dd.p.tid".into(),
867            libdd_tinybytes::BytesString::from_static("bbbbbbbbbbbbbbbb"),
868        );
869        let child_b: Span<BytesData> = Span {
870            trace_id: low_b,
871            span_id: 4,
872            parent_id: 3,
873            name: libdd_tinybytes::BytesString::from_static("child_b"),
874            start: 0,
875            duration: 1,
876            ..Default::default()
877        };
878        let req = map_traces_to_otlp(
879            vec![vec![root_a, child_a], vec![root_b, child_b]],
880            &resource_info,
881            false,
882        );
883        let spans = &req.resource_spans[0].scope_spans[0].spans;
884        assert_eq!(spans.len(), 4);
885        let expect_a = 0xaaaaaaaaaaaaaaaa_1111111111111111_u128
886            .to_be_bytes()
887            .to_vec();
888        let expect_b = 0xbbbbbbbbbbbbbbbb_2222222222222222_u128
889            .to_be_bytes()
890            .to_vec();
891        assert_eq!(spans[0].trace_id, expect_a);
892        assert_eq!(spans[1].trace_id, expect_a);
893        assert_eq!(spans[2].trace_id, expect_b);
894        assert_eq!(spans[3].trace_id, expect_b);
895    }
896
897    #[test]
898    fn chunk_with_malformed_dd_p_tid_on_root_falls_back() {
899        // If the chunk root's "_dd.p.tid" fails to parse, the scan continues looking for
900        // any other parseable value in the chunk before giving up. This keeps a malformed
901        // tag on one span from poisoning the rest of the trace.
902        let resource_info = OtlpResourceInfo::default();
903        let low: u128 = 0xD269B633813FC60C_u128;
904        let mut root: Span<BytesData> = Span {
905            trace_id: low,
906            span_id: 1,
907            name: libdd_tinybytes::BytesString::from_static("root"),
908            start: 0,
909            duration: 1,
910            ..Default::default()
911        };
912        root.meta.insert(
913            "_dd.p.tid".into(),
914            libdd_tinybytes::BytesString::from_static("not-hex"),
915        );
916        let child_no_tag: Span<BytesData> = Span {
917            trace_id: low,
918            span_id: 2,
919            parent_id: 1,
920            name: libdd_tinybytes::BytesString::from_static("child_no_tag"),
921            start: 0,
922            duration: 1,
923            ..Default::default()
924        };
925        let mut child_valid: Span<BytesData> = Span {
926            trace_id: low,
927            span_id: 3,
928            parent_id: 1,
929            name: libdd_tinybytes::BytesString::from_static("child_valid"),
930            start: 0,
931            duration: 1,
932            ..Default::default()
933        };
934        child_valid.meta.insert(
935            "_dd.p.tid".into(),
936            libdd_tinybytes::BytesString::from_static("dddddddddddddddd"),
937        );
938        let req = map_traces_to_otlp(
939            vec![vec![root, child_no_tag, child_valid]],
940            &resource_info,
941            false,
942        );
943        let spans = &req.resource_spans[0].scope_spans[0].spans;
944        // The chunk-level scan skips the malformed root and picks up child_valid's tag,
945        // which is then applied to every span in the chunk.
946        let expected = 0xdddddddddddddddd_d269b633813fc60c_u128
947            .to_be_bytes()
948            .to_vec();
949        assert_eq!(spans[0].trace_id, expected);
950        assert_eq!(spans[1].trace_id, expected);
951        assert_eq!(spans[2].trace_id, expected);
952    }
953
954    #[test]
955    fn test_stats_computed_resource_attr_set_when_enabled() {
956        let resource_info = OtlpResourceInfo {
957            client_computed_stats: true,
958            ..Default::default()
959        };
960        let span: Span<BytesData> = Span {
961            trace_id: 1,
962            span_id: 2,
963            name: libdd_tinybytes::BytesString::from_static("s"),
964            start: 0,
965            duration: 1,
966            ..Default::default()
967        };
968        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
969        let resource_attrs = &req.resource_spans[0].resource.as_ref().unwrap().attributes;
970        let kv = resource_attrs
971            .iter()
972            .find(|a| a.key == "_dd.stats_computed")
973            .expect("_dd.stats_computed must be present when client_computed_stats=true");
974        let val = match kv.value.as_ref().and_then(|v| v.value.as_ref()) {
975            Some(ProtoValue::StringValue(s)) => s.as_str(),
976            other => panic!("expected stringValue, got {other:?}"),
977        };
978        assert_eq!(val, "true");
979    }
980
981    #[test]
982    fn test_stats_computed_resource_attr_absent_when_disabled() {
983        let resource_info = OtlpResourceInfo {
984            client_computed_stats: false,
985            ..Default::default()
986        };
987        let span: Span<BytesData> = Span {
988            trace_id: 1,
989            span_id: 2,
990            name: libdd_tinybytes::BytesString::from_static("s"),
991            start: 0,
992            duration: 1,
993            ..Default::default()
994        };
995        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
996        let resource_attrs = &req.resource_spans[0].resource.as_ref().unwrap().attributes;
997        assert!(
998            !resource_attrs.iter().any(|a| a.key == "_dd.stats_computed"),
999            "_dd.stats_computed must not be emitted when client_computed_stats=false"
1000        );
1001    }
1002
1003    #[test]
1004    fn span_link_flags_are_carried() {
1005        // Regression: the mapper previously hardcoded `flags: 0`, dropping the linked context's
1006        // W3C trace flags. They must survive into the OTLP `Link.flags` field.
1007        let mut span: Span<BytesData> = Span {
1008            trace_id: 1,
1009            span_id: 2,
1010            name: libdd_tinybytes::BytesString::from_static("s"),
1011            start: 0,
1012            duration: 1,
1013            ..Default::default()
1014        };
1015        span.span_links.push(SpanLink {
1016            trace_id: 0x11,
1017            span_id: 0x22,
1018            flags: 1,
1019            ..Default::default()
1020        });
1021        let req = map_traces_to_otlp(vec![vec![span]], &OtlpResourceInfo::default(), false);
1022        let link = &req.resource_spans[0].scope_spans[0].spans[0].links[0];
1023        assert_eq!(
1024            link.flags, 1,
1025            "OTLP Link.flags must carry the span link's flags"
1026        );
1027    }
1028
1029    #[test]
1030    fn span_link_flags_sentinel_bit_masked() {
1031        // The internal "explicitly set" sentinel (bit 31) must never appear in OTLP's
1032        // Link.flags, which downstream consumers treat as the real W3C trace-flags value.
1033        // Covers both sentinel states: kept (0x8000_0001) and explicitly dropped (0x8000_0000).
1034        fn mapped_flags(flags: u32) -> u32 {
1035            let mut span: Span<BytesData> = Span {
1036                trace_id: 1,
1037                span_id: 2,
1038                name: libdd_tinybytes::BytesString::from_static("s"),
1039                start: 0,
1040                duration: 1,
1041                ..Default::default()
1042            };
1043            span.span_links.push(SpanLink {
1044                trace_id: 0x11,
1045                span_id: 0x22,
1046                flags,
1047                ..Default::default()
1048            });
1049            let req = map_traces_to_otlp(vec![vec![span]], &OtlpResourceInfo::default(), false);
1050            req.resource_spans[0].scope_spans[0].spans[0].links[0].flags
1051        }
1052
1053        assert_eq!(
1054            mapped_flags(0x8000_0001),
1055            1,
1056            "OTLP Link.flags must not carry the internal sentinel bit"
1057        );
1058        assert_eq!(
1059            mapped_flags(0x8000_0000),
1060            0,
1061            "an explicit drop decision must still map to flags: 0"
1062        );
1063    }
1064
1065    #[test]
1066    fn test_otel_trace_semantics_enabled() {
1067        // With OTel-semantics on, the DD-promoted attributes (service.name/operation.name/
1068        // resource.name/span.type) and the error.*/span.kind meta tags are omitted; other
1069        // (OTel-standard) meta tags remain.
1070        let resource_info = OtlpResourceInfo {
1071            service: "resource-svc".to_string(),
1072            ..Default::default()
1073        };
1074        let mut span: Span<BytesData> = Span {
1075            trace_id: 1,
1076            span_id: 2,
1077            name: libdd_tinybytes::BytesString::from_static("http.request"),
1078            service: libdd_tinybytes::BytesString::from_static("span-svc"),
1079            resource: libdd_tinybytes::BytesString::from_static("GET /api/users"),
1080            r#type: libdd_tinybytes::BytesString::from_static("web"),
1081            start: 0,
1082            duration: 1,
1083            ..Default::default()
1084        };
1085        span.meta.insert(
1086            libdd_tinybytes::BytesString::from_static("span.kind"),
1087            libdd_tinybytes::BytesString::from_static("client"),
1088        );
1089        span.meta.insert(
1090            libdd_tinybytes::BytesString::from_static("http.method"),
1091            libdd_tinybytes::BytesString::from_static("GET"),
1092        );
1093        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, true);
1094        let attrs = &req.resource_spans[0].scope_spans[0].spans[0].attributes;
1095        let keys: Vec<&str> = attrs.iter().map(|kv| kv.key.as_str()).collect();
1096        for omitted in [
1097            "service.name",
1098            "operation.name",
1099            "resource.name",
1100            "span.type",
1101            "span.kind",
1102        ] {
1103            assert!(
1104                !keys.contains(&omitted),
1105                "OTel-semantics must omit {omitted}"
1106            );
1107        }
1108        assert!(
1109            keys.contains(&"http.method"),
1110            "OTel-standard meta tags must remain"
1111        );
1112    }
1113
1114    #[test]
1115    fn error_message_promoted_to_status_under_otel_semantics() {
1116        // Regression: `error.message` (used by every SDK except .NET) must be promoted to the OTLP
1117        // Status message even when OTel-semantics drops the compat meta tag — otherwise the error
1118        // text is lost entirely (neither in Status nor in the attributes).
1119        let resource_info = OtlpResourceInfo::default();
1120        let mut span: Span<BytesData> = Span {
1121            trace_id: 1,
1122            span_id: 2,
1123            name: libdd_tinybytes::BytesString::from_static("op"),
1124            start: 0,
1125            duration: 1,
1126            error: 1,
1127            ..Default::default()
1128        };
1129        span.meta.insert(
1130            libdd_tinybytes::BytesString::from_static("error.message"),
1131            libdd_tinybytes::BytesString::from_static("boom"),
1132        );
1133        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, true);
1134        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
1135        let status = otlp_span
1136            .status
1137            .as_ref()
1138            .expect("status present on error span");
1139        assert_eq!(status.code, status_code::ERROR);
1140        assert_eq!(
1141            status.message, "boom",
1142            "error.message must be promoted to the OTLP Status message"
1143        );
1144        assert!(
1145            !otlp_span
1146                .attributes
1147                .iter()
1148                .any(|kv| kv.key == "error.message"),
1149            "error.message compat attr must be omitted under OTel-semantics"
1150        );
1151    }
1152
1153    #[test]
1154    fn empty_chunk_does_not_panic() {
1155        // Defensive: an empty chunk should produce no spans and not panic.
1156        let resource_info = OtlpResourceInfo::default();
1157        let empty: Vec<Vec<Span<BytesData>>> = vec![vec![]];
1158        let req = map_traces_to_otlp(empty, &resource_info, false);
1159        let spans = &req.resource_spans[0].scope_spans[0].spans;
1160        assert!(spans.is_empty());
1161    }
1162
1163    #[test]
1164    fn tracestate_from_meta() {
1165        let resource_info = OtlpResourceInfo::default();
1166        let mut span: Span<BytesData> = Span {
1167            trace_id: 1,
1168            span_id: 2,
1169            name: libdd_tinybytes::BytesString::from_static("s"),
1170            start: 0,
1171            duration: 1,
1172            ..Default::default()
1173        };
1174        span.meta.insert(
1175            "tracestate".into(),
1176            libdd_tinybytes::BytesString::from_static("vendor1=abc,rojo=00f067"),
1177        );
1178        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1179        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1180        assert_eq!(s.trace_state, "vendor1=abc,rojo=00f067");
1181    }
1182
1183    #[test]
1184    fn meta_struct_as_bytes_value() {
1185        use libdd_tinybytes::Bytes;
1186        use libdd_trace_protobuf::opentelemetry::proto::common::v1::any_value::Value as PV;
1187        let resource_info = OtlpResourceInfo::default();
1188        let mut span: Span<BytesData> = Span {
1189            trace_id: 1,
1190            span_id: 2,
1191            name: libdd_tinybytes::BytesString::from_static("s"),
1192            start: 0,
1193            duration: 1,
1194            ..Default::default()
1195        };
1196        span.meta_struct
1197            .insert("my_key".into(), Bytes::from(vec![1u8, 2, 3]));
1198        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1199        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1200        let kv = s
1201            .attributes
1202            .iter()
1203            .find(|a| a.key == "my_key")
1204            .expect("my_key attribute not found");
1205        match kv.value.as_ref().unwrap().value {
1206            Some(PV::BytesValue(ref b)) => assert_eq!(b, &vec![1u8, 2, 3]),
1207            ref other => panic!("expected bytes, got {other:?}"),
1208        }
1209    }
1210
1211    #[test]
1212    fn operation_name_attribute() {
1213        use libdd_trace_protobuf::opentelemetry::proto::common::v1::any_value::Value as PV;
1214        let resource_info = OtlpResourceInfo::default();
1215        let span: Span<BytesData> = Span {
1216            trace_id: 1,
1217            span_id: 2,
1218            name: libdd_tinybytes::BytesString::from_static("my.operation"),
1219            start: 0,
1220            duration: 1,
1221            ..Default::default()
1222        };
1223        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1224        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1225        let kv = s
1226            .attributes
1227            .iter()
1228            .find(|a| a.key == "operation.name")
1229            .expect("operation.name attribute not found");
1230        match kv.value.as_ref().unwrap().value {
1231            Some(PV::StringValue(ref v)) => assert_eq!(v, "my.operation"),
1232            ref other => panic!("expected string, got {other:?}"),
1233        }
1234    }
1235
1236    #[test]
1237    fn span_type_attribute() {
1238        use libdd_trace_protobuf::opentelemetry::proto::common::v1::any_value::Value as PV;
1239        let resource_info = OtlpResourceInfo::default();
1240        let span: Span<BytesData> = Span {
1241            trace_id: 1,
1242            span_id: 2,
1243            name: libdd_tinybytes::BytesString::from_static("s"),
1244            r#type: libdd_tinybytes::BytesString::from_static("grpc"),
1245            start: 0,
1246            duration: 1,
1247            ..Default::default()
1248        };
1249        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1250        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1251        let kv = s
1252            .attributes
1253            .iter()
1254            .find(|a| a.key == "span.type")
1255            .expect("span.type attribute not found");
1256        match kv.value.as_ref().unwrap().value {
1257            Some(PV::StringValue(ref v)) => assert_eq!(v, "grpc"),
1258            ref other => panic!("expected string, got {other:?}"),
1259        }
1260    }
1261
1262    #[test]
1263    fn resource_name_attribute_and_span_name() {
1264        use libdd_trace_protobuf::opentelemetry::proto::common::v1::any_value::Value as PV;
1265        let resource_info = OtlpResourceInfo::default();
1266        let span: Span<BytesData> = Span {
1267            trace_id: 1,
1268            span_id: 2,
1269            name: libdd_tinybytes::BytesString::from_static("s"),
1270            resource: libdd_tinybytes::BytesString::from_static("GET /api/users"),
1271            start: 0,
1272            duration: 1,
1273            ..Default::default()
1274        };
1275        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1276        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1277        // resource maps to the OTLP span name
1278        assert_eq!(s.name, "GET /api/users");
1279        // resource also maps to the resource.name attribute
1280        let kv = s
1281            .attributes
1282            .iter()
1283            .find(|a| a.key == "resource.name")
1284            .expect("resource.name attribute not found");
1285        match kv.value.as_ref().unwrap().value {
1286            Some(PV::StringValue(ref v)) => assert_eq!(v, "GET /api/users"),
1287            ref other => panic!("expected string, got {other:?}"),
1288        }
1289    }
1290
1291    #[test]
1292    fn empty_resource_name_not_emitted() {
1293        // A span with no resource set should not emit a resource.name attribute.
1294        let resource_info = OtlpResourceInfo::default();
1295        let span: Span<BytesData> = Span {
1296            trace_id: 1,
1297            span_id: 2,
1298            name: libdd_tinybytes::BytesString::from_static("s"),
1299            // resource is empty (default)
1300            start: 0,
1301            duration: 1,
1302            ..Default::default()
1303        };
1304        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1305        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1306        assert!(
1307            !s.attributes.iter().any(|a| a.key == "resource.name"),
1308            "resource.name should not be emitted when resource is empty"
1309        );
1310    }
1311
1312    #[test]
1313    fn per_span_service_name_attribute() {
1314        use libdd_trace_protobuf::opentelemetry::proto::common::v1::any_value::Value as PV;
1315        // When span.service differs from the resource-level service, service.name is emitted
1316        // as a per-span attribute so the receiver can distinguish between services in a trace.
1317        let resource_info = OtlpResourceInfo {
1318            service: "resource-svc".to_string(),
1319            ..Default::default()
1320        };
1321        let span: Span<BytesData> = Span {
1322            trace_id: 1,
1323            span_id: 2,
1324            name: libdd_tinybytes::BytesString::from_static("s"),
1325            service: libdd_tinybytes::BytesString::from_static("span-svc"),
1326            start: 0,
1327            duration: 1,
1328            ..Default::default()
1329        };
1330        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1331        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1332        let kv = s
1333            .attributes
1334            .iter()
1335            .find(|a| a.key == "service.name")
1336            .expect("service.name attribute not found");
1337        match kv.value.as_ref().unwrap().value {
1338            Some(PV::StringValue(ref v)) => assert_eq!(v, "span-svc"),
1339            ref other => panic!("expected string, got {other:?}"),
1340        }
1341    }
1342
1343    #[test]
1344    fn unsampled_span_flags_zero() {
1345        // _sampling_priority_v1 = 0 means explicitly dropped; flags field must be 0.
1346        let resource_info = OtlpResourceInfo::default();
1347        let mut span: Span<BytesData> = Span {
1348            trace_id: 1,
1349            span_id: 2,
1350            name: libdd_tinybytes::BytesString::from_static("s"),
1351            start: 0,
1352            duration: 1,
1353            ..Default::default()
1354        };
1355        span.metrics.insert("_sampling_priority_v1".into(), 0.0);
1356        let req = map_traces_to_otlp(vec![vec![span]], &resource_info, false);
1357        let s = &req.resource_spans[0].scope_spans[0].spans[0];
1358        assert_eq!(s.flags, 0);
1359    }
1360
1361    #[test]
1362    fn trace_without_sampling_priority_leaves_sampled_flag_unset() {
1363        let resource_info = OtlpResourceInfo::default();
1364        let root: Span<BytesData> = Span {
1365            trace_id: 1,
1366            span_id: 2,
1367            name: libdd_tinybytes::BytesString::from_static("root"),
1368            start: 0,
1369            duration: 1,
1370            ..Default::default()
1371        };
1372        let child: Span<BytesData> = Span {
1373            trace_id: 1,
1374            span_id: 3,
1375            parent_id: 2,
1376            name: libdd_tinybytes::BytesString::from_static("child"),
1377            start: 0,
1378            duration: 1,
1379            ..Default::default()
1380        };
1381
1382        let req = map_traces_to_otlp(vec![vec![root, child]], &resource_info, true);
1383        let spans = &req.resource_spans[0].scope_spans[0].spans;
1384        assert!(spans.iter().all(|span| span.flags == 0));
1385    }
1386
1387    #[test]
1388    fn sampled_trace_sets_sampled_flag_on_every_span() {
1389        let resource_info = OtlpResourceInfo::default();
1390        let root: Span<BytesData> = Span {
1391            trace_id: 1,
1392            span_id: 2,
1393            name: libdd_tinybytes::BytesString::from_static("root"),
1394            start: 0,
1395            duration: 1,
1396            ..Default::default()
1397        };
1398        let mut child: Span<BytesData> = Span {
1399            trace_id: 1,
1400            span_id: 3,
1401            parent_id: 2,
1402            name: libdd_tinybytes::BytesString::from_static("child"),
1403            start: 0,
1404            duration: 1,
1405            ..Default::default()
1406        };
1407        child.metrics.insert("_sampling_priority_v1".into(), 1.0);
1408        let req = map_traces_to_otlp(vec![vec![root, child]], &resource_info, true);
1409        let spans = &req.resource_spans[0].scope_spans[0].spans;
1410        assert!(spans.iter().all(|span| span.flags == 1));
1411    }
1412
1413    #[test]
1414    fn first_priority_sets_sampled_flag_on_every_span() {
1415        let resource_info = OtlpResourceInfo::default();
1416        let mut root: Span<BytesData> = Span {
1417            trace_id: 1,
1418            span_id: 2,
1419            name: libdd_tinybytes::BytesString::from_static("root"),
1420            start: 0,
1421            duration: 1,
1422            ..Default::default()
1423        };
1424        root.metrics.insert("_sampling_priority_v1".into(), -1.0);
1425        let mut child: Span<BytesData> = Span {
1426            trace_id: 1,
1427            span_id: 3,
1428            parent_id: 2,
1429            name: libdd_tinybytes::BytesString::from_static("child"),
1430            start: 0,
1431            duration: 1,
1432            ..Default::default()
1433        };
1434        child.metrics.insert("_sampling_priority_v1".into(), 1.0);
1435
1436        let req = map_traces_to_otlp(vec![vec![child, root]], &resource_info, true);
1437        let spans = &req.resource_spans[0].scope_spans[0].spans;
1438        assert!(spans.iter().all(|span| span.flags == 1));
1439    }
1440
1441    #[test]
1442    fn first_unsampled_priority_clears_sampled_flag_on_every_span() {
1443        let resource_info = OtlpResourceInfo::default();
1444        let mut first: Span<BytesData> = Span {
1445            trace_id: 1,
1446            span_id: 2,
1447            name: libdd_tinybytes::BytesString::from_static("first"),
1448            start: 0,
1449            duration: 1,
1450            ..Default::default()
1451        };
1452        first.metrics.insert("_sampling_priority_v1".into(), -1.0);
1453        let mut second: Span<BytesData> = Span {
1454            trace_id: 1,
1455            span_id: 3,
1456            parent_id: 2,
1457            name: libdd_tinybytes::BytesString::from_static("second"),
1458            start: 0,
1459            duration: 1,
1460            ..Default::default()
1461        };
1462        second.metrics.insert("_sampling_priority_v1".into(), 1.0);
1463
1464        let req = map_traces_to_otlp(vec![vec![first, second]], &resource_info, true);
1465        let spans = &req.resource_spans[0].scope_spans[0].spans;
1466        assert!(spans.iter().all(|span| span.flags == 0));
1467    }
1468
1469    #[test]
1470    fn semantics_disabled_preserves_per_span_sampling_flags() {
1471        let resource_info = OtlpResourceInfo::default();
1472        let root: Span<BytesData> = Span {
1473            trace_id: 1,
1474            span_id: 2,
1475            name: libdd_tinybytes::BytesString::from_static("root"),
1476            start: 0,
1477            duration: 1,
1478            ..Default::default()
1479        };
1480        let mut child: Span<BytesData> = Span {
1481            trace_id: 1,
1482            span_id: 3,
1483            parent_id: 2,
1484            name: libdd_tinybytes::BytesString::from_static("child"),
1485            start: 0,
1486            duration: 1,
1487            ..Default::default()
1488        };
1489        child.metrics.insert("_sampling_priority_v1".into(), 1.0);
1490        let mut fractional: Span<BytesData> = Span {
1491            trace_id: 1,
1492            span_id: 4,
1493            parent_id: 2,
1494            name: libdd_tinybytes::BytesString::from_static("fractional"),
1495            start: 0,
1496            duration: 1,
1497            ..Default::default()
1498        };
1499        fractional
1500            .metrics
1501            .insert("_sampling_priority_v1".into(), 0.5);
1502
1503        let req = map_traces_to_otlp(vec![vec![root, child, fractional]], &resource_info, false);
1504        let spans = &req.resource_spans[0].scope_spans[0].spans;
1505        assert_eq!(spans[0].flags, 0);
1506        assert_eq!(spans[1].flags, 1);
1507        assert_eq!(spans[2].flags, 0);
1508    }
1509}