Skip to main content

log_io/format/
logfmt.rs

1//! logfmt output format.
2//!
3//! Emits `key=value` pairs separated by single spaces and terminated by
4//! a newline. Values that are empty or contain whitespace, `=`, `"`,
5//! `\` or a control character are double-quoted, with JSON-compatible
6//! escapes inside the quotes (`\"`, `\\`, `\n`, `\u001b`, ...), which
7//! is what common logfmt decoders accept. Keys are never quoted:
8//! characters that would break parsing are replaced with `_`.
9
10use core::fmt;
11use core::fmt::Write as _;
12
13use super::Format;
14use super::escape::{
15    JsonEscaper, RESERVED_PREFIX, is_reserved_key, render_once, value_needs_quotes,
16    write_json_escaped, write_sanitized_key,
17};
18use crate::record::Record;
19use crate::value::{Value, write_f64, write_hex};
20
21/// logfmt formatter. See the module docs for the wire format.
22///
23/// User keys named `timestamp`, `level`, `target`, `message`, `file`
24/// or `line` are written as `fields.<name>`.
25///
26/// # Examples
27///
28#[cfg_attr(all(feature = "logfmt"), doc = "```")]
29#[cfg_attr(not(all(feature = "logfmt")), doc = "```ignore")]
30/// use log_io::format::{Format, LogfmtFormat};
31/// use log_io::{Field, Level, Metadata, Record};
32///
33/// let fields = [Field::new("port", 8080_u32), Field::new("path", "/a b")];
34/// let record = Record::new(Metadata::new(Level::Info, "app"), "ok", &fields);
35///
36/// let mut out = String::new();
37/// LogfmtFormat::new().write_record(&record, &mut out)?;
38/// assert_eq!(out, "level=info target=app message=ok port=8080 path=\"/a b\"\n");
39/// # Ok::<(), core::fmt::Error>(())
40/// ```
41#[derive(Debug, Clone, Copy, Default)]
42pub struct LogfmtFormat {
43    timestamp_rfc3339: bool,
44}
45
46impl LogfmtFormat {
47    /// New logfmt formatter.
48    #[must_use]
49    pub const fn new() -> Self {
50        Self {
51            timestamp_rfc3339: false,
52        }
53    }
54
55    /// Emit timestamps as RFC 3339 (`timestamp=2023-11-14T22:13:20.000000000Z`)
56    /// instead of Unix nanoseconds (the default).
57    #[must_use]
58    pub const fn timestamp_rfc3339(mut self, enabled: bool) -> Self {
59        self.timestamp_rfc3339 = enabled;
60        self
61    }
62
63    // Kept out of line: it runs once per record, and inlining it into
64    // every sink's write path made pipeline timings depend on unrelated
65    // code layout (measured swings of 60% between builds).
66    #[inline(never)]
67    fn write_impl<W: fmt::Write + ?Sized>(&self, record: &Record<'_>, w: &mut W) -> fmt::Result {
68        if let Some(ts) = record.metadata.timestamp_unix_nanos {
69            w.write_str("timestamp=")?;
70            if self.timestamp_rfc3339 {
71                super::rfc3339::write_rfc3339_nanos(ts, w)?;
72            } else {
73                write!(w, "{ts}")?;
74            }
75            w.write_char(' ')?;
76        }
77        w.write_str("level=")?;
78        w.write_str(record.metadata.level.as_str())?;
79        w.write_str(" target=")?;
80        write_str_value(w, record.metadata.target)?;
81        w.write_str(" message=")?;
82        write_str_value(w, record.message)?;
83        if let Some(file) = record.metadata.file {
84            w.write_str(" file=")?;
85            write_str_value(w, file)?;
86        }
87        if let Some(line) = record.metadata.line {
88            write!(w, " line={line}")?;
89        }
90        for field in record.all_fields() {
91            w.write_char(' ')?;
92            if is_reserved_key(field.key) {
93                w.write_str(RESERVED_PREFIX)?;
94            }
95            write_sanitized_key(w, field.key)?;
96            w.write_char('=')?;
97            write_value(w, field.value)?;
98        }
99        w.write_char('\n')
100    }
101}
102
103impl Format for LogfmtFormat {
104    fn write_record(&self, record: &Record<'_>, writer: &mut dyn fmt::Write) -> fmt::Result {
105        self.write_impl(record, writer)
106    }
107
108    #[inline]
109    fn write_record_generic<W: fmt::Write>(
110        &self,
111        record: &Record<'_>,
112        writer: &mut W,
113    ) -> fmt::Result {
114        self.write_impl(record, writer)
115    }
116}
117
118#[inline]
119fn write_str_value<W: fmt::Write + ?Sized>(w: &mut W, s: &str) -> fmt::Result {
120    if value_needs_quotes(s) {
121        w.write_char('"')?;
122        write_json_escaped(w, s)?;
123        w.write_char('"')
124    } else {
125        w.write_str(s)
126    }
127}
128
129fn write_value<W: fmt::Write + ?Sized>(w: &mut W, v: Value<'_>) -> fmt::Result {
130    match v {
131        Value::Null => Ok(()),
132        Value::Bool(true) => w.write_str("true"),
133        Value::Bool(false) => w.write_str("false"),
134        Value::I64(n) => write!(w, "{n}"),
135        Value::U64(n) => write!(w, "{n}"),
136        Value::I128(n) => write!(w, "{n}"),
137        Value::U128(n) => write!(w, "{n}"),
138        Value::F64(n) => write_f64(w, n),
139        Value::Str(s) => write_str_value(w, s),
140        Value::Char(c) => {
141            let mut buf = [0_u8; 4];
142            write_str_value(w, c.encode_utf8(&mut buf))
143        }
144        Value::Bytes([]) => w.write_str("\"\""),
145        Value::Bytes(b) => write_hex(w, b),
146        lazy @ (Value::Display(_) | Value::Debug(_) | Value::Error(_)) => {
147            // Rendered once: the quoting decision and the output come
148            // from the same text, so a value that renders differently
149            // each time cannot slip unescaped text through.
150            render_once(&lazy, |text| match text {
151                Some(text) => write_str_value(w, text),
152                // Too long for the no-std stack buffer: always quoted,
153                // streamed through the escaper.
154                None => {
155                    w.write_char('"')?;
156                    write!(JsonEscaper(&mut *w), "{lazy}")?;
157                    w.write_char('"')
158                }
159            })
160        }
161    }
162}
163
164#[cfg(all(test, feature = "std"))]
165mod tests {
166    use super::*;
167    use crate::Level;
168    use crate::record::{Field, Metadata};
169
170    fn fmt_one(fmt: LogfmtFormat, record: &Record<'_>) -> String {
171        let mut s = String::new();
172        fmt.write_record_generic(record, &mut s).unwrap();
173        let mut d = String::new();
174        fmt.write_record(record, &mut d).unwrap();
175        assert_eq!(s, d);
176        s
177    }
178
179    fn field_logfmt(v: Value<'_>) -> String {
180        let fields = [Field::new("v", v)];
181        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
182        let out = fmt_one(LogfmtFormat::new(), &record);
183        let start = out.find(" v=").unwrap() + 3;
184        out[start..out.len() - 1].to_string()
185    }
186
187    /// Renders clean text the first time and an injection the next.
188    struct Changing(core::cell::Cell<u32>);
189
190    impl core::fmt::Display for Changing {
191        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
192            let n = self.0.get();
193            self.0.set(n + 1);
194            if n == 0 {
195                f.write_str("clean")
196            } else {
197                f.write_str("clean\nlevel=error message=forged\u{1b}[2J")
198            }
199        }
200    }
201
202    // Review F-1 (2.1.0): a lazy value was rendered once to decide on
203    // quoting and again to write; when the first render looked clean
204    // the second was written unescaped, so a value that changed between
205    // renders forged log lines.
206    #[test]
207    fn test_logfmt_lazy_value_is_rendered_once() {
208        fn once(value: &Changing) -> String {
209            let fields = [Field::new("v", Value::Display(value))];
210            let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
211            let mut out = String::new();
212            LogfmtFormat::new()
213                .write_record_generic(&record, &mut out)
214                .unwrap();
215            let start = out.find(" v=").unwrap() + 3;
216            out[start..out.len() - 1].to_string()
217        }
218        let value = Changing(core::cell::Cell::new(0));
219        let out = once(&value);
220        assert_eq!(out, "clean");
221        assert_eq!(value.0.get(), 1, "rendered more than once");
222        // The same value, now rendering the hostile text, stays on one
223        // escaped line.
224        let out = once(&value);
225        assert!(!out.contains('\n') && !out.contains('\u{1b}'), "{out:?}");
226        assert!(out.starts_with('"') && out.ends_with('"'), "{out:?}");
227    }
228
229    #[test]
230    fn test_logfmt_simple_record() {
231        let record = Record::new(Metadata::new(Level::Info, "app"), "hello", &[]);
232        assert_eq!(
233            fmt_one(LogfmtFormat::new(), &record),
234            "level=info target=app message=hello\n"
235        );
236    }
237
238    #[test]
239    fn test_logfmt_metadata_and_timestamps() {
240        let meta = Metadata::new(Level::Debug, "a")
241            .with_location("src/x.rs", 9)
242            .with_timestamp(1_700_000_000_000_000_000);
243        let record = Record::new(meta, "m", &[]);
244        assert_eq!(
245            fmt_one(LogfmtFormat::new(), &record),
246            "timestamp=1700000000000000000 level=debug target=a message=m file=src/x.rs line=9\n"
247        );
248        assert!(
249            fmt_one(LogfmtFormat::new().timestamp_rfc3339(true), &record)
250                .starts_with("timestamp=2023-11-14T22:13:20.000000000Z level=debug")
251        );
252    }
253
254    #[test]
255    fn test_logfmt_values_render_per_variant() {
256        assert_eq!(field_logfmt(Value::Null), "");
257        assert_eq!(field_logfmt(Value::Bool(true)), "true");
258        assert_eq!(field_logfmt(Value::Bool(false)), "false");
259        assert_eq!(field_logfmt(Value::I64(-1)), "-1");
260        assert_eq!(field_logfmt(Value::U64(1)), "1");
261        assert_eq!(field_logfmt(Value::I128(-2)), "-2");
262        assert_eq!(field_logfmt(Value::U128(2)), "2");
263        assert_eq!(field_logfmt(Value::F64(1.0)), "1.0");
264        assert_eq!(field_logfmt(Value::F64(f64::NAN)), "NaN");
265        assert_eq!(field_logfmt(Value::F64(f64::INFINITY)), "inf");
266        assert_eq!(field_logfmt(Value::Str("")), "\"\"");
267        assert_eq!(field_logfmt(Value::Str("plain")), "plain");
268        assert_eq!(field_logfmt(Value::Str("a b")), "\"a b\"");
269        assert_eq!(field_logfmt(Value::Str("a\"b")), "\"a\\\"b\"");
270        assert_eq!(field_logfmt(Value::Str("a=b")), "\"a=b\"");
271        assert_eq!(field_logfmt(Value::Str("a\\b")), "\"a\\\\b\"");
272        assert_eq!(field_logfmt(Value::Char('\n')), "\"\\n\"");
273        assert_eq!(field_logfmt(Value::Char('x')), "x");
274        assert_eq!(field_logfmt(Value::Bytes(&[])), "\"\"");
275        assert_eq!(field_logfmt(Value::Bytes(&[1, 255])), "01ff");
276        assert_eq!(field_logfmt(Value::Display(&42)), "42");
277        assert_eq!(field_logfmt(Value::Display(&"two words")), "\"two words\"");
278        assert_eq!(field_logfmt(Value::Debug(&"q")), "\"\\\"q\\\"\"");
279        let e = std::io::Error::other("boom");
280        assert_eq!(field_logfmt(Value::Error(&e)), "boom");
281    }
282
283    #[test]
284    fn test_logfmt_control_characters_are_escaped_and_quoted() {
285        assert_eq!(field_logfmt(Value::Str("\u{1b}[31m")), "\"\\u001b[31m\"");
286        assert_eq!(field_logfmt(Value::Str("x\u{9b}31m")), "\"x\\u009b31m\"");
287        assert_eq!(field_logfmt(Value::Str("a\u{7f}")), "\"a\\u007f\"");
288        assert_eq!(field_logfmt(Value::Str("a\u{2028}b")), "\"a\\u2028b\"");
289        assert_eq!(field_logfmt(Value::Str("tab\there")), "\"tab\\there\"");
290    }
291
292    #[test]
293    fn test_logfmt_keys_are_sanitized_and_reserved_keys_prefixed() {
294        let fields = [
295            Field::new("the key", 1_u32),
296            Field::new("k\u{1b}[0m", 2_u32),
297            Field::new("", 3_u32),
298            Field::new("a=b", 4_u32),
299            Field::new("level", "fake"),
300        ];
301        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
302        let out = fmt_one(LogfmtFormat::new(), &record);
303        assert_eq!(
304            out,
305            "level=info target=t message=m the_key=1 k_[0m=2 _=3 a_b=4 fields.level=fake\n"
306        );
307    }
308
309    #[test]
310    fn test_logfmt_message_and_target_are_quoted_when_needed() {
311        let record = Record::new(Metadata::new(Level::Info, "my app"), "hi there", &[]);
312        assert_eq!(
313            fmt_one(LogfmtFormat::new(), &record),
314            "level=info target=\"my app\" message=\"hi there\"\n"
315        );
316        let record = Record::new(Metadata::new(Level::Info, ""), "", &[]);
317        assert_eq!(
318            fmt_one(LogfmtFormat::new(), &record),
319            "level=info target=\"\" message=\"\"\n"
320        );
321    }
322}