From a7dd65a94080ff96523fb865cff43bf23dda55d9 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Thu, 21 May 2026 12:34:12 +0200 Subject: [PATCH 01/16] feat(trace-utils): add change-buffer foundation types Add the supporting types for the change-buffer module behind a `change-buffer` feature flag: ChangeBuffer (raw memory wrapper), OpCode/BufferedOperation, SpanHeader (repr(C)), SmallTraceMap, Trace, and error types. These are the building blocks for ChangeBufferState which follows in the next commit. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + libdd-trace-utils/Cargo.toml | 2 + libdd-trace-utils/build.rs | 10 + libdd-trace-utils/src/change_buffer/buffer.rs | 154 ++++++++++++++ libdd-trace-utils/src/change_buffer/mod.rs | 90 ++++++++ .../src/change_buffer/operation.rs | 199 ++++++++++++++++++ .../src/change_buffer/span_header.rs | 50 +++++ libdd-trace-utils/src/change_buffer/trace.rs | 112 ++++++++++ libdd-trace-utils/src/change_buffer/utils.rs | 28 +++ libdd-trace-utils/src/lib.rs | 3 + 10 files changed, 649 insertions(+) create mode 100644 libdd-trace-utils/build.rs create mode 100644 libdd-trace-utils/src/change_buffer/buffer.rs create mode 100644 libdd-trace-utils/src/change_buffer/mod.rs create mode 100644 libdd-trace-utils/src/change_buffer/operation.rs create mode 100644 libdd-trace-utils/src/change_buffer/span_header.rs create mode 100644 libdd-trace-utils/src/change_buffer/trace.rs create mode 100644 libdd-trace-utils/src/change_buffer/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 3cab24fa72..7d1384fd00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3471,6 +3471,7 @@ dependencies = [ "rmp", "rmp-serde", "rmpv", + "rustc-hash", "serde", "serde_json", "tempfile", diff --git a/libdd-trace-utils/Cargo.toml b/libdd-trace-utils/Cargo.toml index 9c165ebddb..247680b429 100644 --- a/libdd-trace-utils/Cargo.toml +++ b/libdd-trace-utils/Cargo.toml @@ -45,6 +45,7 @@ libdd-tinybytes = { version = "1.1.1", path = "../libdd-tinybytes", features = [ "serialization", ] } indexmap = "2.11" +rustc-hash = "2" # Compression feature flate2 = { version = "1.0", optional = true } @@ -86,6 +87,7 @@ test-utils = [ "cargo-platform", "urlencoding", ] +change-buffer = [] compression = ["zstd", "flate2"] # FIPS mode uses the FIPS-compliant cryptographic provider (Unix only) fips = ["libdd-common/fips", "libdd-capabilities-impl/fips"] diff --git a/libdd-trace-utils/build.rs b/libdd-trace-utils/build.rs new file mode 100644 index 0000000000..8c0da09479 --- /dev/null +++ b/libdd-trace-utils/build.rs @@ -0,0 +1,10 @@ +fn main() { + let commit = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=GIT_COMMIT={commit}"); +} diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs new file mode 100644 index 0000000000..871a3ac446 --- /dev/null +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -0,0 +1,154 @@ +use crate::change_buffer::utils::*; +use crate::change_buffer::{ChangeBufferError, Result}; + +#[derive(Clone, Copy)] +pub struct ChangeBuffer { + ptr: *mut u8, + len: usize, +} + +impl ChangeBuffer { + /// # Safety + /// + /// The underlying raw memory must not be freed until after this struct's + /// lifetime. Having the calling code manage the memory makes it simpler to + /// integrate with managed runtimes. + pub unsafe fn from_raw_parts(ptr: *const u8, len: usize) -> Self { + Self { + ptr: ptr as *mut u8, + len, + } + } + + fn as_slice(&self) -> &[u8] { + unsafe { std::slice::from_raw_parts(self.ptr, self.len) } + } + + fn as_mut_slice(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) } + } + + pub fn read(&self, index: &mut usize) -> Result { + let size = std::mem::size_of::(); + let slice = self.as_slice(); + let bytes = slice + .get(*index..*index + size) + .ok_or(ChangeBufferError::ReadOutOfBounds { + offset: *index, + len: self.len, + })?; + *index += size; + Ok(T::from_bytes(bytes)) + } + + /// Read a value without bounds checking. + /// # Safety + /// Caller must ensure `*index + size_of::() <= self.len`. + #[inline(always)] + pub unsafe fn read_unchecked(&self, index: &mut usize) -> T { + let size = std::mem::size_of::(); + let slice = self.as_slice(); + let bytes = slice.get_unchecked(*index..*index + size); + *index += size; + T::from_bytes(bytes) + } + + pub fn write_u32(&mut self, offset: usize, value: u32) -> Result<()> { + let len = self.len; + let slice = self.as_mut_slice(); + let target = slice + .get_mut(offset..offset + 4) + .ok_or(ChangeBufferError::WriteOutOfBounds { offset, len })?; + let bytes = value.to_le_bytes(); + target.copy_from_slice(&bytes); + Ok(()) + } + + pub fn clear_count(&mut self) -> Result<()> { + self.write_u32(0, 0) + } +} + +#[cfg(test)] +mod tests { + use super::ChangeBuffer; + use crate::change_buffer::Result; + + #[test] + fn buffer_creation_and_slices() { + let mut buf = unsafe { + let buf: [u8; 256] = std::mem::zeroed(); + ChangeBuffer::from_raw_parts(buf.as_ptr(), 256) + }; + let slice = buf.as_mut_slice(); + assert_eq!(256, slice.len()); + slice[1] = 42; + let slice = buf.as_slice(); + assert_eq!(256, slice.len()); + assert_eq!(42, slice[1]); + } + + #[test] + fn read_and_write() -> Result<()> { + let example = + b"This is an example string, long enough to get 16 bytes out of it, without issue."; + let mut ex_buf = example.to_vec(); + let mut buf = unsafe { ChangeBuffer::from_raw_parts(ex_buf.as_mut_ptr(), ex_buf.len()) }; + let mut index = 8; + assert_eq!(8101238474429984353, buf.read::(&mut index)?); + assert_eq!(7956016061199967596, buf.read::(&mut index)?); + index = 8; + buf.write_u32(index, 8675309)?; + index = 8; + assert_eq!(8675309, buf.read::(&mut index)?); + + Ok(()) + } + + #[test] + fn clear_count() -> Result<()> { + let mut buffer = vec![0xFFu8; 64]; + let mut buf = unsafe { ChangeBuffer::from_raw_parts(buffer.as_mut_ptr(), buffer.len()) }; + buf.clear_count()?; + let mut index = 0; + assert_eq!(0, buf.read::(&mut index)?); + Ok(()) + } + + #[test] + fn read_different_types() -> Result<()> { + let mut buffer = vec![0u8; 64]; + buffer[0..4].copy_from_slice(&42u32.to_le_bytes()); + buffer[8..24].copy_from_slice(&123456789u128.to_le_bytes()); + buffer[24..32].copy_from_slice(&3.14f64.to_le_bytes()); + + let buf = unsafe { ChangeBuffer::from_raw_parts(buffer.as_mut_ptr(), buffer.len()) }; + + let mut index = 0; + assert_eq!(42, buf.read::(&mut index)?); + assert_eq!(4, index); + + index = 8; + assert_eq!(123456789u128, buf.read::(&mut index)?); + + index = 24; + assert_eq!(3.14, buf.read::(&mut index)?); + Ok(()) + } + + #[test] + fn read_out_of_bounds() { + let mut buffer = vec![0u8; 8]; + let buf = unsafe { ChangeBuffer::from_raw_parts(buffer.as_mut_ptr(), buffer.len()) }; + let mut index = 4; + assert!(buf.read::(&mut index).is_err()); + } + + #[test] + fn write_out_of_bounds() { + let mut buffer = vec![0u8; 8]; + let mut buf = unsafe { ChangeBuffer::from_raw_parts(buffer.as_mut_ptr(), buffer.len()) }; + // 8-byte buffer, u32 at offset 5 needs bytes 5..9 — out of bounds + assert!(buf.write_u32(5, 123).is_err()); + } +} diff --git a/libdd-trace-utils/src/change_buffer/mod.rs b/libdd-trace-utils/src/change_buffer/mod.rs new file mode 100644 index 0000000000..4025d1d474 --- /dev/null +++ b/libdd-trace-utils/src/change_buffer/mod.rs @@ -0,0 +1,90 @@ +#![allow(unused)] + +/// Errors that can occur when operating on a [`ChangeBuffer`] or [`ChangeBufferState`]. +#[derive(Debug)] +pub enum ChangeBufferError { + SpanNotFound(u64), + StringNotFound(u32), + ReadOutOfBounds { offset: usize, len: usize }, + WriteOutOfBounds { offset: usize, len: usize }, + UnknownOpcode(u32), +} + +impl std::fmt::Display for ChangeBufferError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ChangeBufferError::SpanNotFound(id) => write!(f, "span not found: {id}"), + ChangeBufferError::StringNotFound(id) => { + write!(f, "string not found internally: {id}") + } + ChangeBufferError::ReadOutOfBounds { offset, len } => { + write!(f, "read out of bounds: offset={offset}, len={len}") + } + ChangeBufferError::WriteOutOfBounds { offset, len } => { + write!(f, "write out of bounds: offset={offset}, len={len}") + } + ChangeBufferError::UnknownOpcode(val) => write!(f, "unknown opcode: {val}"), + } + } +} + +impl std::error::Error for ChangeBufferError {} + +pub type Result = std::result::Result; + +mod utils; +use utils::*; + +mod trace; +pub use trace::*; + +mod operation; +use operation::*; + +mod buffer; +pub use buffer::*; + +pub mod span_header; +pub use span_header::{SpanHeader, SPAN_HEADER_SIZE}; + +use crate::span::v04::Span; +use crate::span::{SpanText, TraceData}; + +fn vec_insert(vec: &mut Vec<(K, V)>, key: K, value: V) { + for entry in vec.iter_mut() { + if entry.0 == key { + entry.1 = value; + return; + } + } + vec.push((key, value)); +} + +fn vec_get<'a, K: PartialEq, V>(vec: &'a [(K, V)], key: &K) -> Option<&'a V> { + for entry in vec { + if entry.0 == *key { + return Some(&entry.1); + } + } + None +} + +fn deferred_meta_insert(vec: &mut Vec<(u32, u32)>, key_id: u32, val_id: u32) { + for entry in vec.iter_mut() { + if entry.0 == key_id { + entry.1 = val_id; + return; + } + } + vec.push((key_id, val_id)); +} + +fn deferred_metric_insert(vec: &mut Vec<(u32, f64)>, key_id: u32, val: f64) { + for entry in vec.iter_mut() { + if entry.0 == key_id { + entry.1 = val; + return; + } + } + vec.push((key_id, val)); +} diff --git a/libdd-trace-utils/src/change_buffer/operation.rs b/libdd-trace-utils/src/change_buffer/operation.rs new file mode 100644 index 0000000000..1c6e056fb9 --- /dev/null +++ b/libdd-trace-utils/src/change_buffer/operation.rs @@ -0,0 +1,199 @@ +use crate::change_buffer::{ChangeBuffer, ChangeBufferError, Result}; + +#[repr(u32)] +#[derive(Debug, Clone)] +pub enum OpCode { + Create = 0, + SetMetaAttr = 1, + SetMetricAttr = 2, + SetServiceName = 3, + SetResourceName = 4, + SetError = 5, + SetStart = 6, + SetDuration = 7, + SetType = 8, + SetName = 9, + SetTraceMetaAttr = 10, + SetTraceMetricsAttr = 11, + SetTraceOrigin = 12, + /// Combined create + name + start. Avoids 3 separate ops per span. + CreateSpan = 13, + /// Combined create + name + service + resource + type + start. + CreateSpanFull = 14, + /// Batch N meta (string→string) tags for one span. + BatchSetMeta = 15, + /// Batch N metric (string→f64) tags for one span. + BatchSetMetric = 16, + // TODO: SpanLinks, SpanEvents, StructAttr +} + +impl TryFrom for OpCode { + type Error = ChangeBufferError; + + fn try_from(val: u32) -> Result { + match val { + 0 => Ok(OpCode::Create), + 1 => Ok(OpCode::SetMetaAttr), + 2 => Ok(OpCode::SetMetricAttr), + 3 => Ok(OpCode::SetServiceName), + 4 => Ok(OpCode::SetResourceName), + 5 => Ok(OpCode::SetError), + 6 => Ok(OpCode::SetStart), + 7 => Ok(OpCode::SetDuration), + 8 => Ok(OpCode::SetType), + 9 => Ok(OpCode::SetName), + 10 => Ok(OpCode::SetTraceMetaAttr), + 11 => Ok(OpCode::SetTraceMetricsAttr), + 12 => Ok(OpCode::SetTraceOrigin), + 13 => Ok(OpCode::CreateSpan), + 14 => Ok(OpCode::CreateSpanFull), + 15 => Ok(OpCode::BatchSetMeta), + 16 => Ok(OpCode::BatchSetMetric), + _ => Err(ChangeBufferError::UnknownOpcode(val)), + } + } +} + +pub struct BufferedOperation { + pub opcode: OpCode, + pub slot_index: u32, +} + +impl BufferedOperation { + pub fn from_buf(buf: &ChangeBuffer, index: &mut usize) -> Result { + // JS writes opcode as u64 (low u32 = opcode, high u32 = 0). + // Read as u64 to consume all 8 bytes, then truncate to u32 for OpCode. + let opcode_u64: u64 = buf.read(index)?; + let opcode = (opcode_u64 as u32).try_into()?; + let slot_index: u32 = buf.read(index)?; + Ok(BufferedOperation { opcode, slot_index }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::change_buffer::Result; + + fn change_buffer_from_vec(buffer: &mut Vec) -> ChangeBuffer { + unsafe { ChangeBuffer::from_raw_parts(buffer.as_mut_ptr(), buffer.len()) } + } + + #[test] + fn opcode_try_from_valid_values() -> Result<()> { + let expected = [ + (0, "Create"), + (1, "SetMetaAttr"), + (2, "SetMetricAttr"), + (3, "SetServiceName"), + (4, "SetResourceName"), + (5, "SetError"), + (6, "SetStart"), + (7, "SetDuration"), + (8, "SetType"), + (9, "SetName"), + (10, "SetTraceMetaAttr"), + (11, "SetTraceMetricsAttr"), + (12, "SetTraceOrigin"), + (13, "CreateSpan"), + (14, "CreateSpanFull"), + (15, "BatchSetMeta"), + (16, "BatchSetMetric"), + ]; + + for (val, name) in expected { + let opcode = OpCode::try_from(val)?; + assert_eq!(opcode.clone() as u32, val); + assert_eq!(name, format!("{:?}", opcode)) + } + + Ok(()) + } + + #[test] + fn opcode_try_from_invalid_value() { + assert!(OpCode::try_from(17u32).is_err()); + assert!(OpCode::try_from(100u32).is_err()); + assert!(OpCode::try_from(u32::MAX).is_err()); + } + + #[test] + fn buffered_operation_from_buf() -> Result<()> { + // Layout: opcode (u64 LE: low u32 = opcode, high u32 = 0) + slot_index (u32 LE) + let opcode: u64 = 3; // SetServiceName + let slot_index: u32 = 42; + + let mut buffer = vec![0u8; 12]; + buffer[0..8].copy_from_slice(&opcode.to_le_bytes()); + buffer[8..12].copy_from_slice(&slot_index.to_le_bytes()); + + let buf = change_buffer_from_vec(&mut buffer); + let mut index = 0; + let op = BufferedOperation::from_buf(&buf, &mut index)?; + + assert_eq!(op.opcode as u32, 3); + assert_eq!(op.slot_index, 42); + assert_eq!(index, 12); + Ok(()) + } + + #[test] + fn buffered_operation_from_buf_advances_index() -> Result<()> { + // Two operations packed sequentially, starting after a u64 count header + // Each op is 12 bytes: opcode(u64) + slot_index(u32) + let mut buffer = vec![0u8; 32]; + // count header (u64) + buffer[0..8].copy_from_slice(&0u64.to_le_bytes()); + // first op at offset 8: opcode(u64) + slot_index(u32) + buffer[8..16].copy_from_slice(&(OpCode::Create as u64).to_le_bytes()); + buffer[16..20].copy_from_slice(&1u32.to_le_bytes()); + // second op at offset 20: opcode(u64) + slot_index(u32) + buffer[20..28].copy_from_slice(&(OpCode::SetError as u64).to_le_bytes()); + buffer[28..32].copy_from_slice(&2u32.to_le_bytes()); + + let buf = change_buffer_from_vec(&mut buffer); + + let mut index = 8; + let op1 = BufferedOperation::from_buf(&buf, &mut index)?; + assert_eq!(op1.opcode as u32, OpCode::Create as u32); + assert_eq!(op1.slot_index, 1); + assert_eq!(index, 20); + + let op2 = BufferedOperation::from_buf(&buf, &mut index)?; + assert_eq!(op2.opcode as u32, OpCode::SetError as u32); + assert_eq!(op2.slot_index, 2); + assert_eq!(index, 32); + + Ok(()) + } + + #[test] + fn buffered_operation_from_buf_invalid_opcode() { + let mut buffer = vec![0u8; 12]; + buffer[0..8].copy_from_slice(&999u64.to_le_bytes()); + buffer[8..12].copy_from_slice(&1u32.to_le_bytes()); + + let buf = change_buffer_from_vec(&mut buffer); + let mut index = 0; + assert!(BufferedOperation::from_buf(&buf, &mut index).is_err()); + } + + #[test] + fn buffered_operation_from_buf_too_small() { + // Only 8 bytes — enough for the opcode but not the slot_index + let mut buffer = vec![0u8; 8]; + buffer[0..8].copy_from_slice(&0u64.to_le_bytes()); + + let buf = change_buffer_from_vec(&mut buffer); + let mut index = 0; + assert!(BufferedOperation::from_buf(&buf, &mut index).is_err()); + } + + #[test] + fn buffered_operation_from_buf_empty() { + let mut buffer = vec![0u8; 0]; + let buf = change_buffer_from_vec(&mut buffer); + let mut index = 0; + assert!(BufferedOperation::from_buf(&buf, &mut index).is_err()); + } +} diff --git a/libdd-trace-utils/src/change_buffer/span_header.rs b/libdd-trace-utils/src/change_buffer/span_header.rs new file mode 100644 index 0000000000..42d5815ba6 --- /dev/null +++ b/libdd-trace-utils/src/change_buffer/span_header.rs @@ -0,0 +1,50 @@ +/// Fixed-layout span header for direct JS DataView access. +/// +/// JS creates a DataView over each span's header in WASM linear memory and +/// writes fields directly — no change buffer protocol, no staging, no copy. +/// +/// String fields store string table IDs (u32) resolved to real strings at +/// flush time. Numeric fields are stored directly. +/// +/// Meta/metrics tags are NOT in the header — they still go through the +/// change buffer protocol since their count varies per span. +#[repr(C)] +#[derive(Default, Clone)] +pub struct SpanHeader { + pub span_id: u64, // offset 0 + pub trace_id_lo: u64, // offset 8 + pub trace_id_hi: u64, // offset 16 + pub parent_id: u64, // offset 24 + pub start: i64, // offset 32 + pub duration: i64, // offset 40 + pub error: i32, // offset 48 + pub name_id: u32, // offset 52 + pub service_id: u32, // offset 56 + pub resource_id: u32, // offset 60 + pub type_id: u32, // offset 64 + /// Index into ChangeBufferState.spans for meta/metrics overflow data. + /// Set when the span is allocated. + pub active: u32, // offset 68 (1 = in use, 0 = free) +} + +/// Size of the header in bytes. Must match the #[repr(C)] layout. +pub const SPAN_HEADER_SIZE: usize = std::mem::size_of::(); + +// Compile-time assertion that the struct is the expected size. +const _: () = assert!(SPAN_HEADER_SIZE == 72); + +/// Field offsets for JS DataView access. +pub mod offsets { + pub const SPAN_ID: usize = 0; + pub const TRACE_ID_LO: usize = 8; + pub const TRACE_ID_HI: usize = 16; + pub const PARENT_ID: usize = 24; + pub const START: usize = 32; + pub const DURATION: usize = 40; + pub const ERROR: usize = 48; + pub const NAME_ID: usize = 52; + pub const SERVICE_ID: usize = 56; + pub const RESOURCE_ID: usize = 60; + pub const TYPE_ID: usize = 64; + pub const ACTIVE: usize = 68; +} diff --git a/libdd-trace-utils/src/change_buffer/trace.rs b/libdd-trace-utils/src/change_buffer/trace.rs new file mode 100644 index 0000000000..8194000455 --- /dev/null +++ b/libdd-trace-utils/src/change_buffer/trace.rs @@ -0,0 +1,112 @@ +#[derive(Default)] +pub struct Trace { + pub meta: Vec<(T, T)>, + pub metrics: Vec<(T, f64)>, + pub origin: Option, + pub sampling_rule_decision: Option, + pub sampling_limit_decision: Option, + pub sampling_agent_decision: Option, + pub span_count: usize, +} + +/// A map optimized for the common case of 1 active trace. +/// +/// In typical Node.js applications, there is usually only one active trace +/// at a time (single-threaded, one request in flight). This structure stores +/// the first trace inline — no heap allocation, no hashing — and falls back +/// to a `Vec` for the rare case of multiple concurrent traces. A `Vec` with +/// linear scan beats `FxHashMap` for small N (<~20) due to better cache +/// locality and zero hashing overhead for u128 keys. +pub struct SmallTraceMap { + /// The first (and usually only) trace, stored inline. + inline_key: u128, + inline_val: Option>, + /// Overflow storage for additional traces. Only allocated when there are + /// 2+ concurrent traces. + overflow: Vec<(u128, Trace)>, +} + +impl Default for SmallTraceMap { + fn default() -> Self { + SmallTraceMap { + inline_key: 0, + inline_val: None, + overflow: Vec::new(), + } + } +} + +impl SmallTraceMap { + /// Returns true if the map contains no traces. + pub fn is_empty(&self) -> bool { + self.inline_val.is_none() + } + + /// Get an immutable reference to a trace by ID. + #[inline] + pub fn get(&self, key: &u128) -> Option<&Trace> { + if let Some(ref val) = self.inline_val { + if self.inline_key == *key { + return Some(val); + } + } + self.overflow + .iter() + .find(|(k, _)| *k == *key) + .map(|(_, v)| v) + } + + /// Get a mutable reference to a trace by ID. + #[inline] + pub fn get_mut(&mut self, key: &u128) -> Option<&mut Trace> { + if let Some(ref mut val) = self.inline_val { + if self.inline_key == *key { + return Some(val); + } + } + self.overflow + .iter_mut() + .find(|(k, _)| *k == *key) + .map(|(_, v)| v) + } + + /// Get a mutable reference to a trace, inserting a default if not present. + /// This is the equivalent of `HashMap::entry(key).or_default()`. + #[inline] + pub fn get_or_insert_default(&mut self, key: u128) -> &mut Trace + where + T: Default, + { + // Hot path: inline slot matches this key or is empty. + if self.inline_key == key || self.inline_val.is_none() { + self.inline_key = key; + return self.inline_val.get_or_insert_with(Trace::default); + } + + // Slow path: linear scan overflow + let pos = self.overflow.iter().position(|(k, _)| *k == key); + match pos { + Some(i) => &mut self.overflow[i].1, + None => { + self.overflow.push((key, Trace::default())); + let last = self.overflow.len() - 1; + &mut self.overflow[last].1 + } + } + } + + /// Remove a trace by ID and return it. + pub fn remove(&mut self, key: &u128) -> Option> { + if self.inline_val.is_some() && self.inline_key == *key { + let val = self.inline_val.take(); + // If there's overflow, promote the last entry to inline + if let Some((k, v)) = self.overflow.pop() { + self.inline_key = k; + self.inline_val = Some(v); + } + return val; + } + let pos = self.overflow.iter().position(|(k, _)| *k == *key)?; + Some(self.overflow.swap_remove(pos).1) + } +} diff --git a/libdd-trace-utils/src/change_buffer/utils.rs b/libdd-trace-utils/src/change_buffer/utils.rs new file mode 100644 index 0000000000..9a870c2b13 --- /dev/null +++ b/libdd-trace-utils/src/change_buffer/utils.rs @@ -0,0 +1,28 @@ +pub trait FromBytes: Sized { + type Bytes: ?Sized; + fn from_bytes(bytes: &[u8]) -> Self; +} + +macro_rules! impl_from_bytes { + ($ty:ty, $len:expr) => { + impl FromBytes for $ty { + type Bytes = $ty; + + // Note that this always does a copy into a new variable. This is + // because the values in the buffer are not aligned. We could save + // ourselves a copy by ensuring alignment from the managed side. + fn from_bytes(bytes: &[u8]) -> Self { + let mut code_buf = [0u8; $len]; + code_buf.copy_from_slice(bytes); + <$ty>::from_le_bytes(code_buf) + } + } + }; +} + +impl_from_bytes!(u128, 16); +impl_from_bytes!(u64, 8); +impl_from_bytes!(f64, 8); +impl_from_bytes!(i64, 8); +impl_from_bytes!(i32, 4); +impl_from_bytes!(u32, 4); diff --git a/libdd-trace-utils/src/lib.rs b/libdd-trace-utils/src/lib.rs index aa8d93c887..289593bbb3 100644 --- a/libdd-trace-utils/src/lib.rs +++ b/libdd-trace-utils/src/lib.rs @@ -22,3 +22,6 @@ pub mod tracer_metadata; pub mod tracer_payload; pub mod span; + +#[cfg(feature = "change-buffer")] +pub mod change_buffer; From a74e1ab75e1973e6992eb3f149e09db725ac5425 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 27 May 2026 11:22:59 +0200 Subject: [PATCH 02/16] chore: various comment improvements, additional const asserts --- libdd-trace-utils/src/change_buffer/buffer.rs | 43 +++++++++++++++---- libdd-trace-utils/src/change_buffer/mod.rs | 26 +++++++++-- .../src/change_buffer/operation.rs | 23 +++++++--- .../src/change_buffer/span_header.rs | 33 +++++++++----- 4 files changed, 96 insertions(+), 29 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 871a3ac446..8485a90795 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -1,6 +1,8 @@ use crate::change_buffer::utils::*; use crate::change_buffer::{ChangeBufferError, Result}; +/// A handle to a change buffer shared with another runtime. The memory is shared, meaning that +/// cloning is cheap (copying the pointer and length). #[derive(Clone, Copy)] pub struct ChangeBuffer { ptr: *mut u8, @@ -10,6 +12,8 @@ pub struct ChangeBuffer { impl ChangeBuffer { /// # Safety /// + /// The underlying raw memory must be valid for reads and writes. + /// /// The underlying raw memory must not be freed until after this struct's /// lifetime. Having the calling code manage the memory makes it simpler to /// integrate with managed runtimes. @@ -20,17 +24,25 @@ impl ChangeBuffer { } } - fn as_slice(&self) -> &[u8] { + /// # Safety + /// + /// Same safety conditions as [std::slice::from_raw_parts]. + unsafe fn as_slice(&self) -> &[u8] { unsafe { std::slice::from_raw_parts(self.ptr, self.len) } } - fn as_mut_slice(&mut self) -> &mut [u8] { + /// # Safety + /// + /// Same safety conditions as [std::slice::from_raw_parts_mut]. + unsafe fn as_mut_slice(&mut self) -> &mut [u8] { unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) } } pub fn read(&self, index: &mut usize) -> Result { let size = std::mem::size_of::(); - let slice = self.as_slice(); + // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at + // construction time. We do not materialize other references during the lifetime of `slice`. + let slice = unsafe { self.as_slice() }; let bytes = slice .get(*index..*index + size) .ok_or(ChangeBufferError::ReadOutOfBounds { @@ -42,20 +54,27 @@ impl ChangeBuffer { } /// Read a value without bounds checking. + /// /// # Safety + /// /// Caller must ensure `*index + size_of::() <= self.len`. #[inline(always)] pub unsafe fn read_unchecked(&self, index: &mut usize) -> T { let size = std::mem::size_of::(); - let slice = self.as_slice(); + // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at + // construction time. We do not materialize other references during the lifetime of `slice`. + let slice = unsafe { self.as_slice() }; let bytes = slice.get_unchecked(*index..*index + size); *index += size; T::from_bytes(bytes) } + /// Write a raw `u32` in the buffer. pub fn write_u32(&mut self, offset: usize, value: u32) -> Result<()> { let len = self.len; - let slice = self.as_mut_slice(); + // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at + // construction time. We do not materialize other references during the lifetime of `slice`. + let slice = unsafe { self.as_mut_slice() }; let target = slice .get_mut(offset..offset + 4) .ok_or(ChangeBufferError::WriteOutOfBounds { offset, len })?; @@ -64,6 +83,8 @@ impl ChangeBuffer { Ok(()) } + /// Clear the op count, which is stored in the first 4 bytes of the buffer. This effectively + /// reset the buffer (semantically), but without actually zeroing the rest. pub fn clear_count(&mut self) -> Result<()> { self.write_u32(0, 0) } @@ -80,10 +101,14 @@ mod tests { let buf: [u8; 256] = std::mem::zeroed(); ChangeBuffer::from_raw_parts(buf.as_ptr(), 256) }; - let slice = buf.as_mut_slice(); - assert_eq!(256, slice.len()); - slice[1] = 42; - let slice = buf.as_slice(); + { + // Safety: slice is the only reference to the buffer in its scope. + let slice = unsafe { buf.as_mut_slice() }; + assert_eq!(256, slice.len()); + slice[1] = 42; + } + // Safety: slice is the only reference to the buffer in its scope. + let slice = unsafe { buf.as_slice() }; assert_eq!(256, slice.len()); assert_eq!(42, slice[1]); } diff --git a/libdd-trace-utils/src/change_buffer/mod.rs b/libdd-trace-utils/src/change_buffer/mod.rs index 4025d1d474..1b21870d04 100644 --- a/libdd-trace-utils/src/change_buffer/mod.rs +++ b/libdd-trace-utils/src/change_buffer/mod.rs @@ -1,12 +1,32 @@ -#![allow(unused)] +//! Change buffer. +//! +//! A change buffer is a contiguous shared memory area between libdatadog and an external runtime. +//! In order to amortize the cost of crossing the FFI when using native spans, the runtime write +//! events in the change buffer instead many times and only flush it by batch, where the call to +//! libdatadog happens. Libdatadog processes the change buffer and reconstruct the corresponding +//! spans. +//! +//! The change buffer is currently designed and used for dd-trace-js, but the idea could be extended +//! to other runtime where the FFI cost is high. +#[allow(unused)] /// Errors that can occur when operating on a [`ChangeBuffer`] or [`ChangeBufferState`]. #[derive(Debug)] pub enum ChangeBufferError { SpanNotFound(u64), + /// A string index didn't have any corresponding entry in the string table. StringNotFound(u32), - ReadOutOfBounds { offset: usize, len: usize }, - WriteOutOfBounds { offset: usize, len: usize }, + /// A read is out of bounds. + ReadOutOfBounds { + offset: usize, + len: usize, + }, + /// A is write is out of bounds. + WriteOutOfBounds { + offset: usize, + len: usize, + }, + /// Unknown opcode. UnknownOpcode(u32), } diff --git a/libdd-trace-utils/src/change_buffer/operation.rs b/libdd-trace-utils/src/change_buffer/operation.rs index 1c6e056fb9..4b7de9cfd4 100644 --- a/libdd-trace-utils/src/change_buffer/operation.rs +++ b/libdd-trace-utils/src/change_buffer/operation.rs @@ -1,7 +1,13 @@ -use crate::change_buffer::{ChangeBuffer, ChangeBufferError, Result}; +//! Change buffer operations. +//! +//! Operations are encoded in the change buffer, and provides an API on spans and their parts. +//! Instead of calling this API as normal functions, the operations are batched in the change +//! buffer, similarly to a bytecode. +use super::{ChangeBuffer, ChangeBufferError, Result}; #[repr(u32)] #[derive(Debug, Clone)] +/// The code of an operation that can be encoded in [ChangeBuffer]. pub enum OpCode { Create = 0, SetMetaAttr = 1, @@ -75,7 +81,10 @@ mod tests { use super::*; use crate::change_buffer::Result; - fn change_buffer_from_vec(buffer: &mut Vec) -> ChangeBuffer { + /// # Safety + /// + /// The original mutable borrowed vec must survive for the lifetime of the change buffer. + unsafe fn change_buffer_from_vec(buffer: &mut Vec) -> ChangeBuffer { unsafe { ChangeBuffer::from_raw_parts(buffer.as_mut_ptr(), buffer.len()) } } @@ -127,7 +136,7 @@ mod tests { buffer[0..8].copy_from_slice(&opcode.to_le_bytes()); buffer[8..12].copy_from_slice(&slot_index.to_le_bytes()); - let buf = change_buffer_from_vec(&mut buffer); + let buf = unsafe { change_buffer_from_vec(&mut buffer) }; let mut index = 0; let op = BufferedOperation::from_buf(&buf, &mut index)?; @@ -151,7 +160,7 @@ mod tests { buffer[20..28].copy_from_slice(&(OpCode::SetError as u64).to_le_bytes()); buffer[28..32].copy_from_slice(&2u32.to_le_bytes()); - let buf = change_buffer_from_vec(&mut buffer); + let buf = unsafe { change_buffer_from_vec(&mut buffer) }; let mut index = 8; let op1 = BufferedOperation::from_buf(&buf, &mut index)?; @@ -173,7 +182,7 @@ mod tests { buffer[0..8].copy_from_slice(&999u64.to_le_bytes()); buffer[8..12].copy_from_slice(&1u32.to_le_bytes()); - let buf = change_buffer_from_vec(&mut buffer); + let buf = unsafe { change_buffer_from_vec(&mut buffer) }; let mut index = 0; assert!(BufferedOperation::from_buf(&buf, &mut index).is_err()); } @@ -184,7 +193,7 @@ mod tests { let mut buffer = vec![0u8; 8]; buffer[0..8].copy_from_slice(&0u64.to_le_bytes()); - let buf = change_buffer_from_vec(&mut buffer); + let buf = unsafe { change_buffer_from_vec(&mut buffer) }; let mut index = 0; assert!(BufferedOperation::from_buf(&buf, &mut index).is_err()); } @@ -192,7 +201,7 @@ mod tests { #[test] fn buffered_operation_from_buf_empty() { let mut buffer = vec![0u8; 0]; - let buf = change_buffer_from_vec(&mut buffer); + let buf = unsafe { change_buffer_from_vec(&mut buffer) }; let mut index = 0; assert!(BufferedOperation::from_buf(&buf, &mut index).is_err()); } diff --git a/libdd-trace-utils/src/change_buffer/span_header.rs b/libdd-trace-utils/src/change_buffer/span_header.rs index 42d5815ba6..83c59e22eb 100644 --- a/libdd-trace-utils/src/change_buffer/span_header.rs +++ b/libdd-trace-utils/src/change_buffer/span_header.rs @@ -1,12 +1,11 @@ /// Fixed-layout span header for direct JS DataView access. /// -/// JS creates a DataView over each span's header in WASM linear memory and -/// writes fields directly — no change buffer protocol, no staging, no copy. +/// JS creates a DataView over each span's header in WASM linear memory and writes fields directly. /// -/// String fields store string table IDs (u32) resolved to real strings at +/// Strings are interned. String fields store string table IDs (u32) resolved to real strings at /// flush time. Numeric fields are stored directly. /// -/// Meta/metrics tags are NOT in the header — they still go through the +/// Meta/metrics tags are NOT in the header. They still go through the /// change buffer protocol since their count varies per span. #[repr(C)] #[derive(Default, Clone)] @@ -27,12 +26,6 @@ pub struct SpanHeader { pub active: u32, // offset 68 (1 = in use, 0 = free) } -/// Size of the header in bytes. Must match the #[repr(C)] layout. -pub const SPAN_HEADER_SIZE: usize = std::mem::size_of::(); - -// Compile-time assertion that the struct is the expected size. -const _: () = assert!(SPAN_HEADER_SIZE == 72); - /// Field offsets for JS DataView access. pub mod offsets { pub const SPAN_ID: usize = 0; @@ -48,3 +41,23 @@ pub mod offsets { pub const TYPE_ID: usize = 64; pub const ACTIVE: usize = 68; } + +/// Size of the header in bytes. Must match the #[repr(C)] layout. +pub const SPAN_HEADER_SIZE: usize = std::mem::size_of::(); + +// Compile-time assertions that the struct layout matches the declared offsets and size. +const _: () = { + assert!(SPAN_HEADER_SIZE == 72); + assert!(std::mem::offset_of!(SpanHeader, span_id) == offsets::SPAN_ID); + assert!(std::mem::offset_of!(SpanHeader, trace_id_lo) == offsets::TRACE_ID_LO); + assert!(std::mem::offset_of!(SpanHeader, trace_id_hi) == offsets::TRACE_ID_HI); + assert!(std::mem::offset_of!(SpanHeader, parent_id) == offsets::PARENT_ID); + assert!(std::mem::offset_of!(SpanHeader, start) == offsets::START); + assert!(std::mem::offset_of!(SpanHeader, duration) == offsets::DURATION); + assert!(std::mem::offset_of!(SpanHeader, error) == offsets::ERROR); + assert!(std::mem::offset_of!(SpanHeader, name_id) == offsets::NAME_ID); + assert!(std::mem::offset_of!(SpanHeader, service_id) == offsets::SERVICE_ID); + assert!(std::mem::offset_of!(SpanHeader, resource_id) == offsets::RESOURCE_ID); + assert!(std::mem::offset_of!(SpanHeader, type_id) == offsets::TYPE_ID); + assert!(std::mem::offset_of!(SpanHeader, active) == offsets::ACTIVE); +}; From b855753989bdf86ffa69978eb8a56854eede2f76 Mon Sep 17 00:00:00 2001 From: Edmund Kump Date: Mon, 1 Jun 2026 17:54:19 -0400 Subject: [PATCH 03/16] fix clippy errors for unused code --- libdd-trace-utils/src/change_buffer/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libdd-trace-utils/src/change_buffer/mod.rs b/libdd-trace-utils/src/change_buffer/mod.rs index 1b21870d04..037209a5ee 100644 --- a/libdd-trace-utils/src/change_buffer/mod.rs +++ b/libdd-trace-utils/src/change_buffer/mod.rs @@ -8,7 +8,8 @@ //! //! The change buffer is currently designed and used for dd-trace-js, but the idea could be extended //! to other runtime where the FFI cost is high. -#[allow(unused)] + +#![allow(unused)] /// Errors that can occur when operating on a [`ChangeBuffer`] or [`ChangeBufferState`]. #[derive(Debug)] From c864fefca5485336683ebf376ec3412ef48326b5 Mon Sep 17 00:00:00 2001 From: Edmund Kump Date: Mon, 1 Jun 2026 18:15:34 -0400 Subject: [PATCH 04/16] fix clippy error for aprox value of Pi const --- libdd-trace-utils/src/change_buffer/buffer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 8485a90795..59c180d075 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -145,7 +145,7 @@ mod tests { let mut buffer = vec![0u8; 64]; buffer[0..4].copy_from_slice(&42u32.to_le_bytes()); buffer[8..24].copy_from_slice(&123456789u128.to_le_bytes()); - buffer[24..32].copy_from_slice(&3.14f64.to_le_bytes()); + buffer[24..32].copy_from_slice(&1.5f64.to_le_bytes()); let buf = unsafe { ChangeBuffer::from_raw_parts(buffer.as_mut_ptr(), buffer.len()) }; @@ -157,7 +157,7 @@ mod tests { assert_eq!(123456789u128, buf.read::(&mut index)?); index = 24; - assert_eq!(3.14, buf.read::(&mut index)?); + assert_eq!(1.5, buf.read::(&mut index)?); Ok(()) } From e9a9b31633c5d0e065a131e81ffadf830169c61d Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Tue, 2 Jun 2026 11:03:26 +0200 Subject: [PATCH 05/16] refactor: more descriptive out-of-bound error --- libdd-trace-utils/src/change_buffer/buffer.rs | 14 ++++++--- libdd-trace-utils/src/change_buffer/mod.rs | 30 +++++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 59c180d075..b6b8dd7d84 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -47,7 +47,8 @@ impl ChangeBuffer { .get(*index..*index + size) .ok_or(ChangeBufferError::ReadOutOfBounds { offset: *index, - len: self.len, + value_len: size, + buffer_len: self.len, })?; *index += size; Ok(T::from_bytes(bytes)) @@ -75,9 +76,14 @@ impl ChangeBuffer { // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at // construction time. We do not materialize other references during the lifetime of `slice`. let slice = unsafe { self.as_mut_slice() }; - let target = slice - .get_mut(offset..offset + 4) - .ok_or(ChangeBufferError::WriteOutOfBounds { offset, len })?; + let target = + slice + .get_mut(offset..offset + 4) + .ok_or(ChangeBufferError::WriteOutOfBounds { + offset, + value_len: offset + 4, + buffer_len: len, + })?; let bytes = value.to_le_bytes(); target.copy_from_slice(&bytes); Ok(()) diff --git a/libdd-trace-utils/src/change_buffer/mod.rs b/libdd-trace-utils/src/change_buffer/mod.rs index 037209a5ee..6426955699 100644 --- a/libdd-trace-utils/src/change_buffer/mod.rs +++ b/libdd-trace-utils/src/change_buffer/mod.rs @@ -19,13 +19,23 @@ pub enum ChangeBufferError { StringNotFound(u32), /// A read is out of bounds. ReadOutOfBounds { + /// The starting offset of the read. offset: usize, - len: usize, + /// The size in bytes of the value attempted to be read starting at `offset`. + /// We have `offset + value_len > buffer_len`. + value_len: usize, + /// The total size of the buffer. + buffer_len: usize, }, /// A is write is out of bounds. WriteOutOfBounds { + /// The starting offset of the write. offset: usize, - len: usize, + /// The size in bytes of the value attempted to be written starting at `offset`. + /// We have `offset + value_len > buffer_len`. + value_len: usize, + /// The total size of the buffer. + buffer_len: usize, }, /// Unknown opcode. UnknownOpcode(u32), @@ -38,11 +48,19 @@ impl std::fmt::Display for ChangeBufferError { ChangeBufferError::StringNotFound(id) => { write!(f, "string not found internally: {id}") } - ChangeBufferError::ReadOutOfBounds { offset, len } => { - write!(f, "read out of bounds: offset={offset}, len={len}") + ChangeBufferError::ReadOutOfBounds { + offset, + value_len, + buffer_len, + } => { + write!(f, "read out of bounds: offset={offset}, value_len={value_len}, buffer_len={buffer_len}") } - ChangeBufferError::WriteOutOfBounds { offset, len } => { - write!(f, "write out of bounds: offset={offset}, len={len}") + ChangeBufferError::WriteOutOfBounds { + offset, + value_len, + buffer_len, + } => { + write!(f, "write out of bounds: offset={offset}, value_len={value_len}, buffer_len={buffer_len}") } ChangeBufferError::UnknownOpcode(val) => write!(f, "unknown opcode: {val}"), } From ded238cedb086d1509f10b0ef374ad15d8c66846 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Tue, 2 Jun 2026 11:19:31 +0200 Subject: [PATCH 06/16] fix: protect against usize overflow in public methods --- libdd-trace-utils/src/change_buffer/buffer.rs | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index b6b8dd7d84..8ea8e3cd5c 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -38,18 +38,25 @@ impl ChangeBuffer { unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) } } + /// Read a value of type `T` starting at offset `index`. pub fn read(&self, index: &mut usize) -> Result { let size = std::mem::size_of::(); - // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at - // construction time. We do not materialize other references during the lifetime of `slice`. + // Safety: the allocation of `self.ptr` is required to be valid for read and writes at + // construction time, and to remain alive for the lifetime of `self`. We do not materialize + // other references during the lifetime of `slice`. let slice = unsafe { self.as_slice() }; + let out_of_bounds_err = || ChangeBufferError::ReadOutOfBounds { + offset: *index, + value_len: size, + buffer_len: self.len, + }; + let Some(end) = index.checked_add(size) else { + return Err(out_of_bounds_err()); + }; + let bytes = slice .get(*index..*index + size) - .ok_or(ChangeBufferError::ReadOutOfBounds { - offset: *index, - value_len: size, - buffer_len: self.len, - })?; + .ok_or_else(out_of_bounds_err)?; *index += size; Ok(T::from_bytes(bytes)) } @@ -58,9 +65,10 @@ impl ChangeBuffer { /// /// # Safety /// - /// Caller must ensure `*index + size_of::() <= self.len`. + /// Caller must ensure `*index + size_of::() <= self.len` and that `index + size_of::() < + /// usize::MAX`. #[inline(always)] - pub unsafe fn read_unchecked(&self, index: &mut usize) -> T { + unsafe fn read_unchecked(&self, index: &mut usize) -> T { let size = std::mem::size_of::(); // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at // construction time. We do not materialize other references during the lifetime of `slice`. @@ -76,14 +84,16 @@ impl ChangeBuffer { // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at // construction time. We do not materialize other references during the lifetime of `slice`. let slice = unsafe { self.as_mut_slice() }; - let target = - slice - .get_mut(offset..offset + 4) - .ok_or(ChangeBufferError::WriteOutOfBounds { - offset, - value_len: offset + 4, - buffer_len: len, - })?; + let out_of_bounds_err = || ChangeBufferError::WriteOutOfBounds { + offset, + value_len: offset + 4, + buffer_len: len, + }; + let Some(end) = offset.checked_add(4) else { + return Err(out_of_bounds_err()); + }; + + let target = slice.get_mut(offset..end).ok_or_else(out_of_bounds_err)?; let bytes = value.to_le_bytes(); target.copy_from_slice(&bytes); Ok(()) From db3ad2d927f95457cfce70f7df90b7ae900439ff Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Tue, 2 Jun 2026 11:22:03 +0200 Subject: [PATCH 07/16] doc: improve comment wording Co-authored-by: Edmund Kump --- libdd-trace-utils/src/change_buffer/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/mod.rs b/libdd-trace-utils/src/change_buffer/mod.rs index 6426955699..efaa6ad8ef 100644 --- a/libdd-trace-utils/src/change_buffer/mod.rs +++ b/libdd-trace-utils/src/change_buffer/mod.rs @@ -1,10 +1,10 @@ //! Change buffer. //! //! A change buffer is a contiguous shared memory area between libdatadog and an external runtime. -//! In order to amortize the cost of crossing the FFI when using native spans, the runtime write -//! events in the change buffer instead many times and only flush it by batch, where the call to -//! libdatadog happens. Libdatadog processes the change buffer and reconstruct the corresponding -//! spans. +//! In order to amortize the cost of crossing the FFI when using native spans, the runtime writes +//! events into the change buffer instead of calling libdatadog many times, and only flushes by +//! batch — that flush is where the call to libdatadog happens. Libdatadog then processes the change +//! buffer and reconstructs the corresponding spans. //! //! The change buffer is currently designed and used for dd-trace-js, but the idea could be extended //! to other runtime where the FFI cost is high. From c31fa4ff5aa5de8ce9af85554b5cbae2436ae056 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Fri, 5 Jun 2026 14:38:23 +0200 Subject: [PATCH 08/16] chore: add missing license headers --- libdd-trace-utils/src/change_buffer/buffer.rs | 3 +++ libdd-trace-utils/src/change_buffer/mod.rs | 3 +++ libdd-trace-utils/src/change_buffer/operation.rs | 3 +++ libdd-trace-utils/src/change_buffer/span_header.rs | 3 +++ libdd-trace-utils/src/change_buffer/trace.rs | 3 +++ libdd-trace-utils/src/change_buffer/utils.rs | 3 +++ 6 files changed, 18 insertions(+) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 8ea8e3cd5c..3be06d5daf 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -1,3 +1,6 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + use crate::change_buffer::utils::*; use crate::change_buffer::{ChangeBufferError, Result}; diff --git a/libdd-trace-utils/src/change_buffer/mod.rs b/libdd-trace-utils/src/change_buffer/mod.rs index efaa6ad8ef..c2944221b5 100644 --- a/libdd-trace-utils/src/change_buffer/mod.rs +++ b/libdd-trace-utils/src/change_buffer/mod.rs @@ -1,3 +1,6 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + //! Change buffer. //! //! A change buffer is a contiguous shared memory area between libdatadog and an external runtime. diff --git a/libdd-trace-utils/src/change_buffer/operation.rs b/libdd-trace-utils/src/change_buffer/operation.rs index 4b7de9cfd4..7643a2eab1 100644 --- a/libdd-trace-utils/src/change_buffer/operation.rs +++ b/libdd-trace-utils/src/change_buffer/operation.rs @@ -1,3 +1,6 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + //! Change buffer operations. //! //! Operations are encoded in the change buffer, and provides an API on spans and their parts. diff --git a/libdd-trace-utils/src/change_buffer/span_header.rs b/libdd-trace-utils/src/change_buffer/span_header.rs index 83c59e22eb..21049bb53c 100644 --- a/libdd-trace-utils/src/change_buffer/span_header.rs +++ b/libdd-trace-utils/src/change_buffer/span_header.rs @@ -1,3 +1,6 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + /// Fixed-layout span header for direct JS DataView access. /// /// JS creates a DataView over each span's header in WASM linear memory and writes fields directly. diff --git a/libdd-trace-utils/src/change_buffer/trace.rs b/libdd-trace-utils/src/change_buffer/trace.rs index 8194000455..5b777892a9 100644 --- a/libdd-trace-utils/src/change_buffer/trace.rs +++ b/libdd-trace-utils/src/change_buffer/trace.rs @@ -1,3 +1,6 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + #[derive(Default)] pub struct Trace { pub meta: Vec<(T, T)>, diff --git a/libdd-trace-utils/src/change_buffer/utils.rs b/libdd-trace-utils/src/change_buffer/utils.rs index 9a870c2b13..bd86f02a0c 100644 --- a/libdd-trace-utils/src/change_buffer/utils.rs +++ b/libdd-trace-utils/src/change_buffer/utils.rs @@ -1,3 +1,6 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + pub trait FromBytes: Sized { type Bytes: ?Sized; fn from_bytes(bytes: &[u8]) -> Self; From 92bf5d5ad50bb16bfa5b377d33eddecec1083b9f Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 8 Jun 2026 14:26:00 +0200 Subject: [PATCH 09/16] refactor: remove clone/copy bound, dangerous and unused --- libdd-trace-utils/src/change_buffer/buffer.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 3be06d5daf..1f4d967044 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -6,7 +6,6 @@ use crate::change_buffer::{ChangeBufferError, Result}; /// A handle to a change buffer shared with another runtime. The memory is shared, meaning that /// cloning is cheap (copying the pointer and length). -#[derive(Clone, Copy)] pub struct ChangeBuffer { ptr: *mut u8, len: usize, From 054991c281df7fc5cb112db9a325a27827f5e571 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 8 Jun 2026 16:45:54 +0200 Subject: [PATCH 10/16] style: put BYTES_SIZE in FromBytes trait --- libdd-trace-utils/src/change_buffer/buffer.rs | 5 ++--- libdd-trace-utils/src/change_buffer/utils.rs | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 1f4d967044..d0518ac346 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -71,12 +71,11 @@ impl ChangeBuffer { /// usize::MAX`. #[inline(always)] unsafe fn read_unchecked(&self, index: &mut usize) -> T { - let size = std::mem::size_of::(); // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at // construction time. We do not materialize other references during the lifetime of `slice`. let slice = unsafe { self.as_slice() }; - let bytes = slice.get_unchecked(*index..*index + size); - *index += size; + let bytes = slice.get_unchecked(*index..*index + T::FROM_BYTES_SIZE); + *index += T::FROM_BYTES_SIZE; T::from_bytes(bytes) } diff --git a/libdd-trace-utils/src/change_buffer/utils.rs b/libdd-trace-utils/src/change_buffer/utils.rs index bd86f02a0c..9599ec5630 100644 --- a/libdd-trace-utils/src/change_buffer/utils.rs +++ b/libdd-trace-utils/src/change_buffer/utils.rs @@ -1,8 +1,14 @@ // Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +/// Data that can be read from raw bytes. pub trait FromBytes: Sized { type Bytes: ?Sized; + + /// The number of bytes needed to be read to extract a value from `T`. Default to + /// `mem::size_of::()`. + const FROM_BYTES_SIZE: usize = std::mem::size_of::(); + fn from_bytes(bytes: &[u8]) -> Self; } From 69d6036477b92aed74d64dd0c9780808886102e3 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 8 Jun 2026 16:48:23 +0200 Subject: [PATCH 11/16] refactor: remove useless Copy bound --- libdd-trace-utils/src/change_buffer/buffer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index d0518ac346..8feff287ba 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -41,7 +41,7 @@ impl ChangeBuffer { } /// Read a value of type `T` starting at offset `index`. - pub fn read(&self, index: &mut usize) -> Result { + pub fn read(&self, index: &mut usize) -> Result { let size = std::mem::size_of::(); // Safety: the allocation of `self.ptr` is required to be valid for read and writes at // construction time, and to remain alive for the lifetime of `self`. We do not materialize @@ -70,7 +70,7 @@ impl ChangeBuffer { /// Caller must ensure `*index + size_of::() <= self.len` and that `index + size_of::() < /// usize::MAX`. #[inline(always)] - unsafe fn read_unchecked(&self, index: &mut usize) -> T { + unsafe fn read_unchecked(&self, index: &mut usize) -> T { // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at // construction time. We do not materialize other references during the lifetime of `slice`. let slice = unsafe { self.as_slice() }; From 8666a53ce4aca6b16d0b65b17ceff637fabff3be Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 8 Jun 2026 16:50:59 +0200 Subject: [PATCH 12/16] style: use direct value instead of closure + or_else --- libdd-trace-utils/src/change_buffer/buffer.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 8feff287ba..6a42dce3e4 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -47,18 +47,16 @@ impl ChangeBuffer { // construction time, and to remain alive for the lifetime of `self`. We do not materialize // other references during the lifetime of `slice`. let slice = unsafe { self.as_slice() }; - let out_of_bounds_err = || ChangeBufferError::ReadOutOfBounds { + let out_of_bounds_err = ChangeBufferError::ReadOutOfBounds { offset: *index, value_len: size, buffer_len: self.len, }; let Some(end) = index.checked_add(size) else { - return Err(out_of_bounds_err()); + return Err(out_of_bounds_err); }; - let bytes = slice - .get(*index..*index + size) - .ok_or_else(out_of_bounds_err)?; + let bytes = slice.get(*index..*index + size).ok_or(out_of_bounds_err)?; *index += size; Ok(T::from_bytes(bytes)) } From 328fcf50cbba978aa9406b813f5126429aef7c1b Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 8 Jun 2026 16:53:30 +0200 Subject: [PATCH 13/16] refactor: remove unused read_unchecked --- libdd-trace-utils/src/change_buffer/buffer.rs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index 6a42dce3e4..c17f892673 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -61,22 +61,6 @@ impl ChangeBuffer { Ok(T::from_bytes(bytes)) } - /// Read a value without bounds checking. - /// - /// # Safety - /// - /// Caller must ensure `*index + size_of::() <= self.len` and that `index + size_of::() < - /// usize::MAX`. - #[inline(always)] - unsafe fn read_unchecked(&self, index: &mut usize) -> T { - // Safety: the allocation of `self.ptr` is guaranteed to be valid for read and writes at - // construction time. We do not materialize other references during the lifetime of `slice`. - let slice = unsafe { self.as_slice() }; - let bytes = slice.get_unchecked(*index..*index + T::FROM_BYTES_SIZE); - *index += T::FROM_BYTES_SIZE; - T::from_bytes(bytes) - } - /// Write a raw `u32` in the buffer. pub fn write_u32(&mut self, offset: usize, value: u32) -> Result<()> { let len = self.len; From 3b012da261554801d5b003f2de85021bb393a7d4 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 8 Jun 2026 16:58:17 +0200 Subject: [PATCH 14/16] fix: use offset_of directly, instead of after-the-fact check --- .../src/change_buffer/span_header.rs | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/libdd-trace-utils/src/change_buffer/span_header.rs b/libdd-trace-utils/src/change_buffer/span_header.rs index 21049bb53c..91965a5e42 100644 --- a/libdd-trace-utils/src/change_buffer/span_header.rs +++ b/libdd-trace-utils/src/change_buffer/span_header.rs @@ -1,6 +1,8 @@ // Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +use std::mem; + /// Fixed-layout span header for direct JS DataView access. /// /// JS creates a DataView over each span's header in WASM linear memory and writes fields directly. @@ -31,36 +33,22 @@ pub struct SpanHeader { /// Field offsets for JS DataView access. pub mod offsets { - pub const SPAN_ID: usize = 0; - pub const TRACE_ID_LO: usize = 8; - pub const TRACE_ID_HI: usize = 16; - pub const PARENT_ID: usize = 24; - pub const START: usize = 32; - pub const DURATION: usize = 40; - pub const ERROR: usize = 48; - pub const NAME_ID: usize = 52; - pub const SERVICE_ID: usize = 56; - pub const RESOURCE_ID: usize = 60; - pub const TYPE_ID: usize = 64; - pub const ACTIVE: usize = 68; + use super::mem; + use super::SpanHeader; + + pub const SPAN_ID: usize = mem::offset_of!(SpanHeader, span_id); + pub const TRACE_ID_LO: usize = mem::offset_of!(SpanHeader, trace_id_lo); + pub const TRACE_ID_HI: usize = mem::offset_of!(SpanHeader, trace_id_hi); + pub const PARENT_ID: usize = mem::offset_of!(SpanHeader, parent_id); + pub const START: usize = mem::offset_of!(SpanHeader, start); + pub const DURATION: usize = mem::offset_of!(SpanHeader, duration); + pub const ERROR: usize = mem::offset_of!(SpanHeader, error); + pub const NAME_ID: usize = mem::offset_of!(SpanHeader, name_id); + pub const SERVICE_ID: usize = mem::offset_of!(SpanHeader, service_id); + pub const RESOURCE_ID: usize = mem::offset_of!(SpanHeader, resource_id); + pub const TYPE_ID: usize = mem::offset_of!(SpanHeader, type_id); + pub const ACTIVE: usize = mem::offset_of!(SpanHeader, active); } /// Size of the header in bytes. Must match the #[repr(C)] layout. -pub const SPAN_HEADER_SIZE: usize = std::mem::size_of::(); - -// Compile-time assertions that the struct layout matches the declared offsets and size. -const _: () = { - assert!(SPAN_HEADER_SIZE == 72); - assert!(std::mem::offset_of!(SpanHeader, span_id) == offsets::SPAN_ID); - assert!(std::mem::offset_of!(SpanHeader, trace_id_lo) == offsets::TRACE_ID_LO); - assert!(std::mem::offset_of!(SpanHeader, trace_id_hi) == offsets::TRACE_ID_HI); - assert!(std::mem::offset_of!(SpanHeader, parent_id) == offsets::PARENT_ID); - assert!(std::mem::offset_of!(SpanHeader, start) == offsets::START); - assert!(std::mem::offset_of!(SpanHeader, duration) == offsets::DURATION); - assert!(std::mem::offset_of!(SpanHeader, error) == offsets::ERROR); - assert!(std::mem::offset_of!(SpanHeader, name_id) == offsets::NAME_ID); - assert!(std::mem::offset_of!(SpanHeader, service_id) == offsets::SERVICE_ID); - assert!(std::mem::offset_of!(SpanHeader, resource_id) == offsets::RESOURCE_ID); - assert!(std::mem::offset_of!(SpanHeader, type_id) == offsets::TYPE_ID); - assert!(std::mem::offset_of!(SpanHeader, active) == offsets::ACTIVE); -}; +pub const SPAN_HEADER_SIZE: usize = mem::size_of::(); From 24c7734c257824d9d035c349bf06bb3beeb0f65f Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 8 Jun 2026 17:40:43 +0200 Subject: [PATCH 15/16] chore: remove deprecated build.rs --- libdd-trace-utils/build.rs | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 libdd-trace-utils/build.rs diff --git a/libdd-trace-utils/build.rs b/libdd-trace-utils/build.rs deleted file mode 100644 index 8c0da09479..0000000000 --- a/libdd-trace-utils/build.rs +++ /dev/null @@ -1,10 +0,0 @@ -fn main() { - let commit = std::process::Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - println!("cargo:rustc-env=GIT_COMMIT={commit}"); -} From fa24219a9ff162adfa9bd0d334ea4e8134593503 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 10 Jun 2026 15:55:50 +0200 Subject: [PATCH 16/16] fix: fix unused variable --- libdd-trace-utils/src/change_buffer/buffer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libdd-trace-utils/src/change_buffer/buffer.rs b/libdd-trace-utils/src/change_buffer/buffer.rs index c17f892673..d18ac93cb9 100644 --- a/libdd-trace-utils/src/change_buffer/buffer.rs +++ b/libdd-trace-utils/src/change_buffer/buffer.rs @@ -56,7 +56,7 @@ impl ChangeBuffer { return Err(out_of_bounds_err); }; - let bytes = slice.get(*index..*index + size).ok_or(out_of_bounds_err)?; + let bytes = slice.get(*index..end).ok_or(out_of_bounds_err)?; *index += size; Ok(T::from_bytes(bytes)) }