From 5a921de02e7d2bf64dfbd2f07feedc270d72d986 Mon Sep 17 00:00:00 2001 From: Anais Raison Date: Wed, 19 Aug 2026 15:29:05 +0200 Subject: [PATCH 1/6] feat(trace-utils): add v1-native agentless JSON encoder brick Isolated brick for APMSP-2812: adds encode_payload_from_v1 and its v1::Span-native helpers (collect_attrs_v1, flatten_attr_into_v1, encode_span_link_v1, encode_span_event_v1) alongside the existing v0.4 agentless encoder. Not wired into any live send path yet. --- .../src/agentless_encoder/mod.rs | 589 +++++++++++++++++ .../src/agentless_encoder/tests_v1.rs | 603 ++++++++++++++++++ 2 files changed, 1192 insertions(+) create mode 100644 libdd-trace-utils/src/agentless_encoder/tests_v1.rs diff --git a/libdd-trace-utils/src/agentless_encoder/mod.rs b/libdd-trace-utils/src/agentless_encoder/mod.rs index 1054b15920..be79062801 100644 --- a/libdd-trace-utils/src/agentless_encoder/mod.rs +++ b/libdd-trace-utils/src/agentless_encoder/mod.rs @@ -27,6 +27,10 @@ //! TODO: span normalization (service/name/resource/type truncation + defaults) use crate::span::v04::{AttributeAnyValue, AttributeArrayValue, Span, SpanEvent, SpanLink}; +use crate::span::v1::{ + AttributeValue as AttributeValueV1, Span as SpanV1, SpanEvent as SpanEventV1, SpanKind, + SpanLink as SpanLinkV1, TraceChunk, +}; use crate::span::{TraceData, SPAN_LINK_FLAGS_SET_SENTINEL}; use crate::tracer_metadata::TracerMetadata; use serde::{ @@ -34,6 +38,8 @@ use serde::{ Serializer, }; use std::borrow::Borrow; +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; @@ -431,6 +437,587 @@ 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: SpanKind) -> Option<&'static str> { + match kind { + SpanKind::Internal => None, + SpanKind::Server => Some("server"), + SpanKind::Client => Some("client"), + SpanKind::Producer => Some("producer"), + 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(mut leaves: Vec<(String, V)>) -> Vec<(String, V)> { + let keep: Vec = { + let mut seen: HashSet<&str> = HashSet::with_capacity(leaves.len()); + leaves + .iter() + .map(|(k, _)| seen.insert(k.as_str())) + .collect() + }; + let mut keep = keep.into_iter(); + leaves.retain(|_| keep.next().unwrap_or(false)); + leaves +} + +/// Recursively flattens a `List`/`KeyValue` attribute into dotted-key leaf entries for the +/// `meta` (string-valued) and `metrics` (numeric) buckets. `Bytes` has no flattened form and is +/// routed to `meta_struct` by the caller before recursing. +fn flatten_attr_into_v1( + key: &mut String, + v: &AttributeValueV1, + meta_out: &mut Vec<(String, String)>, + metrics_out: &mut Vec<(String, f64)>, +) { + match v { + AttributeValueV1::String(s) => meta_out.push((key.clone(), s.borrow().to_owned())), + AttributeValueV1::Bool(b) => { + meta_out.push((key.clone(), if *b { "true" } else { "false" }.to_owned())) + } + AttributeValueV1::Int(i) => metrics_out.push((key.clone(), *i as f64)), + AttributeValueV1::Float(f) => metrics_out.push((key.clone(), *f)), + AttributeValueV1::Bytes(_) => { + // Callers filter `Bytes` out before recursing; unreachable in practice. + } + AttributeValueV1::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); + key.truncate(base_len); + } + } + AttributeValueV1::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); + key.truncate(base_len); + } + } + } +} + +/// Leaves collected by [`collect_attrs_v1`]: `meta` entries, `metrics` entries, and raw +/// `meta_struct`-bound `Bytes` entries. +type CollectedAttrsV1<'a, T> = ( + Vec<(String, String)>, + Vec<(String, f64)>, + Vec<(&'a ::Text, &'a ::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 raw `meta_struct`-bound `Bytes` entries. +fn collect_attrs_v1<'a, T: TraceData>( + span: &'a SpanV1, + chunk: &'a 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<(String, String)> = Vec::new(); + let mut metrics_leaves: Vec<(String, f64)> = Vec::new(); + let mut bytes_attrs: Vec<(&T::Text, &T::Bytes)> = Vec::new(); + let mut key_buf = String::new(); + for (k, v) in merged_attrs { + match v { + AttributeValueV1::Bytes(b) => bytes_attrs.push((k, b)), + _ => { + key_buf.clear(); + key_buf.push_str(k.borrow()); + flatten_attr_into_v1(&mut key_buf, v, &mut meta_leaves, &mut metrics_leaves); + } + } + } + ( + dedup_first_wins_v1(meta_leaves), + dedup_first_wins_v1(metrics_leaves), + bytes_attrs, + ) +} + +/// 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 [`TraceChunk`]-level granularity v1 +/// operates at. +pub fn encode_payload_from_v1( + chunks: &[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 [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 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: &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 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 TraceChunk, span: &'a SpanV1, is_first: bool| { + encode_span_v1(ser, chunk, span, is_first) + }))?; + } + seq.end() + }), + )?; + + map.end() +} + +fn encode_span_v1( + ser: S, + chunk: &TraceChunk, + span: &SpanV1, + 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 (meta_leaves, metrics_leaves, bytes_attrs) = collect_attrs_v1(span, chunk); + let meta_leaves = &meta_leaves; + let metrics_leaves = &metrics_leaves; + let bytes_attrs = &bytes_attrs; + 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 SpanV1, chunk: &'a TraceChunk, meta_leaves: &'a Vec<(String, String)>, 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_str() { + "_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 SpanV1, metrics_leaves: &'a Vec<(String, f64)>, priority: Option| { + let mut metrics = ser.serialize_map(None)?; + let mut trace_root_seen = false; + for (key, val) in metrics_leaves.iter() { + match key.as_str() { + "_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 SpanV1, bytes_attrs: &'a Vec<(&'a T::Text, &'a T::Bytes)>| { + let _ = span; + let mut ms = ser.serialize_map(None)?; + for (k, v) in bytes_attrs.iter() { + let key: &str = (*k).borrow(); + let raw: &[u8] = (*v).borrow(); + ms.serialize_entry(key, &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: &[SpanLinkV1]) -> Option { + let s = serde_json::to_string(&ser_fn!( |ser, links: &'a [SpanLinkV1]| { + let mut seq = ser.serialize_seq(Some(links.len()))?; + for link in links { + seq.serialize_element(&ser_fn!( |ser, link: &'a SpanLinkV1| { + 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: &SpanLinkV1, +) -> 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, AttributeValueV1::String(_) | AttributeValueV1::Bool(_))); + if has_attributes { + map.serialize_entry( + "attributes", + &ser_fn!( |ser, attrs_dd: &'a crate::span::vec_map::DedupedVecMap<'a, T::Text, AttributeValueV1>| { + let mut attrs = ser.serialize_map(None)?; + for (k, v) in attrs_dd.iter() { + let key: &str = k.borrow(); + match v { + AttributeValueV1::String(s) => attrs.serialize_entry(key, s.borrow() as &str)?, + AttributeValueV1::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: &[SpanEventV1]) -> Option { + let s = serde_json::to_string( + &ser_fn!( |ser, events: &'a [SpanEventV1]| { + let mut seq = ser.serialize_seq(Some(events.len()))?; + for event in events { + seq.serialize_element(&ser_fn!( |ser, event: &'a SpanEventV1| { + 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: &AttributeValueV1) -> bool { + matches!( + v, + AttributeValueV1::String(_) + | AttributeValueV1::Bool(_) + | AttributeValueV1::Int(_) + | AttributeValueV1::Float(_) + | AttributeValueV1::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: &AttributeValueV1) -> bool { + matches!( + v, + AttributeValueV1::String(_) + | AttributeValueV1::Bool(_) + | AttributeValueV1::Int(_) + | AttributeValueV1::Float(_) + ) +} + +fn encode_span_event_v1( + ser: S, + event: &SpanEventV1, +) -> 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, AttributeValueV1>| { + 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 AttributeValueV1| { + encode_event_attr_value_v1(ser, v) + }))?; + } + attrs.end() + }), + )?; + } + map.end() +} + +/// Serializes a v1 event attribute value in the v0.4 `{"type": , "_value": ...}` +/// shape. `List` produces `{"type": 4, "array_value": {"values": [...]}}`, filtering nested +/// entries out of the array (no v0.4 array-element equivalent for them). +fn encode_event_attr_value_v1( + ser: S, + v: &AttributeValueV1, +) -> Result { + match v { + AttributeValueV1::List(items) => { + let mut map = ser.serialize_map(Some(2))?; + map.serialize_entry("type", &4u8)?; + map.serialize_entry( + "array_value", + &ser_fn!( |ser, items: &'a Vec>| { + let mut m = ser.serialize_map(Some(1))?; + m.serialize_entry( + "values", + &ser_fn!( |ser, items: &'a Vec>| { + 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 AttributeValueV1| { + encode_event_scalar_v1(ser, elem) + }))?; + } + seq.end() + }), + )?; + m.end() + }), + )?; + map.end() + } + other => encode_event_scalar_v1(ser, other), + } +} + +fn encode_event_scalar_v1( + ser: S, + v: &AttributeValueV1, +) -> Result { + let mut map = ser.serialize_map(Some(2))?; + match v { + AttributeValueV1::String(s) => { + map.serialize_entry("type", &0u8)?; + map.serialize_entry("string_value", s.borrow() as &str)?; + } + AttributeValueV1::Bool(b) => { + map.serialize_entry("type", &1u8)?; + map.serialize_entry("bool_value", b)?; + } + AttributeValueV1::Int(i) => { + map.serialize_entry("type", &2u8)?; + map.serialize_entry("int_value", i)?; + } + AttributeValueV1::Float(f) => { + map.serialize_entry("type", &3u8)?; + map.serialize_entry("double_value", f)?; + } + _ => unreachable!("filtered by is_scalar_array_elem_v1"), + } + map.end() +} + /// `serde::Serialize` adapter that interprets `bytes` as a self-describing /// msgpack value and transcodes it into the destination serializer. /// @@ -464,3 +1051,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..e790fcf594 --- /dev/null +++ b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs @@ -0,0 +1,603 @@ +// 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") +} + +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_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 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"], + serde_json::json!({"type": 0, "string_value": "timeout"}) + ); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn span_event_list_attribute_becomes_array_value_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["type"], 4); + assert_eq!( + value["array_value"]["values"], + serde_json::json!([ + {"type": 0, "string_value": "a"}, + {"type": 2, "int_value": 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()); +} From 1a854a5a026603b112b68639909b59b1186638d3 Mon Sep 17 00:00:00 2001 From: Anais Raison Date: Thu, 20 Aug 2026 13:26:16 +0200 Subject: [PATCH 2/6] fix: cargo --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b45da889f8..94c0826736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3166,7 +3166,7 @@ dependencies = [ [[package]] name = "libdd-ipc-macros" -version = "0.0.1" +version = "0.1.0" dependencies = [ "heck 0.5.0", "proc-macro2", From 55573a50c44136ce8e2acfc0b3aa6cc3317bb0c6 Mon Sep 17 00:00:00 2001 From: Scarlett Date: Thu, 27 Aug 2026 14:55:02 +0200 Subject: [PATCH 3/6] fix: comments --- .../src/agentless_encoder/mod.rs | 55 +++++++++---------- .../src/agentless_encoder/tests_v1.rs | 28 ++++++++++ 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/libdd-trace-utils/src/agentless_encoder/mod.rs b/libdd-trace-utils/src/agentless_encoder/mod.rs index be79062801..bad1a1c80b 100644 --- a/libdd-trace-utils/src/agentless_encoder/mod.rs +++ b/libdd-trace-utils/src/agentless_encoder/mod.rs @@ -481,13 +481,13 @@ fn dedup_first_wins_v1(mut leaves: Vec<(String, V)>) -> Vec<(String, V)> { } /// Recursively flattens a `List`/`KeyValue` attribute into dotted-key leaf entries for the -/// `meta` (string-valued) and `metrics` (numeric) buckets. `Bytes` has no flattened form and is -/// routed to `meta_struct` by the caller before recursing. +/// `meta` (string-valued), `metrics` (numeric), and `meta_struct` (`Bytes`) buckets. fn flatten_attr_into_v1( key: &mut String, v: &AttributeValueV1, meta_out: &mut Vec<(String, String)>, metrics_out: &mut Vec<(String, f64)>, + bytes_out: &mut Vec<(String, T::Bytes)>, ) { match v { AttributeValueV1::String(s) => meta_out.push((key.clone(), s.borrow().to_owned())), @@ -496,15 +496,13 @@ fn flatten_attr_into_v1( } AttributeValueV1::Int(i) => metrics_out.push((key.clone(), *i as f64)), AttributeValueV1::Float(f) => metrics_out.push((key.clone(), *f)), - AttributeValueV1::Bytes(_) => { - // Callers filter `Bytes` out before recursing; unreachable in practice. - } + AttributeValueV1::Bytes(b) => bytes_out.push((key.clone(), b.clone())), AttributeValueV1::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); + flatten_attr_into_v1(key, item, meta_out, metrics_out, bytes_out); key.truncate(base_len); } } @@ -513,28 +511,25 @@ fn flatten_attr_into_v1( 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); + flatten_attr_into_v1(key, v, meta_out, metrics_out, bytes_out); key.truncate(base_len); } } } } -/// Leaves collected by [`collect_attrs_v1`]: `meta` entries, `metrics` entries, and raw -/// `meta_struct`-bound `Bytes` entries. -type CollectedAttrsV1<'a, T> = ( +/// Leaves collected by [`collect_attrs_v1`]: `meta`, `metrics`, and `meta_struct` (`Bytes`) +/// entries. +type CollectedAttrsV1 = ( Vec<(String, String)>, Vec<(String, f64)>, - Vec<(&'a ::Text, &'a ::Bytes)>, + Vec<(String, ::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 raw `meta_struct`-bound `Bytes` entries. -fn collect_attrs_v1<'a, T: TraceData>( - span: &'a SpanV1, - chunk: &'a TraceChunk, -) -> CollectedAttrsV1<'a, T> { +/// `meta` leaves, `metrics` leaves, and `meta_struct` (`Bytes`) leaves. +fn collect_attrs_v1(span: &SpanV1, chunk: &TraceChunk) -> CollectedAttrsV1 { let span_attrs_dd = span.attributes.defensive_dedup(); let chunk_attrs_dd = chunk.attributes.defensive_dedup(); let merged_attrs = span_attrs_dd @@ -547,22 +542,23 @@ fn collect_attrs_v1<'a, T: TraceData>( let mut meta_leaves: Vec<(String, String)> = Vec::new(); let mut metrics_leaves: Vec<(String, f64)> = Vec::new(); - let mut bytes_attrs: Vec<(&T::Text, &T::Bytes)> = Vec::new(); + let mut bytes_leaves: Vec<(String, T::Bytes)> = Vec::new(); let mut key_buf = String::new(); for (k, v) in merged_attrs { - match v { - AttributeValueV1::Bytes(b) => bytes_attrs.push((k, b)), - _ => { - key_buf.clear(); - key_buf.push_str(k.borrow()); - flatten_attr_into_v1(&mut key_buf, v, &mut meta_leaves, &mut metrics_leaves); - } - } + 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, + ); } ( dedup_first_wins_v1(meta_leaves), dedup_first_wins_v1(metrics_leaves), - bytes_attrs, + dedup_first_wins_v1(bytes_leaves), ) } @@ -806,13 +802,12 @@ fn encode_span_v1( if !bytes_attrs.is_empty() { map.serialize_entry( "meta_struct", - &ser_fn!( |ser, span: &'a SpanV1, bytes_attrs: &'a Vec<(&'a T::Text, &'a T::Bytes)>| { + &ser_fn!( |ser, span: &'a SpanV1, bytes_attrs: &'a Vec<(String, T::Bytes)>| { let _ = span; let mut ms = ser.serialize_map(None)?; for (k, v) in bytes_attrs.iter() { - let key: &str = (*k).borrow(); - let raw: &[u8] = (*v).borrow(); - ms.serialize_entry(key, &MsgpackAsJson(raw))?; + let raw: &[u8] = v.borrow(); + ms.serialize_entry(k, &MsgpackAsJson(raw))?; } ms.end() }), diff --git a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs index e790fcf594..e2b5c4c033 100644 --- a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs +++ b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs @@ -366,6 +366,34 @@ fn meta_struct_field_omitted_when_no_bytes_attributes() { 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 nested_key_value_attribute_is_flattened_with_dotted_keys() { From 1fbc4b65b4aa095c2c021c54122a5621fe581bc0 Mon Sep 17 00:00:00 2001 From: Scarlett Date: Fri, 28 Aug 2026 14:21:00 +0200 Subject: [PATCH 4/6] fix: comments --- .../src/agentless_encoder/mod.rs | 135 +++++++++++------- .../src/agentless_encoder/tests_v1.rs | 28 ++++ 2 files changed, 112 insertions(+), 51 deletions(-) diff --git a/libdd-trace-utils/src/agentless_encoder/mod.rs b/libdd-trace-utils/src/agentless_encoder/mod.rs index bad1a1c80b..29551f04c6 100644 --- a/libdd-trace-utils/src/agentless_encoder/mod.rs +++ b/libdd-trace-utils/src/agentless_encoder/mod.rs @@ -37,7 +37,7 @@ use serde::{ ser::{SerializeMap, SerializeSeq}, Serializer, }; -use std::borrow::Borrow; +use std::borrow::{Borrow, Cow}; use std::collections::HashSet; use std::fmt::Write as _; @@ -467,12 +467,12 @@ fn span_kind_to_meta_v1(kind: SpanKind) -> Option<&'static str> { /// 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(mut leaves: Vec<(String, V)>) -> Vec<(String, V)> { +fn dedup_first_wins_v1<'a, V>(mut leaves: Vec<(Cow<'a, str>, V)>) -> 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_str())) + .map(|(k, _)| seen.insert(k.as_ref())) .collect() }; let mut keep = keep.into_iter(); @@ -481,22 +481,28 @@ fn dedup_first_wins_v1(mut leaves: Vec<(String, V)>) -> Vec<(String, V)> { } /// Recursively flattens a `List`/`KeyValue` attribute into dotted-key leaf entries for the -/// `meta` (string-valued), `metrics` (numeric), and `meta_struct` (`Bytes`) buckets. -fn flatten_attr_into_v1( +/// `meta` (string-valued), `metrics` (numeric), and `meta_struct` (`Bytes`) buckets. Only called +/// once an attribute is known to be nested: the resulting key is always a freshly built dotted +/// string, so it's always owned — unlike the top-level scalar fast path in [`collect_attrs_v1`], +/// which can borrow the key/value directly from the source attribute. +fn flatten_attr_into_v1<'a, T: TraceData>( key: &mut String, - v: &AttributeValueV1, - meta_out: &mut Vec<(String, String)>, - metrics_out: &mut Vec<(String, f64)>, - bytes_out: &mut Vec<(String, T::Bytes)>, + v: &'a AttributeValueV1, + 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 { - AttributeValueV1::String(s) => meta_out.push((key.clone(), s.borrow().to_owned())), - AttributeValueV1::Bool(b) => { - meta_out.push((key.clone(), if *b { "true" } else { "false" }.to_owned())) + AttributeValueV1::String(s) => { + meta_out.push((Cow::Owned(key.clone()), Cow::Borrowed(s.borrow()))) } - AttributeValueV1::Int(i) => metrics_out.push((key.clone(), *i as f64)), - AttributeValueV1::Float(f) => metrics_out.push((key.clone(), *f)), - AttributeValueV1::Bytes(b) => bytes_out.push((key.clone(), b.clone())), + AttributeValueV1::Bool(b) => meta_out.push(( + Cow::Owned(key.clone()), + Cow::Borrowed(if *b { "true" } else { "false" }), + )), + AttributeValueV1::Int(i) => metrics_out.push((Cow::Owned(key.clone()), *i as f64)), + AttributeValueV1::Float(f) => metrics_out.push((Cow::Owned(key.clone()), *f)), + AttributeValueV1::Bytes(b) => bytes_out.push((Cow::Owned(key.clone()), b.clone())), AttributeValueV1::List(items) => { let base_len = key.len(); for (i, item) in items.iter().enumerate() { @@ -519,17 +525,22 @@ fn flatten_attr_into_v1( } /// Leaves collected by [`collect_attrs_v1`]: `meta`, `metrics`, and `meta_struct` (`Bytes`) -/// entries. -type CollectedAttrsV1 = ( - Vec<(String, String)>, - Vec<(String, f64)>, - Vec<(String, ::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(span: &SpanV1, chunk: &TraceChunk) -> CollectedAttrsV1 { +fn collect_attrs_v1<'a, T: TraceData>( + span: &'a SpanV1, + chunk: &'a 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 @@ -540,26 +551,44 @@ fn collect_attrs_v1(span: &SpanV1, chunk: &TraceChunk) -> Co && !span_attrs_dd.iter().any(|(k2, _)| k2 == *k) })); - let mut meta_leaves: Vec<(String, String)> = Vec::new(); - let mut metrics_leaves: Vec<(String, f64)> = Vec::new(); - let mut bytes_leaves: Vec<(String, T::Bytes)> = Vec::new(); + 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 { - 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, - ); + 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. + AttributeValueV1::String(s) => { + meta_leaves.push((Cow::Borrowed(k.borrow()), Cow::Borrowed(s.borrow()))) + } + AttributeValueV1::Bool(b) => meta_leaves.push(( + Cow::Borrowed(k.borrow()), + Cow::Borrowed(if *b { "true" } else { "false" }), + )), + AttributeValueV1::Int(i) => metrics_leaves.push((Cow::Borrowed(k.borrow()), *i as f64)), + AttributeValueV1::Float(f) => metrics_leaves.push((Cow::Borrowed(k.borrow()), *f)), + AttributeValueV1::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. + AttributeValueV1::List(_) | AttributeValueV1::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, + ); + } + } + } + CollectedAttrsV1 { + meta: dedup_first_wins_v1(meta_leaves), + metrics: dedup_first_wins_v1(metrics_leaves), + meta_struct: dedup_first_wins_v1(bytes_leaves), } - ( - dedup_first_wins_v1(meta_leaves), - dedup_first_wins_v1(metrics_leaves), - dedup_first_wins_v1(bytes_leaves), - ) } /// V1-native analog of [`encode_payload`]. Downgrades v1's unified attribute model back to the @@ -637,10 +666,10 @@ fn encode_trace_v1( map.end() } -fn encode_span_v1( +fn encode_span_v1<'a, T: TraceData, S: Serializer>( ser: S, - chunk: &TraceChunk, - span: &SpanV1, + chunk: &'a TraceChunk, + span: &'a SpanV1, is_first_in_trace: bool, ) -> Result { let mut map = ser.serialize_map(None)?; @@ -692,10 +721,10 @@ fn encode_span_v1( map.serialize_entry("type", type_str)?; } - let (meta_leaves, metrics_leaves, bytes_attrs) = collect_attrs_v1(span, chunk); - let meta_leaves = &meta_leaves; - let metrics_leaves = &metrics_leaves; - let bytes_attrs = &bytes_attrs; + 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 @@ -707,7 +736,7 @@ fn encode_span_v1( map.serialize_entry( "meta", - &ser_fn!( |ser, span: &'a SpanV1, chunk: &'a TraceChunk, meta_leaves: &'a Vec<(String, String)>, is_first_in_trace: bool, trace_id_high: u64| { + &ser_fn!( |ser, span: &'a SpanV1, chunk: &'a 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(); @@ -739,7 +768,7 @@ fn encode_span_v1( let mut events_seen = false; let mut compute_stats_seen = false; for (key, val) in meta_leaves.iter() { - match key.as_str() { + match key.as_ref() { "_dd.p.tid" => p_tid_seen = true, "_dd.span_links" => span_links_seen = true, "events" => events_seen = true, @@ -775,11 +804,15 @@ fn encode_span_v1( map.serialize_entry( "metrics", - &ser_fn!( |ser, span: &'a SpanV1, metrics_leaves: &'a Vec<(String, f64)>, priority: Option| { + &ser_fn!( |ser, span: &'a SpanV1, 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() { - match key.as_str() { + // 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))?; @@ -802,7 +835,7 @@ fn encode_span_v1( if !bytes_attrs.is_empty() { map.serialize_entry( "meta_struct", - &ser_fn!( |ser, span: &'a SpanV1, bytes_attrs: &'a Vec<(String, T::Bytes)>| { + &ser_fn!( |ser, span: &'a SpanV1, 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() { diff --git a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs index e2b5c4c033..e435493495 100644 --- a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs +++ b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs @@ -394,6 +394,34 @@ fn nested_bytes_attribute_in_list_is_routed_to_meta_struct() { 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() { From 3a1006cf4a32712d3f50747cce51e3c6ae90e5e3 Mon Sep 17 00:00:00 2001 From: Scarlett Date: Wed, 2 Sep 2026 14:57:32 +0200 Subject: [PATCH 5/6] fix: codex comment --- .../src/agentless_encoder/mod.rs | 66 ++++++++----------- .../src/agentless_encoder/tests_v1.rs | 37 +++++++---- 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/libdd-trace-utils/src/agentless_encoder/mod.rs b/libdd-trace-utils/src/agentless_encoder/mod.rs index df8083d543..1aad6483ff 100644 --- a/libdd-trace-utils/src/agentless_encoder/mod.rs +++ b/libdd-trace-utils/src/agentless_encoder/mod.rs @@ -982,38 +982,28 @@ fn encode_span_event_v1( map.end() } -/// Serializes a v1 event attribute value in the v0.4 `{"type": , "_value": ...}` -/// shape. `List` produces `{"type": 4, "array_value": {"values": [...]}}`, filtering nested -/// entries out of the array (no v0.4 array-element equivalent for them). +/// 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: &AttributeValueV1, ) -> Result { match v { AttributeValueV1::List(items) => { - let mut map = ser.serialize_map(Some(2))?; - map.serialize_entry("type", &4u8)?; - map.serialize_entry( - "array_value", - &ser_fn!( |ser, items: &'a Vec>| { - let mut m = ser.serialize_map(Some(1))?; - m.serialize_entry( - "values", - &ser_fn!( |ser, items: &'a Vec>| { - 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 AttributeValueV1| { - encode_event_scalar_v1(ser, elem) - }))?; - } - seq.end() - }), - )?; - m.end() - }), - )?; - map.end() + 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 AttributeValueV1| { + encode_event_scalar_v1(ser, elem) + }), + )?; + } + seq.end() } other => encode_event_scalar_v1(ser, other), } @@ -1023,27 +1013,23 @@ fn encode_event_scalar_v1( ser: S, v: &AttributeValueV1, ) -> Result { - let mut map = ser.serialize_map(Some(2))?; match v { AttributeValueV1::String(s) => { - map.serialize_entry("type", &0u8)?; - map.serialize_entry("string_value", s.borrow() as &str)?; - } - AttributeValueV1::Bool(b) => { - map.serialize_entry("type", &1u8)?; - map.serialize_entry("bool_value", b)?; - } - AttributeValueV1::Int(i) => { - map.serialize_entry("type", &2u8)?; - map.serialize_entry("int_value", i)?; + let s: &str = s.borrow(); + ser.serialize_str(s) } + AttributeValueV1::Bool(b) => ser.serialize_bool(*b), + AttributeValueV1::Int(i) => ser.serialize_i64(*i), AttributeValueV1::Float(f) => { - map.serialize_entry("type", &3u8)?; - map.serialize_entry("double_value", 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"), } - map.end() } /// `serde::Serialize` adapter that interprets `bytes` as a self-describing diff --git a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs index e435493495..63cf226089 100644 --- a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs +++ b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs @@ -597,15 +597,12 @@ fn span_events_serialised_into_meta_as_json_string() { 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"], - serde_json::json!({"type": 0, "string_value": "timeout"}) - ); + assert_eq!(evt["attributes"]["exception.message"], "timeout"); } #[cfg_attr(miri, ignore)] #[test] -fn span_event_list_attribute_becomes_array_value_of_scalars() { +fn span_event_list_attribute_becomes_json_array_of_scalars() { let mut attrs: VecMap = VecMap::new(); attrs.insert( bs("list"), @@ -626,14 +623,7 @@ fn span_event_list_attribute_becomes_array_value_of_scalars() { 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["type"], 4); - assert_eq!( - value["array_value"]["values"], - serde_json::json!([ - {"type": 0, "string_value": "a"}, - {"type": 2, "int_value": 2} - ]) - ); + assert_eq!(value, &serde_json::json!(["a", 2])); } #[cfg_attr(miri, ignore)] @@ -657,3 +647,24 @@ fn span_event_bytes_attribute_is_dropped() { 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); +} From 7d3deb75fce45a697e782e2663c1c5b6c7302268 Mon Sep 17 00:00:00 2001 From: Scarlett Date: Thu, 3 Sep 2026 14:36:10 +0200 Subject: [PATCH 6/6] fix: comments --- .../src/agentless_encoder/mod.rs | 208 ++++++++++-------- .../src/agentless_encoder/tests_v1.rs | 52 +++++ 2 files changed, 169 insertions(+), 91 deletions(-) diff --git a/libdd-trace-utils/src/agentless_encoder/mod.rs b/libdd-trace-utils/src/agentless_encoder/mod.rs index 1aad6483ff..a70710387b 100644 --- a/libdd-trace-utils/src/agentless_encoder/mod.rs +++ b/libdd-trace-utils/src/agentless_encoder/mod.rs @@ -27,10 +27,7 @@ //! TODO: span normalization (service/name/resource/type truncation + defaults) use crate::span::v04::{AttributeAnyValue, AttributeArrayValue, Span, SpanEvent, SpanLink}; -use crate::span::v1::{ - AttributeValue as AttributeValueV1, Span as SpanV1, SpanEvent as SpanEventV1, SpanKind, - SpanLink as SpanLinkV1, TraceChunk, -}; +use crate::span::v1; use crate::span::{TraceData, SPAN_LINK_FLAGS_SET_SENTINEL}; use crate::tracer_metadata::TracerMetadata; use serde::{ @@ -65,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 @@ -455,19 +452,19 @@ const PROMOTED_ATTR_KEYS_V1: &[&str] = &[ /// 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: SpanKind) -> Option<&'static str> { +fn span_kind_to_meta_v1(kind: v1::SpanKind) -> Option<&'static str> { match kind { - SpanKind::Internal => None, - SpanKind::Server => Some("server"), - SpanKind::Client => Some("client"), - SpanKind::Producer => Some("producer"), - SpanKind::Consumer => Some("consumer"), + 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>(mut leaves: Vec<(Cow<'a, str>, V)>) -> Vec<(Cow<'a, str>, V)> { +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 @@ -477,33 +474,43 @@ fn dedup_first_wins_v1<'a, V>(mut leaves: Vec<(Cow<'a, str>, V)>) -> Vec<(Cow<'a }; let mut keep = keep.into_iter(); leaves.retain(|_| keep.next().unwrap_or(false)); - leaves } -/// Recursively flattens a `List`/`KeyValue` attribute into dotted-key leaf entries for the -/// `meta` (string-valued), `metrics` (numeric), and `meta_struct` (`Bytes`) buckets. Only called -/// once an attribute is known to be nested: the resulting key is always a freshly built dotted -/// string, so it's always owned — unlike the top-level scalar fast path in [`collect_attrs_v1`], -/// which can borrow the key/value directly from the source attribute. +/// 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 AttributeValueV1, + 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 { - AttributeValueV1::String(s) => { + v1::AttributeValue::String(s) => { meta_out.push((Cow::Owned(key.clone()), Cow::Borrowed(s.borrow()))) } - AttributeValueV1::Bool(b) => meta_out.push(( + v1::AttributeValue::Bool(b) => meta_out.push(( Cow::Owned(key.clone()), Cow::Borrowed(if *b { "true" } else { "false" }), )), - AttributeValueV1::Int(i) => metrics_out.push((Cow::Owned(key.clone()), *i as f64)), - AttributeValueV1::Float(f) => metrics_out.push((Cow::Owned(key.clone()), *f)), - AttributeValueV1::Bytes(b) => bytes_out.push((Cow::Owned(key.clone()), b.clone())), - AttributeValueV1::List(items) => { + 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('.'); @@ -512,7 +519,7 @@ fn flatten_attr_into_v1<'a, T: TraceData>( key.truncate(base_len); } } - AttributeValueV1::KeyValue(map) => { + v1::AttributeValue::KeyValue(map) => { let base_len = key.len(); for (k, v) in map.defensive_dedup().iter() { key.push('.'); @@ -538,8 +545,8 @@ struct CollectedAttrsV1<'a, T: TraceData> { /// 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 SpanV1, - chunk: &'a TraceChunk, + 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(); @@ -559,19 +566,23 @@ fn collect_attrs_v1<'a, T: TraceData>( 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. - AttributeValueV1::String(s) => { + v1::AttributeValue::String(s) => { meta_leaves.push((Cow::Borrowed(k.borrow()), Cow::Borrowed(s.borrow()))) } - AttributeValueV1::Bool(b) => meta_leaves.push(( + v1::AttributeValue::Bool(b) => meta_leaves.push(( Cow::Borrowed(k.borrow()), Cow::Borrowed(if *b { "true" } else { "false" }), )), - AttributeValueV1::Int(i) => metrics_leaves.push((Cow::Borrowed(k.borrow()), *i as f64)), - AttributeValueV1::Float(f) => metrics_leaves.push((Cow::Borrowed(k.borrow()), *f)), - AttributeValueV1::Bytes(b) => bytes_leaves.push((Cow::Borrowed(k.borrow()), b.clone())), + 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. - AttributeValueV1::List(_) | AttributeValueV1::KeyValue(_) => { + v1::AttributeValue::List(_) | v1::AttributeValue::KeyValue(_) => { key_buf.clear(); key_buf.push_str(k.borrow()); flatten_attr_into_v1( @@ -584,10 +595,20 @@ fn collect_attrs_v1<'a, T: TraceData>( } } } + // 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: dedup_first_wins_v1(meta_leaves), - metrics: dedup_first_wins_v1(metrics_leaves), - meta_struct: dedup_first_wins_v1(bytes_leaves), + meta: meta_leaves, + metrics: metrics_leaves, + meta_struct: bytes_leaves, } } @@ -596,10 +617,10 @@ fn collect_attrs_v1<'a, T: TraceData>( /// 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 [`TraceChunk`]-level granularity v1 +/// attributes) is propagated into every span, matching the [`v1::TraceChunk`]-level granularity v1 /// operates at. pub fn encode_payload_from_v1( - chunks: &[TraceChunk], + chunks: &[v1::TraceChunk], metadata: &TracerMetadata, ) -> Result, serde_json::Error> { let mut bytes = Vec::new(); @@ -608,10 +629,10 @@ pub fn encode_payload_from_v1( let mut map_ser = serializer.serialize_map(Some(1))?; map_ser.serialize_entry( "traces", - &ser_fn!( |ser, chunks: &'a [TraceChunk], metadata: &'a TracerMetadata| { + &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 TraceChunk, metadata: &'a TracerMetadata| { + traces_serializer.serialize_element(&ser_fn!( |ser, chunk: &'a v1::TraceChunk, metadata: &'a TracerMetadata| { encode_trace_v1(ser, chunk, metadata) }))?; } @@ -624,7 +645,7 @@ pub fn encode_payload_from_v1( fn encode_trace_v1( ser: S, - chunk: &TraceChunk, + chunk: &v1::TraceChunk, metadata: &TracerMetadata, ) -> Result { let mut map = ser.serialize_map(None)?; @@ -651,11 +672,11 @@ fn encode_trace_v1( map.serialize_entry( "spans", - &ser_fn!( |ser, chunk: &'a TraceChunk| { + &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 TraceChunk, span: &'a SpanV1, is_first: bool| { + 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) }))?; } @@ -668,8 +689,8 @@ fn encode_trace_v1( fn encode_span_v1<'a, T: TraceData, S: Serializer>( ser: S, - chunk: &'a TraceChunk, - span: &'a SpanV1, + chunk: &'a v1::TraceChunk, + span: &'a v1::Span, is_first_in_trace: bool, ) -> Result { let mut map = ser.serialize_map(None)?; @@ -736,7 +757,7 @@ fn encode_span_v1<'a, T: TraceData, S: Serializer>( map.serialize_entry( "meta", - &ser_fn!( |ser, span: &'a SpanV1, chunk: &'a TraceChunk, meta_leaves: &'a Vec<(Cow<'a, str>, Cow<'a, str>)>, is_first_in_trace: bool, trace_id_high: u64| { + &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(); @@ -804,7 +825,7 @@ fn encode_span_v1<'a, T: TraceData, S: Serializer>( map.serialize_entry( "metrics", - &ser_fn!( |ser, span: &'a SpanV1, metrics_leaves: &'a Vec<(Cow<'a, str>, f64)>, priority: Option| { + &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() { @@ -835,7 +856,7 @@ fn encode_span_v1<'a, T: TraceData, S: Serializer>( if !bytes_attrs.is_empty() { map.serialize_entry( "meta_struct", - &ser_fn!( |ser, span: &'a SpanV1, bytes_attrs: &'a Vec<(Cow<'a, str>, T::Bytes)>| { + &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() { @@ -851,23 +872,25 @@ fn encode_span_v1<'a, T: TraceData, S: Serializer>( /// 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: &[SpanLinkV1]) -> Option { - let s = serde_json::to_string(&ser_fn!( |ser, links: &'a [SpanLinkV1]| { - let mut seq = ser.serialize_seq(Some(links.len()))?; - for link in links { - seq.serialize_element(&ser_fn!( |ser, link: &'a SpanLinkV1| { - encode_span_link_v1(ser, link) - }))?; - } - seq.end() - })) +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: &SpanLinkV1, + link: &v1::SpanLink, ) -> Result { let mut map = ser.serialize_map(None)?; let trace_id_128 = u128::from_be_bytes(link.trace_id); @@ -875,19 +898,22 @@ fn encode_span_link_v1( 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, AttributeValueV1::String(_) | AttributeValueV1::Bool(_))); + 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, AttributeValueV1>| { + &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 { - AttributeValueV1::String(s) => attrs.serialize_entry(key, s.borrow() as &str)?, - AttributeValueV1::Bool(b) => { + 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" })? } _ => {} @@ -914,12 +940,12 @@ fn encode_span_link_v1( /// 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: &[SpanEventV1]) -> Option { +fn serialize_span_events_v1(events: &[v1::SpanEvent]) -> Option { let s = serde_json::to_string( - &ser_fn!( |ser, events: &'a [SpanEventV1]| { + &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 SpanEventV1| { + seq.serialize_element(&ser_fn!( |ser, event: &'a v1::SpanEvent| { encode_span_event_v1(ser, event) }))?; } @@ -931,31 +957,31 @@ fn serialize_span_events_v1(events: &[SpanEventV1]) -> Option(v: &AttributeValueV1) -> bool { +fn is_supported_event_attr_v1(v: &v1::AttributeValue) -> bool { matches!( v, - AttributeValueV1::String(_) - | AttributeValueV1::Bool(_) - | AttributeValueV1::Int(_) - | AttributeValueV1::Float(_) - | AttributeValueV1::List(_) + 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: &AttributeValueV1) -> bool { +fn is_scalar_array_elem_v1(v: &v1::AttributeValue) -> bool { matches!( v, - AttributeValueV1::String(_) - | AttributeValueV1::Bool(_) - | AttributeValueV1::Int(_) - | AttributeValueV1::Float(_) + v1::AttributeValue::String(_) + | v1::AttributeValue::Bool(_) + | v1::AttributeValue::Int(_) + | v1::AttributeValue::Float(_) ) } fn encode_span_event_v1( ser: S, - event: &SpanEventV1, + event: &v1::SpanEvent, ) -> Result { let mut map = ser.serialize_map(None)?; let name: &str = event.name.borrow(); @@ -967,11 +993,11 @@ fn encode_span_event_v1( if has_attributes { map.serialize_entry( "attributes", - &ser_fn!( |ser, attrs_dd: &'a crate::span::vec_map::DedupedVecMap<'a, T::Text, AttributeValueV1>| { + &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 AttributeValueV1| { + attrs.serialize_entry(key, &ser_fn!( |ser, v: &'a v1::AttributeValue| { encode_event_attr_value_v1(ser, v) }))?; } @@ -987,10 +1013,10 @@ fn encode_span_event_v1( /// `List` produces a plain JSON array, filtering out non-scalar entries (no equivalent for them). fn encode_event_attr_value_v1( ser: S, - v: &AttributeValueV1, + v: &v1::AttributeValue, ) -> Result { match v { - AttributeValueV1::List(items) => { + v1::AttributeValue::List(items) => { let scalars: Vec<_> = items .iter() .filter(|e| is_scalar_array_elem_v1(e)) @@ -998,7 +1024,7 @@ fn encode_event_attr_value_v1( let mut seq = ser.serialize_seq(Some(scalars.len()))?; for elem in scalars { seq.serialize_element( - &ser_fn!( |ser, elem: &'a AttributeValueV1| { + &ser_fn!( |ser, elem: &'a v1::AttributeValue| { encode_event_scalar_v1(ser, elem) }), )?; @@ -1011,16 +1037,16 @@ fn encode_event_attr_value_v1( fn encode_event_scalar_v1( ser: S, - v: &AttributeValueV1, + v: &v1::AttributeValue, ) -> Result { match v { - AttributeValueV1::String(s) => { + v1::AttributeValue::String(s) => { let s: &str = s.borrow(); ser.serialize_str(s) } - AttributeValueV1::Bool(b) => ser.serialize_bool(*b), - AttributeValueV1::Int(i) => ser.serialize_i64(*i), - AttributeValueV1::Float(f) => { + 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 { diff --git a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs index 63cf226089..ec6e0284c2 100644 --- a/libdd-trace-utils/src/agentless_encoder/tests_v1.rs +++ b/libdd-trace-utils/src/agentless_encoder/tests_v1.rs @@ -15,6 +15,36 @@ 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(), @@ -437,6 +467,28 @@ fn nested_key_value_attribute_is_flattened_with_dotted_keys() { 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() {