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, QuoteScan, RESERVED_PREFIX, is_reserved_key, 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 twice: once to decide on quoting, once to write.
148            if QuoteScan::needs_quotes(&lazy) {
149                w.write_char('"')?;
150                write!(JsonEscaper(&mut *w), "{lazy}")?;
151                w.write_char('"')
152            } else {
153                write!(w, "{lazy}")
154            }
155        }
156    }
157}
158
159#[cfg(all(test, feature = "std"))]
160mod tests {
161    use super::*;
162    use crate::Level;
163    use crate::record::{Field, Metadata};
164
165    fn fmt_one(fmt: LogfmtFormat, record: &Record<'_>) -> String {
166        let mut s = String::new();
167        fmt.write_record_generic(record, &mut s).unwrap();
168        let mut d = String::new();
169        fmt.write_record(record, &mut d).unwrap();
170        assert_eq!(s, d);
171        s
172    }
173
174    fn field_logfmt(v: Value<'_>) -> String {
175        let fields = [Field::new("v", v)];
176        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
177        let out = fmt_one(LogfmtFormat::new(), &record);
178        let start = out.find(" v=").unwrap() + 3;
179        out[start..out.len() - 1].to_string()
180    }
181
182    #[test]
183    fn test_logfmt_simple_record() {
184        let record = Record::new(Metadata::new(Level::Info, "app"), "hello", &[]);
185        assert_eq!(
186            fmt_one(LogfmtFormat::new(), &record),
187            "level=info target=app message=hello\n"
188        );
189    }
190
191    #[test]
192    fn test_logfmt_metadata_and_timestamps() {
193        let meta = Metadata::new(Level::Debug, "a")
194            .with_location("src/x.rs", 9)
195            .with_timestamp(1_700_000_000_000_000_000);
196        let record = Record::new(meta, "m", &[]);
197        assert_eq!(
198            fmt_one(LogfmtFormat::new(), &record),
199            "timestamp=1700000000000000000 level=debug target=a message=m file=src/x.rs line=9\n"
200        );
201        assert!(
202            fmt_one(LogfmtFormat::new().timestamp_rfc3339(true), &record)
203                .starts_with("timestamp=2023-11-14T22:13:20.000000000Z level=debug")
204        );
205    }
206
207    #[test]
208    fn test_logfmt_values_render_per_variant() {
209        assert_eq!(field_logfmt(Value::Null), "");
210        assert_eq!(field_logfmt(Value::Bool(true)), "true");
211        assert_eq!(field_logfmt(Value::Bool(false)), "false");
212        assert_eq!(field_logfmt(Value::I64(-1)), "-1");
213        assert_eq!(field_logfmt(Value::U64(1)), "1");
214        assert_eq!(field_logfmt(Value::I128(-2)), "-2");
215        assert_eq!(field_logfmt(Value::U128(2)), "2");
216        assert_eq!(field_logfmt(Value::F64(1.0)), "1.0");
217        assert_eq!(field_logfmt(Value::F64(f64::NAN)), "NaN");
218        assert_eq!(field_logfmt(Value::F64(f64::INFINITY)), "inf");
219        assert_eq!(field_logfmt(Value::Str("")), "\"\"");
220        assert_eq!(field_logfmt(Value::Str("plain")), "plain");
221        assert_eq!(field_logfmt(Value::Str("a b")), "\"a b\"");
222        assert_eq!(field_logfmt(Value::Str("a\"b")), "\"a\\\"b\"");
223        assert_eq!(field_logfmt(Value::Str("a=b")), "\"a=b\"");
224        assert_eq!(field_logfmt(Value::Str("a\\b")), "\"a\\\\b\"");
225        assert_eq!(field_logfmt(Value::Char('\n')), "\"\\n\"");
226        assert_eq!(field_logfmt(Value::Char('x')), "x");
227        assert_eq!(field_logfmt(Value::Bytes(&[])), "\"\"");
228        assert_eq!(field_logfmt(Value::Bytes(&[1, 255])), "01ff");
229        assert_eq!(field_logfmt(Value::Display(&42)), "42");
230        assert_eq!(field_logfmt(Value::Display(&"two words")), "\"two words\"");
231        assert_eq!(field_logfmt(Value::Debug(&"q")), "\"\\\"q\\\"\"");
232        let e = std::io::Error::other("boom");
233        assert_eq!(field_logfmt(Value::Error(&e)), "boom");
234    }
235
236    #[test]
237    fn test_logfmt_control_characters_are_escaped_and_quoted() {
238        assert_eq!(field_logfmt(Value::Str("\u{1b}[31m")), "\"\\u001b[31m\"");
239        assert_eq!(field_logfmt(Value::Str("x\u{9b}31m")), "\"x\\u009b31m\"");
240        assert_eq!(field_logfmt(Value::Str("a\u{7f}")), "\"a\\u007f\"");
241        assert_eq!(field_logfmt(Value::Str("a\u{2028}b")), "\"a\\u2028b\"");
242        assert_eq!(field_logfmt(Value::Str("tab\there")), "\"tab\\there\"");
243    }
244
245    #[test]
246    fn test_logfmt_keys_are_sanitized_and_reserved_keys_prefixed() {
247        let fields = [
248            Field::new("the key", 1_u32),
249            Field::new("k\u{1b}[0m", 2_u32),
250            Field::new("", 3_u32),
251            Field::new("a=b", 4_u32),
252            Field::new("level", "fake"),
253        ];
254        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
255        let out = fmt_one(LogfmtFormat::new(), &record);
256        assert_eq!(
257            out,
258            "level=info target=t message=m the_key=1 k_[0m=2 _=3 a_b=4 fields.level=fake\n"
259        );
260    }
261
262    #[test]
263    fn test_logfmt_message_and_target_are_quoted_when_needed() {
264        let record = Record::new(Metadata::new(Level::Info, "my app"), "hi there", &[]);
265        assert_eq!(
266            fmt_one(LogfmtFormat::new(), &record),
267            "level=info target=\"my app\" message=\"hi there\"\n"
268        );
269        let record = Record::new(Metadata::new(Level::Info, ""), "", &[]);
270        assert_eq!(
271            fmt_one(LogfmtFormat::new(), &record),
272            "level=info target=\"\" message=\"\"\n"
273        );
274    }
275}