Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 234 additions & 12 deletions libdd-trace-utils/src/otlp_encoder/mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,34 @@ const MAX_ATTRIBUTES_PER_SPAN: usize = 128;
/// All analogous DD span fields are mapped; meta→attributes (string), metrics→attributes
/// (int/double), links and events mapped to OTLP links and events. Status from span.error and
/// meta["error.msg"].
///
/// The high 64 bits of a 128-bit trace ID are carried in the trace_id field itself or (if not present)
// as the `_dd.p.tid` meta tag, which per RFC #85 is set on the chunk root only. We resolve it once per chunk and apply it to
/// every span so OTLP receivers see the full 128-bit trace_id on every span in the trace.
pub fn map_traces_to_otlp<T: TraceData>(
trace_chunks: Vec<Vec<Span<T>>>,
resource_info: &OtlpResourceInfo,
) -> ExportTraceServiceRequest {
let resource = build_resource(resource_info);
let mut all_spans: Vec<OtlpSpan> = Vec::new();
for chunk in &trace_chunks {
// Resolve the high 64 bits of the 128-bit trace ID once per chunk. For each span,
// prefer the native u128 `trace_id` field (e.g. Python's native spans hold the full
// 128-bit ID there) and fall back to its RFC #85 `_dd.p.tid` meta tag.
let chunk_trace_id_high: u64 = chunk
.iter()
.find_map(|s| {
let high = (s.trace_id >> 64) as u64;
if high != 0 {
return Some(high);
}
s.meta
.get("_dd.p.tid")
.and_then(|v| u64::from_str_radix(v.borrow(), 16).ok())
})
.unwrap_or(0);
for span in chunk {
all_spans.push(map_span(span, &resource_info.service));
all_spans.push(map_span(span, &resource_info.service, chunk_trace_id_high));
}
}
let scope_spans = ScopeSpans {
Expand Down Expand Up @@ -92,16 +111,15 @@ fn build_resource(resource_info: &OtlpResourceInfo) -> Resource {
Resource { attributes }
}

fn map_span<T: TraceData>(span: &Span<T>, resource_service: &str) -> OtlpSpan {
// Reconstruct the full 128-bit trace ID. The v04/v05 wire format carries only the low 64 bits
// in the trace_id field; when a tracer emits a 128-bit ID the high 64 bits are propagated as
// the hex string meta tag "_dd.p.tid".
let trace_id_high: u128 = span
.meta
.get("_dd.p.tid")
.and_then(|v| u64::from_str_radix(v.borrow(), 16).ok())
.unwrap_or(0) as u128;
let trace_id_128 = (trace_id_high << 64) | span.trace_id;
fn map_span<T: TraceData>(
span: &Span<T>,
resource_service: &str,
chunk_trace_id_high: u64,
) -> OtlpSpan {
// Reconstruct the full 128-bit trace ID. The caller resolves the high 64 bits once per
// chunk (from either the native u128 `trace_id` field or the "_dd.p.tid" meta tag).
// All spans in a chunk share the same trace ID.
let trace_id_128 = ((chunk_trace_id_high as u128) << 64) | (span.trace_id as u64 as u128);
let trace_id_hex = format!("{:032x}", trace_id_128);
let span_id_hex = format!("{:016x}", span.span_id);
let parent_span_id = if span.parent_id != 0 {
Expand Down Expand Up @@ -469,9 +487,42 @@ mod tests {
assert_eq!(otlp_span.trace_id, "5b8efff798038103d269b633813fc60c");
}

#[test]
fn test_128bit_trace_id_from_native_span_field() {
// When the span's u128 `trace_id` field already carries the full 128-bit ID (e.g.
// tracers with native spans like Python), the chunk-root meta lookup is skipped and
// the field's high 64 bits are propagated to every span in the chunk.
let resource_info = OtlpResourceInfo::default();
let full: u128 = 0x5b8efff798038103_d269b633813fc60c_u128;
let root: Span<BytesData> = Span {
trace_id: full,
span_id: 1,
name: libdd_tinybytes::BytesString::from_static("root"),
start: 0,
duration: 1,
..Default::default()
};
// Child carries only the low 64 bits; it should still inherit the chunk's high bits.
let child: Span<BytesData> = Span {
trace_id: 0xD269B633813FC60C_u128,
span_id: 2,
parent_id: 1,
name: libdd_tinybytes::BytesString::from_static("child"),
start: 0,
duration: 1,
..Default::default()
};
let req = map_traces_to_otlp(vec![vec![root, child]], &resource_info);
let spans = &req.resource_spans[0].scope_spans[0].spans;
let expected = "5b8efff798038103d269b633813fc60c";
assert_eq!(spans[0].trace_id, expected);
assert_eq!(spans[1].trace_id, expected);
}

#[test]
fn test_128bit_trace_id_without_dd_p_tid() {
// When "_dd.p.tid" is absent the high 64 bits default to zero.
// When the entire chunk has no "_dd.p.tid" the high 64 bits default to zero
// (legacy 64-bit-only trace IDs).
let resource_info = OtlpResourceInfo::default();
let span: Span<BytesData> = Span {
trace_id: 0xD269B633813FC60C_u128,
Expand All @@ -486,6 +537,177 @@ mod tests {
assert_eq!(otlp_span.trace_id, "0000000000000000d269b633813fc60c");
}

#[test]
fn test_128bit_trace_id_propagated_to_chunk_children() {
// Per RFC #85 dd-trace tracers set "_dd.p.tid" only on the chunk root.
// The OTLP mapper must apply that high-bits value to every span in the chunk
// so receivers see the full 128-bit trace_id on every span.
let resource_info = OtlpResourceInfo::default();
let low: u128 = 0xD269B633813FC60C_u128;
let mut root: Span<BytesData> = Span {
trace_id: low,
span_id: 1,
name: libdd_tinybytes::BytesString::from_static("root"),
start: 0,
duration: 1,
..Default::default()
};
root.meta.insert(
"_dd.p.tid".into(),
libdd_tinybytes::BytesString::from_static("5b8efff798038103"),
);
let child_a: Span<BytesData> = Span {
trace_id: low,
span_id: 2,
parent_id: 1,
name: libdd_tinybytes::BytesString::from_static("child_a"),
start: 0,
duration: 1,
..Default::default()
};
let child_b: Span<BytesData> = Span {
trace_id: low,
span_id: 3,
parent_id: 1,
name: libdd_tinybytes::BytesString::from_static("child_b"),
start: 0,
duration: 1,
..Default::default()
};
let req = map_traces_to_otlp(vec![vec![root, child_a, child_b]], &resource_info);
let spans = &req.resource_spans[0].scope_spans[0].spans;
assert_eq!(spans.len(), 3);
let expected = "5b8efff798038103d269b633813fc60c";
for s in spans {
assert_eq!(s.trace_id, expected, "span {} mismatched", s.span_id);
}
}

#[test]
fn test_128bit_trace_id_isolation_across_chunks() {
// The chunk-level high bits must not leak across chunks. Each chunk's spans
// get only their own chunk root's "_dd.p.tid".
let resource_info = OtlpResourceInfo::default();
let low_a: u128 = 0x1111111111111111_u128;
let low_b: u128 = 0x2222222222222222_u128;
let mut root_a: Span<BytesData> = Span {
trace_id: low_a,
span_id: 1,
name: libdd_tinybytes::BytesString::from_static("root_a"),
start: 0,
duration: 1,
..Default::default()
};
root_a.meta.insert(
"_dd.p.tid".into(),
libdd_tinybytes::BytesString::from_static("aaaaaaaaaaaaaaaa"),
);
let child_a: Span<BytesData> = Span {
trace_id: low_a,
span_id: 2,
parent_id: 1,
name: libdd_tinybytes::BytesString::from_static("child_a"),
start: 0,
duration: 1,
..Default::default()
};
let mut root_b: Span<BytesData> = Span {
trace_id: low_b,
span_id: 3,
name: libdd_tinybytes::BytesString::from_static("root_b"),
start: 0,
duration: 1,
..Default::default()
};
root_b.meta.insert(
"_dd.p.tid".into(),
libdd_tinybytes::BytesString::from_static("bbbbbbbbbbbbbbbb"),
);
let child_b: Span<BytesData> = Span {
trace_id: low_b,
span_id: 4,
parent_id: 3,
name: libdd_tinybytes::BytesString::from_static("child_b"),
start: 0,
duration: 1,
..Default::default()
};
let req = map_traces_to_otlp(
vec![vec![root_a, child_a], vec![root_b, child_b]],
&resource_info,
);
let spans = &req.resource_spans[0].scope_spans[0].spans;
assert_eq!(spans.len(), 4);
// Spans 1, 2 belong to chunk A; spans 3, 4 to chunk B.
let expect_a = "aaaaaaaaaaaaaaaa1111111111111111";
let expect_b = "bbbbbbbbbbbbbbbb2222222222222222";
assert_eq!(spans[0].trace_id, expect_a);
assert_eq!(spans[1].trace_id, expect_a);
assert_eq!(spans[2].trace_id, expect_b);
assert_eq!(spans[3].trace_id, expect_b);
}

#[test]
fn test_chunk_with_malformed_dd_p_tid_on_root_falls_back() {
// If the chunk root's "_dd.p.tid" fails to parse, the scan continues looking for
// any other parseable value in the chunk before giving up. This keeps a malformed
// tag on one span from poisoning the rest of the trace.
let resource_info = OtlpResourceInfo::default();
let low: u128 = 0xD269B633813FC60C_u128;
let mut root: Span<BytesData> = Span {
trace_id: low,
span_id: 1,
name: libdd_tinybytes::BytesString::from_static("root"),
start: 0,
duration: 1,
..Default::default()
};
root.meta.insert(
"_dd.p.tid".into(),
libdd_tinybytes::BytesString::from_static("not-hex"),
);
let child_no_tag: Span<BytesData> = Span {
trace_id: low,
span_id: 2,
parent_id: 1,
name: libdd_tinybytes::BytesString::from_static("child_no_tag"),
start: 0,
duration: 1,
..Default::default()
};
let mut child_valid: Span<BytesData> = Span {
trace_id: low,
span_id: 3,
parent_id: 1,
name: libdd_tinybytes::BytesString::from_static("child_valid"),
start: 0,
duration: 1,
..Default::default()
};
child_valid.meta.insert(
"_dd.p.tid".into(),
libdd_tinybytes::BytesString::from_static("dddddddddddddddd"),
);
let req = map_traces_to_otlp(vec![vec![root, child_no_tag, child_valid]], &resource_info);
let spans = &req.resource_spans[0].scope_spans[0].spans;
// The chunk-level scan skips the malformed root and picks up child_valid's tag,
// which is then applied to every span in the chunk.
let expected = "ddddddddddddddddd269b633813fc60c";
assert_eq!(spans[0].trace_id, expected);
assert_eq!(spans[1].trace_id, expected);
assert_eq!(spans[2].trace_id, expected);
}

#[test]
fn test_empty_chunk_does_not_panic() {
// Defensive: an empty chunk should produce no spans and not panic.
let resource_info = OtlpResourceInfo::default();
let empty: Vec<Vec<Span<BytesData>>> = vec![vec![]];
let req = map_traces_to_otlp(empty, &resource_info);
let spans = &req.resource_spans[0].scope_spans[0].spans;
assert!(spans.is_empty());
}

#[test]
fn test_tracestate_from_meta() {
let resource_info = OtlpResourceInfo::default();
Expand Down
Loading