diff --git a/libdd-data-pipeline/tests/snapshots/compare_exporter_v04_to_v05_trace_snapshot_test.json b/libdd-data-pipeline/tests/snapshots/compare_exporter_v04_to_v05_trace_snapshot_test.json index b358a9f60e..8e8136143e 100644 --- a/libdd-data-pipeline/tests/snapshots/compare_exporter_v04_to_v05_trace_snapshot_test.json +++ b/libdd-data-pipeline/tests/snapshots/compare_exporter_v04_to_v05_trace_snapshot_test.json @@ -13,7 +13,8 @@ "meta": { "env": "test-env", "service": "test-service", - "runtime-id": "test-runtime-id-value" + "runtime-id": "test-runtime-id-value", + "events": "[{\"time_unix_nano\":1727211691770715042,\"name\":\"test_span\",\"attributes\":{}},{\"time_unix_nano\":1727211691770716000,\"name\":\"exception\",\"attributes\":{\"exception.count\":1,\"exception.escaped\":true,\"exception.lines\":[\" File \\\"\\\", line 1, in \",\" File \\\"\\\", line 1, in divide\"],\"exception.message\":\"Cannot divide by zero\",\"exception.version\":4.2}}]" }, "metrics": { "_dd_metric1": 1.0, @@ -34,7 +35,8 @@ "meta": { "env": "test-env", "runtime-id": "test-runtime-id-value", - "service": "test-service" + "service": "test-service", + "_dd.span_links": "[{\"trace_id\":\"0000000000000000c151df7d6ee5e2d6\",\"span_id\":\"a3978fb9b92502a8\",\"attributes\":{\"link.name\":\"Job #123\"}},{\"trace_id\":\"527ccbd68a74d57ea918bf567eec151d\",\"span_id\":\"c08c967f0e5e7b0a\"}]" }, "metrics": {}, "type": "" diff --git a/libdd-trace-utils/src/span/mod.rs b/libdd-trace-utils/src/span/mod.rs index 1a122efe99..637efdb6f5 100644 --- a/libdd-trace-utils/src/span/mod.rs +++ b/libdd-trace-utils/src/span/mod.rs @@ -24,6 +24,16 @@ use std::{fmt, ptr}; /// from a static str and check if the string is empty. pub trait SpanText: Debug + Eq + Hash + Borrow + Serialize + Default { fn from_static_str(value: &'static str) -> Self; + + /// Copies this text into an owned [`BytesString`]. + /// + /// Used by the v0.5 conversion, whose shared dictionary always owns its strings so it + /// can hold both interned span text and dynamically-built JSON (span links / events). + /// The default copies the bytes; owned text types (e.g. `BytesString`) should override + /// with a cheaper reference-counted clone. + fn to_bytes_string(&self) -> BytesString { + BytesString::from(>::borrow(self).to_string()) + } } impl SpanText for &str { @@ -36,6 +46,10 @@ impl SpanText for BytesString { fn from_static_str(value: &'static str) -> Self { BytesString::from_static(value) } + + fn to_bytes_string(&self) -> BytesString { + self.clone() + } } pub trait SpanBytes: Debug + Eq + Hash + Borrow<[u8]> + Serialize + Default { diff --git a/libdd-trace-utils/src/span/v05/dict.rs b/libdd-trace-utils/src/span/v05/dict.rs index 25a070922d..f42288af02 100644 --- a/libdd-trace-utils/src/span/v05/dict.rs +++ b/libdd-trace-utils/src/span/v05/dict.rs @@ -8,7 +8,7 @@ use crate::span::SpanText; #[derive(Debug, Clone)] pub struct SharedDict { /// Map strings with their index and keep insertion order(O(1) retrieval complexity). - map: indexmap::IndexSet, + pub(crate) map: indexmap::IndexMap, } impl serde::Serialize for SharedDict { @@ -16,7 +16,11 @@ impl serde::Serialize for SharedDict { where S: serde::Serializer, { - serializer.collect_seq(self.map.iter().map(|entry| -> &str { entry.borrow() })) + serializer.collect_seq( + self.map + .iter() + .map(|(entry, ())| -> &str { entry.borrow() }), + ) } } @@ -32,7 +36,7 @@ impl SharedDict { (index).try_into() } else { let index = self.map.len(); - self.map.insert(s); + self.map.insert(s, ()); index.try_into() } } @@ -43,14 +47,14 @@ impl SharedDict { } pub fn iter(&self) -> impl Iterator { - self.map.iter() + self.map.keys() } } impl Default for SharedDict { fn default() -> Self { Self { - map: indexmap::indexset! {T::default()}, + map: indexmap::indexmap! {T::default() => ()}, } } } @@ -84,8 +88,8 @@ mod tests { assert_eq!(dict.map.len(), 3); - assert_eq!(dict.map[0].as_str(), ""); - assert_eq!(dict.map[1].as_str(), "foo"); - assert_eq!(dict.map[2].as_str(), "bar"); + assert_eq!(dict.map.get_index(0).unwrap().0.as_str(), ""); + assert_eq!(dict.map.get_index(1).unwrap().0.as_str(), "foo"); + assert_eq!(dict.map.get_index(2).unwrap().0.as_str(), "bar"); } } diff --git a/libdd-trace-utils/src/span/v05/mod.rs b/libdd-trace-utils/src/span/v05/mod.rs index 905dc1815a..849a082a30 100644 --- a/libdd-trace-utils/src/span/v05/mod.rs +++ b/libdd-trace-utils/src/span/v05/mod.rs @@ -3,9 +3,14 @@ pub mod dict; -use crate::span::{v05::dict::SharedDict, TraceData}; +use crate::span::v04::{AttributeAnyValue, AttributeArrayValue, SpanEvent, SpanLink}; +use crate::span::{SharedDictBytes, SpanText, TraceData}; use anyhow::Result; -use serde::Serialize; +use indexmap::map::RawEntryApiV1; +use libdd_tinybytes::BytesString; +use serde::ser::{SerializeMap, SerializeSeq}; +use serde::{Serialize, Serializer}; +use std::borrow::Borrow; use std::collections::HashMap; /// Structure that represent a TraceChunk Span which String fields are interned in a shared @@ -28,37 +33,262 @@ pub struct Span { pub r#type: u32, } +/// Serializes a slice of [`SpanLink`]s into the JSON array the Datadog agent and backend +/// expect under the `_dd.span_links` meta key. +/// +/// This matches the agent's `transform.MarshalLinks` +/// (datadog-agent `pkg/trace/transform/transform.go`), the canonical producer of +/// `_dd.span_links`: +/// - `trace_id` is the full 128-bit id hex-encoded as 32 lowercase chars (high 64 bits first, then +/// low 64 bits). +/// - `span_id` is the 64-bit id hex-encoded as 16 lowercase chars. +/// - `tracestate` and `attributes` are only emitted when non-empty. +/// - `flags` is only emitted when not zero. +struct SpanLinksSerializerV05<'a, T: TraceData>(&'a [SpanLink]); +struct SpanLinkSerializerV05<'a, T: TraceData>(&'a SpanLink); + +impl<'a, T: TraceData> Serialize for SpanLinksSerializerV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + let mut seq = serializer.serialize_seq(Some(self.0.len()))?; + for link in self.0 { + seq.serialize_element(&SpanLinkSerializerV05::(link))?; + } + seq.end() + } +} + +impl<'a, T: TraceData> Serialize for SpanLinkSerializerV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + let link = self.0; + let tracestate: &str = link.tracestate.borrow(); + let has_tracestate = !tracestate.is_empty(); + let has_attributes = !link.attributes.is_empty(); + let has_flags = link.flags != 0; + let len = 2 + has_tracestate as usize + has_attributes as usize + has_flags as usize; + let mut map = serializer.serialize_map(Some(len))?; + map.serialize_entry( + "trace_id", + &format!("{:016x}{:016x}", link.trace_id_high, link.trace_id), + )?; + map.serialize_entry("span_id", &format!("{:016x}", link.span_id))?; + if has_tracestate { + map.serialize_entry("tracestate", &link.tracestate)?; + } + if has_attributes { + map.serialize_entry( + "attributes", + &SortedStrMapSerializerV05::(&link.attributes), + )?; + } + if has_flags { + map.serialize_entry("flags", &link.flags)?; + } + map.end() + } +} + +/// Serializes a `HashMap` as a JSON object with keys in sorted order, +/// keeping the output deterministic for snapshot testing +struct SortedStrMapSerializerV05<'a, T: TraceData>(&'a HashMap); + +impl<'a, T: TraceData> Serialize for SortedStrMapSerializerV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + let mut entries: Vec<(&str, &T::Text)> = + self.0.iter().map(|(k, v)| (k.borrow(), v)).collect(); + entries.sort_unstable_by_key(|(k, _)| *k); + let mut map = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +/// Serializes a slice of [`SpanEvent`]s into the JSON array the Datadog agent and backend +/// expect under the `events` meta key, matching the agent's `MarshalEvents` +/// (`{time_unix_nano, name, attributes}` with attributes rendered as natural JSON rather than +/// the v0.4 msgpack tagged form). +struct SpanEventsSerializerV05<'a, T: TraceData>(&'a [SpanEvent]); +struct SpanEventSerializerV05<'a, T: TraceData>(&'a SpanEvent); +struct SpanEventAttributesSerializerV05<'a, T: TraceData>( + &'a HashMap>, +); +struct AttributeAnyValueV05<'a, T: TraceData>(&'a AttributeAnyValue); +struct AttributeArrayValueV05<'a, T: TraceData>(&'a AttributeArrayValue); + +impl<'a, T: TraceData> Serialize for SpanEventsSerializerV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + let mut seq = serializer.serialize_seq(Some(self.0.len()))?; + for event in self.0 { + seq.serialize_element(&SpanEventSerializerV05::(event))?; + } + seq.end() + } +} + +impl<'a, T: TraceData> Serialize for SpanEventSerializerV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + let event = self.0; + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("time_unix_nano", &event.time_unix_nano)?; + map.serialize_entry("name", &event.name)?; + map.serialize_entry( + "attributes", + &SpanEventAttributesSerializerV05::(&event.attributes), + )?; + map.end() + } +} + +impl<'a, T: TraceData> Serialize for SpanEventAttributesSerializerV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + // Sort keys to match Go's `encoding/json` (used by the agent) and keep output + // deterministic, since the source is an unordered `HashMap`. + let mut entries: Vec<(&str, &AttributeAnyValue)> = + self.0.iter().map(|(k, v)| (k.borrow(), v)).collect(); + entries.sort_unstable_by_key(|(k, _)| *k); + let mut map = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + map.serialize_entry(key, &AttributeAnyValueV05::(value))?; + } + map.end() + } +} + +impl<'a, T: TraceData> Serialize for AttributeAnyValueV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + match self.0 { + AttributeAnyValue::SingleValue(value) => { + AttributeArrayValueV05::(value).serialize(serializer) + } + AttributeAnyValue::Array(values) => { + let mut seq = serializer.serialize_seq(Some(values.len()))?; + for value in values { + seq.serialize_element(&AttributeArrayValueV05::(value))?; + } + seq.end() + } + } + } +} + +impl<'a, T: TraceData> Serialize for AttributeArrayValueV05<'a, T> { + fn serialize(&self, serializer: S) -> Result { + match self.0 { + AttributeArrayValue::String(value) => value.serialize(serializer), + AttributeArrayValue::Boolean(value) => serializer.serialize_bool(*value), + AttributeArrayValue::Integer(value) => serializer.serialize_i64(*value), + AttributeArrayValue::Double(value) => serializer.serialize_f64(*value), + } + } +} + +/// Gets the index of the interned string. If the string is not part of the dictionary it is +/// added and its corresponding index returned. +/// +/// Checks if the span text is already interned before creating a +/// new ByteString instance from it. +fn get_or_insert( + dict: &mut SharedDictBytes, + str: &impl SpanText, +) -> Result { + let entry = dict.map.raw_entry_mut_v1().from_key(str.borrow()); + let idx = entry.index(); + entry.or_insert_with(|| (str.to_bytes_string(), ())); + idx.try_into() +} + +/// Converts a v0.4 [`Span`](crate::span::v04::Span) into its v0.5 dictionary-encoded form. +/// +/// The v0.5 format is a fixed 12-element positional array (service, name, resource, trace_id, +/// span_id, parent_id, start, duration, error, meta, metrics, type). It predates `span_links`, +/// `span_events`, and `meta_struct`, none of which have a dedicated slot. +/// +/// `span_links` and `span_events` are carried in `meta` as JSON strings under the +/// `_dd.span_links` and `events` keys, matching the shapes the Datadog agent/backend understand +/// (the agent's `MarshalLinks` / `MarshalEvents`). Both are only emitted when non-empty. +/// +/// `meta_struct` is intentionally dropped: it carries arbitrary binary (msgpack) blobs, the +/// v0.5 `meta` map is string->string only, and there is no agent-side meta-key convention for +/// reconstructing it from a v0.5 payload. Callers that must preserve `meta_struct` should use +/// the v0.4 output format. +/// +/// Carrying links/events requires interning dynamically-built JSON strings, so the shared +/// dictionary always owns its strings ([`SharedDictBytes`]). Borrowed input text is copied into +/// the dictionary; owned text is reference-counted. pub fn from_v04_span( span: crate::span::v04::Span, - dict: &mut SharedDict, + dict: &mut SharedDictBytes, ) -> Result { let meta_len = span.meta.len(); let metrics_len = span.metrics.len(); + + // Serialize span links / span events before `span` is consumed below. v0.5 has no + // dedicated slots for them, so they are flattened into `meta` as JSON strings. + let serialized_span_links = if span.span_links.is_empty() { + None + } else { + Some(serde_json::to_string(&SpanLinksSerializerV05::( + &span.span_links, + ))?) + }; + let serialized_span_events = if span.span_events.is_empty() { + None + } else { + Some(serde_json::to_string(&SpanEventsSerializerV05::( + &span.span_events, + ))?) + }; + + let extra_meta = + serialized_span_links.is_some() as usize + serialized_span_events.is_some() as usize; + + // Intern fields in the same order as the base conversion to keep dictionary indices + // stable; the span links / events keys are appended to `meta` afterwards. + let service = get_or_insert(dict, &span.service)?; + let name = get_or_insert(dict, &span.name)?; + let resource = get_or_insert(dict, &span.resource)?; + let mut meta = span.meta.into_iter().try_fold( + HashMap::with_capacity(meta_len + extra_meta), + |mut meta, (k, v)| -> anyhow::Result> { + meta.insert(get_or_insert(dict, &k)?, get_or_insert(dict, &v)?); + Ok(meta) + }, + )?; + + if let Some(links_json) = serialized_span_links { + let key = dict.get_or_insert(BytesString::from_static("_dd.span_links"))?; + let value = dict.get_or_insert(BytesString::from(links_json))?; + meta.insert(key, value); + } + if let Some(events_json) = serialized_span_events { + let key = dict.get_or_insert(BytesString::from_static("events"))?; + let value = dict.get_or_insert(BytesString::from(events_json))?; + meta.insert(key, value); + } + + let metrics = span.metrics.into_iter().try_fold( + HashMap::with_capacity(metrics_len), + |mut metrics, (k, v)| -> anyhow::Result> { + metrics.insert(get_or_insert(dict, &k)?, v); + Ok(metrics) + }, + )?; + let r#type = get_or_insert(dict, &span.r#type)?; + Ok(Span { - service: dict.get_or_insert(span.service)?, - name: dict.get_or_insert(span.name)?, - resource: dict.get_or_insert(span.resource)?, + service, + name, + resource, trace_id: span.trace_id as u64, span_id: span.span_id, parent_id: span.parent_id, start: span.start, duration: span.duration, error: span.error, - meta: span.meta.into_iter().try_fold( - HashMap::with_capacity(meta_len), - |mut meta, (k, v)| -> anyhow::Result> { - meta.insert(dict.get_or_insert(k)?, dict.get_or_insert(v)?); - Ok(meta) - }, - )?, - metrics: span.metrics.into_iter().try_fold( - HashMap::with_capacity(metrics_len), - |mut metrics, (k, v)| -> anyhow::Result> { - metrics.insert(dict.get_or_insert(k)?, v); - Ok(metrics) - }, - )?, - r#type: dict.get_or_insert(span.r#type)?, + meta, + metrics, + r#type, }) } @@ -66,8 +296,17 @@ pub fn from_v04_span( mod tests { use super::*; use crate::span::v04::{SpanBytes, VecMap}; + use crate::span::BytesData; use libdd_tinybytes::BytesString; + /// Returns the JSON string interned in `meta` under `key`, if present. + fn meta_json(dict: &SharedDictBytes, span: &Span, key: &str) -> Option { + let entries: Vec<&str> = dict.iter().map(|s| s.as_str()).collect(); + let key_idx = entries.iter().position(|s| *s == key)? as u32; + let val_idx = *span.meta.get(&key_idx)?; + Some(entries[val_idx as usize].to_string()) + } + #[test] fn from_span_bytes_test() { let span = SpanBytes { @@ -92,7 +331,7 @@ mod tests { span_events: vec![], }; - let mut dict = SharedDict::default(); + let mut dict = SharedDictBytes::default(); let v05_span = from_v04_span(span, &mut dict).unwrap(); let get_index_from_str = |str: &str| -> u32 { @@ -131,4 +370,276 @@ mod tests { 1.1 ); } + + fn base_span() -> SpanBytes { + SpanBytes { + service: BytesString::from("service"), + name: BytesString::from("name"), + resource: BytesString::from("resource"), + r#type: BytesString::from("type"), + trace_id: 1, + span_id: 1, + parent_id: 0, + start: 1, + duration: 111, + error: 0, + meta: vec![( + BytesString::from("meta_field"), + BytesString::from("meta_value"), + )] + .into(), + metrics: VecMap::new(), + meta_struct: VecMap::new(), + span_links: vec![], + span_events: vec![], + } + } + + /// Span links and span events are flattened into `meta` as agent-compatible JSON under + /// the `_dd.span_links` and `events` keys. + #[test] + fn from_v04_span_serializes_links_and_events_test() { + let mut span = base_span(); + span.span_links = vec![SpanLink:: { + trace_id: 12345, + trace_id_high: 67890, + span_id: 54321, + attributes: HashMap::from([(BytesString::from("key"), BytesString::from("val"))]), + tracestate: BytesString::from("tracestate_value"), + flags: 1, + }]; + span.span_events = vec![SpanEvent:: { + time_unix_nano: 123, + name: BytesString::from("ev1"), + attributes: HashMap::from([( + BytesString::from("str_attr"), + AttributeAnyValue::SingleValue(AttributeArrayValue::String(BytesString::from( + "val", + ))), + )]), + }]; + + let mut dict = SharedDictBytes::default(); + let v05_span = from_v04_span(span, &mut dict).unwrap(); + + let links_json = meta_json(&dict, &v05_span, "_dd.span_links").unwrap(); + assert_eq!( + links_json, + "[{\"trace_id\":\"00000000000109320000000000003039\",\"span_id\":\"000000000000d431\",\"tracestate\":\"tracestate_value\",\"attributes\":{\"key\":\"val\"},\"flags\":1}]" + ); + let events_json = meta_json(&dict, &v05_span, "events").unwrap(); + assert_eq!( + events_json, + "[{\"time_unix_nano\":123,\"name\":\"ev1\",\"attributes\":{\"str_attr\":\"val\"}}]" + ); + // Original meta entry plus the two synthesized keys. + assert_eq!(v05_span.meta.len(), 3); + } + + /// Empty links/events add no meta keys (matches the agent, which only writes them when + /// non-empty). + #[test] + fn from_v04_span_empty_links_events_no_meta_keys_test() { + let mut dict = SharedDictBytes::default(); + let v05_span = from_v04_span(base_span(), &mut dict).unwrap(); + assert_eq!(v05_span.meta.len(), 1); + assert!(meta_json(&dict, &v05_span, "_dd.span_links").is_none()); + assert!(meta_json(&dict, &v05_span, "events").is_none()); + } + + /// `meta_struct` has no v0.5 representation and must be dropped; conversion still succeeds + /// and produces no extra meta keys. + #[test] + fn from_v04_span_drops_meta_struct_test() { + let mut span = base_span(); + span.meta_struct = vec![( + BytesString::from("appsec"), + libdd_tinybytes::Bytes::from_static(&[0x01, 0x02, 0x03]), + )] + .into(); + + let mut dict = SharedDictBytes::default(); + let v05_span = from_v04_span(span, &mut dict).unwrap(); + assert_eq!(v05_span.meta.len(), 1); + assert!(meta_json(&dict, &v05_span, "appsec").is_none()); + assert!(meta_json(&dict, &v05_span, "meta_struct").is_none()); + } + + /// A link with no tracestate and no attributes serializes only hex `trace_id`/`span_id`; + /// `flags` is dropped. + #[test] + fn span_link_minimal_serialization_test() { + let links = vec![SpanLink:: { + trace_id: 0xdead_beef, + trace_id_high: 0, + span_id: 0xfeed, + attributes: HashMap::new(), + tracestate: BytesString::from(""), + flags: 7, + }]; + let json = serde_json::to_string(&SpanLinksSerializerV05::(&links)).unwrap(); + assert_eq!( + json, + "[{\"trace_id\":\"000000000000000000000000deadbeef\",\"span_id\":\"000000000000feed\",\"flags\":7}]" + ); + } + + /// Multiple links serialize as an ordered JSON array preserving input order. + #[test] + fn span_links_multiple_serialization_test() { + let links = vec![ + SpanLink:: { + span_id: 0x22, + ..Default::default() + }, + SpanLink:: { + span_id: 0x44, + ..Default::default() + }, + ]; + let json = serde_json::to_string(&SpanLinksSerializerV05::(&links)).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.as_array().unwrap().len(), 2); + assert_eq!(parsed[0]["span_id"], serde_json::json!("0000000000000022")); + assert_eq!(parsed[1]["span_id"], serde_json::json!("0000000000000044")); + } + + /// A link with tracestate but no attributes emits `tracestate`, omits `attributes`. + #[test] + fn span_link_only_tracestate_serialization_test() { + let links = vec![SpanLink:: { + span_id: 2, + tracestate: BytesString::from("ts"), + ..Default::default() + }]; + let json = serde_json::to_string(&SpanLinksSerializerV05::(&links)).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed[0]["tracestate"], serde_json::json!("ts")); + assert!(parsed[0].get("attributes").is_none()); + } + + /// A link with attributes but no tracestate emits `attributes`, omits `tracestate`. + #[test] + fn span_link_only_attributes_serialization_test() { + let links = vec![SpanLink:: { + span_id: 2, + attributes: HashMap::from([(BytesString::from("k"), BytesString::from("v"))]), + ..Default::default() + }]; + let json = serde_json::to_string(&SpanLinksSerializerV05::(&links)).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed[0]["attributes"]["k"], serde_json::json!("v")); + assert!(parsed[0].get("tracestate").is_none()); + } + + /// Event attributes of every scalar type render as natural JSON. + #[test] + fn span_event_attribute_types_serialization_test() { + let events = vec![SpanEvent:: { + time_unix_nano: 42, + name: BytesString::from("ev"), + attributes: HashMap::from([ + ( + BytesString::from("int_attr"), + AttributeAnyValue::SingleValue(AttributeArrayValue::Integer(-7)), + ), + ( + BytesString::from("dbl_attr"), + AttributeAnyValue::SingleValue(AttributeArrayValue::Double(2.5)), + ), + ( + BytesString::from("bool_attr"), + AttributeAnyValue::SingleValue(AttributeArrayValue::Boolean(true)), + ), + ]), + }]; + let json = serde_json::to_string(&SpanEventsSerializerV05::(&events)).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let attrs = &parsed[0]["attributes"]; + assert_eq!(attrs["int_attr"], serde_json::json!(-7)); + assert_eq!(attrs["dbl_attr"], serde_json::json!(2.5)); + assert_eq!(attrs["bool_attr"], serde_json::json!(true)); + assert_eq!(parsed[0]["time_unix_nano"], serde_json::json!(42)); + assert_eq!(parsed[0]["name"], serde_json::json!("ev")); + } + + /// Arrays of non-string scalars serialize as natural JSON arrays. + #[test] + fn span_event_non_string_array_serialization_test() { + let events = vec![SpanEvent:: { + time_unix_nano: 1, + name: BytesString::from("ev"), + attributes: HashMap::from([( + BytesString::from("arr"), + AttributeAnyValue::Array(vec![ + AttributeArrayValue::Integer(1), + AttributeArrayValue::Boolean(true), + AttributeArrayValue::Double(3.5), + ]), + )]), + }]; + let json = serde_json::to_string(&SpanEventsSerializerV05::(&events)).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed[0]["attributes"]["arr"], + serde_json::json!([1, true, 3.5]) + ); + } + + /// Event and link attributes are emitted with keys in sorted order (matching Go's + /// `encoding/json`, used by the agent) so the output is deterministic despite the + /// `HashMap` source. + #[test] + fn attributes_serialized_in_sorted_key_order_test() { + let events = vec![SpanEvent:: { + time_unix_nano: 1, + name: BytesString::from("ev"), + attributes: HashMap::from([ + ( + BytesString::from("zebra"), + AttributeAnyValue::SingleValue(AttributeArrayValue::Integer(1)), + ), + ( + BytesString::from("alpha"), + AttributeAnyValue::SingleValue(AttributeArrayValue::Integer(2)), + ), + ( + BytesString::from("mike"), + AttributeAnyValue::SingleValue(AttributeArrayValue::Integer(3)), + ), + ]), + }]; + let json = serde_json::to_string(&SpanEventsSerializerV05::(&events)).unwrap(); + assert_eq!( + json, + "[{\"time_unix_nano\":1,\"name\":\"ev\",\"attributes\":{\"alpha\":2,\"mike\":3,\"zebra\":1}}]" + ); + + let links = vec![SpanLink:: { + span_id: 1, + attributes: HashMap::from([ + (BytesString::from("zzz"), BytesString::from("1")), + (BytesString::from("aaa"), BytesString::from("2")), + ]), + ..Default::default() + }]; + let json = serde_json::to_string(&SpanLinksSerializerV05::(&links)).unwrap(); + assert!( + json.contains("\"attributes\":{\"aaa\":\"2\",\"zzz\":\"1\"}"), + "link attributes not sorted: {json}" + ); + } + + /// An event with an empty attributes map renders `"attributes":{}`. + #[test] + fn span_event_empty_attributes_serialization_test() { + let events = vec![SpanEvent:: { + time_unix_nano: 1, + name: BytesString::from("ev"), + attributes: HashMap::new(), + }]; + let json = serde_json::to_string(&SpanEventsSerializerV05::(&events)).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed[0]["attributes"], serde_json::json!({})); + } } diff --git a/libdd-trace-utils/src/tracer_payload.rs b/libdd-trace-utils/src/tracer_payload.rs index 035366fd80..bb4146bf04 100644 --- a/libdd-trace-utils/src/tracer_payload.rs +++ b/libdd-trace-utils/src/tracer_payload.rs @@ -1,7 +1,6 @@ // Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use crate::span::v05::dict::SharedDict; use crate::span::{v04, v05, BytesData, SharedDictBytes, TraceData}; use crate::trace_utils::collect_trace_chunks; use crate::{msgpack_decoder, trace_utils::cmp_send_data_payloads}; @@ -26,7 +25,11 @@ pub enum TraceChunks { /// Collection of TraceChunkSpan. V04(Vec>>), /// Collection of TraceChunkSpan with de-duplicated strings. - V05((SharedDict, Vec>)), + /// + /// The dictionary always owns its strings ([`SharedDictBytes`]) because the v0.5 + /// conversion interns dynamically-built JSON (span links / events) alongside the + /// (possibly borrowed) span text. + V05((SharedDictBytes, Vec>)), /// Collection of v0.4 spans to be serialized as a V1 msgpack payload. V1(Vec>>), }