diff --git a/libdd-trace-utils/src/agentless_encoder/mod.rs b/libdd-trace-utils/src/agentless_encoder/mod.rs index 5734b475f5..a70710387b 100644 --- a/libdd-trace-utils/src/agentless_encoder/mod.rs +++ b/libdd-trace-utils/src/agentless_encoder/mod.rs @@ -27,13 +27,16 @@ //! TODO: span normalization (service/name/resource/type truncation + defaults) use crate::span::v04::{AttributeAnyValue, AttributeArrayValue, Span, SpanEvent, SpanLink}; +use crate::span::v1; use crate::span::{TraceData, SPAN_LINK_FLAGS_SET_SENTINEL}; use crate::tracer_metadata::TracerMetadata; use serde::{ ser::{SerializeMap, SerializeSeq}, Serializer, }; -use std::borrow::Borrow; +use std::borrow::{Borrow, Cow}; +use std::collections::HashSet; +use std::fmt::Write as _; /// Maximum allowed size of a `meta` value before truncation. const MAX_META_VALUE_LEN: usize = 25_000; @@ -59,7 +62,7 @@ const TRUNCATION_SUFFIX: &str = "..."; /// Contrary to a closure, the names of the types have to be named in full /// ```ignore /// Optional generic| serializer| -/// parameter |. | Captured variables from env +/// parameter |. | Captured variables from env /// -------------- --- --------------------------------------------------------- /// ser_fn!( |ser, traces: &'a [Vec>], metadata: &'a TracerMetadata| { /// // Body of the closure @@ -431,6 +434,630 @@ fn serialize_scalar( } } +/// Reserved v0.4 `meta`/`metrics` key names written from dedicated typed fields (`env`, chunk +/// `origin`, ...) rather than from the v1 attribute map — the dedicated field always wins and +/// a colliding attribute is dropped. See +/// [`crate::msgpack_encoder::v04::span_v1::PROMOTED_ATTR_KEYS`] for the sibling list on the +/// msgpack side (this one omits `_dd.p.tid`, which is handled below with "seen" tracking +/// instead, matching this module's own `_dd.p.tid`/`_dd.span_links`/`events` convention). +const PROMOTED_ATTR_KEYS_V1: &[&str] = &[ + "env", + "version", + "component", + "span.kind", + "_dd.origin", + "_dd.p.dm", + "_sampling_priority_v1", +]; + +/// Maps a `SpanKind` to its v0.4 `span.kind` meta string. Returns `None` for `Internal` so +/// callers can skip emitting the default value. +fn span_kind_to_meta_v1(kind: v1::SpanKind) -> Option<&'static str> { + match kind { + v1::SpanKind::Internal => None, + v1::SpanKind::Server => Some("server"), + v1::SpanKind::Client => Some("client"), + v1::SpanKind::Producer => Some("producer"), + v1::SpanKind::Consumer => Some("consumer"), + } +} + +/// Drops entries whose key was already seen, keeping the first occurrence: two distinct +/// attributes can flatten to the same dotted key. +fn dedup_first_wins_v1<'a, V>(leaves: &mut Vec<(Cow<'a, str>, V)>) { + let keep: Vec = { + let mut seen: HashSet<&str> = HashSet::with_capacity(leaves.len()); + leaves + .iter() + .map(|(k, _)| seen.insert(k.as_ref())) + .collect() + }; + let mut keep = keep.into_iter(); + leaves.retain(|_| keep.next().unwrap_or(false)); +} + +/// Recursively flattens a `List`/`KeyValue` attribute into dotted-key leaf entries for `meta`, +/// `metrics`, and `meta_struct`. +/// +/// Only called for nested attributes, so the key is always freshly built and owned — unlike the +/// top-level scalar fast path in [`collect_attrs_v1`], which borrows straight from the source. +/// +/// `key` is a reused buffer: pushed to on the way down, truncated back on the way up, so it's +/// unchanged once the call returns. +/// +/// ## Examples +/// ```text +/// key="a", v=KeyValue{b: "v"} => meta_out += ("a.b", "v") +/// +/// key="list", v=List[String("x"), Int(2)] => meta_out += ("list.0", "x") +/// metrics_out += ("list.1", 2.0) +/// ``` +fn flatten_attr_into_v1<'a, T: TraceData>( + key: &mut String, + v: &'a v1::AttributeValue, + meta_out: &mut Vec<(Cow<'a, str>, Cow<'a, str>)>, + metrics_out: &mut Vec<(Cow<'a, str>, f64)>, + bytes_out: &mut Vec<(Cow<'a, str>, T::Bytes)>, +) { + match v { + v1::AttributeValue::String(s) => { + meta_out.push((Cow::Owned(key.clone()), Cow::Borrowed(s.borrow()))) + } + v1::AttributeValue::Bool(b) => meta_out.push(( + Cow::Owned(key.clone()), + Cow::Borrowed(if *b { "true" } else { "false" }), + )), + v1::AttributeValue::Int(i) => metrics_out.push((Cow::Owned(key.clone()), *i as f64)), + v1::AttributeValue::Float(f) => metrics_out.push((Cow::Owned(key.clone()), *f)), + v1::AttributeValue::Bytes(b) => bytes_out.push((Cow::Owned(key.clone()), b.clone())), + v1::AttributeValue::List(items) => { + let base_len = key.len(); + for (i, item) in items.iter().enumerate() { + key.push('.'); + let _ = write!(key, "{i}"); + flatten_attr_into_v1(key, item, meta_out, metrics_out, bytes_out); + key.truncate(base_len); + } + } + v1::AttributeValue::KeyValue(map) => { + let base_len = key.len(); + for (k, v) in map.defensive_dedup().iter() { + key.push('.'); + key.push_str(k.borrow()); + flatten_attr_into_v1(key, v, meta_out, metrics_out, bytes_out); + key.truncate(base_len); + } + } + } +} + +/// Leaves collected by [`collect_attrs_v1`]: `meta`, `metrics`, and `meta_struct` (`Bytes`) +/// entries. Keys/values borrow from the source span/chunk attributes wherever possible (the +/// common top-level scalar case); only entries produced by flattening a nested `List`/`KeyValue` +/// need an owned, freshly built dotted key. +struct CollectedAttrsV1<'a, T: TraceData> { + meta: Vec<(Cow<'a, str>, Cow<'a, str>)>, + metrics: Vec<(Cow<'a, str>, f64)>, + meta_struct: Vec<(Cow<'a, str>, T::Bytes)>, +} + +/// Merges a span's attributes with its chunk's (span overrides chunk on key collision), +/// drops attributes colliding with a [`PROMOTED_ATTR_KEYS_V1`] name, and splits the rest into +/// `meta` leaves, `metrics` leaves, and `meta_struct` (`Bytes`) leaves. +fn collect_attrs_v1<'a, T: TraceData>( + span: &'a v1::Span, + chunk: &'a v1::TraceChunk, +) -> CollectedAttrsV1<'a, T> { + let span_attrs_dd = span.attributes.defensive_dedup(); + let chunk_attrs_dd = chunk.attributes.defensive_dedup(); + let merged_attrs = span_attrs_dd + .iter() + .filter(|(k, _)| !PROMOTED_ATTR_KEYS_V1.contains(&(*k).borrow())) + .chain(chunk_attrs_dd.iter().filter(|(k, _)| { + !PROMOTED_ATTR_KEYS_V1.contains(&(*k).borrow()) + && !span_attrs_dd.iter().any(|(k2, _)| k2 == *k) + })); + + let mut meta_leaves: Vec<(Cow<'a, str>, Cow<'a, str>)> = Vec::new(); + let mut metrics_leaves: Vec<(Cow<'a, str>, f64)> = Vec::new(); + let mut bytes_leaves: Vec<(Cow<'a, str>, T::Bytes)> = Vec::new(); + let mut key_buf = String::new(); + for (k, v) in merged_attrs { + match v { + // Common case: a top-level scalar attribute maps 1:1 onto a leaf, so its key/value + // can be borrowed straight from the source attribute — no allocation. + v1::AttributeValue::String(s) => { + meta_leaves.push((Cow::Borrowed(k.borrow()), Cow::Borrowed(s.borrow()))) + } + v1::AttributeValue::Bool(b) => meta_leaves.push(( + Cow::Borrowed(k.borrow()), + Cow::Borrowed(if *b { "true" } else { "false" }), + )), + v1::AttributeValue::Int(i) => { + metrics_leaves.push((Cow::Borrowed(k.borrow()), *i as f64)) + } + v1::AttributeValue::Float(f) => metrics_leaves.push((Cow::Borrowed(k.borrow()), *f)), + v1::AttributeValue::Bytes(b) => { + bytes_leaves.push((Cow::Borrowed(k.borrow()), b.clone())) + } + // Nested case: the leaf key has to be built (`key.0`, `key.a.b`, ...), so it can no + // longer borrow the original attribute name alone. + v1::AttributeValue::List(_) | v1::AttributeValue::KeyValue(_) => { + key_buf.clear(); + key_buf.push_str(k.borrow()); + flatten_attr_into_v1( + &mut key_buf, + v, + &mut meta_leaves, + &mut metrics_leaves, + &mut bytes_leaves, + ); + } + } + } + // A nested attribute can flatten into a promoted name (e.g. `span = {kind: "client"}` -> + // `span.kind`) even though its unflattened top-level key wasn't caught by the filter above + // — drop those too, so the dedicated field always wins as documented. + meta_leaves.retain(|(k, _)| !PROMOTED_ATTR_KEYS_V1.contains(&k.as_ref())); + metrics_leaves.retain(|(k, _)| !PROMOTED_ATTR_KEYS_V1.contains(&k.as_ref())); + + dedup_first_wins_v1(&mut meta_leaves); + dedup_first_wins_v1(&mut metrics_leaves); + dedup_first_wins_v1(&mut bytes_leaves); + + CollectedAttrsV1 { + meta: meta_leaves, + metrics: metrics_leaves, + meta_struct: bytes_leaves, + } +} + +/// V1-native analog of [`encode_payload`]. Downgrades v1's unified attribute model back to the +/// same `meta`/`metrics`/`meta_struct`-shaped wire fields, so the emitted JSON body is +/// equivalent to what a v0.4 tracer would produce for the same trace — see +/// [`crate::msgpack_encoder::v04::span_v1`] for the mapping table this mirrors. Chunk-level +/// context (`trace_id`, `origin`, `priority`, `sampling_mechanism`, `dropped_trace`, chunk +/// attributes) is propagated into every span, matching the [`v1::TraceChunk`]-level granularity v1 +/// operates at. +pub fn encode_payload_from_v1( + chunks: &[v1::TraceChunk], + metadata: &TracerMetadata, +) -> Result, serde_json::Error> { + let mut bytes = Vec::new(); + let mut serializer = serde_json::Serializer::new(&mut bytes); + + let mut map_ser = serializer.serialize_map(Some(1))?; + map_ser.serialize_entry( + "traces", + &ser_fn!( |ser, chunks: &'a [v1::TraceChunk], metadata: &'a TracerMetadata| { + let mut traces_serializer = ser.serialize_seq(Some(chunks.len()))?; + for chunk in chunks { + traces_serializer.serialize_element(&ser_fn!( |ser, chunk: &'a v1::TraceChunk, metadata: &'a TracerMetadata| { + encode_trace_v1(ser, chunk, metadata) + }))?; + } + traces_serializer.end() + }), + )?; + SerializeMap::end(map_ser)?; + Ok(bytes) +} + +fn encode_trace_v1( + ser: S, + chunk: &v1::TraceChunk, + metadata: &TracerMetadata, +) -> Result { + let mut map = ser.serialize_map(None)?; + + map.serialize_entry("hostname", &metadata.hostname)?; + if !metadata.env.is_empty() { + map.serialize_entry("env", &metadata.env)?; + } + if !metadata.language.is_empty() { + map.serialize_entry("languageName", &metadata.language)?; + } + if !metadata.language_version.is_empty() { + map.serialize_entry("languageVersion", &metadata.language_version)?; + } + if !metadata.tracer_version.is_empty() { + map.serialize_entry("tracerVersion", &metadata.tracer_version)?; + } + if !metadata.runtime_id.is_empty() { + map.serialize_entry("runtimeID", &metadata.runtime_id)?; + } + if let Some(container_id) = libdd_common::entity_id::get_container_id() { + map.serialize_entry("containerID", container_id)?; + } + + map.serialize_entry( + "spans", + &ser_fn!( |ser, chunk: &'a v1::TraceChunk| { + let mut seq = ser.serialize_seq(Some(chunk.spans.len()))?; + for (i, span) in chunk.spans.iter().enumerate() { + let is_first = i == 0; + seq.serialize_element(&ser_fn!( |ser, chunk: &'a v1::TraceChunk, span: &'a v1::Span, is_first: bool| { + encode_span_v1(ser, chunk, span, is_first) + }))?; + } + seq.end() + }), + )?; + + map.end() +} + +fn encode_span_v1<'a, T: TraceData, S: Serializer>( + ser: S, + chunk: &'a v1::TraceChunk, + span: &'a v1::Span, + is_first_in_trace: bool, +) -> Result { + let mut map = ser.serialize_map(None)?; + + let mut trace_id_low_bytes = [0u8; 8]; + let mut trace_id_high_bytes = [0u8; 8]; + trace_id_low_bytes.copy_from_slice(&chunk.trace_id[8..16]); + trace_id_high_bytes.copy_from_slice(&chunk.trace_id[0..8]); + let trace_id_low = u64::from_be_bytes(trace_id_low_bytes); + let trace_id_high = u64::from_be_bytes(trace_id_high_bytes); + map.serialize_entry( + "trace_id", + &ser_fn!(|ser, trace_id_low: u64| { + ser.collect_str(&format_args!("{trace_id_low:016x}")) + }), + )?; + let span_id = span.span_id; + map.serialize_entry( + "span_id", + &ser_fn!(|ser, span_id: u64| { ser.collect_str(&format_args!("{span_id:016x}")) }), + )?; + let parent_id = span.parent_id; + map.serialize_entry( + "parent_id", + &ser_fn!(|ser, parent_id: u64| { ser.collect_str(&format_args!("{parent_id:016x}")) }), + )?; + + // Resource defaults to name when empty. + let name_str: &str = span.name.borrow(); + let resource_str: &str = span.resource.borrow(); + let service_str: &str = span.service.borrow(); + map.serialize_entry("name", name_str)?; + map.serialize_entry( + "resource", + if resource_str.is_empty() { + name_str + } else { + resource_str + }, + )?; + map.serialize_entry("service", service_str)?; + // v0.4's `error` is emitted as an integer (0/1) on the wire, unlike v1's own `bool` field. + map.serialize_entry("error", &(span.error as i32))?; + map.serialize_entry("start", &span.start.max(0))?; + map.serialize_entry("duration", &span.duration)?; + + let type_str: &str = span.r#type.borrow(); + if !type_str.is_empty() { + map.serialize_entry("type", type_str)?; + } + + let collected = collect_attrs_v1(span, chunk); + let meta_leaves = &collected.meta; + let metrics_leaves = &collected.metrics; + let bytes_attrs = &collected.meta_struct; + let priority = if chunk.dropped_trace { + // v0.4 has no wire-level equivalent of `dropped_trace`; force `USER_REJECT` (-1) + // unless the chunk already carries a negative (reject-like) priority — same + // convention as the msgpack downgrade encoder. + Some(chunk.priority.filter(|&p| p < 0).unwrap_or(-1)) + } else { + chunk.priority + }; + + map.serialize_entry( + "meta", + &ser_fn!( |ser, span: &'a v1::Span, chunk: &'a v1::TraceChunk, meta_leaves: &'a Vec<(Cow<'a, str>, Cow<'a, str>)>, is_first_in_trace: bool, trace_id_high: u64| { + let mut meta = ser.serialize_map(None)?; + + let env: &str = span.env.borrow(); + if !env.is_empty() { + meta.serialize_entry("env", env)?; + } + let version: &str = span.version.borrow(); + if !version.is_empty() { + meta.serialize_entry("version", version)?; + } + let component: &str = span.component.borrow(); + if !component.is_empty() { + meta.serialize_entry("component", component)?; + } + if let Some(kind) = span_kind_to_meta_v1(span.span_kind) { + meta.serialize_entry("span.kind", kind)?; + } + let origin: &str = chunk.origin.borrow(); + if !origin.is_empty() { + meta.serialize_entry("_dd.origin", origin)?; + } + if let Some(mechanism) = chunk.sampling_mechanism { + let mut buf = itoa::Buffer::new(); + meta.serialize_entry("_dd.p.dm", buf.format(-(mechanism as i64)))?; + } + + let mut p_tid_seen = false; + let mut span_links_seen = false; + let mut events_seen = false; + let mut compute_stats_seen = false; + for (key, val) in meta_leaves.iter() { + match key.as_ref() { + "_dd.p.tid" => p_tid_seen = true, + "_dd.span_links" => span_links_seen = true, + "events" => events_seen = true, + "_dd.compute_stats" => compute_stats_seen = true, + _ => {} + }; + meta.serialize_entry(key, val)?; + } + if !p_tid_seen && trace_id_high != 0 { + meta.serialize_entry( + "_dd.p.tid", + &ser_fn!(|ser, trace_id_high: u64| { + ser.collect_str(&format_args!("{trace_id_high:016x}")) + }), + )?; + } + if !span_links_seen && !span.span_links.is_empty() { + if let Some(s) = serialize_span_links_v1(&span.span_links) { + meta.serialize_entry("_dd.span_links", &s)?; + } + } + if !events_seen && !span.span_events.is_empty() { + if let Some(s) = serialize_span_events_v1(&span.span_events) { + meta.serialize_entry("events", &s)?; + } + } + if !compute_stats_seen && is_first_in_trace { + meta.serialize_entry("_dd.compute_stats", "1")?; + } + meta.end() + }), + )?; + + map.serialize_entry( + "metrics", + &ser_fn!( |ser, span: &'a v1::Span, metrics_leaves: &'a Vec<(Cow<'a, str>, f64)>, priority: Option| { + let mut metrics = ser.serialize_map(None)?; + let mut trace_root_seen = false; + for (key, val) in metrics_leaves.iter() { + // serde_json refuses to serialize NaN/Inf; drop them silently. + if !val.is_finite() { + continue; + } + match key.as_ref() { + "_trace_root" => trace_root_seen = true, + "_top_level" => { + metrics.serialize_entry(key, &(*val as u32))?; + continue; + } + _ => {} + } + metrics.serialize_entry(key, val)?; + } + if let Some(p) = priority { + metrics.serialize_entry("_sampling_priority_v1", &(p as f64))?; + } + if !trace_root_seen && span.parent_id == 0 { + metrics.serialize_entry("_trace_root", &1u32)?; + } + metrics.end() + }), + )?; + + if !bytes_attrs.is_empty() { + map.serialize_entry( + "meta_struct", + &ser_fn!( |ser, span: &'a v1::Span, bytes_attrs: &'a Vec<(Cow<'a, str>, T::Bytes)>| { + let _ = span; + let mut ms = ser.serialize_map(None)?; + for (k, v) in bytes_attrs.iter() { + let raw: &[u8] = v.borrow(); + ms.serialize_entry(k, &MsgpackAsJson(raw))?; + } + ms.end() + }), + )?; + } + map.end() +} + +/// Serialize v1 span links to a JSON string suitable for `meta['_dd.span_links']`. Same +/// truncation convention as [`serialize_span_links`]. +fn serialize_span_links_v1(links: &[v1::SpanLink]) -> Option { + let s = serde_json::to_string( + &ser_fn!( |ser, links: &'a [v1::SpanLink]| { + let mut seq = ser.serialize_seq(Some(links.len()))?; + for link in links { + seq.serialize_element(&ser_fn!( |ser, link: &'a v1::SpanLink| { + encode_span_link_v1(ser, link) + }))?; + } + seq.end() + }), + ) + .ok()?; + Some(truncate_with_ellipsis(s, MAX_META_VALUE_LEN)) +} + +fn encode_span_link_v1( + ser: S, + link: &v1::SpanLink, +) -> Result { + let mut map = ser.serialize_map(None)?; + let trace_id_128 = u128::from_be_bytes(link.trace_id); + map.serialize_entry("trace_id", &format!("{trace_id_128:032x}"))?; + map.serialize_entry("span_id", &format!("{:016x}", link.span_id))?; + let attrs_dd = link.attributes.defensive_dedup(); + let attrs_dd = &attrs_dd; + let has_attributes = attrs_dd.iter().any(|(_, v)| { + matches!( + v, + v1::AttributeValue::String(_) | v1::AttributeValue::Bool(_) + ) + }); + if has_attributes { + map.serialize_entry( + "attributes", + &ser_fn!( |ser, attrs_dd: &'a crate::span::vec_map::DedupedVecMap<'a, T::Text, v1::AttributeValue>| { + let mut attrs = ser.serialize_map(None)?; + for (k, v) in attrs_dd.iter() { + let key: &str = k.borrow(); + match v { + v1::AttributeValue::String(s) => attrs.serialize_entry(key, s.borrow() as &str)?, + v1::AttributeValue::Bool(b) => { + attrs.serialize_entry(key, if *b { "true" } else { "false" })? + } + _ => {} + } + } + attrs.end() + }), + )?; + } + // When `flags` is 0, no sampling decision exists, so omit the field. Mask off the internal + // "explicitly set" sentinel (bit 31) before emission, same as `encode_span_link`. + if link.flags != 0 { + map.serialize_entry( + "flags", + &((link.flags & !SPAN_LINK_FLAGS_SET_SENTINEL) as u64), + )?; + } + let tracestate: &str = link.tracestate.borrow(); + if !tracestate.is_empty() { + map.serialize_entry("tracestate", tracestate)?; + } + map.end() +} + +/// Serialize v1 span events to a JSON string suitable for `meta['events']`. Same truncation +/// convention as [`serialize_span_events`]. +fn serialize_span_events_v1(events: &[v1::SpanEvent]) -> Option { + let s = serde_json::to_string( + &ser_fn!( |ser, events: &'a [v1::SpanEvent]| { + let mut seq = ser.serialize_seq(Some(events.len()))?; + for event in events { + seq.serialize_element(&ser_fn!( |ser, event: &'a v1::SpanEvent| { + encode_span_event_v1(ser, event) + }))?; + } + seq.end() + }), + ) + .ok()?; + Some(truncate_with_ellipsis(s, MAX_META_VALUE_LEN)) +} + +/// Returns `true` when `v` can be downgraded to a v0.4 event-attribute (scalar or scalar list). +fn is_supported_event_attr_v1(v: &v1::AttributeValue) -> bool { + matches!( + v, + v1::AttributeValue::String(_) + | v1::AttributeValue::Bool(_) + | v1::AttributeValue::Int(_) + | v1::AttributeValue::Float(_) + | v1::AttributeValue::List(_) + ) +} + +/// Returns `true` when `v` is a scalar that fits in a v0.4 array element (no nesting). +fn is_scalar_array_elem_v1(v: &v1::AttributeValue) -> bool { + matches!( + v, + v1::AttributeValue::String(_) + | v1::AttributeValue::Bool(_) + | v1::AttributeValue::Int(_) + | v1::AttributeValue::Float(_) + ) +} + +fn encode_span_event_v1( + ser: S, + event: &v1::SpanEvent, +) -> Result { + let mut map = ser.serialize_map(None)?; + let name: &str = event.name.borrow(); + map.serialize_entry("name", name)?; + map.serialize_entry("time_unix_nano", &event.time_unix_nano)?; + let attrs_dd = event.attributes.defensive_dedup(); + let attrs_dd = &attrs_dd; + let has_attributes = attrs_dd.iter().any(|(_, v)| is_supported_event_attr_v1(v)); + if has_attributes { + map.serialize_entry( + "attributes", + &ser_fn!( |ser, attrs_dd: &'a crate::span::vec_map::DedupedVecMap<'a, T::Text, v1::AttributeValue>| { + let mut attrs = ser.serialize_map(None)?; + for (k, v) in attrs_dd.iter().filter(|(_, v)| is_supported_event_attr_v1(v)) { + let key: &str = k.borrow(); + attrs.serialize_entry(key, &ser_fn!( |ser, v: &'a v1::AttributeValue| { + encode_event_attr_value_v1(ser, v) + }))?; + } + attrs.end() + }), + )?; + } + map.end() +} + +/// Serializes a v1 event attribute value as a plain JSON value — same shape as +/// [`serialize_scalar`] on the v04-native path, which is what the agentless intake expects. +/// `List` produces a plain JSON array, filtering out non-scalar entries (no equivalent for them). +fn encode_event_attr_value_v1( + ser: S, + v: &v1::AttributeValue, +) -> Result { + match v { + v1::AttributeValue::List(items) => { + let scalars: Vec<_> = items + .iter() + .filter(|e| is_scalar_array_elem_v1(e)) + .collect(); + let mut seq = ser.serialize_seq(Some(scalars.len()))?; + for elem in scalars { + seq.serialize_element( + &ser_fn!( |ser, elem: &'a v1::AttributeValue| { + encode_event_scalar_v1(ser, elem) + }), + )?; + } + seq.end() + } + other => encode_event_scalar_v1(ser, other), + } +} + +fn encode_event_scalar_v1( + ser: S, + v: &v1::AttributeValue, +) -> Result { + match v { + v1::AttributeValue::String(s) => { + let s: &str = s.borrow(); + ser.serialize_str(s) + } + v1::AttributeValue::Bool(b) => ser.serialize_bool(*b), + v1::AttributeValue::Int(i) => ser.serialize_i64(*i), + v1::AttributeValue::Float(f) => { + if f.is_finite() { + ser.serialize_f64(*f) + } else { + // NaN/Inf become JSON null, matching `serialize_scalar` on the v04-native path. + ser.serialize_unit() + } + } + _ => unreachable!("filtered by is_scalar_array_elem_v1"), + } +} + /// `serde::Serialize` adapter that interprets `bytes` as a self-describing /// msgpack value and transcodes it into the destination serializer. /// @@ -464,3 +1091,5 @@ fn truncate_with_ellipsis(mut s: String, max_len: usize) -> String { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_v1; diff --git a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs new file mode 100644 index 0000000000..ec6e0284c2 --- /dev/null +++ b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs @@ -0,0 +1,722 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use super::encode_payload_from_v1; +use crate::span::v1::{ + AttributeValue, AttributeValueBytes, SpanBytes, SpanEventBytes, SpanKind, SpanLinkBytes, + TraceChunkBytes, +}; +use crate::span::vec_map::VecMap; +use crate::tracer_metadata::TracerMetadata; +use libdd_tinybytes::BytesString; +use serde_json::Value; + +fn bs(s: &str) -> BytesString { + BytesString::from_slice(s.as_bytes()).expect("test string must fit in BytesString") +} + +// Demonstrates that serde_json ignores the `len` hint passed to `serialize_map` (other than the +// `Some(0)` special case): the bytes produced are identical whether the hint is `None` or the +// exact entry count, so precomputing it buys nothing for this serializer. +#[test] +fn serde_json_serialize_map_len_hint_does_not_affect_output() { + use serde::ser::{SerializeMap, Serializer}; + + let entries = [("a", 1), ("b", 2), ("c", 3)]; + + let mut buf_none = Vec::new(); + let mut ser = serde_json::Serializer::new(&mut buf_none); + let mut map = ser.serialize_map(None).expect("serialize_map(None)"); + for (k, v) in &entries { + map.serialize_entry(k, v).expect("serialize_entry"); + } + map.end().expect("end"); + + let mut buf_some = Vec::new(); + let mut ser = serde_json::Serializer::new(&mut buf_some); + let mut map = ser + .serialize_map(Some(entries.len())) + .expect("serialize_map(Some(n))"); + for (k, v) in &entries { + map.serialize_entry(k, v).expect("serialize_entry"); + } + map.end().expect("end"); + + assert_eq!(buf_none, buf_some); +} + +fn base_metadata() -> TracerMetadata { + TracerMetadata { + hostname: "host-1".to_string(), + env: "prod".to_string(), + runtime_id: "rt-1".to_string(), + service: "svc".to_string(), + tracer_version: "1.2.3".to_string(), + language: "nodejs".to_string(), + language_version: "v20.11.0".to_string(), + ..Default::default() + } +} + +fn json_from_bytes(b: &[u8]) -> Value { + serde_json::from_slice(b).expect("payload must be valid JSON") +} + +fn minimal_chunk(trace_id: [u8; 16], span: SpanBytes) -> TraceChunkBytes { + TraceChunkBytes { + trace_id, + spans: vec![span], + ..Default::default() + } +} + +fn minimal_span() -> SpanBytes { + SpanBytes { + service: bs("svc"), + name: bs("op"), + resource: bs("res"), + span_id: 1, + start: 1_000, + duration: 500, + ..Default::default() + } +} + +fn encode_first_span(chunks: &[TraceChunkBytes]) -> Value { + let bytes = encode_payload_from_v1(chunks, &base_metadata()).expect("encode ok"); + let v = json_from_bytes(&bytes); + v["traces"][0]["spans"][0].clone() +} + +#[cfg_attr(miri, ignore)] // serde_json overhead is prohibitively slow under Miri +#[test] +fn top_level_payload_shape_and_metadata() { + let chunk = minimal_chunk([0u8; 16], minimal_span()); + let bytes = encode_payload_from_v1(&[chunk], &base_metadata()).unwrap(); + let v = json_from_bytes(&bytes); + + assert!(v.is_object()); + let traces = v.get("traces").unwrap().as_array().unwrap(); + assert_eq!(traces.len(), 1); + + let t = &traces[0]; + assert_eq!(t["hostname"], "host-1"); + assert_eq!(t["env"], "prod"); + assert_eq!(t["languageName"], "nodejs"); + assert_eq!(t["languageVersion"], "v20.11.0"); + assert_eq!(t["tracerVersion"], "1.2.3"); + assert_eq!(t["runtimeID"], "rt-1"); + + let spans = t["spans"].as_array().unwrap(); + assert_eq!(spans.len(), 1); + let s = &spans[0]; + assert_eq!(s["trace_id"], "0000000000000000"); + assert_eq!(s["span_id"], "0000000000000001"); + assert_eq!(s["parent_id"], "0000000000000000"); + assert_eq!(s["name"], "op"); + assert_eq!(s["resource"], "res"); + assert_eq!(s["service"], "svc"); + assert_eq!(s["error"], 0); + assert_eq!(s["start"], 1_000); + assert_eq!(s["duration"], 500); + + // Root span (no parent) gets `_trace_root`; first span of the chunk gets `_dd.compute_stats`. + assert_eq!(s["metrics"]["_trace_root"], 1); + assert_eq!(s["meta"]["_dd.compute_stats"], "1"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn resource_defaults_to_name_when_empty() { + let span = SpanBytes { + service: bs("svc"), + name: bs("op"), + // resource omitted (default empty) + span_id: 1, + start: 0, + duration: 1, + ..Default::default() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["resource"], "op"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn keeps_existing_dd_p_tid_in_meta() { + // When the tracer already supplies `_dd.p.tid`, the encoder must pass it through unchanged + // and must NOT auto-inject a second value. + let mut attrs: VecMap = VecMap::new(); + attrs.insert( + bs("_dd.p.tid"), + AttributeValue::String(bs("5b8efff798038103")), + ); + attrs.insert(bs("some.tag"), AttributeValue::String(bs("kept"))); + let mut tid = [0u8; 16]; + tid[8..].copy_from_slice(&0x1234_5678_9abc_def0_u64.to_be_bytes()); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk(tid, span)]); + assert_eq!(out["trace_id"], "123456789abcdef0"); + assert_eq!(out["meta"]["_dd.p.tid"], "5b8efff798038103"); + assert_eq!(out["meta"]["some.tag"], "kept"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn p_tid_is_auto_injected_from_trace_id_high_bits_when_absent() { + let mut tid = [0u8; 16]; + tid[..8].copy_from_slice(&0xDEAD_BEEF_CAFE_BABE_u64.to_be_bytes()); + tid[8..].copy_from_slice(&0x0123_4567_89AB_CDEF_u64.to_be_bytes()); + let out = encode_first_span(&[minimal_chunk(tid, minimal_span())]); + assert_eq!(out["trace_id"], "0123456789abcdef"); + assert_eq!(out["meta"]["_dd.p.tid"], "deadbeefcafebabe"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn promoted_fields_are_copied_into_meta() { + let span = SpanBytes { + env: bs("prod"), + version: bs("1.2.3"), + component: bs("http"), + span_kind: SpanKind::Server, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["meta"]["env"], "prod"); + assert_eq!(out["meta"]["version"], "1.2.3"); + assert_eq!(out["meta"]["component"], "http"); + assert_eq!(out["meta"]["span.kind"], "server"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_kind_internal_is_not_emitted() { + let out = encode_first_span(&[minimal_chunk([0u8; 16], minimal_span())]); + assert!(out["meta"].get("span.kind").is_none()); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn attribute_sharing_a_promoted_key_name_is_dropped_in_favor_of_the_dedicated_field() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("env"), AttributeValue::String(bs("staging"))); + attrs.insert(bs("http.method"), AttributeValue::String(bs("GET"))); + let span = SpanBytes { + env: bs("prod"), + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["meta"]["env"], "prod"); + assert_eq!(out["meta"]["http.method"], "GET"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_attributes_override_chunk_attributes_of_the_same_key() { + let mut chunk_attrs: VecMap = VecMap::new(); + chunk_attrs.insert(bs("k"), AttributeValue::String(bs("from-chunk"))); + let mut span_attrs: VecMap = VecMap::new(); + span_attrs.insert(bs("k"), AttributeValue::String(bs("from-span"))); + + let chunk = TraceChunkBytes { + attributes: chunk_attrs, + ..minimal_chunk( + [0u8; 16], + SpanBytes { + attributes: span_attrs, + ..minimal_span() + }, + ) + }; + let out = encode_first_span(&[chunk]); + assert_eq!(out["meta"]["k"], "from-span"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn chunk_attributes_propagate_to_every_span() { + let mut chunk_attrs: VecMap = VecMap::new(); + chunk_attrs.insert(bs("shared"), AttributeValue::String(bs("chunk-value"))); + let chunk = TraceChunkBytes { + attributes: chunk_attrs, + spans: vec![ + SpanBytes { + span_id: 1, + ..minimal_span() + }, + SpanBytes { + span_id: 2, + ..minimal_span() + }, + ], + ..Default::default() + }; + let bytes = encode_payload_from_v1(&[chunk], &base_metadata()).unwrap(); + let v = json_from_bytes(&bytes); + let spans = v["traces"][0]["spans"].as_array().unwrap(); + assert_eq!(spans.len(), 2); + assert_eq!(spans[0]["meta"]["shared"], "chunk-value"); + assert_eq!(spans[1]["meta"]["shared"], "chunk-value"); + // Only the first span in the chunk gets _dd.compute_stats. + assert_eq!(spans[0]["meta"]["_dd.compute_stats"], "1"); + assert!(spans[1]["meta"].get("_dd.compute_stats").is_none()); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn trace_id_only_carries_low_64_bits() { + let mut tid = [0u8; 16]; + tid[..8].copy_from_slice(&0xDEAD_BEEF_CAFE_BABE_u64.to_be_bytes()); + tid[8..].copy_from_slice(&0x0123_4567_89AB_CDEF_u64.to_be_bytes()); + let out = encode_first_span(&[minimal_chunk(tid, minimal_span())]); + assert_eq!(out["trace_id"], "0123456789abcdef"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn error_true_emits_one_false_emits_zero() { + let err_span = SpanBytes { + error: true, + ..minimal_span() + }; + assert_eq!( + encode_first_span(&[minimal_chunk([0u8; 16], err_span)])["error"], + 1 + ); + assert_eq!( + encode_first_span(&[minimal_chunk([0u8; 16], minimal_span())])["error"], + 0 + ); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn string_attribute_is_routed_to_meta() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("http.method"), AttributeValue::String(bs("GET"))); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["meta"]["http.method"], "GET"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn bool_attribute_is_stringified_in_meta() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("retry"), AttributeValue::Bool(true)); + attrs.insert(bs("cached"), AttributeValue::Bool(false)); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["meta"]["retry"], "true"); + assert_eq!(out["meta"]["cached"], "false"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn float_and_int_attributes_route_to_metrics_as_f64() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("duration_ms"), AttributeValue::Float(12.5)); + attrs.insert(bs("status"), AttributeValue::Int(200)); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["metrics"]["duration_ms"], 12.5); + assert_eq!(out["metrics"]["status"], 200.0); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn top_level_metric_is_serialized_as_integer_not_float() { + // `_top_level` must render as `1`, not `1.0`, on the wire. + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("_top_level"), AttributeValue::Int(1)); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let bytes = encode_payload_from_v1(&[minimal_chunk([0u8; 16], span)], &base_metadata()) + .expect("encode ok"); + let text = String::from_utf8(bytes).expect("utf8"); + assert!( + text.contains("\"_top_level\":1") || text.contains("\"_top_level\": 1"), + "expected integer rendering of _top_level, got: {text}" + ); + assert!(!text.contains("\"_top_level\":1.0")); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn bytes_attribute_is_transcoded_into_meta_struct() { + #[derive(serde::Serialize)] + struct AppSec<'a> { + rule_id: &'a str, + } + let payload = rmp_serde::to_vec_named(&AppSec { + rule_id: "crs-913-110", + }) + .unwrap(); + + let mut attrs: VecMap = VecMap::new(); + attrs.insert( + bs("_dd.appsec.json"), + AttributeValue::Bytes(libdd_tinybytes::Bytes::from(payload)), + ); + attrs.insert(bs("kept"), AttributeValue::String(bs("yes"))); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + // `Bytes` attributes are routed to `meta_struct`, not `meta`. + assert!(out["meta"].get("_dd.appsec.json").is_none()); + assert_eq!(out["meta"]["kept"], "yes"); + let ms = out["meta_struct"] + .as_object() + .expect("meta_struct must be present and a JSON object"); + assert_eq!(ms["_dd.appsec.json"]["rule_id"], "crs-913-110"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn meta_struct_field_omitted_when_no_bytes_attributes() { + let out = encode_first_span(&[minimal_chunk([0u8; 16], minimal_span())]); + assert!(out.get("meta_struct").is_none()); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn nested_bytes_attribute_in_list_is_routed_to_meta_struct() { + let payload = rmp_serde::to_vec(&42u32).unwrap(); + + let mut attrs: VecMap = VecMap::new(); + attrs.insert( + bs("items"), + AttributeValue::List(vec![ + AttributeValue::String(bs("first")), + AttributeValue::Bytes(libdd_tinybytes::Bytes::from(payload)), + ]), + ); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + + assert_eq!(out["meta"]["items.0"], "first"); + // Previously silently dropped: a `Bytes` value nested inside a `List` has no flattened + // string/numeric form, so it must be routed to `meta_struct` like a top-level `Bytes` would. + let ms = out["meta_struct"] + .as_object() + .expect("meta_struct must be present and a JSON object"); + assert_eq!(ms["items.1"], 42); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn non_finite_metric_attributes_are_dropped() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("nan_metric"), AttributeValue::Float(f64::NAN)); + attrs.insert(bs("inf_metric"), AttributeValue::Float(f64::INFINITY)); + attrs.insert( + bs("neg_inf_metric"), + AttributeValue::Float(f64::NEG_INFINITY), + ); + attrs.insert(bs("finite_metric"), AttributeValue::Float(1.5)); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + + // serde_json can't represent NaN/Inf; encoding must not error and these keys must be absent + // rather than serialized as `null` or aborting the whole payload. + let metrics = out["metrics"] + .as_object() + .expect("metrics must be present and a JSON object"); + assert!(!metrics.contains_key("nan_metric")); + assert!(!metrics.contains_key("inf_metric")); + assert!(!metrics.contains_key("neg_inf_metric")); + assert_eq!(out["metrics"]["finite_metric"], 1.5); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn nested_key_value_attribute_is_flattened_with_dotted_keys() { + let mut inner: VecMap = VecMap::new(); + inner.insert(bs("b"), AttributeValue::String(bs("v"))); + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("a"), AttributeValue::KeyValue(inner)); + let span = SpanBytes { + attributes: attrs, + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["meta"]["a.b"], "v"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn nested_attribute_flattening_to_a_promoted_key_does_not_override_dedicated_field() { + let mut inner: VecMap = VecMap::new(); + inner.insert( + bs("origin"), + AttributeValue::String(bs("attacker-controlled")), + ); + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("_dd"), AttributeValue::KeyValue(inner)); + let chunk = TraceChunkBytes { + origin: bs("rum"), + attributes: attrs, + ..minimal_chunk([0u8; 16], minimal_span()) + }; + let out = encode_first_span(&[chunk]); + // The dedicated `chunk.origin` field must win; the flattened `_dd.origin` leaf (which + // collides only after flattening, not on its unflattened top-level key `_dd`) must be + // dropped rather than emitted as a second `_dd.origin` entry. + assert_eq!(out["meta"]["_dd.origin"], "rum"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn chunk_origin_priority_and_sampling_mechanism_are_mapped() { + let chunk = TraceChunkBytes { + origin: bs("rum"), + priority: Some(1), + sampling_mechanism: Some(3), + ..minimal_chunk([0u8; 16], minimal_span()) + }; + let out = encode_first_span(&[chunk]); + assert_eq!(out["meta"]["_dd.origin"], "rum"); + assert_eq!(out["meta"]["_dd.p.dm"], "-3"); + assert_eq!(out["metrics"]["_sampling_priority_v1"], 1.0); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn dropped_trace_forces_user_reject_priority() { + let chunk = TraceChunkBytes { + dropped_trace: true, + priority: Some(1), + ..minimal_chunk([0u8; 16], minimal_span()) + }; + let out = encode_first_span(&[chunk]); + assert_eq!(out["metrics"]["_sampling_priority_v1"], -1.0); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn dropped_trace_keeps_existing_negative_priority() { + let chunk = TraceChunkBytes { + dropped_trace: true, + priority: Some(-2), + ..minimal_chunk([0u8; 16], minimal_span()) + }; + let out = encode_first_span(&[chunk]); + assert_eq!(out["metrics"]["_sampling_priority_v1"], -2.0); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_links_serialised_into_meta_as_json_string() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("link.name"), AttributeValue::String(bs("scheduled_by"))); + let mut tid = [0u8; 16]; + tid[..8].copy_from_slice(&0x0011_2233_4455_6677_u64.to_be_bytes()); + tid[8..].copy_from_slice(&0x9abc_def0_1234_5678_u64.to_be_bytes()); + let span = SpanBytes { + span_links: thin_vec::thin_vec![SpanLinkBytes { + trace_id: tid, + span_id: 0xfeed_face_dead_beef, + attributes: attrs, + flags: 1, + tracestate: bs("dd=s:1"), + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert!(out.get("span_links").is_none()); + let raw = out["meta"]["_dd.span_links"] + .as_str() + .expect("meta[_dd.span_links] must be a string"); + let links: Value = serde_json::from_str(raw).expect("must be valid JSON"); + let link_obj = &links[0]; + assert_eq!(link_obj["trace_id"], "00112233445566779abcdef012345678"); + assert_eq!(link_obj["span_id"], "feedfacedeadbeef"); + assert_eq!(link_obj["attributes"]["link.name"], "scheduled_by"); + assert_eq!(link_obj["flags"], 1); + assert_eq!(link_obj["tracestate"], "dd=s:1"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_link_attributes_are_filtered_to_string_and_bool() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("kept"), AttributeValue::String(bs("v"))); + attrs.insert(bs("kept_bool"), AttributeValue::Bool(true)); + attrs.insert(bs("dropped"), AttributeValue::Int(1)); + let span = SpanBytes { + span_links: thin_vec::thin_vec![SpanLinkBytes { + trace_id: [0u8; 16], + span_id: 7, + attributes: attrs, + ..Default::default() + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + let raw = out["meta"]["_dd.span_links"].as_str().unwrap(); + let links: Value = serde_json::from_str(raw).unwrap(); + let link = &links[0]; + assert_eq!(link["attributes"]["kept"], "v"); + assert_eq!(link["attributes"]["kept_bool"], "true"); + assert!(link["attributes"].get("dropped").is_none()); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_link_flags_sentinel_bit_masked() { + let span = SpanBytes { + span_links: thin_vec::thin_vec![SpanLinkBytes { + trace_id: [0u8; 16], + span_id: 7, + flags: crate::span::SPAN_LINK_FLAGS_SET_SENTINEL | 0b1, + ..Default::default() + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + let raw = out["meta"]["_dd.span_links"].as_str().unwrap(); + let links: Value = serde_json::from_str(raw).unwrap(); + assert_eq!(links[0]["flags"], 1); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn existing_span_links_meta_is_kept_and_not_overwritten() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert( + bs("_dd.span_links"), + AttributeValue::String(bs("[{\"already\":\"there\"}]")), + ); + let span = SpanBytes { + attributes: attrs, + span_links: thin_vec::thin_vec![SpanLinkBytes { + trace_id: [0u8; 16], + span_id: 1, + ..Default::default() + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert_eq!(out["meta"]["_dd.span_links"], "[{\"already\":\"there\"}]"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_events_serialised_into_meta_as_json_string() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert( + bs("exception.message"), + AttributeValue::String(bs("timeout")), + ); + let span = SpanBytes { + span_events: thin_vec::thin_vec![SpanEventBytes { + time_unix_nano: 1_700_000_000_000_000_000, + name: bs("exception"), + attributes: attrs, + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + assert!(out.get("span_events").is_none()); + let raw = out["meta"]["events"] + .as_str() + .expect("meta[events] must be a string"); + let events: Value = serde_json::from_str(raw).expect("must be valid JSON"); + let evt = &events[0]; + assert_eq!(evt["name"], "exception"); + assert_eq!(evt["time_unix_nano"], 1_700_000_000_000_000_000_u64); + assert_eq!(evt["attributes"]["exception.message"], "timeout"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_event_list_attribute_becomes_json_array_of_scalars() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert( + bs("list"), + AttributeValue::List(vec![ + AttributeValue::String(bs("a")), + AttributeValue::Int(2), + ]), + ); + let span = SpanBytes { + span_events: thin_vec::thin_vec![SpanEventBytes { + time_unix_nano: 1, + name: bs("evt"), + attributes: attrs, + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + let raw = out["meta"]["events"].as_str().unwrap(); + let events: Value = serde_json::from_str(raw).unwrap(); + let value = &events[0]["attributes"]["list"]; + assert_eq!(value, &serde_json::json!(["a", 2])); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_event_bytes_attribute_is_dropped() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert( + bs("blob"), + AttributeValue::Bytes(libdd_tinybytes::Bytes::copy_from_slice(b"\xde\xad")), + ); + let span = SpanBytes { + span_events: thin_vec::thin_vec![SpanEventBytes { + time_unix_nano: 1, + name: bs("evt"), + attributes: attrs, + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + let raw = out["meta"]["events"].as_str().unwrap(); + let events: Value = serde_json::from_str(raw).unwrap(); + assert!(events[0].get("attributes").is_none()); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_event_non_finite_float_attribute_becomes_null() { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("nan"), AttributeValue::Float(f64::NAN)); + attrs.insert(bs("finite"), AttributeValue::Float(1.5)); + let span = SpanBytes { + span_events: thin_vec::thin_vec![SpanEventBytes { + time_unix_nano: 1, + name: bs("evt"), + attributes: attrs, + }], + ..minimal_span() + }; + let out = encode_first_span(&[minimal_chunk([0u8; 16], span)]); + let raw = out["meta"]["events"].as_str().unwrap(); + let events: Value = serde_json::from_str(raw).unwrap(); + assert_eq!(events[0]["attributes"]["nan"], serde_json::Value::Null); + assert_eq!(events[0]["attributes"]["finite"], 1.5); +}