Skip to main content

log_io/format/
json.rs

1//! JSON output format.
2//!
3//! Emits one JSON object per record, terminated by a newline (JSON
4//! Lines). Key order is stable so consumers can rely on prefixes:
5//! `timestamp` (if set), `level`, `target`, `message`, `file` and
6//! `line` (if set), then context fields, then per-call fields.
7
8use core::fmt;
9use core::fmt::Write as _;
10
11use super::Format;
12use super::escape::{JsonEscaper, RESERVED_PREFIX, is_reserved_key, write_json_escaped};
13use crate::record::Record;
14use crate::value::{Value, write_hex};
15
16/// JSON formatter.
17///
18/// The default configuration emits compact JSON Lines. Every string is
19/// escaped so the output is valid JSON for any input, and the escaping
20/// also covers DEL, C1 controls, U+2028/U+2029 and bidi controls so a
21/// record stays on one line even for tools that treat those as line
22/// breaks. User keys named `timestamp`, `level`, `target`, `message`,
23/// `file` or `line` are written as `fields.<name>`.
24///
25/// # Examples
26///
27#[cfg_attr(all(feature = "json"), doc = "```")]
28#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
29/// use log_io::format::{Format, JsonFormat};
30/// use log_io::{Field, Level, Metadata, Record, Value};
31///
32/// let fmt = JsonFormat::new();
33/// let fields = [Field::new("port", 8080_u16), Field::new("level", "spoofed")];
34/// let record = Record::new(Metadata::new(Level::Info, "app"), "started", &fields);
35///
36/// let mut out = String::new();
37/// fmt.write_record(&record, &mut out)?;
38/// assert_eq!(
39///     out,
40///     "{\"level\":\"info\",\"target\":\"app\",\"message\":\"started\",\
41///       \"port\":8080,\"fields.level\":\"spoofed\"}\n"
42/// );
43/// # Ok::<(), core::fmt::Error>(())
44/// ```
45#[derive(Debug, Clone, Copy, Default)]
46pub struct JsonFormat {
47    pretty: bool,
48    timestamp_rfc3339: bool,
49}
50
51impl JsonFormat {
52    /// New compact JSON formatter. Equivalent to `Self::default()`.
53    #[must_use]
54    pub const fn new() -> Self {
55        Self {
56            pretty: false,
57            timestamp_rfc3339: false,
58        }
59    }
60
61    /// Toggle pretty (multi-line, indented) output. Intended for
62    /// reading by eye; line-oriented ingestion needs the compact form.
63    #[must_use]
64    pub const fn pretty(mut self, enabled: bool) -> Self {
65        self.pretty = enabled;
66        self
67    }
68
69    /// Emit timestamps as RFC 3339 strings instead of Unix nanoseconds.
70    ///
71    /// Enabled: `"timestamp":"2023-11-14T22:13:20.000000000Z"`.
72    /// Disabled (default): `"timestamp":1700000000000000000`. Note that
73    /// nanosecond integers exceed 2^53, so consumers that parse JSON
74    /// numbers as doubles lose sub-microsecond precision; prefer the
75    /// RFC 3339 form for such consumers.
76    #[must_use]
77    pub const fn timestamp_rfc3339(mut self, enabled: bool) -> Self {
78        self.timestamp_rfc3339 = enabled;
79        self
80    }
81
82    // Kept out of line: it runs once per record, and inlining it into
83    // every sink's write path made pipeline timings depend on unrelated
84    // code layout (measured swings of 60% between builds).
85    #[inline(never)]
86    fn write_impl<W: fmt::Write + ?Sized>(&self, record: &Record<'_>, w: &mut W) -> fmt::Result {
87        let p = if self.pretty { &PRETTY } else { &COMPACT };
88        w.write_str(p.open)?;
89        if let Some(ts) = record.metadata.timestamp_unix_nanos {
90            w.write_str(p.timestamp)?;
91            if self.timestamp_rfc3339 {
92                w.write_char('"')?;
93                super::rfc3339::write_rfc3339_nanos(ts, w)?;
94                w.write_char('"')?;
95            } else {
96                write!(w, "{ts}")?;
97            }
98            w.write_str(p.next)?;
99        }
100        w.write_str(p.level)?;
101        w.write_str(record.metadata.level.as_str())?;
102        w.write_str(p.target)?;
103        write_string(w, record.metadata.target)?;
104        w.write_str(p.message)?;
105        write_string(w, record.message)?;
106        if let Some(file) = record.metadata.file {
107            w.write_str(p.file)?;
108            write_string(w, file)?;
109        }
110        if let Some(line) = record.metadata.line {
111            w.write_str(p.line)?;
112            write!(w, "{line}")?;
113        }
114        for field in record.all_fields() {
115            w.write_str(p.field)?;
116            if is_reserved_key(field.key) {
117                w.write_str(RESERVED_PREFIX)?;
118            }
119            write_json_escaped(w, field.key)?;
120            w.write_str(p.key_end)?;
121            write_value(w, field.value)?;
122        }
123        w.write_str(p.close)
124    }
125}
126
127/// Punctuation for one layout. The metadata keys always appear in the
128/// same order, so every separator is a fixed string and each one is a
129/// single write.
130struct Punctuation {
131    open: &'static str,
132    timestamp: &'static str,
133    next: &'static str,
134    level: &'static str,
135    target: &'static str,
136    message: &'static str,
137    file: &'static str,
138    line: &'static str,
139    field: &'static str,
140    key_end: &'static str,
141    close: &'static str,
142}
143
144const COMPACT: Punctuation = Punctuation {
145    open: "{",
146    timestamp: "\"timestamp\":",
147    next: ",",
148    level: "\"level\":\"",
149    target: "\",\"target\":",
150    message: ",\"message\":",
151    file: ",\"file\":",
152    line: ",\"line\":",
153    field: ",\"",
154    key_end: "\":",
155    close: "}
156",
157};
158
159const PRETTY: Punctuation = Punctuation {
160    open: "{
161  ",
162    timestamp: "\"timestamp\": ",
163    next: ",
164  ",
165    level: "\"level\": \"",
166    target: "\",
167  \"target\": ",
168    message: ",
169  \"message\": ",
170    file: ",
171  \"file\": ",
172    line: ",
173  \"line\": ",
174    field: ",
175  \"",
176    key_end: "\": ",
177    close: "
178}
179",
180};
181
182impl Format for JsonFormat {
183    fn write_record(&self, record: &Record<'_>, writer: &mut dyn fmt::Write) -> fmt::Result {
184        self.write_impl(record, writer)
185    }
186
187    #[inline]
188    fn write_record_generic<W: fmt::Write>(
189        &self,
190        record: &Record<'_>,
191        writer: &mut W,
192    ) -> fmt::Result {
193        self.write_impl(record, writer)
194    }
195}
196
197#[inline]
198fn write_string<W: fmt::Write + ?Sized>(w: &mut W, s: &str) -> fmt::Result {
199    w.write_char('"')?;
200    write_json_escaped(w, s)?;
201    w.write_char('"')
202}
203
204fn write_value<W: fmt::Write + ?Sized>(w: &mut W, v: Value<'_>) -> fmt::Result {
205    match v {
206        Value::Null => w.write_str("null"),
207        Value::Bool(true) => w.write_str("true"),
208        Value::Bool(false) => w.write_str("false"),
209        Value::I64(n) => write!(w, "{n}"),
210        Value::U64(n) => write!(w, "{n}"),
211        Value::I128(n) => write!(w, "{n}"),
212        Value::U128(n) => write!(w, "{n}"),
213        // `{:?}` always round-trips and always includes `.` or an
214        // exponent, so `1.0` stays a float for schema-inferring
215        // consumers and huge or tiny values stay short.
216        Value::F64(n) if n.is_finite() => write!(w, "{n:?}"),
217        Value::F64(_) => w.write_str("null"),
218        Value::Str(s) => write_string(w, s),
219        Value::Char(c) => {
220            let mut buf = [0_u8; 4];
221            write_string(w, c.encode_utf8(&mut buf))
222        }
223        Value::Bytes(b) => {
224            w.write_char('"')?;
225            write_hex(w, b)?;
226            w.write_char('"')
227        }
228        lazy @ (Value::Display(_) | Value::Debug(_) | Value::Error(_)) => {
229            w.write_char('"')?;
230            write!(JsonEscaper(&mut *w), "{lazy}")?;
231            w.write_char('"')
232        }
233    }
234}
235
236#[cfg(all(test, feature = "std"))]
237mod tests {
238    use super::*;
239    use crate::Level;
240    use crate::record::{Field, Metadata};
241
242    fn fmt_one(json: JsonFormat, record: &Record<'_>) -> String {
243        let mut s = String::new();
244        json.write_record_generic(record, &mut s).unwrap();
245        let mut d = String::new();
246        json.write_record(record, &mut d).unwrap();
247        assert_eq!(s, d);
248        s
249    }
250
251    fn field_json(v: Value<'_>) -> String {
252        let fields = [Field::new("v", v)];
253        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
254        let out = fmt_one(JsonFormat::new(), &record);
255        let start = out.find("\"v\":").unwrap() + 4;
256        out[start..out.len() - 2].to_string()
257    }
258
259    #[test]
260    fn test_json_compact_object_with_trailing_newline() {
261        let record = Record::new(Metadata::new(Level::Info, "app"), "hello", &[]);
262        assert_eq!(
263            fmt_one(JsonFormat::new(), &record),
264            "{\"level\":\"info\",\"target\":\"app\",\"message\":\"hello\"}\n"
265        );
266    }
267
268    #[test]
269    fn test_json_metadata_order_is_stable() {
270        let meta = Metadata::new(Level::Warn, "a")
271            .with_location("f.rs", 7)
272            .with_timestamp(5);
273        let record = Record::new(meta, "m", &[]);
274        assert_eq!(
275            fmt_one(JsonFormat::new(), &record),
276            "{\"timestamp\":5,\"level\":\"warn\",\"target\":\"a\",\"message\":\"m\",\
277             \"file\":\"f.rs\",\"line\":7}\n"
278        );
279    }
280
281    #[test]
282    fn test_json_context_before_fields() {
283        let ctx = [Field::new("trace_id", "abc")];
284        let fields = [Field::new("port", 80_u32)];
285        let record = Record::with_context(Metadata::new(Level::Info, "app"), "msg", &fields, &ctx);
286        let out = fmt_one(JsonFormat::new(), &record);
287        assert!(out.find("\"trace_id\"").unwrap() < out.find("\"port\"").unwrap());
288    }
289
290    #[test]
291    fn test_json_values_render_per_variant() {
292        assert_eq!(field_json(Value::Null), "null");
293        assert_eq!(field_json(Value::Bool(true)), "true");
294        assert_eq!(field_json(Value::Bool(false)), "false");
295        assert_eq!(field_json(Value::I64(-3)), "-3");
296        assert_eq!(field_json(Value::U64(3)), "3");
297        assert_eq!(field_json(Value::I128(i128::MIN)), i128::MIN.to_string());
298        assert_eq!(field_json(Value::U128(u128::MAX)), u128::MAX.to_string());
299        assert_eq!(field_json(Value::F64(1.0)), "1.0");
300        assert_eq!(field_json(Value::F64(1.5)), "1.5");
301        assert_eq!(field_json(Value::F64(1e300)), "1e300");
302        assert_eq!(field_json(Value::F64(f64::NAN)), "null");
303        assert_eq!(field_json(Value::F64(f64::NEG_INFINITY)), "null");
304        assert_eq!(field_json(Value::Str("a\"b\nc")), "\"a\\\"b\\nc\"");
305        assert_eq!(field_json(Value::Char('\n')), "\"\\n\"");
306        assert_eq!(field_json(Value::Char('"')), "\"\\\"\"");
307        assert_eq!(field_json(Value::Bytes(&[0xde, 0xad])), "\"dead\"");
308        assert_eq!(field_json(Value::Display(&"x\ty")), "\"x\\ty\"");
309        assert_eq!(field_json(Value::Debug(&"q")), "\"\\\"q\\\"\"");
310        let e = std::io::Error::other("disk \"full\"");
311        assert_eq!(field_json(Value::Error(&e)), "\"disk \\\"full\\\"\"");
312    }
313
314    #[test]
315    fn test_json_reserved_keys_are_prefixed() {
316        let fields = [
317            Field::new("level", "fake"),
318            Field::new("message", "spoofed"),
319            Field::new("timestamp", 0_u32),
320            Field::new("target", "x"),
321            Field::new("file", "y"),
322            Field::new("line", 1_u32),
323            Field::new("levels", "not reserved"),
324        ];
325        let record = Record::new(Metadata::new(Level::Error, "t"), "real", &fields);
326        let out = fmt_one(JsonFormat::new(), &record);
327        assert_eq!(out.matches("\"level\":").count(), 1, "{out}");
328        assert_eq!(out.matches("\"message\":").count(), 1, "{out}");
329        for k in ["level", "message", "timestamp", "target", "file", "line"] {
330            assert!(out.contains(&format!("\"fields.{k}\":")), "{k}: {out}");
331        }
332        assert!(out.contains("\"levels\":"), "{out}");
333    }
334
335    #[test]
336    fn test_json_keys_are_escaped() {
337        let fields = [Field::new("we\"ird\n", 1_u32)];
338        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
339        let out = fmt_one(JsonFormat::new(), &record);
340        assert!(out.contains("\"we\\\"ird\\n\":1"), "{out}");
341    }
342
343    #[test]
344    fn test_json_timestamp_forms() {
345        let meta = Metadata::new(Level::Info, "t").with_timestamp(1_700_000_000_000_000_000);
346        let record = Record::new(meta, "x", &[]);
347        assert!(fmt_one(JsonFormat::new(), &record).contains("\"timestamp\":1700000000000000000"));
348        assert!(
349            fmt_one(JsonFormat::new().timestamp_rfc3339(true), &record)
350                .contains("\"timestamp\":\"2023-11-14T22:13:20.000000000Z\"")
351        );
352    }
353
354    #[test]
355    fn test_json_pretty_indents_and_breaks_lines() {
356        let fields = [Field::new("port", 80_u32)];
357        let record = Record::new(Metadata::new(Level::Info, "t"), "x", &fields);
358        let out = fmt_one(JsonFormat::new().pretty(true), &record);
359        assert_eq!(
360            out,
361            "{\n  \"level\": \"info\",\n  \"target\": \"t\",\n  \"message\": \"x\",\n  \"port\": 80\n}\n"
362        );
363    }
364}