Skip to main content

libdd_trace_utils/json_log_encoder/
mod.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! JSON "log exporter" trace encoder.
5//!
6//! Emits traces in the newline-delimited JSON format consumed by the Datadog
7//! Forwarder Lambda (the legacy serverless path used when no Datadog Agent /
8//! Lambda Extension is reachable). Each emitted line is a self-contained JSON
9//! document of the form:
10//!
11//! ```text
12//! {"traces":[[ {span}, {span}, ... ]]}\n
13//! ```
14//!
15//! Spans are greedily packed into size-bounded lines. A single span that alone
16//! exceeds the line cap is dropped (and counted in [`EncodeStats::spans_dropped`])
17//! rather than emitted as a truncated, unparseable line.
18//!
19//! See the cross-language specification for the wire contract and the
20//! Forwarder's `is_trace` detection requirements.
21
22mod span;
23
24use crate::span::v04::Span;
25use crate::span::TraceData;
26use span::LogSpan;
27use std::io::Write;
28
29/// Opening bytes of every emitted line: `{"traces":[[`.
30const TRACE_PREFIX: &[u8] = b"{\"traces\":[[";
31/// Closing bytes of every emitted line: `]]}` plus the terminating newline.
32const TRACE_SUFFIX: &[u8] = b"]]}\n";
33/// Fixed per-line overhead contributed by [`TRACE_PREFIX`] and [`TRACE_SUFFIX`].
34const TRACE_FORMAT_OVERHEAD: usize = TRACE_PREFIX.len() + TRACE_SUFFIX.len();
35
36/// Statistics reported by [`encode_traces`].
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct EncodeStats {
39    /// Number of spans successfully written to `out`.
40    pub spans_written: usize,
41    /// Number of spans dropped because a single span exceeded `max_line_size`.
42    pub spans_dropped: usize,
43}
44
45/// Encodes `traces` into newline-delimited JSON "log exporter" lines, writing
46/// them to `out`.
47///
48/// All spans across all input traces are flattened and greedily packed into
49/// lines no larger than `max_line_size` bytes (including the `{"traces":[[` /
50/// `]]}\n` framing). Each line contains a single inner trace array, matching the
51/// reference dd-trace-js exporter. A span whose own serialized size plus the
52/// framing overhead exceeds `max_line_size` is dropped (counted in
53/// [`EncodeStats::spans_dropped`]) and never split across lines.
54///
55/// The caller is responsible for flushing `out` (e.g. stdout) after this returns.
56///
57/// # Errors
58///
59/// Returns any [`std::io::Error`] produced while writing to `out`.
60///
61/// # Examples
62///
63/// ```
64/// use libdd_trace_utils::json_log_encoder::encode_traces;
65/// use libdd_trace_utils::span::v04::SpanSlice;
66///
67/// let span = SpanSlice {
68///     service: "my-fn".into(),
69///     name: "aws.lambda".into(),
70///     resource: "my-fn".into(),
71///     trace_id: 1,
72///     span_id: 2,
73///     ..Default::default()
74/// };
75/// let traces = vec![vec![span]];
76///
77/// let mut out = Vec::new();
78/// let stats = encode_traces(&traces, &mut out, 64 * 1024).unwrap();
79///
80/// assert_eq!(stats.spans_written, 1);
81/// assert!(out.ends_with(b"\n"));
82/// ```
83pub fn encode_traces<T: TraceData>(
84    traces: &[Vec<Span<T>>],
85    out: &mut impl Write,
86    max_line_size: usize,
87) -> std::io::Result<EncodeStats> {
88    let mut stats = EncodeStats::default();
89
90    // Reusable buffers: `span_buf` holds the JSON for the span currently being
91    // considered; `line` accumulates the complete current line. It is primed
92    // with `TRACE_PREFIX` so that a single `write_all` per line can emit
93    // prefix + spans + suffix in one syscall (see `flush_line`).
94    let mut span_buf: Vec<u8> = Vec::with_capacity(512);
95    let mut line: Vec<u8> = Vec::with_capacity(max_line_size.min(64 * 1024));
96    line.extend_from_slice(TRACE_PREFIX);
97    let mut line_span_count: usize = 0;
98
99    for trace in traces {
100        for span in trace {
101            span_buf.clear();
102            serde_json::to_writer(&mut span_buf, &LogSpan(span)).map_err(std::io::Error::other)?;
103            let span_len = span_buf.len();
104
105            // A span that cannot fit on a line by itself is dropped rather than
106            // emitted as a truncated, unparseable line.
107            if span_len + TRACE_FORMAT_OVERHEAD > max_line_size {
108                stats.spans_dropped += 1;
109                tracing::debug!(
110                    span_len,
111                    max_line_size,
112                    "Span too large to send to logs, dropping"
113                );
114                continue;
115            }
116
117            // Flush the current line if appending this span would overflow.
118            // `line` already contains `TRACE_PREFIX`; the emitted line will also
119            // gain `TRACE_SUFFIX`, so account for both here.
120            let comma = usize::from(line_span_count > 0);
121            if line_span_count > 0
122                && line.len() + comma + span_len + TRACE_SUFFIX.len() > max_line_size
123            {
124                flush_line(out, &mut line)?;
125                line_span_count = 0;
126            }
127
128            if line_span_count > 0 {
129                line.push(b',');
130            }
131            line.extend_from_slice(&span_buf);
132            line_span_count += 1;
133            stats.spans_written += 1;
134        }
135    }
136
137    if line_span_count > 0 {
138        flush_line(out, &mut line)?;
139    }
140
141    Ok(stats)
142}
143
144/// Writes one complete line (`{"traces":[[` + joined spans + `]]}\n`) in a single
145/// `write_all`, then resets the line buffer (re-primed with `TRACE_PREFIX`) for
146/// reuse.
147///
148/// `line` is expected to already contain `TRACE_PREFIX` followed by the
149/// comma-joined spans; this appends `TRACE_SUFFIX` to complete the line.
150fn flush_line(out: &mut impl Write, line: &mut Vec<u8>) -> std::io::Result<()> {
151    line.extend_from_slice(TRACE_SUFFIX);
152    out.write_all(line)?;
153    line.clear();
154    line.extend_from_slice(TRACE_PREFIX);
155    Ok(())
156}
157
158#[cfg(test)]
159// `SpanSlice` fields are `Cow<'a, str>` (SliceData::Text), so the `"literal".into()`
160// conversions below are genuine `&str -> Cow` conversions required to compile. clippy on
161// the CI target nonetheless reports them as `useless_conversion` to `&str` (a false positive
162// not reproduced on all hosts); allow it here rather than dropping the necessary `.into()`.
163#[allow(clippy::useless_conversion)]
164mod tests {
165    use super::*;
166    use crate::span::v04::SpanSlice;
167    use serde_json::Value;
168
169    const MAX: usize = 64 * 1024;
170
171    fn lines(out: &[u8]) -> Vec<String> {
172        String::from_utf8(out.to_vec())
173            .unwrap()
174            .lines()
175            .map(|s| s.to_string())
176            .collect()
177    }
178
179    #[test]
180    fn golden_known_span() {
181        let span = SpanSlice {
182            service: "my-fn".into(),
183            name: "aws.lambda".into(),
184            resource: "my-fn".into(),
185            r#type: "serverless".into(),
186            trace_id: 1,
187            span_id: 2,
188            parent_id: 0,
189            start: 1717200000000000000,
190            duration: 1500000,
191            error: 0,
192            meta: [("env".into(), "prod".into())].into_iter().collect(),
193            metrics: [("_sampling_priority_v1".into(), 1.0)]
194                .into_iter()
195                .collect(),
196            ..Default::default()
197        };
198        let mut out = Vec::new();
199        let stats = encode_traces(&[vec![span]], &mut out, MAX).unwrap();
200        assert_eq!(stats.spans_written, 1);
201        assert_eq!(stats.spans_dropped, 0);
202
203        let expected = concat!(
204            "{\"traces\":[[",
205            "{\"trace_id\":\"0000000000000001\",",
206            "\"span_id\":\"0000000000000002\",",
207            "\"parent_id\":\"0000000000000000\",",
208            "\"service\":\"my-fn\",",
209            "\"name\":\"aws.lambda\",",
210            "\"resource\":\"my-fn\",",
211            "\"type\":\"serverless\",",
212            "\"error\":0,",
213            "\"start\":1717200000000000000,",
214            "\"duration\":1500000,",
215            "\"meta\":{\"env\":\"prod\"},",
216            "\"metrics\":{\"_sampling_priority_v1\":1.0}",
217            "}]]}\n",
218        );
219        assert_eq!(String::from_utf8(out).unwrap(), expected);
220    }
221
222    #[test]
223    fn meta_struct_is_omitted() {
224        let span = SpanSlice {
225            trace_id: 1,
226            span_id: 2,
227            meta_struct: [("_dd.appsec.json".into(), [0x81u8, 0xa4].as_slice())]
228                .into_iter()
229                .collect(),
230            ..Default::default()
231        };
232        let mut out = Vec::new();
233        encode_traces(&[vec![span]], &mut out, MAX).unwrap();
234        let text = String::from_utf8(out).unwrap();
235        assert!(
236            !text.contains("meta_struct"),
237            "meta_struct must not be emitted: {text}"
238        );
239        // Still a valid, Forwarder-parseable line.
240        let v: Value = serde_json::from_str(text.trim_end()).unwrap();
241        assert!(v["traces"][0][0]["trace_id"].is_string());
242    }
243
244    #[test]
245    fn hex_high_bits_and_root_parent() {
246        // trace_id with the high 64 bits set => 32 hex chars.
247        let trace_id: u128 = (0xABCDu128 << 64) | 0x1;
248        let span = SpanSlice {
249            trace_id,
250            span_id: 0xFF,
251            parent_id: 0,
252            ..Default::default()
253        };
254        let mut out = Vec::new();
255        encode_traces(&[vec![span]], &mut out, MAX).unwrap();
256        let text = String::from_utf8(out).unwrap();
257        assert!(
258            text.contains("\"trace_id\":\"000000000000abcd0000000000000001\""),
259            "got: {text}"
260        );
261        assert!(text.contains("\"span_id\":\"00000000000000ff\""));
262        assert!(text.contains("\"parent_id\":\"0000000000000000\""));
263    }
264
265    #[test]
266    fn error_is_integer() {
267        let span = SpanSlice {
268            error: 1,
269            ..Default::default()
270        };
271        let mut out = Vec::new();
272        encode_traces(&[vec![span]], &mut out, MAX).unwrap();
273        let text = String::from_utf8(out).unwrap();
274        assert!(text.contains("\"error\":1,"), "got: {text}");
275    }
276
277    #[test]
278    fn empty_maps_and_type_omitted() {
279        let span = SpanSlice {
280            name: "op".into(),
281            ..Default::default()
282        };
283        let mut out = Vec::new();
284        encode_traces(&[vec![span]], &mut out, MAX).unwrap();
285        let text = String::from_utf8(out).unwrap();
286        assert!(!text.contains("\"meta\""), "got: {text}");
287        assert!(!text.contains("\"metrics\""));
288        assert!(!text.contains("\"meta_struct\""));
289        assert!(!text.contains("\"span_links\""));
290        assert!(!text.contains("\"span_events\""));
291        assert!(!text.contains("\"type\""));
292    }
293
294    #[test]
295    fn string_escaping() {
296        let span = SpanSlice {
297            name: "say \"hi\"\n".into(),
298            ..Default::default()
299        };
300        let mut out = Vec::new();
301        encode_traces(&[vec![span]], &mut out, MAX).unwrap();
302        // Each line must remain valid JSON despite the embedded quote/newline.
303        for line in lines(&out) {
304            let parsed: Value = serde_json::from_str(&line).unwrap();
305            assert_eq!(
306                parsed["traces"][0][0]["name"].as_str().unwrap(),
307                "say \"hi\"\n"
308            );
309        }
310    }
311
312    #[test]
313    fn size_cap_batches_into_multiple_lines() {
314        // Build several spans that individually fit but together exceed a small cap.
315        let make = |id: u64| SpanSlice {
316            name: "x".into(),
317            span_id: id,
318            ..Default::default()
319        };
320        let trace: Vec<SpanSlice> = (1..=6).map(make).collect();
321
322        // Determine one span's serialized length to size the cap so ~2 fit per line.
323        let mut one = Vec::new();
324        encode_traces(&[vec![make(1)]], &mut one, MAX).unwrap();
325        // `one` = prefix + span + suffix. The bare span length:
326        let span_len = one.len() - TRACE_FORMAT_OVERHEAD;
327        // Cap fits two spans + a comma but not three.
328        let cap = TRACE_FORMAT_OVERHEAD + span_len * 2 + 1;
329
330        let mut out = Vec::new();
331        let stats = encode_traces(&[trace], &mut out, cap).unwrap();
332        assert_eq!(stats.spans_written, 6);
333        assert_eq!(stats.spans_dropped, 0);
334
335        let emitted = lines(&out);
336        assert_eq!(emitted.len(), 3, "expected 3 lines, got {emitted:?}");
337        for line in &emitted {
338            assert!(line.len() <= cap, "line over cap: {} > {cap}", line.len());
339            let parsed: Value = serde_json::from_str(line).unwrap();
340            assert_eq!(parsed["traces"][0].as_array().unwrap().len(), 2);
341        }
342    }
343
344    #[test]
345    fn oversize_single_span_dropped() {
346        let big = "a".repeat(10_000);
347        let span = SpanSlice {
348            name: big.as_str().into(),
349            span_id: 1,
350            ..Default::default()
351        };
352        let small = SpanSlice {
353            name: "ok".into(),
354            span_id: 2,
355            ..Default::default()
356        };
357        let mut out = Vec::new();
358        // Cap large enough for `small` but far too small for `big`.
359        let stats = encode_traces(&[vec![span, small]], &mut out, 1024).unwrap();
360        assert_eq!(stats.spans_dropped, 1);
361        assert_eq!(stats.spans_written, 1);
362
363        let emitted = lines(&out);
364        assert_eq!(emitted.len(), 1);
365        let parsed: Value = serde_json::from_str(&emitted[0]).unwrap();
366        assert_eq!(parsed["traces"][0][0]["name"].as_str().unwrap(), "ok");
367    }
368
369    #[test]
370    fn metric_non_finite_serializes_null() {
371        // serde_json renders non-finite f64 as JSON null; the line must remain
372        // parseable and the metric values must be null (not NaN/Infinity tokens).
373        let span = SpanSlice {
374            span_id: 1,
375            metrics: [("nan".into(), f64::NAN), ("inf".into(), f64::INFINITY)]
376                .into_iter()
377                .collect(),
378            ..Default::default()
379        };
380        let mut out = Vec::new();
381        encode_traces(&[vec![span]], &mut out, MAX).unwrap();
382        let emitted = lines(&out);
383        assert_eq!(emitted.len(), 1);
384        let parsed: Value = serde_json::from_str(&emitted[0]).unwrap();
385        let metrics = &parsed["traces"][0][0]["metrics"];
386        assert!(metrics["nan"].is_null(), "got: {metrics}");
387        assert!(metrics["inf"].is_null(), "got: {metrics}");
388    }
389
390    #[test]
391    fn multi_trace_flattened_into_one_line() {
392        // Two input traces, both well under the cap, flatten into a single line
393        // whose single inner trace array holds both spans.
394        let span_a = SpanSlice {
395            name: "a".into(),
396            span_id: 1,
397            ..Default::default()
398        };
399        let span_b = SpanSlice {
400            name: "b".into(),
401            span_id: 2,
402            ..Default::default()
403        };
404        let mut out = Vec::new();
405        let stats = encode_traces(&[vec![span_a], vec![span_b]], &mut out, MAX).unwrap();
406        assert_eq!(stats.spans_written, 2);
407        let emitted = lines(&out);
408        assert_eq!(emitted.len(), 1, "expected one line, got {emitted:?}");
409        let parsed: Value = serde_json::from_str(&emitted[0]).unwrap();
410        let inner = parsed["traces"][0].as_array().unwrap();
411        assert_eq!(inner.len(), 2);
412        assert_eq!(parsed["traces"].as_array().unwrap().len(), 1);
413    }
414
415    #[test]
416    fn span_links_and_events_emitted_when_present() {
417        use crate::span::v04::{SpanEvent, SpanLink};
418
419        let span = SpanSlice {
420            span_id: 1,
421            span_links: vec![SpanLink {
422                trace_id: 7,
423                span_id: 8,
424                ..Default::default()
425            }],
426            span_events: vec![SpanEvent {
427                time_unix_nano: 123,
428                name: "evt".into(),
429                ..Default::default()
430            }],
431            ..Default::default()
432        };
433        let mut out = Vec::new();
434        encode_traces(&[vec![span]], &mut out, MAX).unwrap();
435        let emitted = lines(&out);
436        assert_eq!(emitted.len(), 1);
437        let parsed: Value = serde_json::from_str(&emitted[0]).unwrap();
438        let span_json = &parsed["traces"][0][0];
439        // Lock the inner wire shape (field names/values), not just presence.
440        assert_eq!(span_json["span_links"][0]["trace_id"], 7);
441        assert_eq!(span_json["span_links"][0]["span_id"], 8);
442        assert_eq!(span_json["span_events"][0]["name"], "evt");
443        assert_eq!(span_json["span_events"][0]["time_unix_nano"], 123);
444    }
445
446    #[test]
447    fn span_link_flags_sentinel_bit_masked() {
448        // The internal "explicitly set" sentinel (bit 31) must never appear in the emitted
449        // JSON log, which downstream consumers treat as the real W3C trace-flags value.
450        // Covers both sentinel states: kept (0x8000_0001) and explicitly dropped (0x8000_0000).
451        use crate::span::v04::SpanLink;
452
453        fn encoded_flags(flags: u32) -> Value {
454            let span = SpanSlice {
455                span_id: 1,
456                span_links: vec![SpanLink {
457                    trace_id: 7,
458                    span_id: 8,
459                    flags,
460                    ..Default::default()
461                }],
462                ..Default::default()
463            };
464            let mut out = Vec::new();
465            encode_traces(&[vec![span]], &mut out, MAX).unwrap();
466            let emitted = lines(&out);
467            let parsed: Value = serde_json::from_str(&emitted[0]).unwrap();
468            parsed["traces"][0][0]["span_links"][0]["flags"].clone()
469        }
470
471        assert_eq!(
472            encoded_flags(0x8000_0001),
473            1,
474            "the sentinel bit must not leak into the JSON log"
475        );
476        assert_eq!(
477            encoded_flags(0x8000_0000),
478            0,
479            "an explicit drop decision must still emit flags: 0, not omit the field"
480        );
481    }
482
483    #[test]
484    fn empty_inner_trace_writes_nothing() {
485        // One trace containing zero spans: nothing is emitted and no spans counted.
486        let traces: Vec<Vec<SpanSlice>> = vec![vec![]];
487        let mut out = Vec::new();
488        let stats = encode_traces(&traces, &mut out, MAX).unwrap();
489        assert!(out.is_empty());
490        assert_eq!(stats.spans_written, 0);
491        assert_eq!(stats.spans_dropped, 0);
492    }
493
494    #[test]
495    fn empty_input_writes_nothing() {
496        let traces: Vec<Vec<SpanSlice>> = vec![];
497        let mut out = Vec::new();
498        let stats = encode_traces(&traces, &mut out, MAX).unwrap();
499        assert_eq!(stats, EncodeStats::default());
500        assert!(out.is_empty());
501    }
502
503    // Mirrors the Datadog Forwarder `is_trace` detection contract: every line
504    // parses as JSON, top-level `traces` is a non-empty array whose `[0]` is a
505    // non-empty array whose `[0]` has a non-null string `trace_id`, and every
506    // line ends with `\n`.
507    #[test]
508    fn forwarder_is_trace_contract() {
509        let trace: Vec<SpanSlice> = (1u64..=5)
510            .map(|i| SpanSlice {
511                service: "svc".into(),
512                name: "op".into(),
513                trace_id: u128::from(i),
514                span_id: i + 100,
515                ..Default::default()
516            })
517            .collect();
518
519        let mut out = Vec::new();
520        encode_traces(&[trace], &mut out, 200).unwrap();
521
522        let text = String::from_utf8(out).unwrap();
523        assert!(!text.is_empty());
524        // Every line is newline-terminated.
525        for chunk in text.split_inclusive('\n') {
526            assert!(chunk.ends_with('\n'), "line not newline-terminated");
527            let line = chunk.trim_end_matches('\n');
528            let parsed: Value = serde_json::from_str(line).unwrap();
529
530            let traces = parsed.get("traces").and_then(Value::as_array).unwrap();
531            assert!(!traces.is_empty());
532            let first = traces[0].as_array().unwrap();
533            assert!(!first.is_empty());
534            let trace_id = first[0].get("trace_id").unwrap();
535            assert!(trace_id.is_string());
536            assert!(!trace_id.as_str().unwrap().is_empty());
537        }
538    }
539}