From ce7f5bcdff1c413c2a6c7eed612c496e267f98e7 Mon Sep 17 00:00:00 2001 From: Alexandre Rulleau Date: Thu, 1 Oct 2026 17:10:13 +0200 Subject: [PATCH 1/3] feat(trace-utils)!: V1 span payload attributes and native v0.4 downgrade Extend the native v1 span model with payload-level attributes, unspecified span kind and tracer-marked top-level spans. Make the v1 to v0.4 downgrade self-contained: legacy _dd.span_links/_dd.span_events meta for old agents, PHP-compatible JSON float formatting, chunk-root rules shared with v1, and process tags on each trace's first span. Dedup V1 payloads after decoding and keep insertion order in VecMap. --- .../src/span_concentrator/stat_span.rs | 8 +- .../src/agentless_encoder/mod.rs | 2 +- .../src/json_log_encoder/span_v1.rs | 2 +- .../src/msgpack_encoder/v04/mod.rs | 19 +- .../src/msgpack_encoder/v04/span_v1.rs | 1183 +++++++++++++---- .../src/msgpack_encoder/v1/mod.rs | 100 +- .../src/msgpack_encoder/v1/span_v1.rs | 16 +- libdd-trace-utils/src/span/v1/mod.rs | 159 ++- libdd-trace-utils/src/span/vec_map.rs | 17 + libdd-trace-utils/src/trace_filter.rs | 4 +- 10 files changed, 1213 insertions(+), 297 deletions(-) diff --git a/libdd-trace-stats/src/span_concentrator/stat_span.rs b/libdd-trace-stats/src/span_concentrator/stat_span.rs index 25c0976fc2..86048d1769 100644 --- a/libdd-trace-stats/src/span_concentrator/stat_span.rs +++ b/libdd-trace-stats/src/span_concentrator/stat_span.rs @@ -146,10 +146,10 @@ impl<'a, T: TraceData> StatSpan<'a> for SpanV1 { match self.attributes.get(key) { Some(AttributeValue::String(s)) => Some(s.borrow()), // `span_kind` is a dedicated field rather than an attribute; expose it under the same - // "span.kind" key that v0.4 spans (and is_span_eligible) look for. `Internal` is the - // wire-level default and indistinguishable from "unset", so it's treated as no value - // here, leaving room for a chunk-level fallback (see `ChunkSpanView`). - _ if key == "span.kind" && self.span_kind != SpanKind::Internal => { + // "span.kind" key that v0.4 spans (and is_span_eligible) look for. `Unspecified` is + // treated as no value here, leaving room for a chunk-level fallback (see + // `ChunkSpanView`). + _ if key == "span.kind" && self.span_kind != SpanKind::Unspecified => { Some(self.span_kind.as_meta_str()) } _ => None, diff --git a/libdd-trace-utils/src/agentless_encoder/mod.rs b/libdd-trace-utils/src/agentless_encoder/mod.rs index b237f0cd6a..03b818b162 100644 --- a/libdd-trace-utils/src/agentless_encoder/mod.rs +++ b/libdd-trace-utils/src/agentless_encoder/mod.rs @@ -498,7 +498,7 @@ const PROMOTED_ATTR_KEYS_V1: &[&str] = &[ /// 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::Unspecified | v1::SpanKind::Internal => None, v1::SpanKind::Server => Some("server"), v1::SpanKind::Client => Some("client"), v1::SpanKind::Producer => Some("producer"), diff --git a/libdd-trace-utils/src/json_log_encoder/span_v1.rs b/libdd-trace-utils/src/json_log_encoder/span_v1.rs index bbda27b7c8..993fed5db1 100644 --- a/libdd-trace-utils/src/json_log_encoder/span_v1.rs +++ b/libdd-trace-utils/src/json_log_encoder/span_v1.rs @@ -92,7 +92,7 @@ impl<'a, T: TraceData> ChunkContextV1<'a, T> { /// callers can skip emitting the default value. fn span_kind_to_meta(kind: SpanKind) -> Option<&'static str> { match kind { - SpanKind::Internal => None, + SpanKind::Unspecified | SpanKind::Internal => None, SpanKind::Server => Some("server"), SpanKind::Client => Some("client"), SpanKind::Producer => Some("producer"), diff --git a/libdd-trace-utils/src/msgpack_encoder/v04/mod.rs b/libdd-trace-utils/src/msgpack_encoder/v04/mod.rs index b87198730b..d0f267d705 100644 --- a/libdd-trace-utils/src/msgpack_encoder/v04/mod.rs +++ b/libdd-trace-utils/src/msgpack_encoder/v04/mod.rs @@ -73,6 +73,9 @@ macro_rules! write_const_msgpack_str { mod span_v04; mod span_v1; +// Re-exported so introspection (`datadog-sidecar-ffi`) picks the same local root as the wire. +pub use span_v1::local_root_idx; + #[inline(always)] fn to_writer]>>( writer: &mut W, @@ -249,16 +252,15 @@ fn encode_payload_from_v1( writer: &mut W, payload: &TracerPayload, ) -> Result<(), ValueWriteError> { - use span_v1::{ChunkContext, encode_span}; + use span_v1::{ChunkContext, encode_span, local_root_idx}; write_array_len(writer, payload.chunks.len() as u32)?; for chunk in &payload.chunks { - // v0.4 has no wire-level equivalent of `dropped_trace`; the closest historical signal - // is `USER_REJECT` (priority -1), which tells the agent the sampler rejected this trace - // without dropping the spans themselves. Only force it when the chunk doesn't already - // carry a negative (reject-like) priority. + // v0.4 has no wire-level equivalent of `dropped_trace`; preserve the chunk's own + // sampling priority (so AUTO_REJECT `0` stays `0`) and default to `-1` only when the + // chunk carries no priority at all. let priority = if chunk.dropped_trace { - Some(chunk.priority.filter(|&p| p < 0).unwrap_or(-1)) + chunk.priority.or(Some(-1)) } else { chunk.priority }; @@ -272,9 +274,10 @@ fn encode_payload_from_v1( &payload.app_version, &payload.attributes, ); + let root_idx = local_root_idx(chunk.spans.iter()); write_array_len(writer, chunk.spans.len() as u32)?; - for span in &chunk.spans { - encode_span(writer, span, &ctx)?; + for (i, span) in chunk.spans.iter().enumerate() { + encode_span(writer, span, &ctx, i == root_idx, i == 0)?; } } Ok(()) diff --git a/libdd-trace-utils/src/msgpack_encoder/v04/span_v1.rs b/libdd-trace-utils/src/msgpack_encoder/v04/span_v1.rs index 80fc11199a..9ffa6d41f5 100644 --- a/libdd-trace-utils/src/msgpack_encoder/v04/span_v1.rs +++ b/libdd-trace-utils/src/msgpack_encoder/v04/span_v1.rs @@ -10,21 +10,23 @@ //! | v1::Span field / attribute | v0.4 field | //! |---------------------------------------|---------------------------------------------| //! | `env` / `version` / `component` | `meta["env"]` / `meta["version"]` / ... (`env`/`version` fall back to the payload-level `env`/`app_version` when unset on the span) | -//! | `span_kind` | `meta["span.kind"]` (lowercase string) | +//! | `span_kind` | `meta["span.kind"]` (lowercase; `Unspecified` falls back to a `span.kind` string attribute) | //! | `AttributeValue::String` / `Bool` | `meta[k]` (`"true"` / `"false"` for bool) | //! | `AttributeValue::Float` / `Int` | `metrics[k]` (Int cast to `f64`) | //! | `AttributeValue::Bytes` | `meta_struct[k]` (raw bytes) | //! | `AttributeValue::List` | flattened into `meta`/`metrics[k.0]`, `[k.1]`, ... (per element type) | //! | `AttributeValue::KeyValue` | flattened into `meta`/`metrics[k.a]`, `[k.a.b]`, ... (per member, recursively) | //! | `error: bool` | `error: i32` (`true → 1`, `false → 0`) | +//! | `span_links` | native `span_links` (attributes stringified) | +//! | `span_events` | `meta["events"]` (JSON; native `span_events` needs agent 7.63+) | //! | Chunk `trace_id: [u8; 16]` | `trace_id: u64` (low 64) + `meta["_dd.p.tid"]` (hex of high 64, when non-zero) | //! | Chunk `origin` | `meta["_dd.origin"]` | //! | Chunk `priority` | `metrics["_sampling_priority_v1"]` | //! | Chunk `sampling_mechanism` | `meta["_dd.p.dm"]` (`"-{mechanism}"`) | //! | Chunk `attributes` | Applied to every span in the chunk | //! | Payload `env` / `app_version` | Fallback for `meta["env"]` / `meta["version"]` when the span leaves them unset | -//! | Payload `attributes` | Applied to every span, lowest precedence (span > chunk > payload) | -//! | Chunk `dropped_trace: true` | Forces `metrics["_sampling_priority_v1"] = -1` (USER_REJECT) unless the chunk's own priority is already negative | +//! | Payload `attributes` | Applied to every span, lowest precedence (span > chunk > payload); `_dd.tags.process` / `_dd.sdk.otlp_export` only to the first span of each chunk, `_dd.git.*` only to its local root | +//! | Chunk `dropped_trace: true` | Preserves the chunk's `metrics["_sampling_priority_v1"]`, defaulting to `-1` (USER_REJECT) only when no priority is set | //! //! An attribute sharing a name with one of the dedicated fields above (`env`, `version`, //! `component`, `span.kind`, `_dd.p.tid`, `_dd.origin`, `_dd.p.dm`, `_sampling_priority_v1`) is @@ -34,22 +36,13 @@ use crate::span::TraceData; use crate::span::v1::{AttributeValue, Span, SpanEvent, SpanKind, SpanLink}; use crate::span::vec_map::{DedupedVecMap, VecMap}; use rmp::encode::{ - RmpWrite, ValueWriteError, write_array_len, write_bin, write_bool, write_f64, write_i64, - write_map_len, write_sint, write_str, write_u8, write_u32, write_u64, + RmpWrite, ValueWriteError, write_bin, write_f64, write_i64, write_map_len, write_sint, + write_str, write_u64, }; use std::borrow::Borrow; use std::collections::HashSet; use std::fmt::Write as _; -/// Writes a `bool` as the v0.4 string representation (`"true"` / `"false"`). Used wherever a -/// typed V1 `Bool` attribute is downgraded into v0.4 `meta` (which is `String → String` only). -fn write_bool_as_str( - writer: &mut W, - b: bool, -) -> Result<(), ValueWriteError> { - write_str(writer, if b { "true" } else { "false" }) -} - /// Reserved v0.4 `meta`/`metrics` key names written from dedicated typed fields (`span.env`, /// chunk `origin`, ...) rather than from the attribute maps. An attribute sharing one of these /// names would otherwise collide with the dedicated field's entry on the wire; the dedicated @@ -65,11 +58,23 @@ const PROMOTED_ATTR_KEYS: &[&str] = &[ "_sampling_priority_v1", ]; -/// Chunk-level context propagated into every span when downgrading to v0.4. Built once per -/// chunk by the top-level encoder and passed by reference to `encode_span_v1_to_v04`. Also -/// carries payload-level fields (`payload_env`, `payload_app_version`, `payload_attributes`), -/// which apply as a fallback when the span itself doesn't set the equivalent field — v0.4 has -/// neither a chunk nor a payload concept, so both levels collapse onto every span. +/// Payload attributes that v0.4 carries on the first span of each trace only, as tracers wrote +/// them before V1 (process tags, the OTLP export marker), rather than on every span. +const FIRST_SPAN_PAYLOAD_ATTR_KEYS: &[&str] = &["_dd.tags.process", "_dd.sdk.otlp_export"]; + +/// Payload attributes that v0.4 carries on the local-root span of each trace only (git metadata). +const ROOT_SPAN_PAYLOAD_ATTR_KEYS: &[&str] = &["_dd.git.commit.sha", "_dd.git.repository_url"]; + +/// Chunk-level context propagated into the spans of a chunk when downgrading to v0.4. Built once +/// per chunk by the top-level encoder and passed by reference to `encode_span`. Also carries +/// payload-level fields (`payload_env`, `payload_app_version`, `payload_attributes`), which apply +/// as a fallback when the span itself doesn't set the equivalent field — v0.4 has neither a chunk +/// nor a payload concept, so both levels collapse onto the spans. +/// +/// Generic chunk/payload attributes (and env/version fallbacks) collapse onto every span, but the +/// trace-level tags `trace_id` high half (`_dd.p.tid`), `origin` (`_dd.origin`), +/// `sampling_mechanism` (`_dd.p.dm`) and `priority` (`_sampling_priority_v1`) are emitted only on +/// the local-root span — see `encode_span`'s `is_root` argument. /// /// `chunk_attrs_dd` / `payload_attrs_dd` are deduped once here rather than per span: unlike the /// span's own attributes, they're identical for every span in the chunk. @@ -109,11 +114,12 @@ impl<'a, T: TraceData> ChunkContext<'a, T> { } } -/// Maps a `SpanKind` to its v0.4 `span.kind` meta string. Returns `None` for `Internal` so -/// callers can skip emitting the default value. +/// Maps a `SpanKind` to its v0.4 `span.kind` meta string. Returns `None` for `Unspecified` so +/// callers can skip emitting an unset kind. fn span_kind_to_meta(kind: SpanKind) -> Option<&'static str> { match kind { - SpanKind::Internal => None, + SpanKind::Unspecified => None, + SpanKind::Internal => Some("internal"), SpanKind::Server => Some("server"), SpanKind::Client => Some("client"), SpanKind::Producer => Some("producer"), @@ -135,6 +141,17 @@ fn split_trace_id(trace_id: &[u8; 16]) -> (u64, u64) { ) } +/// Index of the chunk's local root: the first span whose parent isn't in the chunk (none, or +/// remote), else `0`. Trace-level context (`_dd.p.tid`, `_dd.origin`, ...) belongs on it only. +pub fn local_root_idx<'a, T: TraceData + 'a>( + mut spans: impl Iterator> + Clone, +) -> usize { + let ids: HashSet = spans.clone().map(|s| s.span_id).collect(); + spans + .position(|s| s.parent_id == 0 || !ids.contains(&s.parent_id)) + .unwrap_or(0) +} + /// Per-bucket counts for the v0.4 `meta`, `metrics`, and `meta_struct` maps. #[derive(Default)] struct BucketCounts { @@ -229,10 +246,19 @@ fn flatten_attr_into( /// # Errors /// /// This function will return any error emitted by the writer. +/// +/// `is_root` marks the chunk's local-root span. Trace-level context (`_dd.p.tid`, `_dd.origin`, +/// `_dd.p.dm`, `_sampling_priority_v1`) is emitted only for it — in v0.4 those tags live on the +/// local root, never on child spans. Generic chunk/payload attributes still propagate to every +/// span (v0.4 has no chunk concept, so they collapse onto each span), except +/// `FIRST_SPAN_PAYLOAD_ATTR_KEYS`, which only `is_first` (the chunk's first span) gets, and +/// `ROOT_SPAN_PAYLOAD_ATTR_KEYS`, which only `is_root` gets. pub(super) fn encode_span( writer: &mut W, span: &Span, chunk: &ChunkContext<'_, T>, + is_root: bool, + is_first: bool, ) -> Result<(), ValueWriteError> { let span_attrs_dd = span.attributes.defensive_dedup(); @@ -258,12 +284,26 @@ pub(super) fn encode_span( })) .chain(chunk.payload_attrs_dd.iter().filter(|(k, _)| { !PROMOTED_ATTR_KEYS.contains(&(*k).borrow()) + && (is_first || !FIRST_SPAN_PAYLOAD_ATTR_KEYS.contains(&(*k).borrow())) + && (is_root || !ROOT_SPAN_PAYLOAD_ATTR_KEYS.contains(&(*k).borrow())) && !span_attrs_dd.iter().any(|(k2, _)| k2 == *k) && !chunk.chunk_attrs_dd.iter().any(|(k2, _)| k2 == *k) })); let (trace_id_low, trace_id_high) = split_trace_id(chunk.trace_id); - let kind_meta = span_kind_to_meta(span.span_kind); + // `Unspecified` is also where a non-standard kind string (e.g. "process") ends up after + // ingestion collapses it; prefer the companion `span.kind` attribute it left behind. + let kind_meta = span_kind_to_meta(span.span_kind).or_else(|| { + span_attrs_dd.iter().find_map(|(k, v)| { + if k.borrow() != "span.kind" { + return None; + } + match v { + AttributeValue::String(s) => Some(s.borrow()), + _ => None, + } + }) + }); // `env`/`version` fall back to the payload-level value when the span doesn't set its own — // mirrors how a v1 tracer can set these once at the payload level instead of duplicating @@ -303,17 +343,22 @@ pub(super) fn encode_span( let meta_leaves = dedup_first_wins(meta_leaves); let metrics_leaves = dedup_first_wins(metrics_leaves); + // Span events go to the legacy `events` meta JSON: native v0.4 `span_events` needs agent 7.63+. + let span_events_json = + (!span.span_events.is_empty()).then(|| span_events_to_legacy_json(&span.span_events)); + // First pass: count bucket sizes so each msgpack map header carries the exact length. let mut counts = BucketCounts::default(); counts.meta += !env.is_empty() as u32; counts.meta += !version.is_empty() as u32; counts.meta += !span.component.borrow().is_empty() as u32; counts.meta += kind_meta.is_some() as u32; - counts.meta += (trace_id_high != 0) as u32; - counts.meta += !chunk.origin.borrow().is_empty() as u32; - counts.meta += chunk.sampling_mechanism.is_some() as u32; + counts.meta += (is_root && trace_id_high != 0) as u32; + counts.meta += (is_root && !chunk.origin.borrow().is_empty()) as u32; + counts.meta += (is_root && chunk.sampling_mechanism.is_some()) as u32; + counts.meta += span_events_json.is_some() as u32; counts.meta += meta_leaves.len() as u32; - counts.metrics += chunk.priority.is_some() as u32; + counts.metrics += (is_root && chunk.priority.is_some()) as u32; counts.metrics += metrics_leaves.len() as u32; counts.meta_struct += bytes_attrs.len() as u32; @@ -324,8 +369,7 @@ pub(super) fn encode_span( + (counts.meta > 0) as u32 + (counts.metrics > 0) as u32 + (counts.meta_struct > 0) as u32 - + (!span.span_links.is_empty()) as u32 - + (!span.span_events.is_empty()) as u32; + + (!span.span_links.is_empty()) as u32; write_map_len(writer, span_len)?; @@ -380,7 +424,7 @@ pub(super) fn encode_span( write_const_msgpack_str!(writer, "span.kind")?; write_str(writer, kind_str)?; } - if trace_id_high != 0 { + if is_root && trace_id_high != 0 { // Lower-case hex without `0x` prefix — the agent expects this format. write_const_msgpack_str!(writer, "_dd.p.tid")?; let mut buf = [0u8; 16]; @@ -390,14 +434,18 @@ pub(super) fn encode_span( .unwrap_or_default(); write_str(writer, hex_str)?; } - if !chunk.origin.borrow().is_empty() { + if is_root && !chunk.origin.borrow().is_empty() { write_const_msgpack_str!(writer, "_dd.origin")?; write_str(writer, chunk.origin.borrow())?; } - if let Some(mechanism) = chunk.sampling_mechanism { + if let Some(mechanism) = chunk.sampling_mechanism.filter(|_| is_root) { write_const_msgpack_str!(writer, "_dd.p.dm")?; - let mut buf = itoa::Buffer::new(); - write_str(writer, buf.format(-(mechanism as i64)))?; + // Always emit a leading '-' so mechanism 0 serializes as "-0", not "0". + write_str(writer, &format!("-{mechanism}"))?; + } + if let Some(events_json) = &span_events_json { + write_const_msgpack_str!(writer, "events")?; + write_str(writer, events_json)?; } for (k, v) in &meta_leaves { write_str(writer, k)?; @@ -409,7 +457,7 @@ pub(super) fn encode_span( write_const_msgpack_str!(writer, "metrics")?; write_map_len(writer, counts.metrics)?; - if let Some(priority) = chunk.priority { + if let Some(priority) = chunk.priority.filter(|_| is_root) { write_const_msgpack_str!(writer, "_sampling_priority_v1")?; write_f64(writer, priority as f64)?; } @@ -437,244 +485,258 @@ pub(super) fn encode_span( if !span.span_links.is_empty() { encode_span_links(writer, &span.span_links)?; } - if !span.span_events.is_empty() { - encode_span_events(writer, &span.span_events)?; - } Ok(()) } -/// Encodes [`v1::SpanLink`](crate::span::v1::SpanLink)s into the v0.4 msgpack wire format -/// (downgrade: v1 input → v0.4 output). The 128-bit `trace_id` is split into -/// `(trace_id, trace_id_high)` u64s. Typed link attributes are downgraded to strings; -/// non-string-coercible variants are dropped because v0.4 link attributes are `String → String` -/// only. +/// Writes the native v0.4 `span_links` field. v0.4 link attributes are `String → String`, so +/// scalars stringify and nested values carry their `json_encode` string. fn encode_span_links( writer: &mut W, span_links: &[SpanLink], ) -> Result<(), ValueWriteError> { write_const_msgpack_str!(writer, "span_links")?; - write_array_len(writer, span_links.len() as u32)?; - + rmp::encode::write_array_len(writer, span_links.len() as u32)?; for link in span_links { let (trace_id_low, trace_id_high) = split_trace_id(&link.trace_id); let attrs_dd = link.attributes.defensive_dedup(); - let attr_count = attrs_dd - .iter() - .filter(|(_, v)| matches!(v, AttributeValue::String(_) | AttributeValue::Bool(_))) - .count() as u32; - - let link_len = 3 // trace_id, trace_id_high, span_id (always) - + (attr_count > 0) as u32 + let link_len = 2 // trace_id, span_id + + (trace_id_high != 0) as u32 + + (!attrs_dd.is_empty()) as u32 + (!link.tracestate.borrow().is_empty()) as u32 + (link.flags != 0) as u32; - write_map_len(writer, link_len)?; write_const_msgpack_str!(writer, "trace_id")?; write_u64(writer, trace_id_low)?; - - write_const_msgpack_str!(writer, "trace_id_high")?; - write_u64(writer, trace_id_high)?; - + if trace_id_high != 0 { + write_const_msgpack_str!(writer, "trace_id_high")?; + write_u64(writer, trace_id_high)?; + } write_const_msgpack_str!(writer, "span_id")?; write_u64(writer, link.span_id)?; - if attr_count > 0 { + if !attrs_dd.is_empty() { write_const_msgpack_str!(writer, "attributes")?; - write_map_len(writer, attr_count)?; + write_map_len(writer, attrs_dd.len() as u32)?; for (k, v) in attrs_dd.iter() { + write_str(writer, k.borrow())?; match v { - AttributeValue::String(s) => { - write_str(writer, k.borrow())?; - write_str(writer, s.borrow())?; - } + AttributeValue::String(s) => write_str(writer, s.borrow())?, AttributeValue::Bool(b) => { - write_str(writer, k.borrow())?; - write_bool_as_str(writer, *b)?; + write_str(writer, if *b { "true" } else { "false" })? } - _ => {} + _ => write_str(writer, &attr_to_php_json(v))?, } } } - if !link.tracestate.borrow().is_empty() { write_const_msgpack_str!(writer, "tracestate")?; write_str(writer, link.tracestate.borrow())?; } - if link.flags != 0 { write_const_msgpack_str!(writer, "flags")?; - write_u32(writer, link.flags)?; + rmp::encode::write_u32(writer, link.flags)?; } } - Ok(()) } -/// Encodes [`v1::SpanEvent`](crate::span::v1::SpanEvent)s into the v0.4 msgpack wire format -/// (downgrade: v1 input → v0.4 output). Typed attributes are downgraded to the v0.4 -/// `{"type": , "_value": ...}` shape — see `write_event_attr_value`. `Bytes` and -/// `KeyValue` have no v0.4 event-attribute equivalent and are dropped. -fn encode_span_events( - writer: &mut W, - span_events: &[SpanEvent], -) -> Result<(), ValueWriteError> { - write_const_msgpack_str!(writer, "span_events")?; - write_array_len(writer, span_events.len() as u32)?; - - for event in span_events { - let attrs_dd = event.attributes.defensive_dedup(); - let attr_count = attrs_dd - .iter() - .filter(|(_, v)| is_supported_event_attr(v)) - .count() as u32; - - let event_len = 2 // time_unix_nano, name (always) - + (attr_count > 0) as u32; - - write_map_len(writer, event_len)?; - - write_const_msgpack_str!(writer, "time_unix_nano")?; - write_u64(writer, event.time_unix_nano)?; - - write_const_msgpack_str!(writer, "name")?; - write_str(writer, event.name.borrow())?; - - if attr_count > 0 { - write_const_msgpack_str!(writer, "attributes")?; - write_map_len(writer, attr_count)?; - for (k, v) in attrs_dd.iter() { - if !is_supported_event_attr(v) { - continue; +/// Serializes native v1 span events to the LEGACY v0.4 `events` meta value: a +/// `json_encode`-byte-identical JSON array of `{name, time_unix_nano, attributes?}` objects, +/// exactly as master's `DDTrace\SpanEvent::jsonSerialize` produced. On the v0.4 downgrade (agent +/// speaks only v0.4) old agents understand this legacy meta tag; the native top-level `span_events` +/// field is not emitted. +/// +/// Format (matching master + master's `dd_trace_span_event.phpt`, byte-for-byte): +/// * `name` — the event name (json string). +/// * `time_unix_nano` — the timestamp as an unquoted JSON number. +/// * `attributes` — emitted only when non-empty. Unlike links (which are `String → String`), event +/// attributes keep their NATIVE JSON types: `Int`/`Float` → numbers, `Bool` → `true`/`false`, +/// nested `List`/`KeyValue` → real JSON arrays/objects (recursively typed) — the exact +/// `json_encode` of the PHP attributes array. Values are produced by [`attr_to_php_json`]. +fn span_events_to_legacy_json(span_events: &[SpanEvent]) -> String { + let mut out = String::from("["); + for (i, event) in span_events.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str("{\"name\":"); + json_escape_str(&mut out, event.name.borrow()); + out.push_str(",\"time_unix_nano\":"); + let _ = write!(out, "{}", event.time_unix_nano); + + // Iterate attributes in their original (PHP insertion) order — `events` is a literal JSON + // string, so order must match master byte-for-byte. Event attributes come from a PHP array + // (unique keys), so no dedup is needed. All types are kept (json_encode types faithfully). + if !event.attributes.is_empty() { + out.push_str(",\"attributes\":{"); + for (j, (k, v)) in event.attributes.iter().enumerate() { + if j > 0 { + out.push(','); } - write_str(writer, k.borrow())?; - write_event_attr_value(writer, v)?; + json_escape_str(&mut out, k.borrow()); + out.push(':'); + write_attr_json(&mut out, v); } + out.push('}'); } - } - - Ok(()) -} -/// Returns `true` when `v` can be downgraded to a v0.4 event-attribute (scalar or scalar list). -fn is_supported_event_attr(v: &AttributeValue) -> bool { - matches!( - v, - AttributeValue::String(_) - | AttributeValue::Bool(_) - | AttributeValue::Int(_) - | AttributeValue::Float(_) - | AttributeValue::List(_) - ) + out.push('}'); + } + out.push(']'); + out } -macro_rules! write_type { - ($writer:expr, $int_type:expr, $str_type:expr) => {{ - write_map_len($writer, 2)?; - write_const_msgpack_str!($writer, "type")?; - write_u8($writer, $int_type)?; - write_str($writer, $str_type)?; - }}; +/// Serializes a non-scalar link/event `AttributeValue` (`List`/`KeyValue`/`Bytes`) into a JSON +/// string byte-identical to PHP's `json_encode($value)` with default flags — the exact bytes the +/// tracer's C serializer produced for these attributes before native nested attributes existed. +/// +/// v0.4 link/event attributes have no nested representation, so the pre-native wire always carried +/// the `json_encode` string; reproducing it here keeps that wire unchanged for old agents. Floats +/// follow PHP's `serialize_precision=-1` format (see [`write_php_json_float`]). `Bytes` cannot +/// originate from a PHP value and is encoded defensively as a (lossy-UTF-8) JSON string. +fn attr_to_php_json(v: &AttributeValue) -> String { + let mut out = String::new(); + write_attr_json(&mut out, v); + out } -/// Writes a v0.4 event-attribute value as `{"type": , "..._value": ...}`. Scalars produce a -/// 2-entry map; `List` produces `{"type": 4, "array_value": {"values": [...]}}` with each -/// element written via `write_event_array_element`. -fn write_event_attr_value( - writer: &mut W, - v: &AttributeValue, -) -> Result<(), ValueWriteError> { +fn write_attr_json(out: &mut String, v: &AttributeValue) { match v { - AttributeValue::String(s) => { - write_type!(writer, 0, "string_value"); - write_str(writer, s.borrow())?; - } - AttributeValue::Bool(b) => { - write_type!(writer, 1, "bool_value"); - write_bool(writer, *b).map_err(ValueWriteError::InvalidDataWrite)?; - } + AttributeValue::String(s) => json_escape_str(out, s.borrow()), + AttributeValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }), AttributeValue::Int(i) => { - write_type!(writer, 2, "int_value"); - write_sint(writer, *i)?; - } - AttributeValue::Float(f) => { - write_type!(writer, 3, "double_value"); - write_f64(writer, *f)?; + let _ = write!(out, "{i}"); } - AttributeValue::List(arr) => { - write_type!(writer, 4, "array_value"); - // Only scalar elements survive the downgrade; nested structural entries are - // skipped because v0.4 array elements must themselves be scalar. - let scalar_elems = arr.iter().filter(|e| is_scalar_array_elem(e)); - let elem_count = scalar_elems.clone().count() as u32; - write_map_len(writer, 1)?; - write_const_msgpack_str!(writer, "values")?; - write_array_len(writer, elem_count)?; - for elem in scalar_elems { - write_event_array_element(writer, elem)?; + AttributeValue::Float(f) => write_php_json_float(out, *f), + AttributeValue::Bytes(b) => json_escape_str(out, &String::from_utf8_lossy(b.borrow())), + AttributeValue::List(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_attr_json(out, item); } + out.push(']'); } - AttributeValue::Bytes(_) | AttributeValue::KeyValue(_) => { - // Filtered upstream by `is_supported_event_attr`; reachable only on a bug. - debug_assert!(false, "unsupported event attribute variant reached writer"); + AttributeValue::KeyValue(map) => { + out.push('{'); + for (i, (k, val)) in map.iter().enumerate() { + if i > 0 { + out.push(','); + } + json_escape_str(out, k.borrow()); + out.push(':'); + write_attr_json(out, val); + } + out.push('}'); } } - Ok(()) } -/// Returns `true` when `v` is a scalar that fits in a v0.4 `AttributeArrayValue` (no nesting). -fn is_scalar_array_elem(v: &AttributeValue) -> bool { - matches!( - v, - AttributeValue::String(_) - | AttributeValue::Bool(_) - | AttributeValue::Int(_) - | AttributeValue::Float(_) - ) +/// Appends `f` exactly as PHP's `json_encode` prints it (`zend_gcvt` mode 0): shortest round-trip +/// digits, in exponent form (`1.0e+20`, `1.0e-5`) when the decimal point position is > 17 or < -3. +fn write_php_json_float(out: &mut String, f: f64) { + if !f.is_finite() { + // json_encode rejects Inf/NaN; 0 is what it substitutes with JSON_PARTIAL_OUTPUT_ON_ERROR. + out.push('0'); + return; + } + if f.is_sign_negative() { + out.push('-'); + } + let (digits, exp) = php_shortest_digits(f.abs()); + let decpt = exp + 1; // zend_dtoa convention: value = 0.DIGITS * 10^decpt + if !(-3..=17).contains(&decpt) { + out.push_str(&digits[..1]); + out.push('.'); + out.push_str(if digits.len() > 1 { &digits[1..] } else { "0" }); + let sign = if exp < 0 { '-' } else { '+' }; + let _ = write!(out, "e{sign}{}", exp.unsigned_abs()); + } else if decpt <= 0 { + out.push_str("0."); + out.push_str(&"0".repeat(decpt.unsigned_abs() as usize)); + out.push_str(&digits); + } else { + let int_len = decpt as usize; + if digits.len() <= int_len { + out.push_str(&digits); + out.push_str(&"0".repeat(int_len - digits.len())); + } else { + out.push_str(&digits[..int_len]); + out.push('.'); + out.push_str(&digits[int_len..]); + } + } } -/// Writes a v0.4 `AttributeArrayValue` (scalar). Same `{"type", "..._value"}` shape as -/// `write_event_attr_value`, minus the `Array` variant — v0.4 array elements are scalar only. -fn write_event_array_element( - writer: &mut W, - v: &AttributeValue, -) -> Result<(), ValueWriteError> { - match v { - AttributeValue::String(s) => { - write_map_len(writer, 2)?; - write_const_msgpack_str!(writer, "type")?; - write_u8(writer, 0)?; - write_const_msgpack_str!(writer, "string_value")?; - write_str(writer, s.borrow())?; - } - AttributeValue::Bool(b) => { - write_map_len(writer, 2)?; - write_const_msgpack_str!(writer, "type")?; - write_u8(writer, 1)?; - write_const_msgpack_str!(writer, "bool_value")?; - write_bool(writer, *b).map_err(ValueWriteError::InvalidDataWrite)?; - } - AttributeValue::Int(i) => { - write_map_len(writer, 2)?; - write_const_msgpack_str!(writer, "type")?; - write_u8(writer, 2)?; - write_const_msgpack_str!(writer, "int_value")?; - write_sint(writer, *i)?; - } - AttributeValue::Float(f) => { - write_map_len(writer, 2)?; - write_const_msgpack_str!(writer, "type")?; - write_u8(writer, 3)?; - write_const_msgpack_str!(writer, "double_value")?; - write_f64(writer, *f)?; +/// Shortest round-trip significant digits and scientific exponent of a finite `f >= 0`. Rust rounds +/// exact halfway ties up where zend_dtoa rounds them to even (e.g. 110767565253548.125 -> ...12). +fn php_shortest_digits(f: f64) -> (String, i32) { + let split = |s: &str| -> (String, i32) { + let (m, e) = s.split_once('e').unwrap_or((s, "0")); + (m.replace('.', ""), e.parse().unwrap_or(0)) + }; + let (digits, exp) = split(&format!("{f:e}")); + let last = digits.as_bytes()[digits.len() - 1] - b'0'; + if last % 2 == 1 { + let head = &digits[..digits.len() - 1]; + for alt in [last - 1, last + 1].into_iter().filter(|&d| d <= 9) { + let cand = format!("{head}{alt}"); + if format!("{}.{}e{exp}", &cand[..1], &cand[1..]).parse::() != Ok(f) { + continue; + } + // A tie iff f's exact decimal expansion (<= 767 digits) is "5000...". + let (exact, exact_exp) = split(&format!("{f:.800e}")); + let lower = if alt < last { &cand } else { &digits }; + if exact_exp == exp { + if let Some(rest) = exact.strip_prefix(lower.as_str()) { + if rest.starts_with('5') && rest[1..].bytes().all(|b| b == b'0') { + return (cand, exp); + } + } + } } - _ => { - // Filtered upstream by `is_scalar_array_elem`; reachable only on a bug. - debug_assert!(false, "non-scalar array element reached writer"); + } + (digits, exp) +} + +/// Appends `s` as a JSON string literal (surrounding quotes included), escaped exactly like PHP's +/// `json_encode` with default flags: `"`, `\`, `/`, the `\b \f \n \r \t` shorthands, other control +/// chars and every non-ASCII code point as a lowercase `\uXXXX` escape (UTF-16, surrogate pairs +/// above U+FFFF). The result is therefore pure ASCII. +fn json_escape_str(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '/' => out.push_str("\\/"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c if c.is_ascii() => out.push(c), + c => { + let cp = c as u32; + if cp <= 0xFFFF { + let _ = write!(out, "\\u{cp:04x}"); + } else { + let v = cp - 0x10000; + let hi = 0xD800 + (v >> 10); + let lo = 0xDC00 + (v & 0x3FF); + let _ = write!(out, "\\u{hi:04x}\\u{lo:04x}"); + } + } } } - Ok(()) + out.push('"'); } #[cfg(test)] @@ -683,6 +745,7 @@ mod tests { //! `TracerPayload` via [`super::super::to_vec_from_v1`] and decodes the bytes with //! `rmpv` to assert on the resulting v0.4 shape — this implicitly checks that the output //! is also valid msgpack consumable by any standard v0.4 decoder (test-agent, agent, etc.). + use super::attr_to_php_json; use crate::span::v1::{ AttributeValue, AttributeValueBytes, SpanBytes, SpanEventBytes, SpanKind, SpanLinkBytes, TraceChunkBytes, TracerPayloadBytes, @@ -707,6 +770,22 @@ mod tests { } } + /// Recursively sorts map entries by key, as map order on the wire is unspecified. + fn sort_maps(v: Value) -> Value { + match v { + Value::Map(entries) => { + let mut entries: Vec<_> = entries + .into_iter() + .map(|(k, v)| (k, sort_maps(v))) + .collect(); + entries.sort_by_key(|(a, _)| a.to_string()); + Value::Map(entries) + } + Value::Array(items) => Value::Array(items.into_iter().map(sort_maps).collect()), + other => other, + } + } + /// Looks up `key` in a msgpack `Value::Map`. Returns `None` when absent so callers can /// distinguish "field missing" from "field empty". fn map_get<'a>(map: &'a Value, key: &str) -> Option<&'a Value> { @@ -810,6 +889,39 @@ mod tests { assert_eq!(map_get(meta, "http.method").unwrap().as_str(), Some("GET")); } + #[test] + fn tid_attribute_is_never_emitted_from_the_attribute_map() { + // A user-set `_dd.p.tid` attribute is dropped on every span: the local root carries the + // chunk trace-id high half instead, other spans carry nothing. + let tid_attr = || { + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("_dd.p.tid"), AttributeValue::String(bs("user-set"))); + attrs + }; + let root = SpanBytes { + attributes: tid_attr(), + ..minimal_span() + }; + let child = SpanBytes { + span_id: 2, + parent_id: 1, + attributes: tid_attr(), + ..minimal_span() + }; + let mut trace_id = [0u8; 16]; + trace_id[..8].copy_from_slice(&0xdead_beef_u64.to_be_bytes()); + let mut payload = minimal_payload(trace_id, root); + payload.chunks[0].spans.push(child); + let traces = encode_and_decode(&payload); + + let root_meta = map_get(&traces[0][0], "meta").expect("root meta present"); + assert_eq!( + map_get(root_meta, "_dd.p.tid").unwrap().as_str(), + Some("00000000deadbeef") + ); + assert!(map_get(&traces[0][1], "meta").is_none_or(|m| map_get(m, "_dd.p.tid").is_none())); + } + #[test] fn flattened_attribute_colliding_with_another_attribute_keeps_first_wins() { // "a" is a List whose first element flattens to "a.0"; "a.0" is also a literal @@ -843,14 +955,77 @@ mod tests { } #[test] - fn span_kind_internal_is_not_emitted() { - // Internal is the default and is implied by the absence of `meta["span.kind"]`. + fn span_kind_unspecified_is_not_emitted() { + // Unspecified is the default and is implied by the absence of `meta["span.kind"]`. let payload = minimal_payload([0u8; 16], minimal_span()); let traces = encode_and_decode(&payload); // meta is None overall since no other field forces it. assert!(map_get(&traces[0][0], "meta").is_none()); } + #[test] + fn span_kind_explicit_internal_is_emitted() { + let span = SpanBytes { + span_kind: SpanKind::Internal, + ..minimal_span() + }; + let traces = encode_and_decode(&minimal_payload([0u8; 16], span)); + let meta = map_get(&traces[0][0], "meta").expect("meta must be present"); + assert_eq!( + map_get(meta, "span.kind").unwrap().as_str(), + Some("internal") + ); + } + + #[test] + fn span_kind_unspecified_with_preserved_string_attribute_round_trips() { + // A non-standard kind (e.g. "process") collapses to Unspecified at ingestion but survives + // as a companion `span.kind` attribute; the downgrade must prefer it over omitting the key. + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("span.kind"), AttributeValue::String(bs("process"))); + let span = SpanBytes { + span_kind: SpanKind::Unspecified, + attributes: attrs, + ..minimal_span() + }; + let payload = minimal_payload([0u8; 16], span); + let traces = encode_and_decode(&payload); + let meta = map_get(&traces[0][0], "meta").expect("meta must be present"); + + assert_eq!( + map_get(meta, "span.kind").unwrap().as_str(), + Some("process") + ); + // Must be written exactly once (promoted, not duplicated as a generic attribute too). + let meta_entries = meta.as_map().expect("meta must be a map"); + let kind_count = meta_entries + .iter() + .filter(|(k, _)| k.as_str() == Some("span.kind")) + .count(); + assert_eq!( + kind_count, 1, + "duplicate \"span.kind\" key written to the wire" + ); + } + + #[test] + fn span_kind_known_value_ignores_stray_span_kind_attribute() { + // A recognized kind always wins over any (unexpected) `span.kind` attribute — the + // attribute fallback only kicks in for `Internal`. + let mut attrs: VecMap = VecMap::new(); + attrs.insert(bs("span.kind"), AttributeValue::String(bs("stale"))); + let span = SpanBytes { + span_kind: SpanKind::Server, + attributes: attrs, + ..minimal_span() + }; + let payload = minimal_payload([0u8; 16], span); + let traces = encode_and_decode(&payload); + let meta = map_get(&traces[0][0], "meta").expect("meta must be present"); + + assert_eq!(map_get(meta, "span.kind").unwrap().as_str(), Some("server")); + } + #[test] fn trace_id_128_bit_splits_into_low_field_and_high_meta() { // trace_id = 0x_DEADBEEF__CAFEBABE_DEADBEEF__CAFEBABE (high | low) @@ -1122,6 +1297,190 @@ mod tests { ); } + #[test] + fn trace_level_tags_only_on_local_root_not_children() { + // _dd.p.tid / _dd.origin / _dd.p.dm / _sampling_priority_v1 are trace-level in v0.4 and + // belong ONLY on the local-root span (parent_id == 0 here). A multi-span chunk must not + // stamp them onto child spans — doing so is what RC-A ("trace tags leak onto children") + // was: the downgrade injected chunk-level context into every span unconditionally. + let mut trace_id = [0u8; 16]; + trace_id[7] = 0xAB; // non-zero high half -> _dd.p.tid + let root = SpanBytes { + span_id: 1, + parent_id: 0, + ..minimal_span() + }; + let child = SpanBytes { + span_id: 2, + parent_id: 1, + ..minimal_span() + }; + let payload = TracerPayloadBytes { + chunks: vec![TraceChunkBytes { + trace_id, + priority: Some(1), + origin: bs("synthetics"), + sampling_mechanism: Some(4), + spans: vec![root, child], + ..Default::default() + }], + ..Default::default() + }; + let traces = encode_and_decode(&payload); + let trace = traces[0].as_array().expect("trace is array"); + assert_eq!(trace.len(), 2); + + // Root (index 0) carries every trace-level tag. + let root_meta = map_get(&trace[0], "meta").expect("root has meta"); + assert!( + map_get(root_meta, "_dd.p.tid").is_some(), + "root must carry _dd.p.tid" + ); + assert_eq!( + map_get(root_meta, "_dd.origin").unwrap().as_str(), + Some("synthetics") + ); + assert_eq!(map_get(root_meta, "_dd.p.dm").unwrap().as_str(), Some("-4")); + let root_metrics = map_get(&trace[0], "metrics").expect("root has metrics"); + assert_eq!( + map_get(root_metrics, "_sampling_priority_v1") + .unwrap() + .as_f64(), + Some(1.0) + ); + + // Child (index 1) must NOT carry any trace-level tag. + if let Some(child_meta) = map_get(&trace[1], "meta") { + assert!( + map_get(child_meta, "_dd.p.tid").is_none(), + "_dd.p.tid leaked onto child" + ); + assert!( + map_get(child_meta, "_dd.origin").is_none(), + "_dd.origin leaked onto child" + ); + assert!( + map_get(child_meta, "_dd.p.dm").is_none(), + "_dd.p.dm leaked onto child" + ); + } + if let Some(child_metrics) = map_get(&trace[1], "metrics") { + assert!( + map_get(child_metrics, "_sampling_priority_v1").is_none(), + "_sampling_priority_v1 leaked onto child" + ); + } + } + + #[test] + fn trace_level_tags_land_on_top_level_span_with_remote_parent() { + // Distributed trace: the local root has a non-zero parent_id (remote parent) but is + // marked _dd.top_level=1. The trace-level tags must land on it, not the first-listed span. + let mut top_level_attrs: VecMap = VecMap::new(); + top_level_attrs.insert(bs("_dd.top_level"), AttributeValue::Float(1.0)); + let leaf = SpanBytes { + span_id: 5, + parent_id: 9, // remote parent, not top level + ..minimal_span() + }; + let local_root = SpanBytes { + span_id: 9, + parent_id: 100, // remote parent + attributes: top_level_attrs, + ..minimal_span() + }; + let payload = TracerPayloadBytes { + chunks: vec![TraceChunkBytes { + trace_id: [0u8; 16], + origin: bs("rum"), + sampling_mechanism: Some(3), + spans: vec![leaf, local_root], + ..Default::default() + }], + ..Default::default() + }; + let traces = encode_and_decode(&payload); + let trace = traces[0].as_array().expect("trace is array"); + + // trace[0] is the leaf (non-root) — no trace-level tags. + if let Some(leaf_meta) = map_get(&trace[0], "meta") { + assert!( + map_get(leaf_meta, "_dd.origin").is_none(), + "_dd.origin leaked onto non-root" + ); + assert!( + map_get(leaf_meta, "_dd.p.dm").is_none(), + "_dd.p.dm leaked onto non-root" + ); + } + // trace[1] is the _dd.top_level local root — it carries them. + let root_meta = map_get(&trace[1], "meta").expect("local root has meta"); + assert_eq!( + map_get(root_meta, "_dd.origin").unwrap().as_str(), + Some("rum") + ); + assert_eq!(map_get(root_meta, "_dd.p.dm").unwrap().as_str(), Some("-3")); + } + + #[test] + fn trace_level_tags_land_on_remote_parent_root_not_first_span() { + // Inferred proxy: the web span is listed first but its parent (the inferred span, whose + // own parent is remote) is in the chunk, so the inferred span is the local root. + let web = SpanBytes { + span_id: 7, + parent_id: 9, + ..minimal_span() + }; + let inferred = SpanBytes { + span_id: 9, + parent_id: 2, + ..minimal_span() + }; + let payload = TracerPayloadBytes { + chunks: vec![TraceChunkBytes { + trace_id: [0u8; 16], + origin: bs("rum"), + spans: vec![web, inferred], + ..Default::default() + }], + ..Default::default() + }; + let traces = encode_and_decode(&payload); + let trace = traces[0].as_array().expect("trace is array"); + + if let Some(web_meta) = map_get(&trace[0], "meta") { + assert!( + map_get(web_meta, "_dd.origin").is_none(), + "_dd.origin leaked onto non-root" + ); + } + let root_meta = map_get(&trace[1], "meta").expect("local root has meta"); + assert_eq!( + map_get(root_meta, "_dd.origin").unwrap().as_str(), + Some("rum") + ); + } + + #[test] + fn sampling_mechanism_zero_encodes_as_negative_zero() { + let payload = TracerPayloadBytes { + chunks: vec![TraceChunkBytes { + trace_id: [0u8; 16], + sampling_mechanism: Some(0), + spans: vec![minimal_span()], + ..Default::default() + }], + ..Default::default() + }; + let traces = encode_and_decode(&payload); + let meta = map_get(&traces[0][0], "meta").expect("meta carries sampling_mechanism"); + assert_eq!( + map_get(meta, "_dd.p.dm").unwrap().as_str(), + Some("-0"), + "mechanism 0 must serialize as `-0`, not `0`, per the agent's convention" + ); + } + #[test] fn chunk_attributes_are_propagated_to_every_span_in_chunk() { let mut chunk_attrs: VecMap = VecMap::new(); @@ -1214,8 +1573,148 @@ mod tests { assert_eq!(map_get(meta, "shared").unwrap().as_str(), Some("chunk")); } + /// Two chunks of two spans; `process_tags_on_span` puts `_dd.tags.process` on each chunk's + /// first span (the pre-V1 shape) instead of on the payload. + fn process_tags_payload(process_tags_on_span: bool) -> TracerPayloadBytes { + let mut payload_attrs: VecMap = VecMap::new(); + if !process_tags_on_span { + payload_attrs.insert( + bs("_dd.tags.process"), + AttributeValue::String(bs("entrypoint.name:app")), + ); + } + payload_attrs.insert(bs("region"), AttributeValue::String(bs("eu"))); + if !process_tags_on_span { + payload_attrs.insert( + bs("_dd.sdk.otlp_export"), + AttributeValue::String(bs("false")), + ); + } + let chunk = |first_id: u64| { + let mut first = SpanBytes { + span_id: first_id, + ..minimal_span() + }; + first + .attributes + .insert(bs("own"), AttributeValue::String(bs("x"))); + if process_tags_on_span { + first.attributes.insert( + bs("_dd.tags.process"), + AttributeValue::String(bs("entrypoint.name:app")), + ); + first.attributes.insert( + bs("_dd.sdk.otlp_export"), + AttributeValue::String(bs("false")), + ); + } + TraceChunkBytes { + trace_id: [0u8; 16], + spans: vec![ + first, + SpanBytes { + span_id: first_id + 1, + parent_id: first_id, + ..minimal_span() + }, + ], + ..Default::default() + } + }; + // Deduped like `into_payload` output: an undeduped map iterates in HashMap order. + let mut payload = TracerPayloadBytes { + attributes: payload_attrs, + chunks: vec![chunk(1), chunk(10)], + ..Default::default() + }; + payload.dedup(); + payload + } + + #[test] + fn payload_process_tags_land_on_the_first_span_of_each_chunk_only() { + let traces = encode_and_decode(&process_tags_payload(false)); + for trace in &traces { + let first = map_get(&trace[0], "meta").expect("meta present"); + assert_eq!( + map_get(first, "_dd.tags.process").unwrap().as_str(), + Some("entrypoint.name:app") + ); + let child = map_get(&trace[1], "meta").expect("meta present"); + assert!(map_get(child, "_dd.tags.process").is_none()); + assert_eq!( + map_get(first, "_dd.sdk.otlp_export").unwrap().as_str(), + Some("false") + ); + assert!(map_get(child, "_dd.sdk.otlp_export").is_none()); + // Other payload attributes still reach every span. + assert_eq!(map_get(child, "region").unwrap().as_str(), Some("eu")); + } + // Same wire as the pre-V1 shape (process tags as a first-span attribute), up to map order. + let sorted = |traces: Vec| traces.into_iter().map(sort_maps).collect::>(); + assert_eq!( + sorted(encode_and_decode(&process_tags_payload(false))), + sorted(encode_and_decode(&process_tags_payload(true))) + ); + } + + #[test] + fn payload_git_metadata_lands_on_the_local_root_only() { + let mut payload_attrs: VecMap = VecMap::new(); + payload_attrs.insert(bs("_dd.git.commit.sha"), AttributeValue::String(bs("abc"))); + let payload = TracerPayloadBytes { + attributes: payload_attrs, + chunks: vec![TraceChunkBytes { + trace_id: [0u8; 16], + // The child comes first, so the first span and the local root differ. + spans: vec![ + SpanBytes { + span_id: 2, + parent_id: 1, + ..minimal_span() + }, + SpanBytes { + span_id: 1, + ..minimal_span() + }, + ], + ..Default::default() + }], + ..Default::default() + }; + let traces = encode_and_decode(&payload); + assert!(map_get(&traces[0][0], "meta").is_none()); + let root = map_get(&traces[0][1], "meta").expect("meta present"); + assert_eq!( + map_get(root, "_dd.git.commit.sha").unwrap().as_str(), + Some("abc") + ); + } + + #[test] + fn dropped_trace_preserves_priority() { + // A dropped_trace chunk keeps its own priority: AUTO_REJECT `0` stays `0`. + let payload = TracerPayloadBytes { + chunks: vec![TraceChunkBytes { + trace_id: [0u8; 16], + dropped_trace: true, + priority: Some(0), + spans: vec![minimal_span()], + ..Default::default() + }], + ..Default::default() + }; + let traces = encode_and_decode(&payload); + let metrics = map_get(&traces[0][0], "metrics").expect("metrics present"); + assert_eq!( + map_get(metrics, "_sampling_priority_v1").unwrap().as_f64(), + Some(0.0) + ); + } + #[test] - fn dropped_trace_forces_user_reject_priority() { + fn dropped_trace_without_priority_defaults_to_user_reject() { + // With no priority set, a dropped_trace chunk defaults to `-1` (USER_REJECT). let payload = TracerPayloadBytes { chunks: vec![TraceChunkBytes { trace_id: [0u8; 16], @@ -1289,8 +1788,18 @@ mod tests { ); } + /// The span's native `span_links` entry at `idx`, with no legacy `_dd.span_links` meta. + fn native_link(span: &Value, idx: usize) -> &Value { + let meta = map_get(span, "meta"); + assert!(meta.is_none_or(|m| map_get(m, "_dd.span_links").is_none())); + &map_get(span, "span_links") + .expect("span_links present") + .as_array() + .unwrap()[idx] + } + #[test] - fn span_link_splits_trace_id_into_low_and_high_fields() { + fn span_link_is_encoded_as_native_v04_span_links() { let mut link_tid = [0u8; 16]; link_tid[..8].copy_from_slice(&0xAAAA_BBBB_CCCC_DDDD_u64.to_be_bytes()); link_tid[8..].copy_from_slice(&0x1111_2222_3333_4444_u64.to_be_bytes()); @@ -1298,7 +1807,6 @@ mod tests { let mut link_attrs: VecMap = VecMap::new(); link_attrs.insert(bs("link.name"), AttributeValue::String(bs("job-42"))); link_attrs.insert(bs("link.retry"), AttributeValue::Bool(true)); - // Non-string/bool typed attrs must be dropped (v0.4 SpanLink is String→String only). link_attrs.insert(bs("link.count"), AttributeValue::Int(5)); let payload = minimal_payload( @@ -1316,11 +1824,7 @@ mod tests { ); let traces = encode_and_decode(&payload); - let links = map_get(&traces[0][0], "span_links").expect("span_links present"); - let links_arr = links.as_array().expect("span_links is array"); - assert_eq!(links_arr.len(), 1); - let link = &links_arr[0]; - + let link = native_link(&traces[0][0], 0); assert_eq!( map_get(link, "trace_id").unwrap().as_u64(), Some(0x1111_2222_3333_4444) @@ -1335,15 +1839,43 @@ mod tests { Some("dd=t.dm:-1") ); assert_eq!(map_get(link, "flags").unwrap().as_u64(), Some(3)); - - let attrs = map_get(link, "attributes").expect("string attrs preserved"); + // v0.4 link attributes are String -> String: scalars stringify. + let attrs = map_get(link, "attributes").unwrap(); assert_eq!( map_get(attrs, "link.name").unwrap().as_str(), Some("job-42") ); assert_eq!(map_get(attrs, "link.retry").unwrap().as_str(), Some("true")); - // Int attr was dropped — v0.4 SpanLink schema cannot carry it. - assert!(map_get(attrs, "link.count").is_none()); + assert_eq!(map_get(attrs, "link.count").unwrap().as_str(), Some("5")); + } + + #[test] + fn span_link_omits_unset_optional_fields() { + let mut link_tid = [0u8; 16]; + link_tid[8..].copy_from_slice(&0x1111_2222_3333_4444_u64.to_be_bytes()); + let payload = minimal_payload( + [0u8; 16], + SpanBytes { + span_links: ThinVec::from_iter([SpanLinkBytes { + trace_id: link_tid, + span_id: 7, + attributes: VecMap::new(), + tracestate: bs(""), + flags: 0, + }]), + ..minimal_span() + }, + ); + let traces = encode_and_decode(&payload); + let link = native_link(&traces[0][0], 0); + let mut keys: Vec<&str> = link + .as_map() + .unwrap() + .iter() + .map(|(k, _)| k.as_str().unwrap()) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, ["span_id", "trace_id"]); } #[test] @@ -1367,39 +1899,190 @@ mod tests { ); let traces = encode_and_decode(&payload); - let events = map_get(&traces[0][0], "span_events").expect("span_events present"); - let events_arr = events.as_array().expect("span_events is array"); - assert_eq!(events_arr.len(), 1); - let event = &events_arr[0]; + let span = &traces[0][0]; + // No native span_events field on the v0.4 downgrade — events go to the legacy `events` + // meta, json_encode-byte-identical to master (native attribute types preserved: + // bool/int/float stay JSON bool/number, NOT the native `{"type":..}` shape). + assert!(map_get(span, "span_events").is_none()); + let meta = map_get(span, "meta").expect("meta present"); + let expected = "[{\"name\":\"oops\",\"time_unix_nano\":1700000000000000000,\ +\"attributes\":{\"kind\":\"exception\",\"escaped\":true,\"count\":3,\"ratio\":0.75}}]"; + assert_eq!(map_get(meta, "events").unwrap().as_str(), Some(expected)); + } - assert_eq!(map_get(event, "name").unwrap().as_str(), Some("oops")); - assert_eq!( - map_get(event, "time_unix_nano").unwrap().as_u64(), - Some(1_700_000_000_000_000_000) + /// Locks `attr_to_php_json` to PHP `json_encode($v)` (default flags): slash + non-ASCII + /// escaping, whole-number floats without a trailing `.0`, list vs object, nested structures. + /// The right-hand strings are the literal bytes captured from `php -r 'echo json_encode(...)'`. + #[test] + fn attr_to_php_json_matches_php_json_encode() { + let list = |v: Vec| AttributeValue::List(v); + let s = |x: &str| AttributeValue::String(bs(x)); + let mut ab: VecMap = VecMap::new(); + ab.insert(bs("a"), AttributeValue::Int(1)); + ab.insert( + bs("b"), + list(vec![AttributeValue::Int(2), AttributeValue::Int(3)]), ); - // Each typed attribute decodes to a `{"type": , "_value": value}` map. - let attrs = map_get(event, "attributes").expect("event attributes present"); - let kind = map_get(attrs, "kind").unwrap(); - assert_eq!(map_get(kind, "type").unwrap().as_u64(), Some(0)); assert_eq!( - map_get(kind, "string_value").unwrap().as_str(), - Some("exception") + attr_to_php_json(&list(vec![AttributeValue::Int(3), AttributeValue::Int(4)])), + "[3,4]" + ); + assert_eq!( + attr_to_php_json(&list(vec![s("5"), s("6")])), + r#"["5","6"]"# + ); + assert_eq!( + attr_to_php_json(&AttributeValue::KeyValue(ab)), + r#"{"a":1,"b":[2,3]}"# + ); + // Whole-number floats drop the fractional part; decimal-range floats round-trip shortest. + assert_eq!( + attr_to_php_json(&list(vec![AttributeValue::Float(1.0)])), + "[1]" + ); + assert_eq!( + attr_to_php_json(&list(vec![AttributeValue::Float(0.75)])), + "[0.75]" + ); + assert_eq!( + attr_to_php_json(&list(vec![AttributeValue::Float(1.5)])), + "[1.5]" + ); + // String escaping: forward slash, quote/backslash, control shorthands, non-ASCII, astral. + assert_eq!(attr_to_php_json(&list(vec![s("a/b")])), r#"["a\/b"]"#); + assert_eq!(attr_to_php_json(&list(vec![s("q\"\\")])), r#"["q\"\\"]"#); + assert_eq!(attr_to_php_json(&list(vec![s("t\tn\n")])), r#"["t\tn\n"]"#); + // Build expected `\uXXXX` escapes via an explicit backslash so no literal `\u` bigram + // appears in this source (non-ASCII escapes are what json_encode's default flags emit). + let bslash = '\\'; + assert_eq!( + attr_to_php_json(&list(vec![s("é")])), + format!("[\"{bslash}u00e9\"]") ); - - let escaped = map_get(attrs, "escaped").unwrap(); - assert_eq!(map_get(escaped, "type").unwrap().as_u64(), Some(1)); assert_eq!( - map_get(escaped, "bool_value").unwrap().as_bool(), - Some(true) + attr_to_php_json(&list(vec![s("😀")])), + format!("[\"{bslash}ud83d{bslash}ude00\"]") + ); + assert_eq!( + attr_to_php_json(&list(vec![AttributeValue::Bool(true)])), + "[true]" + ); + } + + /// Locks float formatting to PHP `json_encode($f)` (serialize_precision=-1). The right-hand + /// strings are the literal output of `php -r 'echo json_encode($f);'` (PHP 8.3). + #[test] + fn attr_to_php_json_floats_match_php_json_encode() { + let cases: &[(f64, &str)] = &[ + (1e20, "1.0e+20"), + (-1e20, "-1.0e+20"), + (1e-5, "1.0e-5"), + (9.99e-5, "9.99e-5"), + (1e-4, "0.0001"), + (0.1, "0.1"), + (0.05, "0.05"), + (1.5, "1.5"), + (1e15, "1000000000000000"), + (1e16, "10000000000000000"), + (1e17, "1.0e+17"), + (123456789012345678.0, "1.2345678901234568e+17"), + (12345678901234567.0, "12345678901234568"), + (1.0 / 3.0, "0.3333333333333333"), + (-2.5e-7, "-2.5e-7"), + (1e100, "1.0e+100"), + (f64::MAX, "1.7976931348623157e+308"), + (5e-324, "5.0e-324"), + (0.0, "0"), + (-0.0, "-0"), + (1.0, "1"), + (3.0, "3"), + (100.0, "100"), + // 110767565253548.125 exactly, a halfway tie: zend_dtoa rounds to even, Rust up. + (f64::from_bits(0x42d9_2f85_584a_eb08), "110767565253548.12"), + ]; + for &(f, want) in cases { + assert_eq!( + attr_to_php_json(&AttributeValueBytes::Float(f)), + want, + "{f:e}" + ); + } + // Inf/NaN have no JSON form; json_encode's partial-output substitute is 0. + assert_eq!(attr_to_php_json(&AttributeValueBytes::Float(f64::NAN)), "0"); + } + + #[test] + fn span_link_nested_attr_downgrades_to_json_string() { + // v0.4 link attributes are strings: a native nested value carries its json_encode string. + let mut link_attrs: VecMap = VecMap::new(); + link_attrs.insert(bs("plain"), AttributeValue::String(bs("v"))); + link_attrs.insert( + bs("nums"), + AttributeValue::List(vec![AttributeValue::Int(3), AttributeValue::Int(4)]), + ); + let mut kv: VecMap = VecMap::new(); + kv.insert(bs("a"), AttributeValue::Int(1)); + link_attrs.insert(bs("obj"), AttributeValue::KeyValue(kv)); + + let payload = minimal_payload( + [0u8; 16], + SpanBytes { + span_links: ThinVec::from_iter([SpanLinkBytes { + trace_id: [0u8; 16], + span_id: 7, + attributes: link_attrs, + tracestate: bs(""), + flags: 0, + }]), + ..minimal_span() + }, + ); + + let traces = encode_and_decode(&payload); + let attrs = map_get(native_link(&traces[0][0], 0), "attributes").unwrap(); + assert_eq!(map_get(attrs, "plain").unwrap().as_str(), Some("v")); + assert_eq!(map_get(attrs, "nums").unwrap().as_str(), Some("[3,4]")); + assert_eq!(map_get(attrs, "obj").unwrap().as_str(), Some("{\"a\":1}")); + } + + #[test] + fn span_event_nested_attr_in_legacy_events_meta_keeps_native_json_arrays() { + // Unlike links (String → String), event attributes keep native JSON types in the legacy + // `events` meta: a nested list is a real JSON array, not a stringified `string_value`. + // Byte target: master's tests/ext/request-replayer/dd_trace_span_event.phpt. + let mut event_attrs: VecMap = VecMap::new(); + event_attrs.insert(bs("arg1"), AttributeValue::String(bs("value1"))); + event_attrs.insert( + bs("int_array"), + AttributeValue::List(vec![AttributeValue::Int(3), AttributeValue::Int(4)]), + ); + event_attrs.insert( + bs("string_array"), + AttributeValue::List(vec![ + AttributeValue::String(bs("5")), + AttributeValue::String(bs("6")), + ]), ); - let count = map_get(attrs, "count").unwrap(); - assert_eq!(map_get(count, "type").unwrap().as_u64(), Some(2)); - assert_eq!(map_get(count, "int_value").unwrap().as_i64(), Some(3)); + let payload = minimal_payload( + [0u8; 16], + SpanBytes { + span_events: ThinVec::from_iter([SpanEventBytes { + time_unix_nano: 1720037568765201300, + name: bs("event-name"), + attributes: event_attrs, + }]), + ..minimal_span() + }, + ); - let ratio = map_get(attrs, "ratio").unwrap(); - assert_eq!(map_get(ratio, "type").unwrap().as_u64(), Some(3)); - assert_eq!(map_get(ratio, "double_value").unwrap().as_f64(), Some(0.75)); + let traces = encode_and_decode(&payload); + let span = &traces[0][0]; + assert!(map_get(span, "span_events").is_none()); + let meta = map_get(span, "meta").expect("meta present"); + let expected = "[{\"name\":\"event-name\",\"time_unix_nano\":1720037568765201300,\ +\"attributes\":{\"arg1\":\"value1\",\"int_array\":[3,4],\"string_array\":[\"5\",\"6\"]}}]"; + assert_eq!(map_get(meta, "events").unwrap().as_str(), Some(expected)); } } diff --git a/libdd-trace-utils/src/msgpack_encoder/v1/mod.rs b/libdd-trace-utils/src/msgpack_encoder/v1/mod.rs index be9920bee9..9848ed4a34 100644 --- a/libdd-trace-utils/src/msgpack_encoder/v1/mod.rs +++ b/libdd-trace-utils/src/msgpack_encoder/v1/mod.rs @@ -1288,20 +1288,23 @@ mod v1_payload_tests { } #[test] - fn span_kind_is_always_emitted_as_uint() { - // Default SpanKind (Internal=1) must be emitted. The encoded payload contains - // `kind_key (0x10) | uint 1 (0x01)`. - let chunk = make_chunk(vec![make_span("svc", "op", 1)], [0u8; 16]); - let payload = TracerPayloadBytes { - chunks: vec![chunk], - ..Default::default() + fn span_kind_unspecified_is_omitted_and_internal_is_emitted() { + // Unspecified (0, the default) is left off the wire; an explicit Internal is written as + // `kind_key (0x10) | uint 1 (0x01)`, exactly those 2 extra bytes. + let encode = |kind: SpanKind| { + let span = V1Span { + span_kind: kind, + ..make_span("svc", "op", 1) + }; + to_vec_from_v1(&TracerPayloadBytes { + chunks: vec![make_chunk(vec![span], [0u8; 16])], + ..Default::default() + }) }; - let encoded = to_vec_from_v1(&payload); - let pat = [0x10u8, 0x01u8]; - assert!( - encoded.windows(2).any(|w| w == pat), - "Kind (key=16) Internal (=1) must be emitted" - ); + let unspecified = encode(SpanKind::Unspecified); + let internal = encode(SpanKind::Internal); + assert_eq!(internal.len(), unspecified.len() + 2); + assert!(internal.windows(2).any(|w| w == [0x10u8, 0x01u8])); } #[test] @@ -1588,4 +1591,75 @@ mod v1_payload_tests { "interning should reduce repeated payload size" ); } + + // Regression: the FFI v1 builder inserts attributes without maintaining the VecMap deduped + // invariant, so before `TracerPayload::dedup` the encoder had to dedup on the fly (and warn) + // on every encode. `dedup` (called from the builder's `into_payload` finalize) must leave the + // invariant set at every level while keeping last-write-wins, so the encode is warning-free + // and only the winning values reach the wire. + #[test] + fn dedup_sets_invariant_and_encodes_last_write_wins() { + use libdd_tinybytes::Bytes; + + let mut attributes: VecMap> = + VecMap::new(); + // Duplicate string key: last write ("POST") must win. + attributes.insert(bs("http.method"), AttributeValue::String(bs("GET"))); + attributes.insert(bs("http.method"), AttributeValue::String(bs("POST"))); + // A numeric attribute (v0.4 metric). + attributes.insert(bs("rows"), AttributeValue::Int(42)); + // A meta_struct bytes attribute (msgpack blob), duplicated: last write ("NEW") must win. + attributes.insert( + bs("_dd.appsec.json"), + AttributeValue::Bytes(Bytes::from_underlying(b"OLD".to_vec())), + ); + attributes.insert( + bs("_dd.appsec.json"), + AttributeValue::Bytes(Bytes::from_underlying(b"NEW".to_vec())), + ); + assert!(!attributes.is_deduped()); + + let span = V1Span { + attributes, + ..make_span("svc", "op", 1) + }; + let mut payload = TracerPayloadBytes { + chunks: vec![make_chunk(vec![span], [0u8; 16])], + ..Default::default() + }; + + // Before finalize the map carries duplicates and would trigger the encoder's defensive + // dedup (and its warning). + assert!(!payload.chunks[0].spans[0].attributes.is_deduped()); + + payload.dedup(); + + let attrs = &payload.chunks[0].spans[0].attributes; + // The deduped invariant is set, so the encoder's `defensive_dedup` takes the no-warn + // borrowed branch. + assert!(attrs.is_deduped()); + // Duplicates collapsed: http.method, rows, _dd.appsec.json. + assert_eq!(attrs.len(), 3); + + let encoded = to_vec_from_v1(&payload); + assert!(!encoded.is_empty()); + // The winning meta_struct bytes are on the wire; the shadowed duplicate is not. + assert!( + encoded.windows(3).any(|w| w == b"NEW"), + "winning meta_struct bytes must be encoded" + ); + assert!( + !encoded.windows(3).any(|w| w == b"OLD"), + "shadowed meta_struct duplicate must not be encoded" + ); + // Winning string value on the wire; shadowed value gone. + assert!( + encoded.windows(4).any(|w| w == b"POST"), + "last-write-wins string value must be encoded" + ); + assert!( + !encoded.windows(3).any(|w| w == b"GET"), + "shadowed string duplicate must not be encoded" + ); + } } diff --git a/libdd-trace-utils/src/msgpack_encoder/v1/span_v1.rs b/libdd-trace-utils/src/msgpack_encoder/v1/span_v1.rs index dcb7acd8fc..bc8e73e09e 100644 --- a/libdd-trace-utils/src/msgpack_encoder/v1/span_v1.rs +++ b/libdd-trace-utils/src/msgpack_encoder/v1/span_v1.rs @@ -5,7 +5,7 @@ //! (Convention documented in [`crate::msgpack_encoder`].) use crate::span::TraceData; -use crate::span::v1::{AttributeValue, Span, SpanEvent, SpanLink}; +use crate::span::v1::{AttributeValue, Span, SpanEvent, SpanKind, SpanLink}; use crate::span::vec_map::VecMap; use rmp::encode::{ RmpWrite, ValueWriteError, write_array_len, write_bin, write_bool, write_f64, write_map_len, @@ -247,8 +247,9 @@ pub(super) fn encode_span( let has_env = !span.env.borrow().is_empty(); let has_version = !span.version.borrow().is_empty(); let has_component = !span.component.borrow().is_empty(); + let has_kind = span.span_kind != SpanKind::Unspecified; - let span_len = 3 // span_id, start, kind — always present + let span_len = 2 // span_id, start — always present + (!span.service.borrow().is_empty()) as u32 + (!span.name.borrow().is_empty()) as u32 + (!span.resource.borrow().is_empty()) as u32 @@ -261,7 +262,8 @@ pub(super) fn encode_span( + (!span.span_events.is_empty()) as u32 + has_env as u32 + has_version as u32 - + has_component as u32; + + has_component as u32 + + has_kind as u32; write_map_len(writer, span_len)?; @@ -331,9 +333,11 @@ pub(super) fn encode_span( write_uint8(writer, SpanKey::Component as u8)?; table.write_interned(writer, span.component.borrow())?; } - // SpanKind is always emitted (default = Internal). - write_uint8(writer, SpanKey::Kind as u8)?; - write_uint(writer, span.span_kind as u64)?; + // Unspecified (0) is the proto3 default, so it is left off the wire. + if has_kind { + write_uint8(writer, SpanKey::Kind as u8)?; + write_uint(writer, span.span_kind as u64)?; + } Ok(()) } diff --git a/libdd-trace-utils/src/span/v1/mod.rs b/libdd-trace-utils/src/span/v1/mod.rs index cd5021903a..5ecf694669 100644 --- a/libdd-trace-utils/src/span/v1/mod.rs +++ b/libdd-trace-utils/src/span/v1/mod.rs @@ -6,11 +6,12 @@ use crate::span::{BytesData, SliceData, TraceData}; pub use thin_vec::ThinVec; /// OpenTelemetry SpanKind values, encoded on the wire as a `uint32`. -/// Unset or unrecognized kinds default to [`SpanKind::Internal`]. +/// Unset or unrecognized kinds are [`SpanKind::Unspecified`] (OTel `SPAN_KIND_UNSPECIFIED`). #[repr(u32)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SpanKind { #[default] + Unspecified = 0, Internal = 1, Server = 2, Client = 3, @@ -20,20 +21,23 @@ pub enum SpanKind { impl SpanKind { /// Parses a v0.4 `span.kind` meta value into a [`SpanKind`]. - /// Unrecognized values map to [`SpanKind::Internal`]. + /// Unrecognized values map to [`SpanKind::Unspecified`]. pub fn from_meta(s: &str) -> Self { match s { + "internal" => SpanKind::Internal, "server" => SpanKind::Server, "client" => SpanKind::Client, "producer" => SpanKind::Producer, "consumer" => SpanKind::Consumer, - _ => SpanKind::Internal, + _ => SpanKind::Unspecified, } } - /// Renders this [`SpanKind`] as the lowercase string used for the v0.4 `span.kind` meta value. + /// Renders this [`SpanKind`] as the lowercase string used for the v0.4 `span.kind` meta value + /// (empty for `Unspecified`). pub fn as_meta_str(&self) -> &'static str { match self { + SpanKind::Unspecified => "", SpanKind::Internal => "internal", SpanKind::Server => "server", SpanKind::Client => "client", @@ -44,14 +48,15 @@ impl SpanKind { } impl From for SpanKind { - /// OTEL SpanKind wire value → enum; unset/unknown → Internal (per OTEL spec). + /// OTEL SpanKind wire value → enum; unset/unknown → Unspecified. fn from(kind: u32) -> Self { match kind { + 1 => SpanKind::Internal, 2 => SpanKind::Server, 3 => SpanKind::Client, 4 => SpanKind::Producer, 5 => SpanKind::Consumer, - _ => SpanKind::Internal, + _ => SpanKind::Unspecified, } } } @@ -180,13 +185,142 @@ pub type AttributeValueSlice<'a> = AttributeValue>; pub type TraceChunkSlice<'a> = TraceChunk>; pub type TracerPayloadSlice<'a> = TracerPayload>; +type AttributeMap = VecMap<::Text, AttributeValue>; + +/// Applies `f` to an attribute map, then to every map nested in its values. `f` runs first so a +/// dedup never visits the values of dropped duplicates. +fn visit_attr_map( + map: &mut AttributeMap, + f: &mut impl FnMut(&mut AttributeMap), +) { + f(map); + for v in map.values_mut() { + v.visit_attr_maps(f); + } +} + +impl AttributeValue { + fn visit_attr_maps(&mut self, f: &mut impl FnMut(&mut AttributeMap)) { + match self { + AttributeValue::KeyValue(map) => visit_attr_map(map, f), + AttributeValue::List(list) => list.iter_mut().for_each(|v| v.visit_attr_maps(f)), + _ => {} + } + } +} + +impl Span { + fn visit_attr_maps(&mut self, f: &mut impl FnMut(&mut AttributeMap)) { + visit_attr_map(&mut self.attributes, f); + for link in &mut self.span_links { + visit_attr_map(&mut link.attributes, f); + } + for event in &mut self.span_events { + visit_attr_map(&mut event.attributes, f); + } + } + + /// Dedup this span's attribute maps, including its links' and events'. + pub fn dedup(&mut self) { + self.visit_attr_maps(&mut VecMap::dedup); + } +} + +impl TraceChunk { + fn visit_attr_maps(&mut self, f: &mut impl FnMut(&mut AttributeMap)) { + visit_attr_map(&mut self.attributes, f); + for span in &mut self.spans { + span.visit_attr_maps(f); + } + } + + /// Dedup the chunk's attribute maps and those of every span it carries. + pub fn dedup(&mut self) { + self.visit_attr_maps(&mut VecMap::dedup); + } +} + +impl TracerPayload { + fn visit_attr_maps(&mut self, f: &mut impl FnMut(&mut AttributeMap)) { + visit_attr_map(&mut self.attributes, f); + for chunk in &mut self.chunks { + chunk.visit_attr_maps(f); + } + } + + /// Dedup every attribute map, so encoding takes the no-copy path of `defensive_dedup`. + pub fn dedup(&mut self) { + self.visit_attr_maps(&mut VecMap::dedup); + } + + /// Flags every attribute map as deduped without scanning, for a payload decoded from an + /// encoding of an already-deduped payload (see [`VecMap::mark_deduped`]). + pub fn mark_deduped(&mut self) { + self.visit_attr_maps(&mut VecMap::mark_deduped); + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn span_kind_default_is_internal() { - assert_eq!(SpanKind::default(), SpanKind::Internal); + fn span_kind_default_is_unspecified() { + assert_eq!(SpanKind::default(), SpanKind::Unspecified); + assert_eq!(SpanKind::from(0), SpanKind::Unspecified); + assert_eq!(SpanKind::from(1), SpanKind::Internal); + assert_eq!(SpanKind::from(9), SpanKind::Unspecified); + } + + fn nested_attrs() -> AttributeMap { + let mut inner = VecMap::new(); + inner.insert("k".into(), AttributeValue::Int(1)); + inner.insert("k".into(), AttributeValue::Int(2)); + let mut map = VecMap::new(); + map.insert("m".into(), AttributeValue::KeyValue(inner)); + map + } + + fn nested_is_deduped(map: &AttributeMap) -> bool { + matches!(map.get("m"), Some(AttributeValue::KeyValue(m)) if m.is_deduped()) + } + + #[test] + fn payload_dedup_reaches_nested_maps_and_keeps_flags_set() { + let mut payload = TracerPayloadBytes { + attributes: nested_attrs(), + chunks: vec![TraceChunk { + spans: vec![Span { + attributes: nested_attrs(), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + payload.dedup(); + let span_attrs = &payload.chunks[0].spans[0].attributes; + assert!(payload.attributes.is_deduped() && span_attrs.is_deduped()); + assert!(nested_is_deduped(&payload.attributes) && nested_is_deduped(span_attrs)); + let Some(AttributeValue::KeyValue(m)) = span_attrs.get("m") else { + panic!("nested map missing"); + }; + assert_eq!(m.len(), 1); + assert_eq!(m.get("k"), Some(&AttributeValue::Int(2))); + } + + #[test] + fn payload_mark_deduped_flags_nested_maps_without_dropping_entries() { + let mut payload = TracerPayloadBytes { + attributes: nested_attrs(), + ..Default::default() + }; + payload.mark_deduped(); + assert!(payload.attributes.is_deduped() && nested_is_deduped(&payload.attributes)); + let Some(AttributeValue::KeyValue(m)) = payload.attributes.get("m") else { + panic!("nested map missing"); + }; + assert_eq!(m.len(), 2); } #[test] @@ -196,12 +330,13 @@ mod tests { assert_eq!(SpanKind::from_meta("producer"), SpanKind::Producer); assert_eq!(SpanKind::from_meta("consumer"), SpanKind::Consumer); assert_eq!(SpanKind::from_meta("internal"), SpanKind::Internal); - assert_eq!(SpanKind::from_meta(""), SpanKind::Internal); - assert_eq!(SpanKind::from_meta("anything-else"), SpanKind::Internal); + assert_eq!(SpanKind::from_meta(""), SpanKind::Unspecified); + assert_eq!(SpanKind::from_meta("anything-else"), SpanKind::Unspecified); } #[test] fn span_kind_repr_matches_otel_spec() { + assert_eq!(SpanKind::Unspecified as u32, 0); assert_eq!(SpanKind::Internal as u32, 1); assert_eq!(SpanKind::Server as u32, 2); assert_eq!(SpanKind::Client as u32, 3); @@ -210,9 +345,9 @@ mod tests { } #[test] - fn span_default_has_internal_kind() { + fn span_default_has_unspecified_kind() { let s = SpanBytes::default(); - assert_eq!(s.span_kind, SpanKind::Internal); + assert_eq!(s.span_kind, SpanKind::Unspecified); assert!(!s.error); assert!(s.attributes.is_empty()); } diff --git a/libdd-trace-utils/src/span/vec_map.rs b/libdd-trace-utils/src/span/vec_map.rs index 15a3673969..a61ce1a5c2 100644 --- a/libdd-trace-utils/src/span/vec_map.rs +++ b/libdd-trace-utils/src/span/vec_map.rs @@ -176,6 +176,13 @@ impl VecMap { self.data.iter_mut() } + /// Iterate mutably over the values, including duplicate entries. Unlike [Self::iter_mut], this + /// keeps the `deduped` flag: keys can't change, so no duplicate can appear. + #[inline] + pub fn values_mut(&mut self) -> impl Iterator { + self.data.iter_mut().map(|(_, v)| v) + } + /// Return the length of the underlying vector, thus including duplicate entries. #[inline] pub fn len(&self) -> usize { @@ -586,6 +593,16 @@ mod tests { assert!(!m.is_deduped()); } + #[test] + fn values_mut_keeps_dedup_flag() { + let mut m = VecMap::new(); + m.insert("a", 1); + m.dedup(); + m.values_mut().for_each(|v| *v += 1); + assert!(m.is_deduped()); + assert_eq!(m.get("a"), Some(&2)); + } + #[test] fn iter_mut_dirties_dedup_flag() { let mut m = VecMap::new(); diff --git a/libdd-trace-utils/src/trace_filter.rs b/libdd-trace-utils/src/trace_filter.rs index 59fa37ddf1..b307a9e45a 100644 --- a/libdd-trace-utils/src/trace_filter.rs +++ b/libdd-trace-utils/src/trace_filter.rs @@ -123,12 +123,12 @@ impl<'a, T: TraceData> Span<'a> for span::v1::Span { Some(AttributeValue::String(s)) => Some(s.borrow()), // `env`, `version`, `component`, and `span.kind` are "promoted" to dedicated span // fields rather than stored in `attributes` (see the V1 downgrade encoder). Empty - // text / the default `Internal` kind are treated as "unset" here. + // text / the default `Unspecified` kind are treated as "unset" here. _ => match key { "env" if !self.env.borrow().is_empty() => Some(self.env.borrow()), "version" if !self.version.borrow().is_empty() => Some(self.version.borrow()), "component" if !self.component.borrow().is_empty() => Some(self.component.borrow()), - "span.kind" if self.span_kind != SpanKind::Internal => { + "span.kind" if self.span_kind != SpanKind::Unspecified => { Some(self.span_kind.as_meta_str()) } _ => None, From d4a8fedf9d86d2297c5fa603ae0ceb190b797567 Mon Sep 17 00:00:00 2001 From: Alexandre Rulleau Date: Thu, 1 Oct 2026 17:10:20 +0200 Subject: [PATCH 2/3] feat(sidecar)!: V1 trace send path with /info negotiation Send traces to the agent as v1 when /info advertises /v1.0/traces and downgrade to v0.4 otherwise, using the session's /info for the first send. Tracer-marked top-level spans are honoured in the sidecar. --- datadog-sidecar/src/service/mod.rs | 2 + datadog-sidecar/src/service/sidecar_server.rs | 431 ++++++++++++++++-- datadog-sidecar/src/tracer.rs | 2 + 3 files changed, 408 insertions(+), 27 deletions(-) diff --git a/datadog-sidecar/src/service/mod.rs b/datadog-sidecar/src/service/mod.rs index b9c73c63c0..7e1ae8e4f8 100644 --- a/datadog-sidecar/src/service/mod.rs +++ b/datadog-sidecar/src/service/mod.rs @@ -93,6 +93,8 @@ pub struct SessionConfig { pub parent_session_id: Option, /// Optional OTLP metrics intake endpoint. pub otlp_metrics_endpoint: Option, + /// `DD_TRACE_AGENT_PROTOCOL_VERSION=0.4`: send v0.4 even when the agent advertises V1. + pub force_v04_traces: bool, } #[derive(Debug, Deserialize, Serialize)] diff --git a/datadog-sidecar/src/service/sidecar_server.rs b/datadog-sidecar/src/service/sidecar_server.rs index 57da9a8703..dcd9f940e0 100644 --- a/datadog-sidecar/src/service/sidecar_server.rs +++ b/datadog-sidecar/src/service/sidecar_server.rs @@ -15,8 +15,10 @@ use libdd_ipc::SeqpacketConn; use libdd_ipc::platform::{FileBackedHandle, ShmHandle}; use libdd_telemetry::metrics::MetricContext; use libdd_telemetry::worker::{LifecycleAction, TelemetryActions, TelemetryWorkerStats}; +use libdd_trace_utils::msgpack_encoder; use libdd_trace_utils::send_with_retry::{RetryBackoffType, RetryStrategy}; use libdd_trace_utils::span::BytesData; +use libdd_trace_utils::span::trace_utils_v1; use libdd_trace_utils::trace_utils::SendData; use libdd_trace_utils::tracer_payload::TraceChunks; use libdd_trace_utils::tracer_payload::TraceEncoding; @@ -55,8 +57,10 @@ use crate::service::stats_flusher::{ use crate::service::telemetry::InProcessTelemetryClientFactory; use crate::service::tracing::trace_flusher::TraceFlusherStats; use crate::tokio_util::run_or_spawn_shared; +use futures::FutureExt; use libdd_capabilities_impl::NativeCapabilities; use libdd_common::tag::Tag; +use libdd_data_pipeline::agent_info::schema::AgentInfoStruct; use libdd_dogstatsd_client::{DogStatsDActionOwned, DogStatsDClient}; use libdd_ipc::ipc_server::OwnedServerConn; use libdd_live_debugger::sender::{DebuggerType, agent_info_supports_debugger_v2_endpoint}; @@ -214,6 +218,17 @@ impl ConnectionSidecarHandler { } } +/// The trace route the agent advertises in `/info` once it supports the V1 trace protocol. +const V1_TRACES_ENDPOINT: &str = "/v1.0/traces"; + +/// Whether the agent's `/info` advertises `/v1.0/traces`. Matches +/// `TraceExporter::refresh_v1_active` so the sidecar and in-process exporter negotiate V1 alike. +fn agent_info_advertises_v1(info: &AgentInfoStruct) -> bool { + info.endpoints + .as_ref() + .is_some_and(|e| e.iter().any(|p| p == V1_TRACES_ENDPOINT)) +} + impl SidecarServer { #[cfg(unix)] pub(crate) fn with_appsec_telemetry( @@ -346,25 +361,33 @@ impl SidecarServer { /// tracers when it can't decode the body, and populating them only on the agent side would /// leave a gap whenever a tracer is upgraded ahead of its agent. /// - /// `target` is `tracer::Config::endpoint_v1`, already normalized to the agent's - /// `/v1.0/traces` route (or the shared intake URL for agentless sessions) by - /// `tracer::Config::set_endpoint`. + /// Sends a V1 payload, negotiating with the agent (see [`Self::agent_supports_v1`]): V1 to + /// `v1_target` when the agent advertises `/v1.0/traces` or agentless, else downgrade to v0.4. + /// `force_v04` (`DD_TRACE_AGENT_PROTOCOL_VERSION=0.4`) always downgrades. + #[allow(clippy::too_many_arguments)] fn send_trace_v1( &self, + session: &SessionInfo, generic: TracerGenericTags, lang_interpreter: &str, lang_vendor: &str, data: tinybytes::Bytes, - target: &Endpoint, + v1_target: &Endpoint, + v04_target: &Endpoint, retry_interval: u64, + force_v04: bool, ) { + let supports_v1 = !force_v04 && Self::agent_supports_v1(session, v04_target); match decode_to_trace_chunks(data, TraceEncoding::V1) { - Ok((payload, size)) => { - let TraceChunks::V1(tracer_payload) = &payload else { + Ok((mut payload, size)) => { + let TraceChunks::V1(tracer_payload) = &mut payload else { unreachable!( "decode_to_trace_chunks(_, TraceEncoding::V1) always returns TraceChunks::V1" ); }; + // The payload was encoded from a builder that already deduped every map + // (`TracerPayloadV1Builder::into_payload`), so don't dedup it a second time. + tracer_payload.mark_deduped(); // Cheap refcounted clones: decouples the header strings from `payload`'s // borrow so `payload` can still be moved into `enqueue_trace` below. let lang = tracer_payload.language_name.clone(); @@ -372,6 +395,16 @@ impl SidecarServer { let tracer_version = tracer_payload.tracer_version.clone(); let container_id = tracer_payload.container_id.clone(); + // The V1 endpoint expects the tracer to mark top-level spans (`_dd.top_level`). + if supports_v1 + && tracer_payload + .chunks + .iter() + .any(|c| !c.spans.iter().any(trace_utils_v1::has_top_level)) + { + warn!("V1 trace chunk without a top-level span; the tracer must mark them"); + } + let headers = TracerHeaderTags { lang: lang.borrow(), lang_version: lang_version.borrow(), @@ -381,12 +414,43 @@ impl SidecarServer { container_id: container_id.borrow(), generic, }; - debug!( - "Received {} bytes of data for {:?} with headers {:?}", - size, target, headers - ); - trace!("Parsed the trace payload and enqueuing it for sending: {payload:?}"); - self.enqueue_trace(payload, size, headers, target, retry_interval); + + if supports_v1 { + debug!( + "Received {} bytes of data for {:?} with headers {:?}", + size, v1_target, headers + ); + trace!("Parsed the trace payload and enqueuing it for sending: {payload:?}"); + self.enqueue_trace(payload, size, headers, v1_target, retry_interval); + return; + } + + // Agent lacks `/v1.0/traces`: downgrade to v0.4 and re-decode so the existing v0.4 + // send path handles serialization/retries unchanged. + let TraceChunks::V1(tracer_payload) = &payload else { + unreachable!("payload is still TraceChunks::V1"); + }; + let v04_bytes = msgpack_encoder::v04::to_vec_from_v1(tracer_payload); + match decode_to_trace_chunks(tinybytes::Bytes::from(v04_bytes), TraceEncoding::V04) + { + Ok((v04_payload, v04_size)) => { + debug!( + "Agent does not advertise /v1.0/traces; downgrading to v0.4 ({} bytes) for {:?} with headers {:?}", + v04_size, v04_target, headers + ); + self.enqueue_trace( + v04_payload, + v04_size, + headers, + v04_target, + retry_interval, + ); + } + Err(e) => error!( + "Failed to re-decode downgraded v0.4 trace payload with error {:?}", + e + ), + } } Err(e) => { error!( @@ -397,6 +461,23 @@ impl SidecarServer { } } + /// Whether the agent accepts V1 (non-blocking). Fails closed to v0.4 until `/info` advertises + /// `/v1.0/traces`, so we never send V1 to an agent that can't decode it. Agentless is always + /// V1. + fn agent_supports_v1(session: &SessionInfo, v04_target: &Endpoint) -> bool { + if v04_target.api_key.is_some() { + return true; + } + // Peek the session's `/info` (fetched since `set_session_config`) without blocking the + // send path. A fetcher keyed by the trace endpoint would be a separate, cold one. + session + .agent_infos + .lock_or_panic() + .as_ref() + .and_then(|guard| guard.get().now_or_never()) + .is_some_and(|info| agent_info_advertises_v1(&info)) + } + fn send_trace( &self, headers: TracerHeaderTags, @@ -898,6 +979,7 @@ impl SidecarInterface for ConnectionSidecarHandler { cfg.language_version.clone_from(&config.language_version); cfg.tracer_version.clone_from(&config.tracer_version); cfg.retry_interval = config.retry_interval.as_millis() as u64; + cfg.force_v04 = config.force_v04_traces; }); session.modify_otlp_metrics_endpoint(|endpoint| { *endpoint = config.otlp_metrics_endpoint.clone(); @@ -1099,20 +1181,28 @@ impl SidecarInterface for ConnectionSidecarHandler { self.track_instance(&instance_id); let session = self.server.get_session(&instance_id.session_id); let trace_config = session.get_trace_config(); - if let Some(endpoint) = trace_config.endpoint_v1.clone() { + if let (Some(v1_endpoint), Some(v04_endpoint)) = ( + trace_config.endpoint_v1.clone(), + trace_config.endpoint.clone(), + ) { let server = self.server.clone(); let retry_interval = trace_config.retry_interval; + let force_v04 = trace_config.force_v04; + let session = session.clone(); tokio::spawn(async move { match handle.map() { Ok(mapped) => { let bytes = tinybytes::Bytes::from(mapped); server.send_trace_v1( + &session, generic, &lang_interpreter, &lang_vendor, bytes, - &endpoint, + &v1_endpoint, + &v04_endpoint, retry_interval, + force_v04, ); } Err(e) => error!("Failed mapping shared trace data memory: {}", e), @@ -1138,18 +1228,26 @@ impl SidecarInterface for ConnectionSidecarHandler { let session = self.server.get_session(&instance_id.session_id); let trace_config = session.get_trace_config(); - if let Some(endpoint) = trace_config.endpoint_v1.clone() { + if let (Some(v1_endpoint), Some(v04_endpoint)) = ( + trace_config.endpoint_v1.clone(), + trace_config.endpoint.clone(), + ) { let server = self.server.clone(); let retry_interval = trace_config.retry_interval; + let force_v04 = trace_config.force_v04; + let session = session.clone(); tokio::spawn(async move { let bytes = tinybytes::Bytes::from(data); server.send_trace_v1( + &session, generic, &lang_interpreter, &lang_vendor, bytes, - &endpoint, + &v1_endpoint, + &v04_endpoint, retry_interval, + force_v04, ); }); } else { @@ -2174,7 +2272,9 @@ mod tests { fn sample_v1_trace_payload_bytes() -> Vec { use libdd_tinybytes::BytesString; use libdd_trace_utils::msgpack_encoder::v1::to_vec_from_v1; - use libdd_trace_utils::span::v1::{Span as V1Span, TraceChunkBytes, TracerPayloadBytes}; + use libdd_trace_utils::span::v1::{ + AttributeValue, Span as V1Span, TraceChunkBytes, TracerPayloadBytes, + }; fn bs(s: &str) -> BytesString { BytesString::from_slice(s.as_bytes()).expect("test string must fit in BytesString") @@ -2187,6 +2287,8 @@ mod tests { span_id: 42, start: 1_700_000_000_000, duration: 1_500, + // The tracer marks top-level spans itself; the sidecar no longer adds the metric. + attributes: vec![(bs("_dd.top_level"), AttributeValue::Float(1.0))].into(), ..Default::default() }; @@ -2207,16 +2309,41 @@ mod tests { to_vec_from_v1(&payload) } - /// Agentful sessions have their trace endpoint normalized to `/v0.4/traces` by - /// `tracer::Config::set_endpoint` since it doesn't know ahead of time which encoding will be - /// used. This exercises the full `send_trace_v1_bytes` chain to ensure V1 payloads are - /// redirected to the agent's `/v1.0/traces` route instead, and that `lang_interpreter`/ - /// `lang_vendor` (which the V1 payload model has no room for) survive as headers, alongside - /// `lang`/`lang_version`/`tracer_version` (which are read back out of the decoded V1 payload - /// — see `send_trace_v1`). + /// Mocks `/info` advertising `endpoints` and primes the session's agent-info cache, as + /// `set_session_config` does. The send path peeks non-blockingly, so the cache must be resolved + /// before sending. + async fn prime_agent_info( + http_server: &MockServer, + server: &SidecarServer, + session_id: &str, + endpoints: &[&str], + ) { + let body = serde_json::json!({ "endpoints": endpoints }).to_string(); + http_server + .mock_async(|when, then| { + when.path("/info"); + then.status(200) + .header("content-type", "application/json") + .header("datadog-agent-state", "state-hash-1") + .body(body); + }) + .await; + let endpoint = Endpoint { + url: http_server.url("/").parse().unwrap(), + ..Endpoint::default() + }; + let guard = server.agent_infos.query_for(endpoint); + // Await the initial fetch so the cached future is resolved by the time we send. + guard.get().await; + *server.get_session(session_id).agent_infos.lock_or_panic() = Some(guard); + } + + /// When the agent advertises `/v1.0/traces`, `send_trace_v1_bytes` routes V1 to that endpoint, + /// and `lang_interpreter`/`lang_vendor` (absent from the V1 payload model) survive as headers + /// alongside the payload-carried `lang`/`lang_version`/`tracer_version` (see `send_trace_v1`). #[tokio::test] #[cfg_attr(miri, ignore)] - async fn send_trace_v1_bytes_routes_to_v1_endpoint() { + async fn send_trace_v1_bytes_routes_to_v1_endpoint_when_agent_advertises_v1() { let http_server = MockServer::start_async().await; let v1_mock = http_server .mock_async(|when, then| { @@ -2226,7 +2353,11 @@ mod tests { .header("datadog-meta-lang-interpreter-vendor", "cpython-vendor") .header("datadog-meta-lang", "rust") .header("datadog-meta-lang-version", "1.87") - .header("datadog-meta-tracer-version", "9.9.9"); + .header("datadog-meta-tracer-version", "9.9.9") + // Top-level spans are marked with the tracer key on the V1 path. + .header("datadog-client-computed-top-level", "true") + .body_includes("_dd.top_level") + .body_excludes("_top_level"); then.status(200); }) .await; @@ -2251,11 +2382,22 @@ mod tests { cfg.set_endpoint(endpoint).unwrap(); }); + prime_agent_info( + &http_server, + &handler.server, + &instance_id.session_id, + &["/v0.4/traces", "/v1.0/traces"], + ) + .await; + handler .send_trace_v1_bytes( instance_id, sample_v1_trace_payload_bytes(), - TracerGenericTags::default(), + TracerGenericTags { + client_computed_top_level: true, + ..Default::default() + }, "cpython".to_owned(), "cpython-vendor".to_owned(), ) @@ -2269,6 +2411,241 @@ mod tests { v1_mock.assert_async().await; assert_eq!(v04_mock.calls_async().await, 0); } + + /// When the agent does not advertise `/v1.0/traces`, `send_trace_v1_bytes` downgrades to v0.4 + /// and sends to `/v0.4/traces` instead, so old agents keep receiving traces. + #[tokio::test] + #[cfg_attr(miri, ignore)] + async fn send_trace_v1_bytes_downgrades_to_v04_when_agent_lacks_v1() { + let http_server = MockServer::start_async().await; + let v1_mock = http_server + .mock_async(|when, then| { + when.method(POST).path("/v1.0/traces"); + then.status(200); + }) + .await; + let v04_mock = http_server + .mock_async(|when, then| { + when.method(POST) + .path("/v0.4/traces") + .header("datadog-meta-lang-interpreter", "cpython") + .header("datadog-meta-lang-interpreter-vendor", "cpython-vendor") + .header("datadog-meta-lang", "rust") + .header("datadog-meta-lang-version", "1.87") + .header("datadog-meta-tracer-version", "9.9.9") + // The downgrade keeps the tracer's own top-level marks and header. + .header_missing("datadog-client-computed-top-level") + .body_includes("_dd.top_level"); + then.status(200); + }) + .await; + + let handler = test_handler(SidecarServer::default()); + let instance_id = InstanceId::new("session", "runtime"); + + handler + .server + .get_session(&instance_id.session_id) + .modify_trace_config(|cfg| { + let endpoint = Endpoint { + url: http_server.url("/").parse().unwrap(), + ..Endpoint::default() + }; + cfg.set_endpoint(endpoint).unwrap(); + }); + + prime_agent_info( + &http_server, + &handler.server, + &instance_id.session_id, + &["/v0.4/traces"], + ) + .await; + + handler + .send_trace_v1_bytes( + instance_id, + sample_v1_trace_payload_bytes(), + TracerGenericTags::default(), + "cpython".to_owned(), + "cpython-vendor".to_owned(), + ) + .await; + + sleep(TokioDuration::from_millis(50)).await; + handler.server.trace_flusher.join().await.unwrap(); + + v04_mock.assert_async().await; + assert_eq!(v1_mock.calls_async().await, 0); + } + + /// `force_v04` (`DD_TRACE_AGENT_PROTOCOL_VERSION=0.4`) downgrades even when the agent + /// advertises `/v1.0/traces`. + #[tokio::test] + #[cfg_attr(miri, ignore)] + async fn send_trace_v1_bytes_downgrades_to_v04_when_forced() { + let http_server = MockServer::start_async().await; + let v1_mock = http_server + .mock_async(|when, then| { + when.method(POST).path("/v1.0/traces"); + then.status(200); + }) + .await; + let v04_mock = http_server + .mock_async(|when, then| { + when.method(POST).path("/v0.4/traces"); + then.status(200); + }) + .await; + + let handler = test_handler(SidecarServer::default()); + let instance_id = InstanceId::new("session", "runtime"); + + handler + .server + .get_session(&instance_id.session_id) + .modify_trace_config(|cfg| { + let endpoint = Endpoint { + url: http_server.url("/").parse().unwrap(), + ..Endpoint::default() + }; + cfg.set_endpoint(endpoint).unwrap(); + cfg.force_v04 = true; + }); + + prime_agent_info( + &http_server, + &handler.server, + &instance_id.session_id, + &["/v0.4/traces", "/v1.0/traces"], + ) + .await; + + handler + .send_trace_v1_bytes( + instance_id, + sample_v1_trace_payload_bytes(), + TracerGenericTags::default(), + "cpython".to_owned(), + "cpython-vendor".to_owned(), + ) + .await; + + sleep(TokioDuration::from_millis(50)).await; + handler.server.trace_flusher.join().await.unwrap(); + + v04_mock.assert_async().await; + assert_eq!(v1_mock.calls_async().await, 0); + } + + /// The first trace after `set_session_config` goes out as V1 once the session's `/info` + /// fetch has resolved: the send path reads the session's agent info rather than starting a + /// separate fetcher keyed by the `/v0.4/traces` endpoint. + #[tokio::test] + #[cfg_attr(miri, ignore)] + async fn first_send_after_session_config_uses_v1() { + let http_server = MockServer::start_async().await; + let info_mock = http_server + .mock_async(|when, then| { + when.path("/info"); + then.status(200) + .header("content-type", "application/json") + .header("datadog-agent-state", "state-hash-1") + .body(r#"{"endpoints":["/v0.4/traces","/v1.0/traces"]}"#); + }) + .await; + let v1_mock = http_server + .mock_async(|when, then| { + when.method(POST) + .path("/v1.0/traces") + .header("datadog-client-computed-top-level", "true") + .body_includes("_dd.top_level") + .body_excludes("_top_level"); + then.status(200); + }) + .await; + let v04_mock = http_server + .mock_async(|when, then| { + when.method(POST).path("/v0.4/traces"); + then.status(200); + }) + .await; + + let handler = test_handler(SidecarServer::default()); + let instance_id = InstanceId::new("session", "runtime"); + let endpoint = Endpoint { + url: http_server.url("/").parse().unwrap(), + ..Endpoint::default() + }; + handler + .set_session_config( + instance_id.session_id.clone(), + #[cfg(windows)] + Default::default(), + SessionConfig { + endpoint: endpoint.clone(), + dogstatsd_endpoint: endpoint, + language: "rust".to_owned(), + language_version: "1.87".to_owned(), + tracer_version: "9.9.9".to_owned(), + flush_interval: Duration::from_secs(1), + remote_config_poll_interval: Duration::from_secs(5), + telemetry_heartbeat_interval: Duration::from_secs(60), + telemetry_extended_heartbeat_interval: Duration::from_secs(3600), + force_flush_size: 5_000_000, + force_drop_size: 10_000_000, + retry_interval: Duration::from_millis(100), + log_level: String::new(), + log_file: crate::config::LogMethod::Disabled, + remote_config_products: vec![], + remote_config_capabilities: vec![], + remote_config_enabled: false, + process_tags: vec![], + peer_tag_keys: vec![], + span_kinds_stats_computed: vec![], + hostname: String::new(), + root_service: String::new(), + root_session_id: None, + parent_session_id: None, + otlp_metrics_endpoint: None, + force_v04_traces: false, + }, + false, + ) + .await; + + // Wait for the session's `/info` fetch, as the agent answered before the first flush. + let session_info = handler + .server + .get_session(&instance_id.session_id) + .agent_infos + .lock_or_panic() + .as_ref() + .expect("an agentful session holds an agent info guard") + .get(); + session_info.await; + + handler + .send_trace_v1_bytes( + instance_id, + sample_v1_trace_payload_bytes(), + TracerGenericTags { + client_computed_top_level: true, + ..Default::default() + }, + "cpython".to_owned(), + "cpython-vendor".to_owned(), + ) + .await; + + sleep(TokioDuration::from_millis(50)).await; + handler.server.trace_flusher.join().await.unwrap(); + + assert_eq!(v04_mock.calls_async().await, 0); + v1_mock.assert_async().await; + // One fetcher per agent: sending must not start a second `/info` poller. + assert_eq!(info_mock.calls_async().await, 1); + } } // TODO: APMSP-1079 - Unit tests are sparse for the sidecar server. We should add more. diff --git a/datadog-sidecar/src/tracer.rs b/datadog-sidecar/src/tracer.rs index be7fab4719..8127e23c09 100644 --- a/datadog-sidecar/src/tracer.rs +++ b/datadog-sidecar/src/tracer.rs @@ -61,6 +61,8 @@ pub struct Config { pub language_version: String, pub tracer_version: String, pub retry_interval: u64, + /// Downgrade V1 payloads to v0.4 regardless of the agent's `/info`. + pub force_v04: bool, } impl Config { From 89d5b1fac3f42516d15e2576b325896e1334eace Mon Sep 17 00:00:00 2001 From: Alexandre Rulleau Date: Thu, 1 Oct 2026 17:10:26 +0200 Subject: [PATCH 3/3] feat(sidecar-ffi)!: pointer-handle V1 builder and send FFI for tracers Add the V1 trace builder FFI (Box-per-node pointer handles for chunks, spans, links, events and attribute maps), ddog_send_traces_to_sidecar_v1, and the v04-to-v1 transcode used by the in-process sender. Shrink ddog_TracerMetadataV1. --- datadog-sidecar-ffi/src/lib.rs | 194 +++- datadog-sidecar-ffi/src/span.rs | 1530 ++++++++++++++++++++------ datadog-sidecar-ffi/src/span_v04.rs | 60 + datadog-sidecar-ffi/tests/sidecar.rs | 2 + datadog-sidecar-ffi/tests/span.rs | 857 +++++++++------ 5 files changed, 1972 insertions(+), 671 deletions(-) create mode 100644 datadog-sidecar-ffi/src/span_v04.rs diff --git a/datadog-sidecar-ffi/src/lib.rs b/datadog-sidecar-ffi/src/lib.rs index 7e2c2a3db1..ce482923d7 100644 --- a/datadog-sidecar-ffi/src/lib.rs +++ b/datadog-sidecar-ffi/src/lib.rs @@ -12,8 +12,10 @@ pub mod remote_config_notification; #[cfg(target_os = "linux")] pub mod signal_flush; pub mod span; +pub mod span_v04; -use crate::span::TracesBytes; +use crate::span::{TracerPayloadV1Builder, populate_payload_metadata}; +use crate::span_v04::TracesBytes; use datadog_sidecar::agent_remote_config::{AgentRemoteConfigWriter, new_reader, reader_from_shm}; use datadog_sidecar::config; use datadog_sidecar::config::LogMethod; @@ -691,6 +693,7 @@ pub unsafe extern "C" fn ddog_sidecar_session_set_config( root_service: ffi::CharSlice, root_session_id: ffi::CharSlice, parent_session_id: ffi::CharSlice, + force_v04_traces: bool, ) -> MaybeError { unsafe { let session_id_str: String = session_id.to_utf8_lossy().into(); @@ -751,6 +754,7 @@ pub unsafe extern "C" fn ddog_sidecar_session_set_config( Some(parent_session_id.to_utf8_lossy().into()) }, retry_interval: Duration::from_millis(retry_interval_milliseconds as u64), + force_v04_traces, }; #[cfg(unix)] let _ = win_remote_config_notification; @@ -2010,6 +2014,13 @@ pub struct SenderParameters { pub url: CharSlice<'static>, } +/// Payload-level tracer metadata for the V1 send path not already carried by the sender's +/// `tracer_headers_tags` (lang, tracer_version, container_id live there and are routed from there). +#[repr(C)] +pub struct TracerMetadataV1 { + pub runtime_id: CharSlice<'static>, +} + #[unsafe(no_mangle)] #[allow(clippy::missing_safety_doc)] pub unsafe extern "C" fn ddog_send_traces_to_sidecar( @@ -2099,6 +2110,143 @@ pub unsafe extern "C" fn ddog_send_traces_to_sidecar( } } +/// V1 counterpart of `ddog_send_traces_to_sidecar`: sends the [`crate::span`] builder's native V1 +/// payload to the agent's `/v1.0/traces`. Consumes `builder`. Payload metadata comes at send time +/// from `parameters.tracer_headers_tags` and `metadata`; lang_interpreter/lang_vendor go as +/// headers. +#[unsafe(no_mangle)] +#[allow(clippy::missing_safety_doc)] +pub unsafe extern "C" fn ddog_send_traces_to_sidecar_v1( + builder: Box, + parameters: &mut SenderParameters, + metadata: &TracerMetadataV1, +) { + let mut payload = builder.into_payload(); + let size: usize = payload.chunks.iter().map(|c| c.spans.len()).sum(); + + // Check connection to the sidecar + if parameters.transport.is_closed() { + tracing::info!( + "Skipping flushing traces of size {} as connection to sidecar failed", + size + ); + return; + } + + let tags = ¶meters.tracer_headers_tags; + populate_payload_metadata( + &mut payload, + &tags.container_id.to_utf8_lossy(), + &tags.lang.to_utf8_lossy(), + &tags.lang_version.to_utf8_lossy(), + &tags.tracer_version.to_utf8_lossy(), + &metadata.runtime_id.to_utf8_lossy(), + ); + + let lang_interpreter = parameters + .tracer_headers_tags + .lang_interpreter + .to_utf8_lossy() + .into_owned(); + let lang_vendor = parameters + .tracer_headers_tags + .lang_vendor + .to_utf8_lossy() + .into_owned(); + + // Create and map shared memory + let shm = check!( + ShmHandle::new(parameters.limit), + "Failed to create shared memory" + ); + + let mut mapped_shm = check!(shm.clone().map(), "Failed to map shared memory"); + + // Write traces to the shared memory as a native V1 payload + let mut shm_slice = mapped_shm.as_slice_mut(); + let shm_slice_len = shm_slice.len(); + let written = match msgpack_encoder::v1::write_to_slice_from_v1(&mut shm_slice, &payload) { + Ok(()) => shm_slice_len - shm_slice.len(), + Err(_) => { + tracing::error!("Failed serializing the traces"); + return; + } + }; + + // Send traces to the sidecar via the shared memory handler + let mut size_hint = written; + if parameters.n_requests > 0 { + size_hint = size_hint.max((parameters.buffer_size / parameters.n_requests + 1) as usize); + } + + let send_error = blocking::send_trace_v1_shm( + &mut parameters.transport, + ¶meters.instance_id, + shm, + size_hint, + TracerGenericTags { + client_computed_top_level: parameters.tracer_headers_tags.client_computed_top_level, + client_computed_stats: parameters.tracer_headers_tags.client_computed_stats, + ..Default::default() + }, + lang_interpreter, + lang_vendor, + ); + + // Retry sending traces via bytes if there was an error. `lang_interpreter`/`lang_vendor` were + // moved into the shm send above, so re-derive them here (only on this rare path). + if send_error.is_err() { + match blocking::send_trace_v1_bytes( + &mut parameters.transport, + ¶meters.instance_id, + msgpack_encoder::v1::to_vec_with_capacity_from_v1(&payload, written as u32), + TracerGenericTags { + client_computed_top_level: parameters.tracer_headers_tags.client_computed_top_level, + client_computed_stats: parameters.tracer_headers_tags.client_computed_stats, + ..Default::default() + }, + parameters + .tracer_headers_tags + .lang_interpreter + .to_utf8_lossy() + .into_owned(), + parameters + .tracer_headers_tags + .lang_vendor + .to_utf8_lossy() + .into_owned(), + ) { + Ok(_) => {} + Err(_) => tracing::debug!( + "Failed sending traces via shm to sidecar: {}", + unsafe { send_error.err().unwrap_unchecked() }.to_string() + ), + }; + } + + tracing::event!(target: "info", tracing::Level::INFO, "Flushing v1 trace of size {} to send-queue for {}", size, parameters.url); +} + +/// Downgrades a native V1 builder to the in-memory v0.4 collection for the in-process `coms.c` +/// sender (PHP <= 8.2). Consumes `builder`. Returns the collection (not one whole-payload +/// CharSlice) so `auto_flush` can frame each trace individually; a single CharSlice would drop +/// extra traces of a multi-trace payload. Empty collection on error; free with +/// [`crate::span_v04::ddog_free_traces`]. +#[unsafe(no_mangle)] +pub extern "C" fn ddog_downgrade_v1_builder_to_v04_traces( + builder: Box, +) -> Box { + let payload = builder.into_payload(); + let v04_bytes = msgpack_encoder::v04::to_vec_from_v1(&payload); + match libdd_trace_utils::tracer_payload::decode_to_trace_chunks( + libdd_tinybytes::Bytes::from(v04_bytes), + libdd_trace_utils::tracer_payload::TraceEncoding::V04, + ) { + Ok((libdd_trace_utils::tracer_payload::TraceChunks::V04(traces), _)) => Box::new(traces), + _ => Box::default(), + } +} + /// Drops the agent info reader. #[unsafe(no_mangle)] #[allow(clippy::missing_safety_doc)] @@ -2223,6 +2371,50 @@ mod tests { } } + #[test] + fn downgrade_v1_builder_to_v04_traces_preserves_all_traces() { + use crate::span::ddog_v1_new_builder; + use crate::span_v04::{ddog_free_traces, ddog_serialize_trace_into_charslice}; + use libdd_tinybytes::BytesString; + + // Two chunks (traces) with a shared service; the whole-payload CharSlice form drops the + // second under the coms framing, the TracesBytes form must keep both. + let mut builder = ddog_v1_new_builder(); + let c0 = builder.push_chunk(0, 1); + // Safety: `c0`/`c1` are live chunk nodes, `s0`/`s1` their spans (Box-per-node handles). + unsafe { + let sp = (*(*c0).push_span()).span_mut(); + sp.service = BytesString::from_slice(b"svc-shared").unwrap(); + sp.name = BytesString::from_slice(b"op-one").unwrap(); + sp.span_id = 1; + } + let c1 = builder.push_chunk(0, 2); + unsafe { + let sp = (*(*c1).push_span()).span_mut(); + sp.service = BytesString::from_slice(b"svc-shared").unwrap(); + sp.name = BytesString::from_slice(b"op-two").unwrap(); + sp.span_id = 2; + } + + let mut traces = ddog_downgrade_v1_builder_to_v04_traces(builder); + assert_eq!(traces.len(), 2, "both traces must survive the downgrade"); + assert_eq!(traces[0].len(), 1); + assert_eq!(traces[1].len(), 1); + assert_eq!(traces[0][0].service.as_str(), "svc-shared"); + assert_eq!(traces[0][0].name.as_str(), "op-one"); + assert_eq!(traces[1][0].name.as_str(), "op-two"); + + // Each trace serializes to a valid, non-empty msgpack array-of-1 for the background sender. + for i in 0..traces.len() { + let slice = ddog_serialize_trace_into_charslice(&mut traces[i]); + assert!(!slice.is_empty()); + // Safety: `slice` is the owned allocation just returned above. + unsafe { crate::span::ddog_free_charslice(slice) }; + } + + ddog_free_traces(traces); + } + #[test] fn otlp_metrics_endpoint_inherits_agent_test_token_when_missing() { let agent_endpoint = Endpoint { diff --git a/datadog-sidecar-ffi/src/span.rs b/datadog-sidecar-ffi/src/span.rs index 1d00cd0c33..22b242581f 100644 --- a/datadog-sidecar-ffi/src/span.rs +++ b/datadog-sidecar-ffi/src/span.rs @@ -1,604 +1,1416 @@ -// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +//! Box-per-node FFI builder for the native V1 trace payload +//! ([`libdd_trace_utils::span::v1::TracerPayload`]), storing readable [`BytesString`]s directly. +//! +//! Each chunk/span/link/event is its OWN heap allocation (`Box::into_raw`), stored as a raw pointer +//! in its parent node. C is handed that node pointer directly and per-node mutators and getters +//! materialize `&mut *ptr` / `&*ptr` against the node's own allocation — so a held node pointer +//! stays valid across sibling pushes (no parent-`Vec` reallocation can move an existing node) and +//! no mutation reborrows `&mut builder` (which would pop the tag of an outstanding node pointer). +//! This is Stacked- and Tree-Borrows clean. The boxes are folded back into the inline payload model +//! by [`TracerPayloadV1Builder::into_payload`], or freed by its [`Drop`]. Payload env / app_version +//! / hostname are set on the builder; the rest is applied at send time (see +//! [`populate_payload_metadata`]). + use libdd_common_ffi::slice::{AsBytes, CharSlice}; use libdd_tinybytes::{Bytes, BytesString}; -use libdd_trace_utils::span::v04::{ - AttributeAnyValueBytes, AttributeArrayValueBytes, SpanBytes, SpanEventBytes, SpanLinkBytes, - VecMap, +use libdd_trace_utils::msgpack_encoder::v04::local_root_idx; +use libdd_trace_utils::span::v1::{ + AttributeValueBytes, SpanBytes, SpanEventBytes, SpanKind, SpanLinkBytes, TraceChunkBytes, + TracerPayloadBytes, }; +use libdd_trace_utils::span::vec_map::VecMap; use std::borrow::Cow; -use std::collections::HashMap; -use std::ffi::{CString, c_char}; - -fn convert_char_slice_to_bytes_string(slice: CharSlice) -> BytesString { - // TODO: Strip the invalid bytes in the tracer instead - // TODO: replace with - // [from_utf8_lossy_owned](https://doc.rust-lang.org/std/string/struct.String.html#method.from_utf8_lossy_owned) - // once stabilized +use std::ffi::{CStr, CString, c_char}; +use std::fmt::Write as _; +use std::ptr; + +/// Attribute value type tags from [`ddog_v1_value_type`], so a C caller picks the matching typed +/// value getter. +pub const DDOG_V1_ATTR_STRING: u32 = 0; +pub const DDOG_V1_ATTR_INT: u32 = 1; +pub const DDOG_V1_ATTR_DOUBLE: u32 = 2; +pub const DDOG_V1_ATTR_BOOL: u32 = 3; +pub const DDOG_V1_ATTR_BYTES: u32 = 4; +pub const DDOG_V1_ATTR_KEYVALUE: u32 = 5; +pub const DDOG_V1_ATTR_LIST: u32 = 6; + +type AttrVecMap = VecMap; + +/// A chunk node in the builder: its own heap allocation, so a `*mut ChunkNode` handed to C stays +/// valid across sibling chunk pushes. Its spans live as separate `Box` allocations. +pub struct ChunkNode { + chunk: TraceChunkBytes, + spans: Vec<*mut SpanNode>, +} + +impl ChunkNode { + pub fn chunk(&self) -> &TraceChunkBytes { + &self.chunk + } + + pub fn chunk_mut(&mut self) -> &mut TraceChunkBytes { + &mut self.chunk + } + + pub fn spans(&self) -> &[*mut SpanNode] { + &self.spans + } + + /// Appends an empty span, returning a pointer to its own (heap) node. + pub fn push_span(&mut self) -> *mut SpanNode { + let node = Box::into_raw(Box::new(SpanNode { + span: SpanBytes::default(), + links: Vec::new(), + events: Vec::new(), + })); + self.spans.push(node); + node + } +} + +/// A span node in the builder: its own heap allocation, so a held `*mut SpanNode` stays valid +/// across sibling span pushes into the same chunk (the inferred-span case). Links/events are +/// likewise separate `Box` allocations. +pub struct SpanNode { + span: SpanBytes, + links: Vec<*mut SpanLinkBytes>, + events: Vec<*mut SpanEventBytes>, +} + +impl SpanNode { + pub fn span(&self) -> &SpanBytes { + &self.span + } + + pub fn span_mut(&mut self) -> &mut SpanBytes { + &mut self.span + } + + pub fn links(&self) -> &[*mut SpanLinkBytes] { + &self.links + } + + pub fn events(&self) -> &[*mut SpanEventBytes] { + &self.events + } + + /// Appends an empty link, returning a pointer to its own (heap) allocation. + pub fn push_link(&mut self) -> *mut SpanLinkBytes { + let link = Box::into_raw(Box::new(SpanLinkBytes::default())); + self.links.push(link); + link + } + + /// Appends an empty event, returning a pointer to its own (heap) allocation. + pub fn push_event(&mut self) -> *mut SpanEventBytes { + let event = Box::into_raw(Box::new(SpanEventBytes::default())); + self.events.push(event); + event + } +} + +/// Frees a chunk node and everything below it. +/// +/// # Safety +/// `ptr` must be a live `Box::into_raw(ChunkNode)` allocation not freed elsewhere. +unsafe fn free_chunk_node(ptr: *mut ChunkNode) { + // Safety: per the fn contract; each span pointer is likewise a live, unshared allocation. + let node = unsafe { Box::from_raw(ptr) }; + for &span in &node.spans { + unsafe { free_span_node(span) }; + } +} + +/// Frees a span node and its links/events. +/// +/// # Safety +/// `ptr` must be a live `Box::into_raw(SpanNode)` allocation not freed elsewhere. +unsafe fn free_span_node(ptr: *mut SpanNode) { + // Safety: per the fn contract; link/event pointers are live, unshared allocations. + let node = unsafe { Box::from_raw(ptr) }; + for &link in &node.links { + drop(unsafe { Box::from_raw(link) }); + } + for &event in &node.events { + drop(unsafe { Box::from_raw(event) }); + } +} + +/// Builds a native V1 [`TracerPayloadBytes`] holding readable strings. Each node is its own heap +/// allocation (see the module docs); the builder owns the top-level chunk pointers. +#[derive(Default)] +pub struct TracerPayloadV1Builder { + chunks: Vec<*mut ChunkNode>, + attributes: AttrVecMap, + env: BytesString, + app_version: BytesString, + hostname: BytesString, +} + +// SAFETY: the builder owns its node boxes and is only ever driven from a single tracer thread, +// then consumed synchronously by the send path; the raw pointers carry no cross-thread state. +unsafe impl Send for TracerPayloadV1Builder {} + +impl TracerPayloadV1Builder { + pub fn chunks(&self) -> &[*mut ChunkNode] { + &self.chunks + } + + /// Payload-level attributes (the TracerPayload `attributes` map). + pub fn attributes(&self) -> &AttrVecMap { + &self.attributes + } + + /// Sets the payload `env` / `app_version` / `hostname` fields. + pub fn set_metadata( + &mut self, + env: BytesString, + app_version: BytesString, + hostname: BytesString, + ) { + self.env = env; + self.app_version = app_version; + self.hostname = hostname; + } + + /// Appends an empty chunk with the given 128-bit trace id (high/low halves), returning a + /// pointer to its own (heap) node. + pub fn push_chunk(&mut self, trace_id_high: u64, trace_id_low: u64) -> *mut ChunkNode { + let node = Box::into_raw(Box::new(ChunkNode { + chunk: TraceChunkBytes { + trace_id: trace_id_bytes(trace_id_high, trace_id_low), + ..Default::default() + }, + spans: Vec::new(), + })); + self.chunks.push(node); + node + } + + /// Consumes the builder, folding the node boxes back into the inline payload model. The one + /// dedup of the send path runs here, where the maps were filled (possibly overwriting keys). + pub fn into_payload(mut self) -> TracerPayloadBytes { + // Move the chunk pointers out so `Drop` (which runs at the end of this fn over the + // now-empty `chunks`) never double-frees the nodes reclaimed below. + let chunks = std::mem::take(&mut self.chunks); + let mut payload = TracerPayloadBytes { + attributes: std::mem::take(&mut self.attributes), + env: std::mem::take(&mut self.env), + app_version: std::mem::take(&mut self.app_version), + hostname: std::mem::take(&mut self.hostname), + ..Default::default() + }; + for cptr in chunks { + // Safety: `cptr` is a live `Box::into_raw` allocation, moved out of `self` and + // reclaimed exactly once here. + let ChunkNode { mut chunk, spans } = *unsafe { Box::from_raw(cptr) }; + for sptr in spans { + // Safety: as above. + let SpanNode { + mut span, + links, + events, + } = *unsafe { Box::from_raw(sptr) }; + span.span_links = links + .into_iter() + .map(|l| *unsafe { Box::from_raw(l) }) + .collect(); + span.span_events = events + .into_iter() + .map(|e| *unsafe { Box::from_raw(e) }) + .collect(); + chunk.spans.push(span); + } + payload.chunks.push(chunk); + } + payload.dedup(); + payload + } +} + +impl Drop for TracerPayloadV1Builder { + fn drop(&mut self) { + for &cptr in &self.chunks { + // Safety: every pointer in `chunks` is a live `Box::into_raw` allocation; + // `into_payload` empties `chunks` before drop, so a node is never freed + // twice. + unsafe { free_chunk_node(cptr) }; + } + } +} + +/// Composes a 128-bit trace id from its high/low 64-bit halves into 16 big-endian bytes. +pub fn trace_id_bytes(high: u64, low: u64) -> [u8; 16] { + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(&high.to_be_bytes()); + bytes[8..].copy_from_slice(&low.to_be_bytes()); + bytes +} + +/// High 64 bits of a 16-byte big-endian trace id. +fn trace_id_high(bytes: &[u8; 16]) -> u64 { + let mut half = [0u8; 8]; + half.copy_from_slice(&bytes[..8]); + u64::from_be_bytes(half) +} + +/// Low 64 bits of a 16-byte big-endian trace id. +fn trace_id_low(bytes: &[u8; 16]) -> u64 { + let mut half = [0u8; 8]; + half.copy_from_slice(&bytes[8..]); + u64::from_be_bytes(half) +} + +/// Borrows a stored `BytesString` as a `CharSlice`. +#[inline] +fn char_slice_of<'a>(field: &BytesString) -> CharSlice<'a> { + let s = field.as_str(); + // Safety: `BytesString` guarantees valid UTF-8; the slice borrows `field`, which the caller + // keeps alive and unmodified while C reads it. + unsafe { CharSlice::from_raw_parts(s.as_ptr().cast(), s.len()) } +} + +/// Copies a `CharSlice` into a `BytesString`, replacing invalid UTF-8 (lossy). +pub fn bytes_string_from_slice(slice: CharSlice) -> BytesString { match String::from_utf8_lossy(slice.as_bytes()) { Cow::Owned(s) => s.into(), + // Safety: `from_utf8_lossy` borrowed, so the bytes are valid UTF-8. Cow::Borrowed(_) => unsafe { - // Safety: if `from_utf8_lossy` returns a borrowed `str`, the latter can't be anything - // else than the original slice which is thus valid UTF8. - BytesString::from_bytes_unchecked(Bytes::from_underlying(slice.as_bytes().to_vec())) + BytesString::from_bytes_unchecked(slice.as_bytes().to_vec().into()) }, } } -#[inline] -fn set_string_field(field: &mut BytesString, slice: CharSlice) { - if slice.is_empty() { - return; +/// Wraps a static NUL-terminated C string literal without copying it (lossy for invalid UTF-8). +/// +/// # Safety +/// `string` must point to a NUL-terminated string that lives for the rest of the program. +pub unsafe fn bytes_string_from_literal(string: *const c_char) -> BytesString { + // Safety: per the fn contract. + let cstring: &'static CStr = unsafe { CStr::from_ptr(string) }; + match String::from_utf8_lossy(cstring.to_bytes()) { + Cow::Owned(s) => s.into(), + Cow::Borrowed(s) => BytesString::from_static(s), } - *field = convert_char_slice_to_bytes_string(slice); } +/// Sets a string field, leaving it unchanged for an empty slice so absent values stay unset. #[inline] -fn get_string_field(field: &BytesString) -> CharSlice<'_> { - let string = field.as_str(); - // Safety: `field` is a `ByteString`, which guarantees it is backed by valid UTF8 bytes. The - // lifetime of the returned slice makes sure it borrows from `field`. - unsafe { CharSlice::from_raw_parts(string.as_ptr().cast(), string.len()) } +fn set_field(field: &mut BytesString, value: CharSlice) { + if !value.is_empty() { + *field = bytes_string_from_slice(value); + } } -#[inline] -fn insert_hashmap(map: &mut HashMap, key: CharSlice, value: V) { - if key.is_empty() { - return; +/// Deep-clones an attribute value (`AttributeValue` can't derive `Clone`). +fn clone_attr(value: &AttributeValueBytes) -> AttributeValueBytes { + match value { + AttributeValueBytes::String(s) => AttributeValueBytes::String(s.clone()), + AttributeValueBytes::Float(f) => AttributeValueBytes::Float(*f), + AttributeValueBytes::Int(i) => AttributeValueBytes::Int(*i), + AttributeValueBytes::Bool(b) => AttributeValueBytes::Bool(*b), + AttributeValueBytes::Bytes(b) => AttributeValueBytes::Bytes(b.clone()), + AttributeValueBytes::KeyValue(m) => AttributeValueBytes::KeyValue( + m.iter().map(|(k, v)| (k.clone(), clone_attr(v))).collect(), + ), + AttributeValueBytes::List(list) => { + AttributeValueBytes::List(list.iter().map(clone_attr).collect()) + } } - let bytes_str_key = convert_char_slice_to_bytes_string(key); - map.insert(bytes_str_key, value); } -#[inline] -fn insert_vec_map(map: &mut VecMap, key: CharSlice, value: V) { - if key.is_empty() { - return; - } - let bytes_str_key = convert_char_slice_to_bytes_string(key); - map.insert(bytes_str_key, value); +// ------------------- Builder lifecycle ------------------- + +/// Creates a new, empty V1 payload builder. Free it with [`ddog_v1_free_builder`], or hand it to +/// `ddog_send_traces_to_sidecar_v1`, which consumes it. +#[unsafe(no_mangle)] +pub extern "C" fn ddog_v1_new_builder() -> Box { + Box::default() } -#[inline] -fn remove_vec_map_slow(map: &mut VecMap, key: CharSlice) { - let bytes_str_key = convert_char_slice_to_bytes_string(key); - map.remove_slow(&bytes_str_key); +/// Frees a V1 payload builder. +#[unsafe(no_mangle)] +pub extern "C" fn ddog_v1_free_builder(_builder: Box) {} + +/// Sets the payload `env` / `app_version` / `hostname` fields (empty = unset). +#[unsafe(no_mangle)] +pub extern "C" fn ddog_set_payload_metadata( + builder: &mut TracerPayloadV1Builder, + env: CharSlice, + app_version: CharSlice, + hostname: CharSlice, +) { + builder.set_metadata( + bytes_string_from_slice(env), + bytes_string_from_slice(app_version), + bytes_string_from_slice(hostname), + ); } -#[inline] -fn exists_vec_map(map: &VecMap, key: CharSlice) -> bool { - let bytes_str_key = convert_char_slice_to_bytes_string(key); - map.contains_key(&bytes_str_key) -} - -/// The return value is an owned array of slices (`Box<[CharSlice<'a>]>`) that must be dropped -/// explicitly. -fn get_vec_map_keys<'a, V>( - map: &'a VecMap, - out_count: &mut usize, -) -> *mut CharSlice<'a> { - let mut keys: Vec<&str> = map.iter().map(|(k, _)| k.as_str()).collect(); - keys.sort_unstable(); - keys.dedup(); - - let slices: Box<[CharSlice]> = keys - .iter() - .map(|key| { - // Safety: `BytesString` enforces that the content is valid UTF8. We returne a slice - // with lifetime `'a`, which guarantees that the underlying allocation will remain live - // and immutable for the lifetime of the slice. - unsafe { CharSlice::from_raw_parts(key.as_ptr().cast(), key.len()) } - }) - .collect(); +// ------------------- Chunk / span / link / event creation ------------------- - *out_count = slices.len(); - Box::into_raw(slices) as *mut CharSlice<'a> +/// Appends a chunk carrying the 128-bit trace id (high/low halves), returning its node pointer. +#[unsafe(no_mangle)] +pub extern "C" fn ddog_new_chunk( + builder: &mut TracerPayloadV1Builder, + trace_id_high: u64, + trace_id_low: u64, +) -> *mut ChunkNode { + builder.push_chunk(trace_id_high, trace_id_low) } -fn new_vector_item(vec: &mut Vec) -> &mut T { - vec.push(T::default()); - // Safety: we just pushed a value to the vector, so `last_mut()` returns `Some` - unsafe { vec.last_mut().unwrap_unchecked() } +/// Number of spans already in `chunk`. +/// +/// # Safety +/// `chunk` must be a live chunk node pointer from [`ddog_new_chunk`] (applies to every chunk fn). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_chunk_span_count(chunk: *mut ChunkNode) -> usize { + unsafe { (*chunk).spans.len() } } -fn new_vector_push(vec: &mut Vec, el: T) -> &mut T { - vec.push(el); - // Safety: we just pushed a value to the vector, so `last_mut()` returns `Some` - unsafe { vec.last_mut().unwrap_unchecked() } +/// Appends an empty span to `chunk`, returning its node pointer. +/// +/// # Safety +/// See [`ddog_chunk_span_count`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_new_span(chunk: *mut ChunkNode) -> *mut SpanNode { + unsafe { (*chunk).push_span() } } +/// Appends an empty link to `span`, returning its node pointer. +/// +/// # Safety +/// `span` must be a live span node pointer from [`ddog_new_span`] (applies to every span fn). #[unsafe(no_mangle)] -fn set_event_attribute( - event: &mut SpanEventBytes, - key: CharSlice, - new_item: AttributeArrayValueBytes, -) { - let bytes_str_key = convert_char_slice_to_bytes_string(key); +pub unsafe extern "C" fn ddog_new_link(span: *mut SpanNode) -> *mut SpanLinkBytes { + unsafe { (*span).push_link() } +} - // remove any previous - let previous = event.attributes.remove(&bytes_str_key); +/// Appends an empty event to `span`, returning its node pointer. +/// +/// # Safety +/// See [`ddog_new_link`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_new_event(span: *mut SpanNode) -> *mut SpanEventBytes { + unsafe { (*span).push_event() } +} - // merge old + new - let merged = match previous { - None => AttributeAnyValueBytes::SingleValue(new_item), - Some(AttributeAnyValueBytes::SingleValue(x)) => { - AttributeAnyValueBytes::Array(vec![x, new_item]) - } - Some(AttributeAnyValueBytes::Array(mut arr)) => { - arr.push(new_item); - AttributeAnyValueBytes::Array(arr) - } - }; +// ------------------- Span fields ------------------- - event.attributes.insert(bytes_str_key, merged); +/// # Safety +/// See [`ddog_new_link`] (applies to every `ddog_span_set_*` / `ddog_set_span_*`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_span_set_id(span: *mut SpanNode, value: u64) { + unsafe { (*span).span.span_id = value }; } -// ------------------ TracesBytes ------------------ +/// # Safety +/// See [`ddog_span_set_id`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_span_set_parent_id(span: *mut SpanNode, value: u64) { + unsafe { (*span).span.parent_id = value }; +} -pub type TraceBytes = Vec; -pub type TracesBytes = Vec; +/// # Safety +/// See [`ddog_span_set_id`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_span_set_start(span: *mut SpanNode, value: i64) { + unsafe { (*span).span.start = value }; +} +/// # Safety +/// See [`ddog_span_set_id`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_traces() -> Box { - Box::default() +pub unsafe extern "C" fn ddog_span_set_duration(span: *mut SpanNode, value: i64) { + unsafe { (*span).span.duration = value }; } +/// # Safety +/// See [`ddog_span_set_id`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_free_traces(_traces: Box) {} +pub unsafe extern "C" fn ddog_span_set_error(span: *mut SpanNode, error: bool) { + unsafe { (*span).span.error = error }; +} +/// Reads the span error flag (e.g. to mirror it onto an inferred span). +/// +/// # Safety +/// See [`ddog_new_link`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_traces_size(traces: &TracesBytes) -> usize { - traces.len() +pub unsafe extern "C" fn ddog_span_get_error(span: *mut SpanNode) -> bool { + unsafe { (*span).span.error } } +/// # Safety +/// See [`ddog_span_get_error`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_set_span_service(span: *mut SpanNode, value: CharSlice) { + unsafe { set_field(&mut (*span).span.service, value) }; +} + +/// # Safety +/// See [`ddog_span_get_error`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_set_span_name(span: *mut SpanNode, value: CharSlice) { + unsafe { set_field(&mut (*span).span.name, value) }; +} + +/// # Safety +/// See [`ddog_span_get_error`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_trace( - traces: &mut TracesBytes, - index: usize, -) -> Option<&mut TraceBytes> { - traces.get_mut(index) +pub unsafe extern "C" fn ddog_set_span_resource(span: *mut SpanNode, value: CharSlice) { + unsafe { set_field(&mut (*span).span.resource, value) }; } -// ------------------ TraceBytes ------------------ +/// # Safety +/// See [`ddog_span_get_error`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_set_span_type(span: *mut SpanNode, value: CharSlice) { + unsafe { set_field(&mut (*span).span.r#type, value) }; +} +/// # Safety +/// See [`ddog_span_get_error`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_traces_new_trace(traces: &mut TracesBytes) -> &mut TraceBytes { - new_vector_item(traces) +pub unsafe extern "C" fn ddog_set_span_env(span: *mut SpanNode, value: CharSlice) { + unsafe { set_field(&mut (*span).span.env, value) }; } +/// # Safety +/// See [`ddog_span_get_error`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_trace_size(trace: &TraceBytes) -> usize { - trace.len() +pub unsafe extern "C" fn ddog_set_span_version(span: *mut SpanNode, value: CharSlice) { + unsafe { set_field(&mut (*span).span.version, value) }; } +/// # Safety +/// See [`ddog_span_get_error`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span(trace: &mut TraceBytes, index: usize) -> Option<&mut SpanBytes> { - trace.get_mut(index) +pub unsafe extern "C" fn ddog_set_span_component(span: *mut SpanNode, value: CharSlice) { + unsafe { set_field(&mut (*span).span.component, value) }; } -// ------------------- SpanBytes ------------------- +/// Sets the span kind from an OTEL wire value (unset/unknown → Unspecified). +/// +/// # Safety +/// See [`ddog_new_link`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_set_span_kind(span: *mut SpanNode, kind: u32) { + unsafe { (*span).span.span_kind = SpanKind::from(kind) }; +} +/// Sets the span kind from a v0.4 `span.kind` string. Returns `false` for a non-canonical kind, +/// which has no wire slot, so the caller keeps it as a plain attribute. +/// +/// # Safety +/// See [`ddog_new_link`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_trace_new_span(trace: &mut TraceBytes) -> &mut SpanBytes { - new_vector_item(trace) +pub unsafe extern "C" fn ddog_set_span_kind_str(span: *mut SpanNode, value: CharSlice) -> bool { + let kind = SpanKind::from_meta(&String::from_utf8_lossy(value.as_bytes())); + unsafe { (*span).span.span_kind = kind }; + kind != SpanKind::Unspecified } +// ------------------- Chunk fields ------------------- + +/// # Safety +/// See [`ddog_chunk_span_count`] (applies to every `ddog_set_chunk_*`). #[unsafe(no_mangle)] -pub extern "C" fn ddog_trace_new_span_with_capacities( - trace: &mut TraceBytes, - meta_size: usize, - metrics_size: usize, -) -> &mut SpanBytes { - new_vector_push( - trace, - SpanBytes { - meta: VecMap::with_capacity(meta_size), - metrics: VecMap::with_capacity(metrics_size), - ..SpanBytes::default() - }, - ) +pub unsafe extern "C" fn ddog_set_chunk_origin(chunk: *mut ChunkNode, origin: CharSlice) { + unsafe { set_field(&mut (*chunk).chunk.origin, origin) }; } -/// The returned slice is an owned allocation that must be properly freed using -/// [`ddog_free_charslice`]. +/// # Safety +/// See [`ddog_set_chunk_origin`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_span_debug_log(span: &SpanBytes) -> CharSlice<'static> { - let debug_str = format!("{span:?}"); - let len = debug_str.len(); - let cstring = CString::new(debug_str).unwrap_or_default(); +pub unsafe extern "C" fn ddog_set_chunk_sampling_priority(chunk: *mut ChunkNode, priority: i32) { + unsafe { (*chunk).chunk.priority = Some(priority) }; +} - // Safety: `CString` is an owned, valid UTF8 string. - unsafe { CharSlice::from_raw_parts(cstring.into_raw().cast(), len) } +/// # Safety +/// See [`ddog_set_chunk_origin`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_set_chunk_sampling_mechanism(chunk: *mut ChunkNode, mechanism: u32) { + unsafe { (*chunk).chunk.sampling_mechanism = Some(mechanism) }; } -/// Frees an owned [`CharSlice`]. Note that some functions of this API return borrowed slices that -/// must NOT be freed. Only a few selected functions return slices that must be freed, and this is -/// mentioned explicitly in their documentation. +// ------------------- Link / event fields ------------------- + +/// # Safety +/// `link` must be a live link node pointer from [`ddog_new_link`] (applies to every +/// `ddog_link_*`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_link_set_trace_id( + link: *mut SpanLinkBytes, + trace_id_high: u64, + trace_id_low: u64, +) { + unsafe { (*link).trace_id = trace_id_bytes(trace_id_high, trace_id_low) }; +} + +/// # Safety +/// See [`ddog_link_set_trace_id`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_link_set_span_id(link: *mut SpanLinkBytes, value: u64) { + unsafe { (*link).span_id = value }; +} + +/// # Safety +/// See [`ddog_link_set_trace_id`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_link_set_flags(link: *mut SpanLinkBytes, value: u32) { + unsafe { (*link).flags = value }; +} + +/// # Safety +/// See [`ddog_link_set_trace_id`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_link_set_tracestate(link: *mut SpanLinkBytes, value: CharSlice) { + unsafe { set_field(&mut (*link).tracestate, value) }; +} + +/// # Safety +/// `event` must be a live event node pointer from [`ddog_new_event`] (applies to every +/// `ddog_event_*`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_event_set_name(event: *mut SpanEventBytes, value: CharSlice) { + unsafe { set_field(&mut (*event).name, value) }; +} + +/// # Safety +/// See [`ddog_event_set_name`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_event_set_time(event: *mut SpanEventBytes, time_unix_nano: u64) { + unsafe { (*event).time_unix_nano = time_unix_nano }; +} + +// ------------------- Attributes ------------------- +// +// Every attribute map is filled through the `ddog_attributes_add_*` family and read through the +// `ddog_v1_attributes_*` family, on an `Attributes` handle. A node's handle is a raw place +// projection off the node pointer (no intermediate `&mut`), so it shares the node pointer's tag +// and stays valid across other calls on the same node (Stacked and Tree Borrows). + +/// Opaque handle to an attribute map: the payload's, a chunk's, span's, link's or event's +/// (`ddog_*_get_attributes`), or an owned nested map's ([`ddog_attr_map_get_attributes`]). +pub struct Attributes { + _private: [u8; 0], +} + +#[inline] +fn attributes_handle(map: *mut AttrVecMap) -> *mut Attributes { + map.cast() +} + +/// The map behind an [`Attributes`] handle. /// /// # Safety +/// `attrs` must be a live handle, not aliased by another live reference for `'a`. +#[inline] +pub unsafe fn attrs_mut<'a>(attrs: *mut Attributes) -> &'a mut AttrVecMap { + unsafe { &mut *attrs.cast::() } +} + +/// # Safety +/// `attrs` must be a live handle. +#[inline] +unsafe fn attrs_ref<'a>(attrs: *const Attributes) -> &'a AttrVecMap { + unsafe { &*attrs.cast::() } +} + +/// The payload's attribute map. /// -/// `slice` must be an owned char slice that has been returned by one of the functions of this API. +/// # Safety +/// `builder` must be a live builder from [`ddog_v1_new_builder`]. #[unsafe(no_mangle)] -pub unsafe extern "C" fn ddog_free_charslice(slice: CharSlice<'static>) { - let (ptr, len) = slice.as_raw_parts(); +pub unsafe extern "C" fn ddog_payload_get_attributes( + builder: *mut TracerPayloadV1Builder, +) -> *mut Attributes { + attributes_handle(unsafe { &raw mut (*builder).attributes }) +} - if len == 0 || ptr.is_null() { - return; - } +/// # Safety +/// See [`ddog_chunk_span_count`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_chunk_get_attributes(chunk: *mut ChunkNode) -> *mut Attributes { + attributes_handle(unsafe { &raw mut (*chunk).chunk.attributes }) +} - unsafe { - let owned_ptr = ptr as *mut c_char; - let _ = Box::from_raw(owned_ptr); +/// # Safety +/// See [`ddog_new_link`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_span_get_attributes(span: *mut SpanNode) -> *mut Attributes { + attributes_handle(unsafe { &raw mut (*span).span.attributes }) +} + +/// # Safety +/// See [`ddog_link_set_trace_id`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_link_get_attributes(link: *mut SpanLinkBytes) -> *mut Attributes { + attributes_handle(unsafe { &raw mut (*link).attributes }) +} + +/// # Safety +/// See [`ddog_event_set_name`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_event_get_attributes(event: *mut SpanEventBytes) -> *mut Attributes { + attributes_handle(unsafe { &raw mut (*event).attributes }) +} + +/// # Safety +/// `attrs` must be a live [`Attributes`] handle (applies to every `ddog_attributes_add_*`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_attributes_add_str( + attrs: *mut Attributes, + key: CharSlice, + value: CharSlice, +) { + let value = AttributeValueBytes::String(bytes_string_from_slice(value)); + unsafe { attrs_mut(attrs) }.insert(bytes_string_from_slice(key), value); +} + +/// # Safety +/// See [`ddog_attributes_add_str`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_attributes_add_int( + attrs: *mut Attributes, + key: CharSlice, + value: i64, +) { + unsafe { attrs_mut(attrs) }.insert( + bytes_string_from_slice(key), + AttributeValueBytes::Int(value), + ); +} + +/// # Safety +/// See [`ddog_attributes_add_str`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_attributes_add_double( + attrs: *mut Attributes, + key: CharSlice, + value: f64, +) { + unsafe { attrs_mut(attrs) }.insert( + bytes_string_from_slice(key), + AttributeValueBytes::Float(value), + ); +} + +/// # Safety +/// See [`ddog_attributes_add_str`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_attributes_add_bool( + attrs: *mut Attributes, + key: CharSlice, + value: bool, +) { + unsafe { attrs_mut(attrs) }.insert( + bytes_string_from_slice(key), + AttributeValueBytes::Bool(value), + ); +} + +/// Bytes attribute (v0.4 `meta_struct`), copied verbatim. +/// +/// # Safety +/// See [`ddog_attributes_add_str`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_attributes_add_bytes( + attrs: *mut Attributes, + key: CharSlice, + value: CharSlice, +) { + let value = AttributeValueBytes::Bytes(Bytes::copy_from_slice(value.as_bytes())); + unsafe { attrs_mut(attrs) }.insert(bytes_string_from_slice(key), value); +} + +/// Sets `attrs[key]` to `list`, which is consumed. +/// +/// # Safety +/// See [`ddog_attributes_add_str`]; `list` must be a live list from [`ddog_attr_list_new`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_attributes_add_list( + attrs: *mut Attributes, + key: CharSlice, + list: *mut AttrList, +) { + let value = unsafe { take_list(list) }; + unsafe { attrs_mut(attrs) }.insert(bytes_string_from_slice(key), value); +} + +/// Sets `attrs[key]` to `map`, which is consumed. +/// +/// # Safety +/// See [`ddog_attributes_add_str`]; `map` must be a live map from [`ddog_attr_map_new`], other than +/// the one `attrs` belongs to. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_attributes_add_map( + attrs: *mut Attributes, + key: CharSlice, + map: *mut AttrMap, +) { + let value = unsafe { take_map(map) }; + unsafe { attrs_mut(attrs) }.insert(bytes_string_from_slice(key), value); +} + +/// Copies the attribute `key` from `from_span` onto `to_span`, returning whether the source had it; +/// removes it from the source when `delete_source` is set. Type-preserving. +/// +/// # Safety +/// `from_span`/`to_span` must be live, distinct span node pointers from [`ddog_new_span`]; `key` +/// a static NUL-terminated string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_transfer_span_attr( + from_span: *mut SpanNode, + to_span: *mut SpanNode, + key: *const c_char, + delete_source: bool, +) -> bool { + let key = unsafe { bytes_string_from_literal(key) }; + // The two spans are distinct allocations, so the read-clone and the writes don't alias. + let value = match unsafe { (*from_span).span.attributes.get(&key) } { + Some(v) => clone_attr(v), + None => return false, + }; + unsafe { (*to_span).span.attributes.insert(key.clone(), value) }; + if delete_source { + unsafe { (*from_span).span.attributes.remove_slow(&key) }; } + true +} + +// ------------------- Nested attributes: owned containers ------------------- +// +// C builds a nested value bottom-up: allocate a list/map, fill it, push it into its parent (which +// takes ownership), and finally add the outermost container to an attribute map. + +/// An owned `List` attribute value under construction. Opaque to C. +pub struct AttrList(Vec); + +/// An owned `KeyValue` attribute value under construction. Opaque to C. +pub struct AttrMap(AttrVecMap); + +#[inline] +unsafe fn take_list(list: *mut AttrList) -> AttributeValueBytes { + AttributeValueBytes::List(unsafe { Box::from_raw(list) }.0) +} + +#[inline] +unsafe fn take_map(map: *mut AttrMap) -> AttributeValueBytes { + AttributeValueBytes::KeyValue(unsafe { Box::from_raw(map) }.0) } +/// Allocates an empty list with room for `capacity` elements, owned by C until it is consumed. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_service(span: &mut SpanBytes, slice: CharSlice) { - set_string_field(&mut span.service, slice); +pub extern "C" fn ddog_attr_list_new(capacity: usize) -> *mut AttrList { + Box::into_raw(Box::new(AttrList(Vec::with_capacity(capacity)))) } +/// Allocates an empty map with room for `capacity` members, owned by C until it is consumed. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_service(span: &mut SpanBytes) -> CharSlice<'_> { - get_string_field(&span.service) +pub extern "C" fn ddog_attr_map_new(capacity: usize) -> *mut AttrMap { + Box::into_raw(Box::new(AttrMap(VecMap::with_capacity(capacity)))) } +/// The attribute map of an owned nested `map`, filled with the `ddog_attributes_add_*` family. +/// +/// # Safety +/// `map` must be a live map from [`ddog_attr_map_new`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_name(span: &mut SpanBytes, slice: CharSlice) { - set_string_field(&mut span.name, slice); +pub unsafe extern "C" fn ddog_attr_map_get_attributes(map: *mut AttrMap) -> *mut Attributes { + attributes_handle(unsafe { &raw mut (*map).0 }) } +/// # Safety +/// `list` must be a live list from [`ddog_attr_list_new`] (applies to every `ddog_attr_list_*`); +/// a `child` is consumed. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_name(span: &mut SpanBytes) -> CharSlice<'_> { - get_string_field(&span.name) +pub unsafe extern "C" fn ddog_attr_list_push_str(list: *mut AttrList, value: CharSlice) { + unsafe { + (*list) + .0 + .push(AttributeValueBytes::String(bytes_string_from_slice(value))) + }; } +/// # Safety +/// See [`ddog_attr_list_push_str`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_resource(span: &mut SpanBytes, slice: CharSlice) { - set_string_field(&mut span.resource, slice); +pub unsafe extern "C" fn ddog_attr_list_push_int(list: *mut AttrList, value: i64) { + unsafe { (*list).0.push(AttributeValueBytes::Int(value)) }; } +/// # Safety +/// See [`ddog_attr_list_push_str`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_resource(span: &mut SpanBytes) -> CharSlice<'_> { - get_string_field(&span.resource) +pub unsafe extern "C" fn ddog_attr_list_push_double(list: *mut AttrList, value: f64) { + unsafe { (*list).0.push(AttributeValueBytes::Float(value)) }; } +/// # Safety +/// See [`ddog_attr_list_push_str`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_type(span: &mut SpanBytes, slice: CharSlice) { - set_string_field(&mut span.r#type, slice); +pub unsafe extern "C" fn ddog_attr_list_push_bool(list: *mut AttrList, value: bool) { + unsafe { (*list).0.push(AttributeValueBytes::Bool(value)) }; } +/// # Safety +/// See [`ddog_attr_list_push_str`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_type(span: &mut SpanBytes) -> CharSlice<'_> { - get_string_field(&span.r#type) +pub unsafe extern "C" fn ddog_attr_list_push_bytes(list: *mut AttrList, value: CharSlice) { + let value = AttributeValueBytes::Bytes(Bytes::copy_from_slice(value.as_bytes())); + unsafe { (*list).0.push(value) }; } +/// # Safety +/// See [`ddog_attr_list_push_str`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_trace_id(span: &mut SpanBytes, value: u64) { - span.trace_id = value as u128; +pub unsafe extern "C" fn ddog_attr_list_push_list(list: *mut AttrList, child: *mut AttrList) { + unsafe { (*list).0.push(take_list(child)) }; } +/// # Safety +/// See [`ddog_attr_list_push_str`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_trace_id(span: &mut SpanBytes) -> u64 { - span.trace_id as u64 +pub unsafe extern "C" fn ddog_attr_list_push_map(list: *mut AttrList, child: *mut AttrMap) { + unsafe { (*list).0.push(take_map(child)) }; } +// ------------------- Read-back ------------------- +// +// Getters take the node pointers directly; children come as the parent's node-pointer array, so a +// reader walks the tree without re-resolving any index. Returned slices borrow the builder and are +// valid until it is next mutated or freed. + +/// The builder's chunk node pointers; `len` receives their count. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_id(span: &mut SpanBytes, value: u64) { - span.span_id = value; +pub extern "C" fn ddog_v1_get_chunks( + builder: &TracerPayloadV1Builder, + len: &mut usize, +) -> *const *mut ChunkNode { + *len = builder.chunks.len(); + builder.chunks.as_ptr() } +/// Number of chunks in the builder. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_id(span: &mut SpanBytes) -> u64 { - span.span_id +pub extern "C" fn ddog_v1_get_chunk_count(builder: &TracerPayloadV1Builder) -> usize { + builder.chunks.len() } +/// The chunk's span node pointers; `len` receives their count. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_parent_id(span: &mut SpanBytes, value: u64) { - span.parent_id = value; +pub extern "C" fn ddog_v1_get_spans(chunk: &ChunkNode, len: &mut usize) -> *const *mut SpanNode { + *len = chunk.spans.len(); + chunk.spans.as_ptr() } +/// The chunk's local-root span, as the v0.4 wire picks it (`local_root_idx`), or null if empty. +/// Chunk-level trace tags (trace_id_high, sampling priority/mechanism, origin) belong on it only. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_parent_id(span: &mut SpanBytes) -> u64 { - span.parent_id +pub extern "C" fn ddog_v1_get_chunk_root_span(chunk: &ChunkNode) -> *mut SpanNode { + // Safety: the span pointers are live allocations owned by the chunk. + let root = local_root_idx(chunk.spans.iter().map(|&s| unsafe { &(*s).span })); + chunk.spans.get(root).copied().unwrap_or(ptr::null_mut()) } +/// The span's link node pointers; `len` receives their count. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_start(span: &mut SpanBytes, value: i64) { - span.start = value; +pub extern "C" fn ddog_v1_get_links(span: &SpanNode, len: &mut usize) -> *const *mut SpanLinkBytes { + *len = span.links.len(); + span.links.as_ptr() } +/// The span's event node pointers; `len` receives their count. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_start(span: &mut SpanBytes) -> i64 { - span.start +pub extern "C" fn ddog_v1_get_events( + span: &SpanNode, + len: &mut usize, +) -> *const *mut SpanEventBytes { + *len = span.events.len(); + span.events.as_ptr() } #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_duration(span: &mut SpanBytes, value: i64) { - span.duration = value; +pub extern "C" fn ddog_v1_get_chunk_trace_id_high(chunk: &ChunkNode) -> u64 { + trace_id_high(&chunk.chunk.trace_id) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_duration(span: &mut SpanBytes) -> i64 { - span.duration +pub extern "C" fn ddog_v1_get_chunk_trace_id_low(chunk: &ChunkNode) -> u64 { + trace_id_low(&chunk.chunk.trace_id) } +/// Reads the chunk sampling priority; returns `false` (and leaves `out` untouched) when unset. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_span_error(span: &mut SpanBytes, value: i32) { - span.error = value; +pub extern "C" fn ddog_v1_get_chunk_sampling_priority(chunk: &ChunkNode, out: &mut i32) -> bool { + chunk.chunk.priority.map(|p| *out = p).is_some() } +/// Reads the chunk sampling mechanism; returns `false` (and leaves `out` untouched) when unset. #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_error(span: &mut SpanBytes) -> i32 { - span.error +pub extern "C" fn ddog_v1_get_chunk_sampling_mechanism(chunk: &ChunkNode, out: &mut u32) -> bool { + chunk.chunk.sampling_mechanism.map(|m| *out = m).is_some() } #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_span_meta(span: &mut SpanBytes, key: CharSlice, value: CharSlice) { - insert_vec_map( - &mut span.meta, - key, - BytesString::from_slice(value.as_bytes()).unwrap_or_default(), - ); +pub extern "C" fn ddog_v1_get_chunk_origin(chunk: &ChunkNode) -> CharSlice<'_> { + char_slice_of(&chunk.chunk.origin) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_del_span_meta(span: &mut SpanBytes, key: CharSlice) { - remove_vec_map_slow(&mut span.meta, key); +pub extern "C" fn ddog_v1_get_span_service(span: &SpanNode) -> CharSlice<'_> { + char_slice_of(&span.span.service) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_meta<'a>( - span: &'a mut SpanBytes, - key: CharSlice<'_>, -) -> CharSlice<'a> { - let bytes_str_key = convert_char_slice_to_bytes_string(key); - match span.meta.get(&bytes_str_key) { - // Safety: value is a `ByteString`, which guarantees it is valid UTF8. We return a slice - // that borrows from `span`, making sure it remains alive and immutable for the lifetime of - // the slice. - Some(value) => unsafe { - CharSlice::from_raw_parts(value.as_str().as_ptr().cast(), value.as_str().len()) - }, - None => CharSlice::empty(), - } +pub extern "C" fn ddog_v1_get_span_name(span: &SpanNode) -> CharSlice<'_> { + char_slice_of(&span.span.name) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_has_span_meta(span: &mut SpanBytes, key: CharSlice) -> bool { - exists_vec_map(&span.meta, key) +pub extern "C" fn ddog_v1_get_span_resource(span: &SpanNode) -> CharSlice<'_> { + char_slice_of(&span.span.resource) } -/// The return value is an owned array of slices (`Box<[CharSlice]>`) that must be freed explicitly -/// through [`ddog_span_free_keys_ptr`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_span_meta_get_keys<'a>( - span: &'a mut SpanBytes, - out_count: &mut usize, -) -> *mut CharSlice<'a> { - get_vec_map_keys(&span.meta, out_count) +pub extern "C" fn ddog_v1_get_span_type(span: &SpanNode) -> CharSlice<'_> { + char_slice_of(&span.span.r#type) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_span_metrics(span: &mut SpanBytes, key: CharSlice, val: f64) { - insert_vec_map(&mut span.metrics, key, val); +pub extern "C" fn ddog_v1_get_span_env(span: &SpanNode) -> CharSlice<'_> { + char_slice_of(&span.span.env) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_del_span_metrics(span: &mut SpanBytes, key: CharSlice) { - remove_vec_map_slow(&mut span.metrics, key); +pub extern "C" fn ddog_v1_get_span_version(span: &SpanNode) -> CharSlice<'_> { + char_slice_of(&span.span.version) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_metrics( - span: &mut SpanBytes, - key: CharSlice, - result: &mut f64, -) -> bool { - let bytes_str_key = convert_char_slice_to_bytes_string(key); - match span.metrics.get(&bytes_str_key) { - Some(&value) => { - *result = value; - true - } - None => false, - } +pub extern "C" fn ddog_v1_get_span_component(span: &SpanNode) -> CharSlice<'_> { + char_slice_of(&span.span.component) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_has_span_metrics(span: &mut SpanBytes, key: CharSlice) -> bool { - exists_vec_map(&span.metrics, key) +pub extern "C" fn ddog_v1_get_span_id(span: &SpanNode) -> u64 { + span.span.span_id } #[unsafe(no_mangle)] -pub extern "C" fn ddog_span_metrics_get_keys<'a>( - span: &'a mut SpanBytes, - out_count: &mut usize, -) -> *mut CharSlice<'a> { - get_vec_map_keys(&span.metrics, out_count) +pub extern "C" fn ddog_v1_get_span_parent_id(span: &SpanNode) -> u64 { + span.span.parent_id } #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_span_meta_struct(span: &mut SpanBytes, key: CharSlice, val: CharSlice) { - insert_vec_map( - &mut span.meta_struct, - key, - Bytes::copy_from_slice(val.as_bytes()), - ); +pub extern "C" fn ddog_v1_get_span_start(span: &SpanNode) -> i64 { + span.span.start } #[unsafe(no_mangle)] -pub extern "C" fn ddog_del_span_meta_struct(span: &mut SpanBytes, key: CharSlice) { - remove_vec_map_slow(&mut span.meta_struct, key); +pub extern "C" fn ddog_v1_get_span_duration(span: &SpanNode) -> i64 { + span.span.duration } #[unsafe(no_mangle)] -pub extern "C" fn ddog_get_span_meta_struct<'a>( - span: &'a mut SpanBytes, - key: CharSlice<'_>, -) -> CharSlice<'a> { - let bytes_str_key = convert_char_slice_to_bytes_string(key); - match span.meta_struct.get(&bytes_str_key) { - // Safety: value is a `ByteString`, which guarantees it is valid UTF8. We return a slice - // that borrows from `span`, ensuring it remains alive and unchanged for the lifetime of - // the slice. - Some(value) => unsafe { CharSlice::from_raw_parts(value.as_ptr().cast(), value.len()) }, - None => CharSlice::empty(), - } +pub extern "C" fn ddog_v1_get_span_error(span: &SpanNode) -> bool { + span.span.error } +/// The span kind as its OTEL wire value. #[unsafe(no_mangle)] -pub extern "C" fn ddog_has_span_meta_struct(span: &mut SpanBytes, key: CharSlice) -> bool { - exists_vec_map(&span.meta_struct, key) +pub extern "C" fn ddog_v1_get_span_kind(span: &SpanNode) -> u32 { + span.span.span_kind as u32 } -/// The return value is an array of slices (`Box<[CharSlice]>`) that must be freed explicitly -/// through [`ddog_span_free_keys_ptr`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_span_meta_struct_get_keys<'a>( - span: &'a mut SpanBytes, - out_count: &mut usize, -) -> *mut CharSlice<'a> { - get_vec_map_keys(&span.meta_struct, out_count) +pub extern "C" fn ddog_v1_get_link_trace_id_high(link: &SpanLinkBytes) -> u64 { + trace_id_high(&link.trace_id) } -/// # Safety -/// -/// `keys_ptr` must have been returned by one of the `ddog_xxx_get_keys()` functions, and must not -/// have been already freed. #[unsafe(no_mangle)] -pub unsafe extern "C" fn ddog_span_free_keys_ptr(keys_ptr: *mut CharSlice<'_>, count: usize) { - if keys_ptr.is_null() || count == 0 { - return; - } +pub extern "C" fn ddog_v1_get_link_trace_id_low(link: &SpanLinkBytes) -> u64 { + trace_id_low(&link.trace_id) +} - // Safety: all `xxx_get_keys()` functions return from `get_vec_map_keys()`, which returns a - // `Box<[T]>`. It is an official guarantee of `Vec` that this can be freely converted to and - // from `Box<[T]>` when `len == capacity`. - unsafe { - Vec::from_raw_parts(keys_ptr, count, count); - } +#[unsafe(no_mangle)] +pub extern "C" fn ddog_v1_get_link_span_id(link: &SpanLinkBytes) -> u64 { + link.span_id } -// ------------------- SpanLinkBytes ------------------- +#[unsafe(no_mangle)] +pub extern "C" fn ddog_v1_get_link_flags(link: &SpanLinkBytes) -> u32 { + link.flags +} #[unsafe(no_mangle)] -pub extern "C" fn ddog_span_new_link(span: &mut SpanBytes) -> &mut SpanLinkBytes { - new_vector_item(&mut span.span_links) +pub extern "C" fn ddog_v1_get_link_tracestate(link: &SpanLinkBytes) -> CharSlice<'_> { + char_slice_of(&link.tracestate) } #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_link_tracestate(link: &mut SpanLinkBytes, slice: CharSlice) { - set_string_field(&mut link.tracestate, slice); +pub extern "C" fn ddog_v1_get_event_time(event: &SpanEventBytes) -> u64 { + event.time_unix_nano } #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_link_trace_id(link: &mut SpanLinkBytes, value: u64) { - link.trace_id = value; +pub extern "C" fn ddog_v1_get_event_name(event: &SpanEventBytes) -> CharSlice<'_> { + char_slice_of(&event.name) } +// ---- Attribute maps and values ---- + +/// Opaque handle to one attribute value inside an [`Attributes`] map or a list. +pub struct AttrValue { + _private: [u8; 0], +} + +#[inline] +fn value_handle(value: &AttributeValueBytes) -> *const AttrValue { + (value as *const AttributeValueBytes).cast() +} + +/// # Safety +/// `value` must be a live handle from a `ddog_v1_*` getter. +#[inline] +unsafe fn value_ref<'a>(value: *const AttrValue) -> &'a AttributeValueBytes { + unsafe { &*value.cast::() } +} + +/// Number of entries in `attrs`, including not-yet-deduped repeated keys (the last one wins). +/// +/// # Safety +/// `attrs` must be a live [`Attributes`] handle (applies to every `ddog_v1_attributes_*`). #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_link_trace_id_high(link: &mut SpanLinkBytes, value: u64) { - link.trace_id_high = value; +pub unsafe extern "C" fn ddog_v1_attributes_len(attrs: *const Attributes) -> usize { + unsafe { attrs_ref(attrs) }.len() } +/// Key of the entry at `idx` (empty if out of range). +/// +/// # Safety +/// See [`ddog_v1_attributes_len`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_link_span_id(link: &mut SpanLinkBytes, value: u64) { - link.span_id = value; +pub unsafe extern "C" fn ddog_v1_attributes_key<'a>( + attrs: *const Attributes, + idx: usize, +) -> CharSlice<'a> { + match unsafe { attrs_ref(attrs) }.iter().nth(idx) { + Some((k, _)) => char_slice_of(k), + None => CharSlice::empty(), + } } +/// Value of the entry at `idx` (null if out of range). +/// +/// # Safety +/// See [`ddog_v1_attributes_len`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_link_flags(link: &mut SpanLinkBytes, value: u32) { - link.flags = value; +pub unsafe extern "C" fn ddog_v1_attributes_value( + attrs: *const Attributes, + idx: usize, +) -> *const AttrValue { + match unsafe { attrs_ref(attrs) }.iter().nth(idx) { + Some((_, v)) => value_handle(v), + None => ptr::null(), + } } +/// Value of `key` (null if absent). +/// +/// # Safety +/// See [`ddog_v1_attributes_len`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_link_attributes( - link: &mut SpanLinkBytes, +pub unsafe extern "C" fn ddog_v1_attributes_get( + attrs: *const Attributes, key: CharSlice, - val: CharSlice, -) { - insert_hashmap( - &mut link.attributes, - key, - BytesString::from_slice(val.as_bytes()).unwrap_or_default(), - ); +) -> *const AttrValue { + let key = String::from_utf8_lossy(key.as_bytes()); + match unsafe { attrs_ref(attrs) }.get(key.as_ref()) { + Some(v) => value_handle(v), + None => ptr::null(), + } } -// ------------------- SpanEventBytes ------------------- +/// [`DDOG_V1_ATTR_*`] type tag of `value`. +/// +/// # Safety +/// `value` must be a live [`AttrValue`] handle (applies to every `ddog_v1_value_*`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_v1_value_type(value: *const AttrValue) -> u32 { + match unsafe { value_ref(value) } { + AttributeValueBytes::String(_) => DDOG_V1_ATTR_STRING, + AttributeValueBytes::Int(_) => DDOG_V1_ATTR_INT, + AttributeValueBytes::Float(_) => DDOG_V1_ATTR_DOUBLE, + AttributeValueBytes::Bool(_) => DDOG_V1_ATTR_BOOL, + AttributeValueBytes::Bytes(_) => DDOG_V1_ATTR_BYTES, + AttributeValueBytes::KeyValue(_) => DDOG_V1_ATTR_KEYVALUE, + AttributeValueBytes::List(_) => DDOG_V1_ATTR_LIST, + } +} +/// The string (empty unless a `String`). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_span_new_event(span: &mut SpanBytes) -> &mut SpanEventBytes { - new_vector_item(&mut span.span_events) +pub unsafe extern "C" fn ddog_v1_value_str<'a>(value: *const AttrValue) -> CharSlice<'a> { + match unsafe { value_ref(value) } { + AttributeValueBytes::String(s) => char_slice_of(s), + _ => CharSlice::empty(), + } } +/// The bytes (empty unless `Bytes`). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_event_name(event: &mut SpanEventBytes, slice: CharSlice) { - set_string_field(&mut event.name, slice); +pub unsafe extern "C" fn ddog_v1_value_bytes<'a>(value: *const AttrValue) -> CharSlice<'a> { + match unsafe { value_ref(value) } { + // Safety: the slice borrows `b`, which lives as long as the value. + AttributeValueBytes::Bytes(b) => unsafe { + CharSlice::from_raw_parts(b.as_ref().as_ptr().cast(), b.len()) + }, + _ => CharSlice::empty(), + } } +/// The integer (0 unless an `Int`). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_set_event_time(event: &mut SpanEventBytes, val: u64) { - event.time_unix_nano = val; +pub unsafe extern "C" fn ddog_v1_value_int(value: *const AttrValue) -> i64 { + match unsafe { value_ref(value) } { + AttributeValueBytes::Int(v) => *v, + _ => 0, + } } +/// The double (0.0 unless a `Float`). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_event_attributes_str( - event: &mut SpanEventBytes, - key: CharSlice, - val: CharSlice, -) { - set_event_attribute( - event, - key, - AttributeArrayValueBytes::String(convert_char_slice_to_bytes_string(val)), - ); +pub unsafe extern "C" fn ddog_v1_value_double(value: *const AttrValue) -> f64 { + match unsafe { value_ref(value) } { + AttributeValueBytes::Float(v) => *v, + _ => 0.0, + } } +/// The boolean (false unless a true `Bool`). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_event_attributes_bool( - event: &mut SpanEventBytes, - key: CharSlice, - val: bool, -) { - set_event_attribute(event, key, AttributeArrayValueBytes::Boolean(val)); +pub unsafe extern "C" fn ddog_v1_value_bool(value: *const AttrValue) -> bool { + matches!(unsafe { value_ref(value) }, AttributeValueBytes::Bool(true)) } +/// Number of elements (0 unless a `List`). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_event_attributes_int( - event: &mut SpanEventBytes, - key: CharSlice, - val: i64, -) { - set_event_attribute(event, key, AttributeArrayValueBytes::Integer(val)); +pub unsafe extern "C" fn ddog_v1_value_list_len(value: *const AttrValue) -> usize { + match unsafe { value_ref(value) } { + AttributeValueBytes::List(l) => l.len(), + _ => 0, + } } +/// Element `idx` (null unless a `List` with that element). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. #[unsafe(no_mangle)] -pub extern "C" fn ddog_add_event_attributes_float( - event: &mut SpanEventBytes, - key: CharSlice, - val: f64, +pub unsafe extern "C" fn ddog_v1_value_list_get( + value: *const AttrValue, + idx: usize, +) -> *const AttrValue { + match unsafe { value_ref(value) } { + AttributeValueBytes::List(l) => l.get(idx).map_or(ptr::null(), value_handle), + _ => ptr::null(), + } +} + +/// The members of a `KeyValue`, read with the `ddog_v1_attributes_*` family (null otherwise). +/// +/// # Safety +/// See [`ddog_v1_value_type`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_v1_value_map(value: *const AttrValue) -> *const Attributes { + match unsafe { value_ref(value) } { + AttributeValueBytes::KeyValue(m) => (m as *const AttrVecMap).cast(), + _ => ptr::null(), + } +} + +// ------------------- Payload-level metadata ------------------- + +/// Populates the send-time payload fields (env / app_version / hostname / git come from the +/// builder). +pub fn populate_payload_metadata( + payload: &mut TracerPayloadBytes, + container_id: &str, + language_name: &str, + language_version: &str, + tracer_version: &str, + runtime_id: &str, ) { - set_event_attribute(event, key, AttributeArrayValueBytes::Double(val)); + fn bs(s: &str) -> BytesString { + BytesString::from_slice(s.as_bytes()).unwrap_or_default() + } + payload.container_id = bs(container_id); + payload.language_name = bs(language_name); + payload.language_version = bs(language_version); + payload.tracer_version = bs(tracer_version); + payload.runtime_id = bs(runtime_id); } -// ------------------- Export Functions ------------------- +// ------------------- Debug logging ------------------- -/// The returned slice is an owned allocation that must be properly freed using +/// Renders 16 big-endian trace-id bytes as a 32-char lowercase hex string. +fn hex16(bytes: &[u8; 16]) -> String { + let mut s = String::with_capacity(32); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Renders a typed V1 attribute value in a compact, readable form (strings quoted, byte blobs shown +/// as a length, key-values/lists rendered recursively). +fn render_attr_value(value: &AttributeValueBytes) -> String { + match value { + AttributeValueBytes::String(s) => format!("{:?}", s.as_str()), + AttributeValueBytes::Int(i) => i.to_string(), + AttributeValueBytes::Float(f) => f.to_string(), + AttributeValueBytes::Bool(b) => b.to_string(), + AttributeValueBytes::Bytes(b) => format!("<{} bytes>", b.len()), + AttributeValueBytes::KeyValue(m) => { + let inner: Vec = m + .iter() + .map(|(k, v)| format!("{}: {}", k.as_str(), render_attr_value(v))) + .collect(); + format!("{{{}}}", inner.join(", ")) + } + AttributeValueBytes::List(list) => { + let inner: Vec = list.iter().map(render_attr_value).collect(); + format!("[{}]", inner.join(", ")) + } + } +} + +/// Renders a V1 span (plus its chunk's trace id) as a readable diagnostic string. Link/event counts +/// are passed explicitly because, mid-build, they live on the [`SpanNode`], not the span's own +/// (still-empty) inline vectors. +fn render_span_debug( + span: &SpanBytes, + chunk: Option<&TraceChunkBytes>, + links: usize, + events: usize, +) -> String { + let mut out = String::new(); + if let Some(c) = chunk { + let _ = write!(out, "trace_id={} ", hex16(&c.trace_id)); + } + let _ = write!( + out, + "service={:?} name={:?} resource={:?} type={:?} span_id={} parent_id={} \ + start={} duration={} error={} kind={:?} env={:?} version={:?} component={:?}", + span.service.as_str(), + span.name.as_str(), + span.resource.as_str(), + span.r#type.as_str(), + span.span_id, + span.parent_id, + span.start, + span.duration, + span.error, + span.span_kind, + span.env.as_str(), + span.version.as_str(), + span.component.as_str(), + ); + let attrs: Vec = span + .attributes + .iter() + .map(|(k, v)| format!("{}={}", k.as_str(), render_attr_value(v))) + .collect(); + let _ = write!( + out, + " attributes={{{}}} links={} events={}", + attrs.join(", "), + links, + events, + ); + out +} + +/// Renders a span for dd-trace-php's `DD_TRACE_DEBUG` "Encoding span" line. Takes the owning +/// chunk/span node pointers (the outer frame's still-live handles), so it works mid-build before +/// the nodes are folded into the inline payload. The returned owned slice must be freed with /// [`ddog_free_charslice`]. +/// +/// # Safety +/// `chunk`/`span` must be live node pointers previously returned by +/// `ddog_new_chunk`/`ddog_new_span` (with `span` a span of `chunk`). #[unsafe(no_mangle)] -pub extern "C" fn ddog_serialize_trace_into_charslice( - trace: &mut TraceBytes, +pub unsafe extern "C" fn ddog_v1_span_debug_log( + chunk: *mut ChunkNode, + span: *mut SpanNode, ) -> CharSlice<'static> { - match rmp_serde::encode::to_vec_named(&vec![trace]) { - Ok(vec) => { - let boxed_str = vec.into_boxed_slice(); - let boxed_len = boxed_str.len(); + // Safety: per the fn contract, both are live node pointers. + let (chunk_node, span_node) = unsafe { (&*chunk, &*span) }; + let debug_str = render_span_debug( + span_node.span(), + Some(&chunk_node.chunk), + span_node.links.len(), + span_node.events.len(), + ); + // An empty (or NUL-containing, hence unrepresentable) render owns no allocation: return a + // borrowed empty slice so a zero length always means "nothing to free" in + // `ddog_free_charslice`. + let cstring = match CString::new(debug_str) { + Ok(c) if !c.as_bytes().is_empty() => c, + _ => return CharSlice::empty(), + }; + let len = cstring.as_bytes().len(); + + // Safety: `CString` owns a `len + 1` byte allocation (payload + NUL); the pointer is freed by + // `ddog_free_charslice`, which reclaims that same shape. + unsafe { CharSlice::from_raw_parts(cstring.into_raw().cast(), len) } +} - let leaked_ptr = Box::into_raw(boxed_str) as *const c_char; +// ------------------- Shared free helper ------------------- - unsafe { CharSlice::from_raw_parts(leaked_ptr, boxed_len) } - } - Err(_) => CharSlice::empty(), +/// Frees an owned [`CharSlice`]. Only the few functions that document it return owned slices (the +/// V1 [`ddog_v1_span_debug_log`] and the v0.4 +/// [`crate::span_v04::ddog_serialize_trace_into_charslice`]); borrowed slices must NOT be passed +/// here. An owned slice allocates `len + 1` bytes (payload + NUL), reclaimed here with that exact +/// shape; a zero-length slice is always borrowed and owns nothing. +/// +/// # Safety +/// +/// `slice` must be an owned char slice that has been returned by one of the functions of this API. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_free_charslice(slice: CharSlice<'static>) { + let (ptr, len) = slice.as_raw_parts(); + + if len == 0 || ptr.is_null() { + return; + } + + // The allocation is `len + 1` bytes (payload + trailing NUL); reconstruct the same layout. + unsafe { + let _ = Vec::from_raw_parts(ptr as *mut u8, len + 1, len + 1); } } diff --git a/datadog-sidecar-ffi/src/span_v04.rs b/datadog-sidecar-ffi/src/span_v04.rs new file mode 100644 index 0000000000..32c6f3704c --- /dev/null +++ b/datadog-sidecar-ffi/src/span_v04.rs @@ -0,0 +1,60 @@ +// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! In-memory v0.4 trace collection (`TracesBytes`), kept only as the downgrade target of the native +//! V1 builder for the in-process (`coms.c`) sender (the tracer builds V1 directly, see +//! [`crate::span`]). + +use libdd_common_ffi::slice::CharSlice; +use libdd_trace_utils::span::v04::SpanBytes; +use std::ffi::c_char; + +// ------------------ TracesBytes ------------------ + +pub type TraceBytes = Vec; +pub type TracesBytes = Vec; + +#[unsafe(no_mangle)] +pub extern "C" fn ddog_free_traces(_traces: Box) {} + +#[unsafe(no_mangle)] +pub extern "C" fn ddog_get_traces_size(traces: &TracesBytes) -> usize { + traces.len() +} + +#[unsafe(no_mangle)] +pub extern "C" fn ddog_get_trace( + traces: &mut TracesBytes, + index: usize, +) -> Option<&mut TraceBytes> { + traces.get_mut(index) +} + +// ------------------- Serialization / export helpers ------------------- + +/// Serializes one v0.4 trace as a msgpack array-of-1, the framing the background sender +/// (`ddtrace_send_traces_via_thread`) expects. Returns an owned slice; free with +/// [`crate::span::ddog_free_charslice`]. +#[unsafe(no_mangle)] +pub extern "C" fn ddog_serialize_trace_into_charslice( + trace: &mut TraceBytes, +) -> CharSlice<'static> { + match rmp_serde::encode::to_vec_named(&vec![trace]) { + Ok(mut vec) => { + if vec.is_empty() { + return CharSlice::empty(); + } + let payload_len = vec.len(); + // Append a trailing NUL so the allocation is `payload_len + 1` bytes, matching the + // shape `ddog_free_charslice` reclaims. The terminator is excluded from the reported + // length, so consumers only ever see the msgpack payload. + vec.push(0); + let boxed_str = vec.into_boxed_slice(); + + let leaked_ptr = Box::into_raw(boxed_str) as *const c_char; + + unsafe { CharSlice::from_raw_parts(leaked_ptr, payload_len) } + } + Err(_) => CharSlice::empty(), + } +} diff --git a/datadog-sidecar-ffi/tests/sidecar.rs b/datadog-sidecar-ffi/tests/sidecar.rs index c62d18d0a1..89bd8b18e6 100644 --- a/datadog-sidecar-ffi/tests/sidecar.rs +++ b/datadog-sidecar-ffi/tests/sidecar.rs @@ -123,6 +123,7 @@ fn test_ddog_sidecar_register_app() { "".into(), "".into(), "".into(), + false, ) .unwrap_none(); @@ -181,6 +182,7 @@ fn test_ddog_sidecar_register_app() { "".into(), "".into(), "".into(), + false, ) .unwrap_none(); diff --git a/datadog-sidecar-ffi/tests/span.rs b/datadog-sidecar-ffi/tests/span.rs index dcc7789dec..552484f0d6 100644 --- a/datadog-sidecar-ffi/tests/span.rs +++ b/datadog-sidecar-ffi/tests/span.rs @@ -1,372 +1,607 @@ -// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +//! Integration tests for the native V1 payload builder FFI ([`datadog_sidecar_ffi::span`]). They +//! fill and read the payload through the exported `extern "C"` functions only, as a tracer does. + use datadog_sidecar_ffi::span::*; -use libdd_common_ffi::slice::*; -use libdd_tinybytes::*; -use libdd_trace_utils::span::v04::*; +use libdd_common_ffi::slice::{AsBytes, CharSlice}; +use libdd_trace_utils::msgpack_encoder::v1::to_vec_from_v1; use std::collections::HashMap; +use std::ffi::CStr; -#[test] -#[cfg_attr(miri, ignore)] -fn test_set_get_all_core_fields() { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(&mut traces); - let span = ddog_trace_new_span(trace); - - ddog_set_span_service(span, CharSlice::from("my-service")); - assert_eq!(ddog_get_span_service(span), CharSlice::from("my-service")); - - ddog_set_span_name(span, CharSlice::from("my-span")); - assert_eq!(ddog_get_span_name(span), CharSlice::from("my-span")); - - ddog_set_span_resource(span, CharSlice::from("my-resource")); - assert_eq!(ddog_get_span_resource(span), CharSlice::from("my-resource")); - - ddog_set_span_type(span, CharSlice::from("web")); - assert_eq!(ddog_get_span_type(span), CharSlice::from("web")); - - ddog_set_span_trace_id(span, 123); - assert_eq!(ddog_get_span_trace_id(span), 123); +fn cs(s: &str) -> CharSlice<'_> { + CharSlice::from(s) +} - ddog_set_span_id(span, 456); - assert_eq!(ddog_get_span_id(span), 456); +fn string(slice: CharSlice) -> String { + slice.to_utf8_lossy().into_owned() +} - ddog_set_span_parent_id(span, 789); - assert_eq!(ddog_get_span_parent_id(span), 789); +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} - ddog_set_span_start(span, 1000); - assert_eq!(ddog_get_span_start(span), 1000); +/// The entries of an attribute map by key; map order is unspecified, so tests never index it. +unsafe fn attrs_by_key(attrs: *const Attributes) -> HashMap { + unsafe { + (0..ddog_v1_attributes_len(attrs)) + .map(|i| { + ( + string(ddog_v1_attributes_key(attrs, i)), + ddog_v1_attributes_value(attrs, i), + ) + }) + .collect() + } +} - ddog_set_span_duration(span, 5000); - assert_eq!(ddog_get_span_duration(span), 5000); +/// Node pointers returned as a C array. +unsafe fn nodes(ptr: *const *mut T, len: usize) -> Vec<*mut T> { + unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec() +} - ddog_set_span_error(span, 1); - assert_eq!(ddog_get_span_error(span), 1); +fn spans_of(chunk: *mut ChunkNode) -> Vec<*mut SpanNode> { + let mut len = 0; + unsafe { nodes(ddog_v1_get_spans(&*chunk, &mut len), len) } +} - ddog_free_traces(traces); +/// A builder with one chunk holding one span, the common fixture. +fn one_span(trace_id_low: u64) -> (Box, *mut ChunkNode, *mut SpanNode) { + let mut b = ddog_v1_new_builder(); + let chunk = ddog_new_chunk(&mut b, 0, trace_id_low); + let span = unsafe { ddog_new_span(chunk) }; + (b, chunk, span) } #[test] -#[cfg_attr(miri, ignore)] -fn test_meta_crud() { +fn builds_span_with_promoted_and_typed_attributes() { + let (b, _, span) = one_span(0x0123456789abcdef); unsafe { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(&mut traces); - let span = ddog_trace_new_span(trace); - - let key = CharSlice::from("foo"); - let val = CharSlice::from("bar"); - - let key2 = CharSlice::from("foo2"); - let val2 = CharSlice::from("baz"); - - assert!(!ddog_has_span_meta(span, key)); - assert!(!ddog_has_span_meta(span, key2)); - - ddog_add_span_meta(span, key, val); - ddog_add_span_meta(span, key, val); // Check for duplicates - ddog_add_span_meta(span, key2, val2); - - assert!(ddog_has_span_meta(span, key)); - assert!(ddog_has_span_meta(span, key2)); - - assert_eq!(ddog_get_span_meta(span, key), val); - assert_eq!(ddog_get_span_meta(span, key2), val2); - - let mut count = 0; - let keys = ddog_span_meta_get_keys(span, &mut count); - let keys_slice = std::slice::from_raw_parts(keys, count); - assert_eq!(count, 2); - assert!(keys_slice.iter().any(|k| k == &key)); - assert!(keys_slice.iter().any(|k| k == &key2)); - ddog_span_free_keys_ptr(keys, count); - - ddog_del_span_meta(span, key); - assert!(!ddog_has_span_meta(span, key)); + ddog_set_span_service(span, cs("svc")); + ddog_set_span_name(span, cs("op")); + ddog_set_span_resource(span, cs("res")); + ddog_span_set_id(span, 42); + ddog_span_set_start(span, 1_000); + ddog_span_set_duration(span, 500); + ddog_span_set_error(span, true); + ddog_set_span_kind(span, 2); // Server + let attrs = ddog_span_get_attributes(span); + ddog_attributes_add_str(attrs, cs("k_str"), cs("v_str")); + ddog_attributes_add_int(attrs, cs("k_int"), 7); + } - ddog_free_traces(traces); + let encoded = to_vec_from_v1(&b.into_payload()); + let expected_tid = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, + ]; + assert!(contains(&encoded, &expected_tid)); + for s in [b"svc" as &[u8], b"op", b"res", b"k_str", b"v_str", b"k_int"] { + assert!( + contains(&encoded, s), + "{} should appear", + String::from_utf8_lossy(s) + ); } + // SpanKind Server = 2: key 16 (0x10) then uint 2 (0x02) + assert!(contains(&encoded, &[0x10, 0x02])); } #[test] -#[cfg_attr(miri, ignore)] -fn test_metrics_crud() { +fn getters_round_trip_setters() { + let mut b = ddog_v1_new_builder(); + let chunk = ddog_new_chunk(&mut b, 0xaabb, 0xccdd); unsafe { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(&mut traces); - let span = ddog_trace_new_span(trace); - - let key = CharSlice::from("load"); - let val = 42.0; + ddog_set_chunk_sampling_priority(chunk, 2); + ddog_set_chunk_origin(chunk, cs("lambda")); + ddog_set_chunk_sampling_mechanism(chunk, 4); + ddog_attributes_add_str(ddog_chunk_get_attributes(chunk), cs("c_key"), cs("c_val")); + ddog_attributes_add_str( + ddog_payload_get_attributes(&mut *b), + cs("p_key"), + cs("p_val"), + ); + } - let key2 = CharSlice::from("load2"); - let val2 = 84.0; + let span = unsafe { ddog_new_span(chunk) }; + let (link, event); + unsafe { + ddog_set_span_service(span, cs("svc")); + ddog_set_span_name(span, cs("op")); + ddog_set_span_resource(span, cs("res")); + ddog_set_span_type(span, cs("web")); + ddog_set_span_env(span, cs("prod")); + ddog_set_span_version(span, cs("1.2.3")); + ddog_set_span_component(span, cs("pdo")); + ddog_span_set_id(span, 42); + ddog_span_set_parent_id(span, 7); + ddog_span_set_start(span, 1_000); + ddog_span_set_duration(span, 500); + ddog_span_set_error(span, true); + ddog_set_span_kind(span, 3); // Client + let attrs = ddog_span_get_attributes(span); + ddog_attributes_add_str(attrs, cs("a_str"), cs("v")); + ddog_attributes_add_int(attrs, cs("a_int"), 11); + ddog_attributes_add_double(attrs, cs("a_dbl"), 1.5); + ddog_attributes_add_bool(attrs, cs("a_bool"), true); + ddog_attributes_add_bytes(attrs, cs("a_bytes"), cs("raw")); + + link = ddog_new_link(span); + ddog_link_set_trace_id(link, 0x11, 0x22); + ddog_link_set_span_id(link, 9); + ddog_link_set_flags(link, 1); + ddog_link_set_tracestate(link, cs("dd=s:1")); + ddog_attributes_add_str(ddog_link_get_attributes(link), cs("l_key"), cs("l_val")); + + event = ddog_new_event(span); + ddog_event_set_time(event, 123); + ddog_event_set_name(event, cs("exception")); + ddog_attributes_add_int(ddog_event_get_attributes(event), cs("e_int"), 5); + } - assert!(!ddog_has_span_metrics(span, key)); - assert!(!ddog_has_span_metrics(span, key2)); + // Tree walk: the node arrays hand back the pointers the creators returned. + let mut len = 0; + assert_eq!( + unsafe { nodes(ddog_v1_get_chunks(&b, &mut len), len) }, + [chunk] + ); + assert_eq!(ddog_v1_get_chunk_count(&b), 1); + assert_eq!(spans_of(chunk), [span]); + assert_eq!(ddog_v1_get_chunk_root_span(unsafe { &*chunk }), span); + let (s, l, e) = unsafe { (&*span, &*link, &*event) }; + assert_eq!( + unsafe { nodes(ddog_v1_get_links(s, &mut len), len) }, + [link] + ); + assert_eq!( + unsafe { nodes(ddog_v1_get_events(s, &mut len), len) }, + [event] + ); - ddog_add_span_metrics(span, key, val); - ddog_add_span_metrics(span, key, val); // Check for duplicates - ddog_add_span_metrics(span, key2, val2); + // Chunk and payload. + let c = unsafe { &*chunk }; + assert_eq!(ddog_v1_get_chunk_trace_id_high(c), 0xaabb); + assert_eq!(ddog_v1_get_chunk_trace_id_low(c), 0xccdd); + let mut prio = 0; + assert!(ddog_v1_get_chunk_sampling_priority(c, &mut prio)); + assert_eq!(prio, 2); + let mut mech = 0; + assert!(ddog_v1_get_chunk_sampling_mechanism(c, &mut mech)); + assert_eq!(mech, 4); + assert_eq!(string(ddog_v1_get_chunk_origin(c)), "lambda"); + unsafe { + let chunk_attrs = attrs_by_key(ddog_chunk_get_attributes(chunk)); + assert_eq!(string(ddog_v1_value_str(chunk_attrs["c_key"])), "c_val"); + let payload_attrs = ddog_payload_get_attributes(&mut *b); + let p_val = ddog_v1_attributes_get(payload_attrs, cs("p_key")); + assert_eq!(string(ddog_v1_value_str(p_val)), "p_val"); + assert!(ddog_v1_attributes_get(payload_attrs, cs("missing")).is_null()); + } - assert!(ddog_has_span_metrics(span, key)); - assert!(ddog_has_span_metrics(span, key2)); + // Span. + assert_eq!(string(ddog_v1_get_span_service(s)), "svc"); + assert_eq!(string(ddog_v1_get_span_name(s)), "op"); + assert_eq!(string(ddog_v1_get_span_resource(s)), "res"); + assert_eq!(string(ddog_v1_get_span_type(s)), "web"); + assert_eq!(string(ddog_v1_get_span_env(s)), "prod"); + assert_eq!(string(ddog_v1_get_span_version(s)), "1.2.3"); + assert_eq!(string(ddog_v1_get_span_component(s)), "pdo"); + assert_eq!(ddog_v1_get_span_id(s), 42); + assert_eq!(ddog_v1_get_span_parent_id(s), 7); + assert_eq!(ddog_v1_get_span_start(s), 1_000); + assert_eq!(ddog_v1_get_span_duration(s), 500); + assert!(ddog_v1_get_span_error(s)); + assert_eq!(ddog_v1_get_span_kind(s), 3); + unsafe { + let attrs = attrs_by_key(ddog_span_get_attributes(span)); + assert_eq!(attrs.len(), 5); + assert_eq!(ddog_v1_value_type(attrs["a_str"]), DDOG_V1_ATTR_STRING); + assert_eq!(string(ddog_v1_value_str(attrs["a_str"])), "v"); + assert_eq!(ddog_v1_value_type(attrs["a_int"]), DDOG_V1_ATTR_INT); + assert_eq!(ddog_v1_value_int(attrs["a_int"]), 11); + assert_eq!(ddog_v1_value_type(attrs["a_dbl"]), DDOG_V1_ATTR_DOUBLE); + assert_eq!(ddog_v1_value_double(attrs["a_dbl"]), 1.5); + assert_eq!(ddog_v1_value_type(attrs["a_bool"]), DDOG_V1_ATTR_BOOL); + assert!(ddog_v1_value_bool(attrs["a_bool"])); + assert_eq!(ddog_v1_value_type(attrs["a_bytes"]), DDOG_V1_ATTR_BYTES); + assert_eq!(string(ddog_v1_value_bytes(attrs["a_bytes"])), "raw"); + // A typed getter on another type returns the default. + assert_eq!(ddog_v1_value_int(attrs["a_str"]), 0); + assert!(ddog_v1_attributes_value(ddog_span_get_attributes(span), 99).is_null()); + } - let mut result = 0.0; - assert!(ddog_get_span_metrics(span, key, &mut result)); - assert_eq!(result, val); - assert!(ddog_get_span_metrics(span, key2, &mut result)); - assert_eq!(result, val2); + // Link and event. + assert_eq!(ddog_v1_get_link_trace_id_high(l), 0x11); + assert_eq!(ddog_v1_get_link_trace_id_low(l), 0x22); + assert_eq!(ddog_v1_get_link_span_id(l), 9); + assert_eq!(ddog_v1_get_link_flags(l), 1); + assert_eq!(string(ddog_v1_get_link_tracestate(l)), "dd=s:1"); + assert_eq!(ddog_v1_get_event_time(e), 123); + assert_eq!(string(ddog_v1_get_event_name(e)), "exception"); + unsafe { + let lattrs = attrs_by_key(ddog_link_get_attributes(link)); + assert_eq!(string(ddog_v1_value_str(lattrs["l_key"])), "l_val"); + let eattrs = attrs_by_key(ddog_event_get_attributes(event)); + assert_eq!(ddog_v1_value_int(eattrs["e_int"]), 5); + } - let mut count = 0; - let keys = ddog_span_metrics_get_keys(span, &mut count); - let keys_slice = std::slice::from_raw_parts(keys, count); - assert_eq!(count, 2); - assert!(keys_slice.iter().any(|k| k == &key)); - assert!(keys_slice.iter().any(|k| k == &key2)); - ddog_span_free_keys_ptr(keys, count); + ddog_v1_free_builder(b); +} - ddog_del_span_metrics(span, key); - assert!(!ddog_has_span_metrics(span, key)); +#[test] +fn nested_attributes_round_trip() { + let (b, _, span) = one_span(1); + unsafe { + // root: { a: "x", items: ["first", 42, { flag: true }] } + let inner = ddog_attr_map_new(1); + ddog_attributes_add_bool(ddog_attr_map_get_attributes(inner), cs("flag"), true); + let items = ddog_attr_list_new(3); + ddog_attr_list_push_str(items, cs("first")); + ddog_attr_list_push_int(items, 42); + ddog_attr_list_push_map(items, inner); + let root = ddog_attr_map_new(2); + ddog_attributes_add_str(ddog_attr_map_get_attributes(root), cs("a"), cs("x")); + ddog_attributes_add_list(ddog_attr_map_get_attributes(root), cs("items"), items); + ddog_attributes_add_map(ddog_span_get_attributes(span), cs("root"), root); + let nums = ddog_attr_list_new(2); + ddog_attr_list_push_double(nums, 1.5); + let nested = ddog_attr_list_new(1); + ddog_attr_list_push_bool(nested, false); + ddog_attr_list_push_list(nums, nested); + ddog_attributes_add_list(ddog_span_get_attributes(span), cs("nums"), nums); + + let attrs = attrs_by_key(ddog_span_get_attributes(span)); + assert_eq!(ddog_v1_value_type(attrs["root"]), DDOG_V1_ATTR_KEYVALUE); + let root = attrs_by_key(ddog_v1_value_map(attrs["root"])); + assert_eq!(string(ddog_v1_value_str(root["a"])), "x"); + let items = root["items"]; + assert_eq!(ddog_v1_value_type(items), DDOG_V1_ATTR_LIST); + assert_eq!(ddog_v1_value_list_len(items), 3); + assert_eq!( + string(ddog_v1_value_str(ddog_v1_value_list_get(items, 0))), + "first" + ); + assert_eq!(ddog_v1_value_int(ddog_v1_value_list_get(items, 1)), 42); + let inner = attrs_by_key(ddog_v1_value_map(ddog_v1_value_list_get(items, 2))); + assert!(ddog_v1_value_bool(inner["flag"])); + assert!(ddog_v1_value_list_get(items, 3).is_null()); + assert!(ddog_v1_value_map(items).is_null()); + + let nums = attrs["nums"]; + assert_eq!(ddog_v1_value_double(ddog_v1_value_list_get(nums, 0)), 1.5); + let nested = ddog_v1_value_list_get(nums, 1); + assert_eq!(ddog_v1_value_list_len(nested), 1); + assert!(!ddog_v1_value_bool(ddog_v1_value_list_get(nested, 0))); + } - ddog_free_traces(traces); + let encoded = to_vec_from_v1(&b.into_payload()); + for s in [b"root" as &[u8], b"items", b"first", b"flag", b"nums"] { + assert!( + contains(&encoded, s), + "{} should appear", + String::from_utf8_lossy(s) + ); } } #[test] -#[cfg_attr(miri, ignore)] -fn test_meta_struct_crud() { +fn an_overwritten_key_is_encoded_once_with_the_last_value() { + let (b, _, span) = one_span(1); unsafe { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(&mut traces); - let span = ddog_trace_new_span(trace); - - let key = CharSlice::from("bin"); - let val = CharSlice::from("binary_value"); - - let key2 = CharSlice::from("bin2"); - let val2 = CharSlice::from("another_binary_value"); - - assert!(!ddog_has_span_meta_struct(span, key)); - assert!(!ddog_has_span_meta_struct(span, key2)); - - ddog_add_span_meta_struct(span, key, val); - ddog_add_span_meta_struct(span, key, val); // Check for duplicates - ddog_add_span_meta_struct(span, key2, val2); - - assert!(ddog_has_span_meta_struct(span, key)); - assert!(ddog_has_span_meta_struct(span, key2)); - - assert_eq!(ddog_get_span_meta_struct(span, key), val); - assert_eq!(ddog_get_span_meta_struct(span, key2), val2); - - let mut count = 0; - let keys = ddog_span_meta_struct_get_keys(span, &mut count); - let keys_slice = std::slice::from_raw_parts(keys, count); - assert_eq!(count, 2); - assert!(keys_slice.iter().any(|k| k == &key)); - assert!(keys_slice.iter().any(|k| k == &key2)); - ddog_span_free_keys_ptr(keys, count); + let attrs = ddog_span_get_attributes(span); + ddog_attributes_add_str(attrs, cs("dup"), cs("first-value")); + ddog_attributes_add_str(attrs, cs("dup"), cs("last-value")); + // Before dedup the reader sees both entries; `get` resolves to the last write. + assert_eq!(ddog_v1_attributes_len(attrs), 2); + assert_eq!( + string(ddog_v1_value_str(ddog_v1_attributes_get(attrs, cs("dup")))), + "last-value" + ); + } + let payload = b.into_payload(); + assert_eq!(payload.chunks[0].spans[0].attributes.len(), 1); + let encoded = to_vec_from_v1(&payload); + assert!(contains(&encoded, b"last-value")); + assert!(!contains(&encoded, b"first-value")); +} - ddog_del_span_meta_struct(span, key); - assert!(!ddog_has_span_meta_struct(span, key)); +#[test] +fn node_pointers_stay_valid_across_sibling_pushes() { + let mut b = ddog_v1_new_builder(); + let first_chunk = ddog_new_chunk(&mut b, 0, 1); + let first_span = unsafe { ddog_new_span(first_chunk) }; + let first_link = unsafe { ddog_new_link(first_span) }; + // Enough siblings to reallocate every parent vector. + for i in 0..64 { + let chunk = ddog_new_chunk(&mut b, 0, i + 2); + unsafe { + ddog_new_span(chunk); + ddog_new_span(first_chunk); + ddog_new_link(first_span); + } + } + unsafe { + ddog_span_set_id(first_span, 77); + ddog_link_set_span_id(first_link, 88); + } + assert_eq!(ddog_v1_get_chunk_count(&b), 65); + assert_eq!(spans_of(first_chunk).len(), 65); + assert_eq!(ddog_v1_get_span_id(unsafe { &*first_span }), 77); + assert_eq!(ddog_v1_get_link_span_id(unsafe { &*first_link }), 88); + let payload = b.into_payload(); + assert_eq!(payload.chunks[0].spans[0].span_id, 77); + assert_eq!(payload.chunks[0].spans[0].span_links[0].span_id, 88); +} - ddog_free_traces(traces); +#[test] +fn encoder_streams_repeated_string_once() { + // "shared" used as service in two chunks must appear as raw bytes exactly once on the + // wire: the encoder's streaming string table emits the second occurrence as a uint id. + let mut b = ddog_v1_new_builder(); + for (i, name) in [(1, "op1"), (2, "op2")] { + let chunk = ddog_new_chunk(&mut b, 0, i); + unsafe { + let span = ddog_new_span(chunk); + ddog_set_span_service(span, cs("shared")); + ddog_set_span_name(span, cs(name)); + ddog_span_set_id(span, i); + } } + let encoded = to_vec_from_v1(&b.into_payload()); + let occurrences = encoded + .windows(b"shared".len()) + .filter(|w| *w == b"shared") + .count(); + assert_eq!( + occurrences, 1, + "repeated string must be interned on the wire" + ); } #[test] -#[cfg_attr(miri, ignore)] -fn test_span_debug_log_output() { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(&mut traces); - let span = ddog_trace_new_span(trace); +fn builds_links_and_events() { + let (b, _, span) = one_span(1); + unsafe { + ddog_set_span_service(span, cs("svc")); + ddog_span_set_id(span, 1); + let link = ddog_new_link(span); + ddog_link_set_trace_id(link, 0xaa, 0xbb); + ddog_link_set_span_id(link, 9); + ddog_link_set_flags(link, 1); + ddog_link_set_tracestate(link, cs("dd=s:1")); + ddog_attributes_add_str( + ddog_link_get_attributes(link), + cs("link.attr"), + cs("link.val"), + ); + let event = ddog_new_event(span); + ddog_event_set_time(event, 123); + ddog_event_set_name(event, cs("exception")); + ddog_attributes_add_int(ddog_event_get_attributes(event), cs("ev.attr"), 5); + } - ddog_set_span_name(span, CharSlice::from("debug-span")); - let debug_output = ddog_span_debug_log(span); + let encoded = to_vec_from_v1(&b.into_payload()); + for s in [ + b"exception" as &[u8], + b"dd=s:1", + b"link.attr", + b"link.val", + b"ev.attr", + ] { + assert!( + contains(&encoded, s), + "{} should appear", + String::from_utf8_lossy(s) + ); + } + let expected_link_tid = [0, 0, 0, 0, 0, 0, 0, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0xbb]; + assert!(contains(&encoded, &expected_link_tid)); +} - let expected_output = "Span { service: , name: debug-span, resource: , type: , trace_id: 0, span_id: 0, parent_id: 0, start: 0, duration: 0, error: 0, meta: VecMap { data: [], deduped: true }, metrics: VecMap { data: [], deduped: true }, meta_struct: VecMap { data: [], deduped: true }, span_links: [], span_events: [] }"; +#[test] +fn chunk_level_fields_encoded() { + let (b, chunk, span) = one_span(1); + unsafe { + ddog_set_chunk_sampling_priority(chunk, 2); + ddog_set_chunk_origin(chunk, cs("lambda")); + ddog_set_chunk_sampling_mechanism(chunk, 4); + ddog_set_span_service(span, cs("svc")); + ddog_span_set_id(span, 1); + } + let encoded = to_vec_from_v1(&b.into_payload()); + assert!(contains(&encoded, b"lambda")); + // sampling_mechanism = 4 (chunk key 0x07 + fixint 0x04) + assert!(contains(&encoded, &[0x07, 0x04])); +} - assert_eq!( - str::from_utf8(debug_output.as_bytes()).expect("non utf8 debug representation"), - expected_output +#[test] +fn populate_metadata_sets_container_id_and_fields() { + let (mut b, _, span) = one_span(1); + unsafe { + ddog_set_span_service(span, cs("svc")); + ddog_span_set_id(span, 1); + } + ddog_set_payload_metadata(&mut b, cs("prod"), cs("4.5.6"), cs("my-host")); + let mut payload = b.into_payload(); + + populate_payload_metadata( + &mut payload, + "container-xyz", + "php", + "8.3", + "1.2.3", + "runtime-uuid", ); + let encoded = to_vec_from_v1(&payload); + for s in [ + b"container-xyz" as &[u8], + b"php", + b"8.3", + b"1.2.3", + b"runtime-uuid", + b"prod", + b"my-host", + b"4.5.6", + ] { + assert!( + contains(&encoded, s), + "{} should appear", + String::from_utf8_lossy(s) + ); + } +} +#[test] +fn span_kind_str_maps_canonical_kinds_only() { + let (_b, _, span) = one_span(1); unsafe { - ddog_free_charslice(debug_output); + assert!(ddog_set_span_kind_str(span, cs("producer"))); + assert_eq!(ddog_v1_get_span_kind(&*span), 4); + assert!(!ddog_set_span_kind_str(span, cs("process"))); + assert_eq!(ddog_v1_get_span_kind(&*span), 0); } - ddog_free_traces(traces); } -fn get_bytes_str(value: &'static str) -> BytesString { - From::from(value) -} -fn get_bytes(value: &'static str) -> Bytes { - From::from(String::from(value)) +#[test] +fn transfer_span_attr_copies_and_optionally_deletes() { + let (b, chunk, root) = one_span(1); + let inferred = unsafe { ddog_new_span(chunk) }; + let key = |k: &'static CStr| k.as_ptr(); + unsafe { + let root_attrs = ddog_span_get_attributes(root); + let inferred_attrs = ddog_span_get_attributes(inferred); + ddog_attributes_add_str(root_attrs, cs("error.message"), cs("boom")); + ddog_attributes_add_double(root_attrs, cs("_dd.agent_psr"), 0.5); + + // Copy, keeping the source. + assert!(ddog_transfer_span_attr( + root, + inferred, + key(c"error.message"), + false + )); + assert!(!ddog_v1_attributes_get(root_attrs, cs("error.message")).is_null()); + let copied = ddog_v1_attributes_get(inferred_attrs, cs("error.message")); + assert_eq!(ddog_v1_value_type(copied), DDOG_V1_ATTR_STRING); + assert_eq!(string(ddog_v1_value_str(copied)), "boom"); + + // Move, deleting the source; the type is preserved. + assert!(ddog_transfer_span_attr( + root, + inferred, + key(c"_dd.agent_psr"), + true + )); + assert!(ddog_v1_attributes_get(root_attrs, cs("_dd.agent_psr")).is_null()); + let moved = ddog_v1_attributes_get(inferred_attrs, cs("_dd.agent_psr")); + assert_eq!(ddog_v1_value_type(moved), DDOG_V1_ATTR_DOUBLE); + assert_eq!(ddog_v1_value_double(moved), 0.5); + + // An absent key is a no-op. + assert!(!ddog_transfer_span_attr( + root, + inferred, + key(c"missing"), + true + )); + assert_eq!(ddog_v1_attributes_len(inferred_attrs), 2); + assert_eq!(ddog_v1_attributes_len(root_attrs), 1); + } + let encoded = to_vec_from_v1(&b.into_payload()); + for s in [b"error.message" as &[u8], b"boom", b"_dd.agent_psr"] { + assert!( + contains(&encoded, s), + "{} should appear", + String::from_utf8_lossy(s) + ); + } } #[test] -#[cfg_attr(miri, ignore)] -fn test_full_link() { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(traces.as_mut()); - let span = ddog_trace_new_span(trace); - let link = ddog_span_new_link(span); - - ddog_set_link_trace_id(link, 1); - ddog_set_link_trace_id_high(link, 2); - ddog_set_link_span_id(link, 3); - ddog_set_link_flags(link, 4); - ddog_set_link_tracestate(link, CharSlice::from("tracestate")); - ddog_add_link_attributes(link, CharSlice::from("attribute"), CharSlice::from("value")); - - let expected_link = SpanLinkBytes { - trace_id: 1, - trace_id_high: 2, - span_id: 3, - attributes: HashMap::from([(get_bytes_str("attribute"), get_bytes_str("value"))]), - tracestate: get_bytes_str("tracestate"), - flags: 4, - }; - assert_eq!(*link, expected_link); - - assert_eq!(span.span_links.len(), 1); - assert_eq!(span.span_links[0], expected_link); - - ddog_free_traces(traces); +fn span_debug_log_renders_readable_string() { + let (_b, chunk, span) = one_span(0xdead); + unsafe { + ddog_set_span_service(span, cs("my-service")); + ddog_set_span_name(span, cs("my-operation")); + ddog_set_span_resource(span, cs("GET /x")); + ddog_span_set_id(span, 42); + ddog_span_set_parent_id(span, 7); + ddog_span_set_start(span, 1_000); + ddog_span_set_duration(span, 500); + ddog_span_set_error(span, true); + ddog_set_span_kind(span, 2); // Server + ddog_set_span_component(span, cs("pdo")); + ddog_attributes_add_int(ddog_span_get_attributes(span), cs("http.status_code"), 200); + ddog_new_link(span); + ddog_new_event(span); + } + + let slice = unsafe { ddog_v1_span_debug_log(chunk, span) }; + let rendered = string(slice); + for part in [ + "my-service", + "my-operation", + "resource=\"GET /x\"", + "span_id=42", + "parent_id=7", + "error=true", + "kind=Server", + "component=\"pdo\"", + "http.status_code=200", + "links=1", + "events=1", + ] { + assert!(rendered.contains(part), "{part} should appear: {rendered}"); + } + unsafe { ddog_free_charslice(slice) }; +} + +/// Builds one chunk from `(span_id, parent_id)` pairs and returns its spans. +fn chunk_with( + ids: &[(u64, u64)], +) -> ( + Box, + *mut ChunkNode, + Vec<*mut SpanNode>, +) { + let mut b = ddog_v1_new_builder(); + let chunk = ddog_new_chunk(&mut b, 0, 1); + let spans = ids + .iter() + .map(|&(id, parent)| unsafe { + let span = ddog_new_span(chunk); + ddog_span_set_id(span, id); + ddog_span_set_parent_id(span, parent); + span + }) + .collect(); + (b, chunk, spans) } #[test] -#[cfg_attr(miri, ignore)] -fn test_full_event() { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(traces.as_mut()); - let span = ddog_trace_new_span(trace); - let event = ddog_span_new_event(span); - - ddog_set_event_time(event, 1); - ddog_set_event_name(event, CharSlice::from("name")); - ddog_add_event_attributes_str( - event, - CharSlice::from("str_attribute"), - CharSlice::from("value"), - ); - ddog_add_event_attributes_bool(event, CharSlice::from("bool_attribute"), false); - ddog_add_event_attributes_int(event, CharSlice::from("int_attribute"), 1); - ddog_add_event_attributes_float(event, CharSlice::from("array_attribute"), 2.0); - ddog_add_event_attributes_str( - event, - CharSlice::from("array_attribute"), - CharSlice::from("other_value"), - ); +fn chunk_root_span_picks_local_root_not_the_first_span() { + // The first span has a parent in the chunk, so the later parent-less span is the root. + let (_b, chunk, spans) = chunk_with(&[(1, 2), (2, 0)]); + assert_eq!(ddog_v1_get_chunk_root_span(unsafe { &*chunk }), spans[1]); +} - let expected_event = SpanEventBytes { - time_unix_nano: 1, - name: get_bytes_str("name"), - attributes: HashMap::from([ - ( - get_bytes_str("str_attribute"), - AttributeAnyValueBytes::SingleValue(AttributeArrayValueBytes::String( - get_bytes_str("value"), - )), - ), - ( - get_bytes_str("bool_attribute"), - AttributeAnyValueBytes::SingleValue(AttributeArrayValueBytes::Boolean(false)), - ), - ( - get_bytes_str("int_attribute"), - AttributeAnyValueBytes::SingleValue(AttributeArrayValueBytes::Integer(1)), - ), - ( - get_bytes_str("array_attribute"), - AttributeAnyValueBytes::Array(vec![ - AttributeArrayValueBytes::Double(2.0), - AttributeArrayValueBytes::String(get_bytes_str("other_value")), - ]), - ), - ]), - }; - assert_eq!(*event, expected_event); - - assert_eq!(span.span_events.len(), 1); - assert_eq!(span.span_events[0], expected_event); - - ddog_free_traces(traces); +#[test] +fn chunk_root_span_picks_remote_parent_root() { + // A remote-parent root (e.g. amqp deliver, an inferred span) whose parent isn't in the chunk. + let (_b, chunk, spans) = chunk_with(&[(7, 9), (9, 2)]); + assert_eq!(ddog_v1_get_chunk_root_span(unsafe { &*chunk }), spans[1]); } #[test] -#[cfg_attr(miri, ignore)] -fn test_full_span() { - let mut traces = ddog_get_traces(); - let trace = ddog_traces_new_trace(traces.as_mut()); - let span = ddog_trace_new_span(trace); - let link = ddog_span_new_link(span); - - ddog_set_link_trace_id(link, 10); - ddog_set_link_span_id(link, 20); - ddog_set_link_flags(link, 30); - - let event = ddog_span_new_event(span); - - ddog_set_event_time(event, 123456); - ddog_set_event_name(event, CharSlice::from("event_name")); - - ddog_set_span_service(span, CharSlice::from("service")); - ddog_set_span_name(span, CharSlice::from("operation")); - ddog_set_span_resource(span, CharSlice::from("resource")); - ddog_set_span_type(span, CharSlice::from("type")); - ddog_set_span_trace_id(span, 1); - ddog_set_span_id(span, 2); - ddog_set_span_parent_id(span, 3); - ddog_set_span_start(span, 4); - ddog_set_span_duration(span, 5); - ddog_set_span_error(span, 6); - ddog_add_span_meta( - span, - CharSlice::from("meta_key"), - CharSlice::from("meta_value"), - ); - ddog_add_span_metrics(span, CharSlice::from("metric_key"), 1.0); - ddog_add_span_meta_struct( - span, - CharSlice::from("meta_struct_key"), - CharSlice::from("meta_struct_value"), - ); +fn chunk_root_span_falls_back_to_the_first_span_without_a_recognizable_root() { + // Every parent is in the chunk (malformed cycle): fall back to the first span, as the wire + // does. + let (_b, chunk, spans) = chunk_with(&[(1, 2), (2, 1)]); + assert_eq!(ddog_v1_get_chunk_root_span(unsafe { &*chunk }), spans[0]); +} - let expected_span = SpanBytes { - service: get_bytes_str("service"), - name: get_bytes_str("operation"), - resource: get_bytes_str("resource"), - r#type: get_bytes_str("type"), - trace_id: 1, - span_id: 2, - parent_id: 3, - start: 4, - duration: 5, - error: 6, - meta: vec![(get_bytes_str("meta_key"), get_bytes_str("meta_value"))].into(), - metrics: vec![(get_bytes_str("metric_key"), 1.0)].into(), - meta_struct: vec![( - get_bytes_str("meta_struct_key"), - get_bytes("meta_struct_value"), - )] - .into(), - span_links: vec![SpanLinkBytes { - trace_id: 10, - span_id: 20, - flags: 30, - ..Default::default() - }], - span_events: vec![SpanEventBytes { - time_unix_nano: 123456, - name: get_bytes_str("event_name"), - attributes: HashMap::new(), - }], - }; - - assert_eq!(*span, expected_span); - - ddog_free_traces(traces); +#[test] +fn chunk_root_span_is_null_for_an_empty_chunk() { + let (_b, chunk, _) = chunk_with(&[]); + assert!(ddog_v1_get_chunk_root_span(unsafe { &*chunk }).is_null()); }