Skip to content

Commit 7ae80b1

Browse files
committed
feat(pipeline): add span_events bindings
Add `addSpanEvent` to append OpenTelemetry-style span events onto the top-level v0.4 `span_events` field that libdatadog already serializes. Like meta_struct there is no change-buffer opcode, so the event is appended directly to the span after draining the queue (span_events do not depend on any other queued op, so bypassing queue ordering is safe). Attributes arrive as a flat little-endian buffer with per-value type tags (String=0, Boolean=1, Integer=2, Double=3, Array=4) matching libdatadog's AttributeArrayValue discriminants; every read is bounded against the buffer so a malformed/truncated buffer errors instead of panicking. A `getSpanEventsJson` helper serializes events via the same serde impl used for the msgpack wire format, exercised by new round-trip tests covering each scalar type, arrays, and bounds.
1 parent 148b808 commit 7ae80b1

5 files changed

Lines changed: 297 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pipeline/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ wasm-bindgen = "0.2"
1212
wasm-bindgen-futures = "0.4"
1313
js-sys = "0.3"
1414
serde = { version = "1.0", features = ["derive"] }
15+
serde_json = "1"
1516
libdatadog-nodejs-capabilities = { path = "../capabilities" }
1617
libdd-capabilities = { git = "https://github.com/DataDog/libdatadog.git", branch = "main" }
1718
libdd-data-pipeline = { git = "https://github.com/DataDog/libdatadog.git", branch = "main", default-features = false }

crates/pipeline/src/lib.rs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ use trace_data::*;
1818
mod stats;
1919

2020
use libdd_trace_utils::change_buffer::{ChangeBuffer, ChangeBufferState};
21+
use libdd_trace_utils::span::v04::{AttributeAnyValue, AttributeArrayValue, SpanEvent};
22+
use span_string::SpanString;
23+
use std::collections::HashMap;
2124

2225
mod utils;
2326
use utils::*;
@@ -27,6 +30,112 @@ fn init() {
2730
console_error_panic_hook::set_once();
2831
}
2932

33+
// --- span event attribute decoding ---
34+
//
35+
// `addSpanEvent` receives its attributes as a flat little-endian buffer built
36+
// by dd-trace-js. Layout: repeated entries until the buffer is exhausted, each
37+
// [key_len: u32][key: utf8][tag: u8] + value
38+
// where the value depends on `tag`:
39+
// 0 String [len: u32][utf8]
40+
// 1 Boolean [u8 (0/1)]
41+
// 2 Integer [i64]
42+
// 3 Double [f64]
43+
// 4 Array [count: u32] then `count` items, each [item_tag: u8][scalar]
44+
// (item_tag must be 0..=3; nested arrays are rejected)
45+
// The tags mirror libdatadog's `AttributeArrayValue` discriminants
46+
// (String=0, Boolean=1, Integer=2, Double=3, Array=4). Every read is bounded
47+
// against the buffer so a malformed/truncated buffer errors instead of
48+
// panicking (matching the hardening in `stringTableInsertMany`/`prepareChunk`).
49+
50+
fn se_need(buf: &[u8], idx: usize, n: usize) -> Result<(), JsValue> {
51+
// Avoid `idx + n` overflowing: on wasm32 `usize` is 32-bit, and `n` can be
52+
// a u32-derived length (e.g. a crafted `key_len`) near `usize::MAX`, which
53+
// would wrap and let a too-large read slip past the bound and trap on the
54+
// slice. `idx` never exceeds `buf.len()` (it only advances after a checked
55+
// read), so `buf.len() - idx` is the safe remaining-byte form.
56+
if idx > buf.len() || n > buf.len() - idx {
57+
return Err(JsValue::from_str(
58+
"addSpanEvent: truncated span-event attribute buffer",
59+
));
60+
}
61+
Ok(())
62+
}
63+
64+
fn se_read_u8(buf: &[u8], idx: &mut usize) -> Result<u8, JsValue> {
65+
se_need(buf, *idx, 1)?;
66+
let b = buf[*idx];
67+
*idx += 1;
68+
Ok(b)
69+
}
70+
71+
fn se_read_u32(buf: &[u8], idx: &mut usize) -> Result<u32, JsValue> {
72+
se_need(buf, *idx, 4)?;
73+
Ok(get_num(buf, idx))
74+
}
75+
76+
fn se_read_str(buf: &[u8], idx: &mut usize) -> Result<SpanString, JsValue> {
77+
let len = se_read_u32(buf, idx)? as usize;
78+
se_need(buf, *idx, len)?;
79+
let s = std::str::from_utf8(&buf[*idx..*idx + len])
80+
.map_err(|e| JsValue::from_str(&format!("addSpanEvent: invalid utf8: {e}")))?;
81+
*idx += len;
82+
Ok(s.into())
83+
}
84+
85+
fn se_read_scalar(
86+
buf: &[u8],
87+
idx: &mut usize,
88+
tag: u8,
89+
) -> Result<AttributeArrayValue<WasmTraceData>, JsValue> {
90+
match tag {
91+
0 => Ok(AttributeArrayValue::String(se_read_str(buf, idx)?)),
92+
1 => Ok(AttributeArrayValue::Boolean(se_read_u8(buf, idx)? != 0)),
93+
2 => {
94+
se_need(buf, *idx, 8)?;
95+
Ok(AttributeArrayValue::Integer(get_num(buf, idx)))
96+
}
97+
3 => {
98+
se_need(buf, *idx, 8)?;
99+
Ok(AttributeArrayValue::Double(get_num(buf, idx)))
100+
}
101+
_ => Err(JsValue::from_str(
102+
"addSpanEvent: invalid span-event attribute tag",
103+
)),
104+
}
105+
}
106+
107+
fn decode_span_event_attributes(
108+
buf: &[u8],
109+
) -> Result<HashMap<SpanString, AttributeAnyValue<WasmTraceData>>, JsValue> {
110+
let mut attributes = HashMap::new();
111+
let mut idx = 0usize;
112+
while idx < buf.len() {
113+
let key = se_read_str(buf, &mut idx)?;
114+
let tag = se_read_u8(buf, &mut idx)?;
115+
let value = if tag == 4 {
116+
let count = se_read_u32(buf, &mut idx)? as usize;
117+
// Each item is at least 1 byte (its tag), so cap the pre-allocation
118+
// to the remaining buffer: an inflated count can't force a huge
119+
// allocation, and the per-item bounded reads catch truncation.
120+
let mut items = Vec::with_capacity(count.min(buf.len().saturating_sub(idx)));
121+
for _ in 0..count {
122+
let item_tag = se_read_u8(buf, &mut idx)?;
123+
if item_tag == 4 {
124+
return Err(JsValue::from_str(
125+
"addSpanEvent: nested arrays are not supported",
126+
));
127+
}
128+
items.push(se_read_scalar(buf, &mut idx, item_tag)?);
129+
}
130+
AttributeAnyValue::Array(items)
131+
} else {
132+
AttributeAnyValue::SingleValue(se_read_scalar(buf, &mut idx, tag)?)
133+
};
134+
attributes.insert(key, value);
135+
}
136+
Ok(attributes)
137+
}
138+
30139
#[wasm_bindgen]
31140
/// All mutable state is behind RefCell to allow `&self` methods on the
32141
/// wasm-bindgen wrapper. This prevents re-entrant borrow panics when:
@@ -501,6 +610,51 @@ impl WasmSpanState {
501610
.unwrap_or(JsValue::NULL))
502611
}
503612

613+
// Span events (OpenTelemetry-style) are serialized by libdatadog as the
614+
// top-level v0.4 `span_events` field when present. Like meta_struct there
615+
// is no change-buffer opcode, so the event is appended directly to the span
616+
// after draining the queue (span_events do not depend on any other queued
617+
// op, so bypassing queue ordering is safe). `attrs_buf` is the flat typed
618+
// attribute encoding decoded by `decode_span_event_attributes`.
619+
#[wasm_bindgen(js_name = "addSpanEvent")]
620+
pub fn add_span_event(
621+
&self,
622+
span_id: u64,
623+
name: &str,
624+
time_unix_nano: u64,
625+
attrs_buf: &[u8],
626+
) -> Result<(), JsValue> {
627+
self.flush_change_queue()?;
628+
// Decode before borrowing cbs mutably so a malformed buffer errors
629+
// without holding the borrow.
630+
let attributes = decode_span_event_attributes(attrs_buf)?;
631+
let mut cbs = self.cbs.borrow_mut();
632+
let span = cbs
633+
.span_mut(span_id)
634+
.map_err(|e| JsValue::from_str(&e.to_string()))?;
635+
span.span_events.push(SpanEvent {
636+
time_unix_nano,
637+
name: name.into(),
638+
attributes,
639+
});
640+
Ok(())
641+
}
642+
643+
// Test/inspection helper: serialize the span's events to JSON via the same
644+
// serde `Serialize` impl libdatadog uses for the msgpack wire format, so
645+
// the `type`/`*_value` shape mirrors exactly what is sent to the agent
646+
// (String=0, Boolean=1, Integer=2, Double=3, Array=4).
647+
#[wasm_bindgen(js_name = "getSpanEventsJson")]
648+
pub fn get_span_events_json(&self, span_id: u64) -> Result<String, JsValue> {
649+
self.flush_change_queue()?;
650+
let cbs = self.cbs.borrow();
651+
let span = cbs
652+
.get_span(span_id)
653+
.map_err(|e| JsValue::from_str(&e.to_string()))?;
654+
serde_json::to_string(&span.span_events)
655+
.map_err(|e| JsValue::from_str(&format!("getSpanEventsJson: {e}")))
656+
}
657+
504658
// Trace-level attributes live on the Segment (keyed by segment_id, which
505659
// JS allocates and shares across spans in the same local trace).
506660
#[wasm_bindgen(js_name = "getTraceMetaAttr")]

crates/pipeline/src/trace_data.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
use libdd_trace_utils::span::TraceData;
2+
use serde::Serialize;
23

34
use crate::span_bytes::SpanBytesImpl;
45
use crate::span_string::SpanString;
56

6-
#[derive(Clone, Default, Debug, PartialEq)]
7+
// `Serialize` is derived only so the test helper `getSpanEventsJson` can
8+
// serialize `Vec<SpanEvent<WasmTraceData>>` (serde's derive on the generic
9+
// `SpanEvent<T>` requires `T: Serialize`). The unit struct carries no data.
10+
#[derive(Clone, Default, Debug, PartialEq, Serialize)]
711
pub struct WasmTraceData;
812

913
impl TraceData for WasmTraceData {

test/pipeline.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@ class Span {
9090
return this.nativeSpans.state.getMetaStruct(this.spanIdBig, key)
9191
}
9292

93+
addSpanEvent (name, timeUnixNano, attributes = {}) {
94+
this.nativeSpans.state.addSpanEvent(
95+
this.spanIdBig,
96+
name,
97+
BigInt(timeUnixNano),
98+
encodeSpanEventAttrs(attributes)
99+
)
100+
return this
101+
}
102+
103+
getSpanEvents () {
104+
return JSON.parse(this.nativeSpans.state.getSpanEventsJson(this.spanIdBig))
105+
}
106+
93107
finish () {
94108
this.duration = BigInt(Date.now()) * 1000000n - this._startTime
95109
return this
@@ -296,6 +310,41 @@ class NativeSpansInterface {
296310
}
297311
}
298312

313+
// Build the flat span-event attribute buffer consumed by the Rust decoder
314+
// (`decode_span_event_attributes` in crates/pipeline/src/lib.rs). This mirrors
315+
// what dd-trace-js's `addSpanEvent` wrapper produces. Tags: String=0,
316+
// Boolean=1, Integer=2, Double=3, Array=4 (matching libdatadog's
317+
// AttributeArrayValue discriminants).
318+
function encodeSpanEventAttrs (attributes) {
319+
const enc = new TextEncoder()
320+
const chunks = []
321+
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n >>> 0, 0); return b }
322+
const i64 = (n) => { const b = Buffer.alloc(8); b.writeBigInt64LE(BigInt(n), 0); return b }
323+
const f64 = (n) => { const b = Buffer.alloc(8); b.writeDoubleLE(n, 0); return b }
324+
const str = (s) => { const sb = Buffer.from(enc.encode(s)); return Buffer.concat([u32(sb.length), sb]) }
325+
// Returns `[tag][value]` — used both for single values and array items.
326+
const scalar = (v) => {
327+
if (typeof v === 'string') return Buffer.concat([Buffer.from([0]), str(v)])
328+
if (typeof v === 'boolean') return Buffer.concat([Buffer.from([1]), Buffer.from([v ? 1 : 0])])
329+
if (typeof v === 'number') {
330+
return Number.isInteger(v)
331+
? Buffer.concat([Buffer.from([2]), i64(v)])
332+
: Buffer.concat([Buffer.from([3]), f64(v)])
333+
}
334+
throw new TypeError(`unsupported span-event attribute value: ${typeof v}`)
335+
}
336+
for (const [key, value] of Object.entries(attributes)) {
337+
chunks.push(str(key))
338+
if (Array.isArray(value)) {
339+
chunks.push(Buffer.from([4]), u32(value.length))
340+
for (const item of value) chunks.push(scalar(item))
341+
} else {
342+
chunks.push(scalar(value))
343+
}
344+
}
345+
return new Uint8Array(Buffer.concat(chunks))
346+
}
347+
299348
describe('pipeline', () => {
300349
let nativeSpans
301350

@@ -414,6 +463,93 @@ describe('pipeline', () => {
414463
})
415464
})
416465

466+
describe('span_events', () => {
467+
it('appends an event with no attributes', () => {
468+
const span = nativeSpans.createSpan()
469+
span.addSpanEvent('exception', 1727211691770716000n)
470+
471+
const events = span.getSpanEvents()
472+
assert.strictEqual(events.length, 1)
473+
assert.strictEqual(events[0].name, 'exception')
474+
assert.strictEqual(events[0].time_unix_nano, 1727211691770716000)
475+
// Empty attributes are skipped by libdatadog's serializer.
476+
assert.strictEqual(events[0].attributes, undefined)
477+
})
478+
479+
it('round-trips scalar attributes of every type with correct type tags', () => {
480+
const span = nativeSpans.createSpan()
481+
span.addSpanEvent('evt', 1000n, {
482+
s: 'hello',
483+
b: true,
484+
i: 42,
485+
d: 3.5
486+
})
487+
488+
const [event] = span.getSpanEvents()
489+
assert.strictEqual(event.name, 'evt')
490+
assert.strictEqual(event.time_unix_nano, 1000)
491+
// type tags: String=0, Boolean=1, Integer=2, Double=3
492+
assert.deepStrictEqual(event.attributes.s, { type: 0, string_value: 'hello' })
493+
assert.deepStrictEqual(event.attributes.b, { type: 1, bool_value: true })
494+
assert.deepStrictEqual(event.attributes.i, { type: 2, int_value: 42 })
495+
assert.deepStrictEqual(event.attributes.d, { type: 3, double_value: 3.5 })
496+
})
497+
498+
it('round-trips an array attribute (type 4) with typed items', () => {
499+
const span = nativeSpans.createSpan()
500+
span.addSpanEvent('evt', 1n, { tags: ['a', 'b'], nums: [1, 2, 3] })
501+
502+
const [event] = span.getSpanEvents()
503+
assert.deepStrictEqual(event.attributes.tags, {
504+
type: 4,
505+
array_value: { values: [{ type: 0, string_value: 'a' }, { type: 0, string_value: 'b' }] }
506+
})
507+
assert.deepStrictEqual(event.attributes.nums, {
508+
type: 4,
509+
array_value: { values: [{ type: 2, int_value: 1 }, { type: 2, int_value: 2 }, { type: 2, int_value: 3 }] }
510+
})
511+
})
512+
513+
it('appends multiple events in order', () => {
514+
const span = nativeSpans.createSpan()
515+
span.addSpanEvent('first', 1n)
516+
span.addSpanEvent('second', 2n, { k: 'v' })
517+
518+
const events = span.getSpanEvents()
519+
assert.strictEqual(events.length, 2)
520+
assert.strictEqual(events[0].name, 'first')
521+
assert.strictEqual(events[1].name, 'second')
522+
assert.deepStrictEqual(events[1].attributes.k, { type: 0, string_value: 'v' })
523+
})
524+
525+
it('returns an empty array for a span with no events', () => {
526+
const span = nativeSpans.createSpan()
527+
assert.deepStrictEqual(span.getSpanEvents(), [])
528+
})
529+
530+
it('rejects a truncated attribute buffer instead of panicking', () => {
531+
const span = nativeSpans.createSpan()
532+
// key_len=5 but no key bytes follow → bounded read must error.
533+
const bad = new Uint8Array([5, 0, 0, 0])
534+
assert.throws(
535+
() => span.nativeSpans.state.addSpanEvent(span.spanIdBig, 'evt', 1n, bad),
536+
/truncated span-event attribute buffer/
537+
)
538+
})
539+
540+
it('rejects an overflowing key_len without trapping (wasm32 usize)', () => {
541+
const span = nativeSpans.createSpan()
542+
// key_len = 0xFFFFFFFF: on wasm32 `idx + key_len` would wrap and slip
543+
// past the bound, trapping on the slice. The remaining-byte form must
544+
// reject it as a truncated buffer instead.
545+
const bad = new Uint8Array([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])
546+
assert.throws(
547+
() => span.nativeSpans.state.addSpanEvent(span.spanIdBig, 'evt', 1n, bad),
548+
/truncated span-event attribute buffer/
549+
)
550+
})
551+
})
552+
417553
describe('span timing', () => {
418554
it('should set and get start time', () => {
419555
const span = nativeSpans.createSpan()

0 commit comments

Comments
 (0)