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