Skip to main content

libdd_trace_utils/otlp_encoder/
mapper.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! Maps Datadog trace/spans to OTLP ExportTraceServiceRequest.
5
6use super::json_types::{
7    self, AnyValue, ExportTraceServiceRequest, InstrumentationScope, KeyValue, OtlpSpan,
8    OtlpSpanEvent, OtlpSpanLink, Resource, ResourceSpans, ScopeSpans, Status,
9};
10use super::OtlpResourceInfo;
11use crate::span::v04::{Span, SpanEvent, SpanLink};
12use crate::span::TraceData;
13use std::borrow::Borrow;
14
15/// Maximum number of attributes per span; excess are dropped and counted.
16const MAX_ATTRIBUTES_PER_SPAN: usize = 128;
17
18/// Maps Datadog trace chunks and resource info to an OTLP ExportTraceServiceRequest.
19///
20/// Resource: SDK-level attributes (service.name, deployment.environment.name, telemetry.sdk.*,
21/// runtime-id). InstrumentationScope: present but empty (DD SDKs don't have a scope concept).
22/// All analogous DD span fields are mapped; meta→attributes (string), metrics→attributes
23/// (int/double), links and events mapped to OTLP links and events. Status from span.error and
24/// meta["error.msg"].
25pub fn map_traces_to_otlp<T: TraceData>(
26    trace_chunks: Vec<Vec<Span<T>>>,
27    resource_info: &OtlpResourceInfo,
28) -> ExportTraceServiceRequest {
29    let resource = build_resource(resource_info);
30    let mut all_spans: Vec<OtlpSpan> = Vec::new();
31    for chunk in &trace_chunks {
32        for span in chunk {
33            all_spans.push(map_span(span, &resource_info.service));
34        }
35    }
36    let scope_spans = ScopeSpans {
37        scope: Some(InstrumentationScope::default()),
38        spans: all_spans,
39        schema_url: None,
40    };
41    let resource_spans = ResourceSpans {
42        resource: Some(resource),
43        scope_spans: vec![scope_spans],
44    };
45    ExportTraceServiceRequest {
46        resource_spans: vec![resource_spans],
47    }
48}
49
50fn build_resource(resource_info: &OtlpResourceInfo) -> Resource {
51    let mut attributes: Vec<KeyValue> = Vec::new();
52    if !resource_info.service.is_empty() {
53        attributes.push(KeyValue {
54            key: "service.name".to_string(),
55            value: AnyValue::StringValue(resource_info.service.clone()),
56        });
57    }
58    if !resource_info.env.is_empty() {
59        attributes.push(KeyValue {
60            key: "deployment.environment.name".to_string(),
61            value: AnyValue::StringValue(resource_info.env.clone()),
62        });
63    }
64    if !resource_info.app_version.is_empty() {
65        attributes.push(KeyValue {
66            key: "service.version".to_string(),
67            value: AnyValue::StringValue(resource_info.app_version.clone()),
68        });
69    }
70    attributes.push(KeyValue {
71        key: "telemetry.sdk.name".to_string(),
72        value: AnyValue::StringValue("datadog".to_string()),
73    });
74    if !resource_info.language.is_empty() {
75        attributes.push(KeyValue {
76            key: "telemetry.sdk.language".to_string(),
77            value: AnyValue::StringValue(resource_info.language.clone()),
78        });
79    }
80    if !resource_info.tracer_version.is_empty() {
81        attributes.push(KeyValue {
82            key: "telemetry.sdk.version".to_string(),
83            value: AnyValue::StringValue(resource_info.tracer_version.clone()),
84        });
85    }
86    if !resource_info.runtime_id.is_empty() {
87        attributes.push(KeyValue {
88            key: "runtime-id".to_string(),
89            value: AnyValue::StringValue(resource_info.runtime_id.clone()),
90        });
91    }
92    Resource { attributes }
93}
94
95fn map_span<T: TraceData>(span: &Span<T>, resource_service: &str) -> OtlpSpan {
96    // Reconstruct the full 128-bit trace ID. The v04/v05 wire format carries only the low 64 bits
97    // in the trace_id field; when a tracer emits a 128-bit ID the high 64 bits are propagated as
98    // the hex string meta tag "_dd.p.tid".
99    let trace_id_high: u128 = span
100        .meta
101        .get("_dd.p.tid")
102        .and_then(|v| u64::from_str_radix(v.borrow(), 16).ok())
103        .unwrap_or(0) as u128;
104    let trace_id_128 = (trace_id_high << 64) | span.trace_id;
105    let trace_id_hex = format!("{:032x}", trace_id_128);
106    let span_id_hex = format!("{:016x}", span.span_id);
107    let parent_span_id = if span.parent_id != 0 {
108        Some(format!("{:016x}", span.parent_id))
109    } else {
110        None
111    };
112    let start_nano = span.start;
113    let end_nano = span.start + span.duration;
114    let start_time_unix_nano = start_nano.to_string();
115    let end_time_unix_nano = end_nano.to_string();
116    // Prefer explicit "span.kind" tag (set by OTEL-instrumented tracers); fall back to
117    // the Datadog span type field for DD-instrumented spans.
118    let kind = span
119        .meta
120        .get("span.kind")
121        .map(|v| tag_to_otlp_kind(v.borrow()))
122        .unwrap_or_else(|| dd_type_to_otlp_kind(span.r#type.borrow()));
123    let (attributes, dropped_attributes_count) = map_attributes(span, resource_service);
124    let error_msg = span.meta.get("error.msg").map(|v| v.borrow().to_string());
125    let status = if span.error != 0 {
126        Status {
127            message: error_msg,
128            code: json_types::status_code::ERROR,
129        }
130    } else {
131        Status {
132            message: None,
133            code: json_types::status_code::UNSET,
134        }
135    };
136    // Set flags from sampling priority: 1 = sampled/keep, 0 = dropped.
137    let flags = span
138        .metrics
139        .get("_sampling_priority_v1")
140        .map(|p| if *p >= 1.0 { 1u32 } else { 0u32 });
141    let trace_state = span
142        .meta
143        .get("tracestate")
144        .map(|v| v.borrow().to_string())
145        .filter(|s| !s.is_empty());
146    let links = span.span_links.iter().map(map_span_link).collect();
147    let (events, dropped_events_count) = map_span_events(&span.span_events);
148    OtlpSpan {
149        trace_id: trace_id_hex,
150        span_id: span_id_hex,
151        parent_span_id,
152        trace_state,
153        name: span.resource.borrow().to_string(),
154        kind,
155        start_time_unix_nano,
156        end_time_unix_nano,
157        attributes,
158        status,
159        links,
160        events,
161        dropped_attributes_count: if dropped_attributes_count > 0 {
162            Some(dropped_attributes_count as u32)
163        } else {
164            None
165        },
166        dropped_events_count: if dropped_events_count > 0 {
167            Some(dropped_events_count as u32)
168        } else {
169            None
170        },
171        flags,
172    }
173}
174
175fn map_span_link<T: TraceData>(link: &SpanLink<T>) -> OtlpSpanLink {
176    let trace_id_128 = ((link.trace_id_high as u128) << 64) | (link.trace_id as u128);
177    let trace_id_hex = format!("{:032x}", trace_id_128);
178    let span_id_hex = format!("{:016x}", link.span_id);
179    let trace_state = if link.tracestate.borrow().is_empty() {
180        None
181    } else {
182        Some(link.tracestate.borrow().to_string())
183    };
184    let attributes: Vec<KeyValue> = link
185        .attributes
186        .iter()
187        .map(|(k, v)| KeyValue {
188            key: k.borrow().to_string(),
189            value: AnyValue::StringValue(v.borrow().to_string()),
190        })
191        .collect();
192    OtlpSpanLink {
193        trace_id: trace_id_hex,
194        span_id: span_id_hex,
195        trace_state,
196        attributes,
197        dropped_attributes_count: None,
198    }
199}
200
201fn map_span_events<T: TraceData>(events: &[SpanEvent<T>]) -> (Vec<OtlpSpanEvent>, usize) {
202    const MAX_EVENTS_PER_SPAN: usize = 128;
203    let mut otlp_events = Vec::with_capacity(events.len().min(MAX_EVENTS_PER_SPAN));
204    for ev in events.iter().take(MAX_EVENTS_PER_SPAN) {
205        let attributes: Vec<KeyValue> = ev
206            .attributes
207            .iter()
208            .map(|(k, v)| event_attr_to_key_value(k, v))
209            .collect();
210        otlp_events.push(OtlpSpanEvent {
211            time_unix_nano: ev.time_unix_nano.to_string(),
212            name: ev.name.borrow().to_string(),
213            attributes,
214            dropped_attributes_count: None,
215        });
216    }
217    let dropped = events.len().saturating_sub(otlp_events.len());
218    (otlp_events, dropped)
219}
220
221fn event_attr_to_key_value<T: TraceData>(
222    k: &T::Text,
223    v: &crate::span::v04::AttributeAnyValue<T>,
224) -> KeyValue {
225    use crate::span::v04::AttributeArrayValue;
226    let value = match v {
227        crate::span::v04::AttributeAnyValue::SingleValue(av) => match av {
228            AttributeArrayValue::String(s) => AnyValue::StringValue(s.borrow().to_string()),
229            AttributeArrayValue::Boolean(b) => AnyValue::BoolValue(*b),
230            AttributeArrayValue::Integer(i) => AnyValue::IntValue(*i),
231            AttributeArrayValue::Double(d) => AnyValue::DoubleValue(*d),
232        },
233        crate::span::v04::AttributeAnyValue::Array(items) => {
234            let values = items
235                .iter()
236                .map(|item| match item {
237                    AttributeArrayValue::String(s) => AnyValue::StringValue(s.borrow().to_string()),
238                    AttributeArrayValue::Boolean(b) => AnyValue::BoolValue(*b),
239                    AttributeArrayValue::Integer(i) => AnyValue::IntValue(*i),
240                    AttributeArrayValue::Double(d) => AnyValue::DoubleValue(*d),
241                })
242                .collect();
243            AnyValue::ArrayValue(crate::otlp_encoder::json_types::ArrayValue { values })
244        }
245    };
246    KeyValue {
247        key: k.borrow().to_string(),
248        value,
249    }
250}
251
252/// Maps the explicit "span.kind" meta tag (set by OTEL-instrumented tracers) to an OTLP SpanKind.
253fn tag_to_otlp_kind(t: &str) -> i32 {
254    match t.to_lowercase().as_str() {
255        "server" => json_types::span_kind::SERVER,
256        "client" => json_types::span_kind::CLIENT,
257        "producer" => json_types::span_kind::PRODUCER,
258        "consumer" => json_types::span_kind::CONSUMER,
259        "internal" => json_types::span_kind::INTERNAL,
260        _ => json_types::span_kind::UNSPECIFIED,
261    }
262}
263
264/// Maps the Datadog span type field (set by DD-instrumented tracers) to an OTLP SpanKind.
265fn dd_type_to_otlp_kind(t: &str) -> i32 {
266    match t.to_lowercase().as_str() {
267        "server" | "web" | "http" => json_types::span_kind::SERVER,
268        "client" => json_types::span_kind::CLIENT,
269        "producer" => json_types::span_kind::PRODUCER,
270        "consumer" => json_types::span_kind::CONSUMER,
271        _ => json_types::span_kind::INTERNAL,
272    }
273}
274
275fn map_attributes<T: TraceData>(span: &Span<T>, resource_service: &str) -> (Vec<KeyValue>, usize) {
276    let mut attrs: Vec<KeyValue> = Vec::new();
277    // Add service.name when the span's service differs from the resource-level service.
278    let span_service = span.service.borrow();
279    let has_per_span_service = !span_service.is_empty() && span_service != resource_service;
280    if has_per_span_service {
281        attrs.push(KeyValue {
282            key: "service.name".to_string(),
283            value: AnyValue::StringValue(span_service.to_string()),
284        });
285    }
286    let operation_name = span.name.borrow();
287    let has_operation_name = !operation_name.is_empty();
288    if has_operation_name {
289        attrs.push(KeyValue {
290            key: "operation.name".to_string(),
291            value: AnyValue::StringValue(operation_name.to_string()),
292        });
293    }
294    let span_type = span.r#type.borrow();
295    let has_span_type = !span_type.is_empty();
296    if has_span_type {
297        attrs.push(KeyValue {
298            key: "span.type".to_string(),
299            value: AnyValue::StringValue(span_type.to_string()),
300        });
301    }
302    let resource_name = span.resource.borrow();
303    let has_resource_name = !resource_name.is_empty();
304    if has_resource_name {
305        attrs.push(KeyValue {
306            key: "resource.name".to_string(),
307            value: AnyValue::StringValue(resource_name.to_string()),
308        });
309    }
310    for (k, v) in span.meta.iter() {
311        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
312            break;
313        }
314        attrs.push(KeyValue {
315            key: k.borrow().to_string(),
316            value: AnyValue::StringValue(v.borrow().to_string()),
317        });
318    }
319    for (k, v) in span.metrics.iter() {
320        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
321            break;
322        }
323        let value = if v.fract() == 0.0 && (*v >= i64::MIN as f64 && *v <= i64::MAX as f64) {
324            AnyValue::IntValue(*v as i64)
325        } else {
326            AnyValue::DoubleValue(*v)
327        };
328        attrs.push(KeyValue {
329            key: k.borrow().to_string(),
330            value,
331        });
332    }
333    for (k, v) in span.meta_struct.iter() {
334        if attrs.len() >= MAX_ATTRIBUTES_PER_SPAN {
335            break;
336        }
337        attrs.push(KeyValue {
338            key: k.borrow().to_string(),
339            value: AnyValue::BytesValue(v.borrow().to_vec()),
340        });
341    }
342    let total = (if has_per_span_service { 1 } else { 0 })
343        + (if has_operation_name { 1 } else { 0 })
344        + (if has_span_type { 1 } else { 0 })
345        + (if has_resource_name { 1 } else { 0 })
346        + span.meta.len()
347        + span.metrics.len()
348        + span.meta_struct.len();
349    let dropped = total.saturating_sub(attrs.len());
350    (attrs, dropped)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::otlp_encoder::OtlpResourceInfo;
357    use crate::span::BytesData;
358
359    #[test]
360    fn test_trace_id_span_id_format() {
361        let resource_info = OtlpResourceInfo::default();
362        let span: Span<BytesData> = Span {
363            trace_id: 0xD269B633813FC60C_u128, // low 64 bits only (v04 wire format)
364            span_id: 0xEEE19B7EC3C1B174,
365            parent_id: 0xEEE19B7EC3C1B173,
366            name: libdd_tinybytes::BytesString::from_static("test"),
367            service: libdd_tinybytes::BytesString::from_static("svc"),
368            resource: libdd_tinybytes::BytesString::from_static("res"),
369            r#type: libdd_tinybytes::BytesString::from_static("web"),
370            start: 1544712660000000000,
371            duration: 1000000000,
372            error: 0,
373            ..Default::default()
374        };
375        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
376        let rs = &req.resource_spans[0];
377        let otlp_span = &rs.scope_spans[0].spans[0];
378        assert_eq!(otlp_span.trace_id, "0000000000000000d269b633813fc60c");
379        assert_eq!(otlp_span.span_id, "eee19b7ec3c1b174");
380        assert_eq!(
381            otlp_span.parent_span_id.as_deref(),
382            Some("eee19b7ec3c1b173")
383        );
384        assert_eq!(otlp_span.kind, json_types::span_kind::SERVER);
385        assert_eq!(otlp_span.start_time_unix_nano, "1544712660000000000");
386        assert_eq!(otlp_span.end_time_unix_nano, "1544712661000000000");
387        assert_eq!(rs.scope_spans[0].scope.as_ref().unwrap().name, None);
388    }
389
390    #[test]
391    fn test_status_error_message_from_meta() {
392        let resource_info = OtlpResourceInfo::default();
393        let mut span: Span<BytesData> = Span {
394            trace_id: 1,
395            span_id: 2,
396            name: libdd_tinybytes::BytesString::from_static("err_span"),
397            start: 0,
398            duration: 1,
399            error: 1,
400            ..Default::default()
401        };
402        span.meta.insert(
403            libdd_tinybytes::BytesString::from_static("error.msg"),
404            libdd_tinybytes::BytesString::from_static("something broke"),
405        );
406        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
407        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
408        let status = &otlp_span.status;
409        assert_eq!(status.code, json_types::status_code::ERROR);
410        assert_eq!(status.message.as_deref(), Some("something broke"));
411    }
412
413    #[test]
414    fn test_metrics_as_int_or_double() {
415        let resource_info = OtlpResourceInfo::default();
416        let mut span: Span<BytesData> = Span {
417            trace_id: 1,
418            span_id: 2,
419            name: libdd_tinybytes::BytesString::from_static("m"),
420            start: 0,
421            duration: 1,
422            ..Default::default()
423        };
424        span.metrics
425            .insert(libdd_tinybytes::BytesString::from_static("count"), 42.0);
426        span.metrics.insert(
427            libdd_tinybytes::BytesString::from_static("rate"),
428            std::f64::consts::PI,
429        );
430        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
431        let json = serde_json::to_value(&req).unwrap();
432        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
433        let count_kv = attrs
434            .as_array()
435            .unwrap()
436            .iter()
437            .find(|a| a["key"] == "count")
438            .unwrap();
439        assert_eq!(count_kv["value"]["intValue"], "42");
440        let rate_kv = attrs
441            .as_array()
442            .unwrap()
443            .iter()
444            .find(|a| a["key"] == "rate")
445            .unwrap();
446        let rate = rate_kv["value"]["doubleValue"].as_f64().unwrap();
447        assert!((rate - std::f64::consts::PI).abs() < 1e-9);
448    }
449
450    #[test]
451    fn test_128bit_trace_id_from_dd_p_tid() {
452        // When "_dd.p.tid" is present it supplies the high 64 bits of the trace ID.
453        // Low 64 bits come from span.trace_id; the two are concatenated to form a 128-bit hex ID.
454        let resource_info = OtlpResourceInfo::default();
455        let mut span: Span<BytesData> = Span {
456            trace_id: 0xD269B633813FC60C_u128, // low 64 bits
457            span_id: 1,
458            name: libdd_tinybytes::BytesString::from_static("s"),
459            start: 0,
460            duration: 1,
461            ..Default::default()
462        };
463        span.meta.insert(
464            "_dd.p.tid".into(),
465            libdd_tinybytes::BytesString::from_static("5b8efff798038103"),
466        );
467        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
468        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
469        assert_eq!(otlp_span.trace_id, "5b8efff798038103d269b633813fc60c");
470    }
471
472    #[test]
473    fn test_128bit_trace_id_without_dd_p_tid() {
474        // When "_dd.p.tid" is absent the high 64 bits default to zero.
475        let resource_info = OtlpResourceInfo::default();
476        let span: Span<BytesData> = Span {
477            trace_id: 0xD269B633813FC60C_u128,
478            span_id: 1,
479            name: libdd_tinybytes::BytesString::from_static("s"),
480            start: 0,
481            duration: 1,
482            ..Default::default()
483        };
484        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
485        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
486        assert_eq!(otlp_span.trace_id, "0000000000000000d269b633813fc60c");
487    }
488
489    #[test]
490    fn test_tracestate_from_meta() {
491        let resource_info = OtlpResourceInfo::default();
492        let mut span: Span<BytesData> = Span {
493            trace_id: 1,
494            span_id: 2,
495            name: libdd_tinybytes::BytesString::from_static("s"),
496            start: 0,
497            duration: 1,
498            ..Default::default()
499        };
500        span.meta.insert(
501            "tracestate".into(),
502            libdd_tinybytes::BytesString::from_static("vendor1=abc,rojo=00f067"),
503        );
504        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
505        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
506        assert_eq!(
507            otlp_span.trace_state.as_deref(),
508            Some("vendor1=abc,rojo=00f067")
509        );
510    }
511
512    #[test]
513    fn test_meta_struct_as_bytes_value() {
514        use libdd_tinybytes::Bytes;
515        let resource_info = OtlpResourceInfo::default();
516        let mut span: Span<BytesData> = Span {
517            trace_id: 1,
518            span_id: 2,
519            name: libdd_tinybytes::BytesString::from_static("s"),
520            start: 0,
521            duration: 1,
522            ..Default::default()
523        };
524        span.meta_struct
525            .insert("my_key".into(), Bytes::from(vec![1u8, 2, 3]));
526        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
527        let json = serde_json::to_value(&req).unwrap();
528        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
529        let kv = attrs
530            .as_array()
531            .unwrap()
532            .iter()
533            .find(|a| a["key"] == "my_key")
534            .expect("my_key attribute not found");
535        // Per the protobuf JSON mapping, bytes are base64-encoded.
536        assert_eq!(kv["value"]["bytesValue"], "AQID");
537    }
538
539    #[test]
540    fn test_operation_name_attribute() {
541        let resource_info = OtlpResourceInfo::default();
542        let span: Span<BytesData> = Span {
543            trace_id: 1,
544            span_id: 2,
545            name: libdd_tinybytes::BytesString::from_static("my.operation"),
546            start: 0,
547            duration: 1,
548            ..Default::default()
549        };
550        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
551        let json = serde_json::to_value(&req).unwrap();
552        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
553        let kv = attrs
554            .as_array()
555            .unwrap()
556            .iter()
557            .find(|a| a["key"] == "operation.name")
558            .expect("operation.name attribute not found");
559        assert_eq!(kv["value"]["stringValue"], "my.operation");
560    }
561
562    #[test]
563    fn test_span_type_attribute() {
564        let resource_info = OtlpResourceInfo::default();
565        let span: Span<BytesData> = Span {
566            trace_id: 1,
567            span_id: 2,
568            name: libdd_tinybytes::BytesString::from_static("s"),
569            r#type: libdd_tinybytes::BytesString::from_static("grpc"),
570            start: 0,
571            duration: 1,
572            ..Default::default()
573        };
574        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
575        let json = serde_json::to_value(&req).unwrap();
576        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
577        let kv = attrs
578            .as_array()
579            .unwrap()
580            .iter()
581            .find(|a| a["key"] == "span.type")
582            .expect("span.type attribute not found");
583        assert_eq!(kv["value"]["stringValue"], "grpc");
584    }
585
586    #[test]
587    fn test_resource_name_attribute() {
588        let resource_info = OtlpResourceInfo::default();
589        let span: Span<BytesData> = Span {
590            trace_id: 1,
591            span_id: 2,
592            name: libdd_tinybytes::BytesString::from_static("s"),
593            resource: libdd_tinybytes::BytesString::from_static("GET /api/users"),
594            start: 0,
595            duration: 1,
596            ..Default::default()
597        };
598        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
599        let json = serde_json::to_value(&req).unwrap();
600        let otlp_span = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
601        // resource maps to the OTLP span name
602        assert_eq!(otlp_span["name"], "GET /api/users");
603        // resource also maps to the resource.name attribute
604        let kv = otlp_span["attributes"]
605            .as_array()
606            .unwrap()
607            .iter()
608            .find(|a| a["key"] == "resource.name")
609            .expect("resource.name attribute not found");
610        assert_eq!(kv["value"]["stringValue"], "GET /api/users");
611    }
612
613    #[test]
614    fn test_empty_resource_name_not_emitted() {
615        // A span with no resource set should not emit a resource.name attribute.
616        // In practice DD spans always have a resource, but the mapper is defensive about
617        // empty fields from the wire.
618        let resource_info = OtlpResourceInfo::default();
619        let span: Span<BytesData> = Span {
620            trace_id: 1,
621            span_id: 2,
622            name: libdd_tinybytes::BytesString::from_static("s"),
623            // resource is empty (default)
624            start: 0,
625            duration: 1,
626            ..Default::default()
627        };
628        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
629        let json = serde_json::to_value(&req).unwrap();
630        let attrs = json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]
631            .as_array()
632            .unwrap();
633        assert!(
634            !attrs.iter().any(|a| a["key"] == "resource.name"),
635            "resource.name should not be emitted when resource is empty"
636        );
637    }
638
639    #[test]
640    fn test_per_span_service_name_attribute() {
641        // When span.service differs from the resource-level service, service.name is emitted
642        // as a per-span attribute so the receiver can distinguish between services in a trace.
643        let resource_info = OtlpResourceInfo {
644            service: "resource-svc".to_string(),
645            ..Default::default()
646        };
647        let span: Span<BytesData> = Span {
648            trace_id: 1,
649            span_id: 2,
650            name: libdd_tinybytes::BytesString::from_static("s"),
651            service: libdd_tinybytes::BytesString::from_static("span-svc"),
652            start: 0,
653            duration: 1,
654            ..Default::default()
655        };
656        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
657        let json = serde_json::to_value(&req).unwrap();
658        let attrs = &json["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"];
659        let kv = attrs
660            .as_array()
661            .unwrap()
662            .iter()
663            .find(|a| a["key"] == "service.name")
664            .expect("service.name attribute not found");
665        assert_eq!(kv["value"]["stringValue"], "span-svc");
666    }
667
668    #[test]
669    fn test_unsampled_span_flags_zero() {
670        // _sampling_priority_v1 = 0 means explicitly dropped; flags field must be 0.
671        let resource_info = OtlpResourceInfo::default();
672        let mut span: Span<BytesData> = Span {
673            trace_id: 1,
674            span_id: 2,
675            name: libdd_tinybytes::BytesString::from_static("s"),
676            start: 0,
677            duration: 1,
678            ..Default::default()
679        };
680        span.metrics.insert("_sampling_priority_v1".into(), 0.0);
681        let req = map_traces_to_otlp(vec![vec![span]], &resource_info);
682        let otlp_span = &req.resource_spans[0].scope_spans[0].spans[0];
683        assert_eq!(otlp_span.flags, Some(0));
684    }
685}