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
4//! by a newline. The format follows the conventions popularized by
5//! Heroku and Brandur: values containing whitespace, `=`, or `"` are
6//! double-quoted with `\"` and `\\` escapes; control characters are
7//! C-escaped; bare alphanumerics are emitted without quotes.
8
9use core::fmt;
10
11use super::Format;
12use crate::record::Record;
13use crate::value::Value;
14
15/// logfmt formatter. See module docs for the wire format.
16///
17/// # Example
18///
19/// ```
20/// use log_io::format::{Format, LogfmtFormat};
21/// use log_io::{Field, Level, Metadata, Record, Value};
22///
23/// let fmt = LogfmtFormat::new();
24/// let fields = [Field::new("port", Value::U64(8080))];
25/// let record = Record::new(Metadata::new(Level::Info, "app"), "ok", &fields);
26///
27/// let mut out = String::new();
28/// fmt.write_record(&record, &mut out).unwrap();
29/// assert!(out.contains("port=8080"));
30/// assert!(out.ends_with('\n'));
31/// ```
32#[derive(Debug, Clone, Copy, Default)]
33pub struct LogfmtFormat {
34    _private: (),
35}
36
37impl LogfmtFormat {
38    /// New logfmt formatter.
39    pub const fn new() -> Self {
40        Self { _private: () }
41    }
42}
43
44impl Format for LogfmtFormat {
45    fn write_record<W: fmt::Write + ?Sized>(
46        &self,
47        record: &Record<'_>,
48        writer: &mut W,
49    ) -> fmt::Result {
50        let mut first = true;
51        if let Some(ts) = record.metadata.timestamp_unix_nanos {
52            kv_prefix(writer, &mut first)?;
53            write!(writer, "timestamp={ts}")?;
54        }
55        kv_prefix(writer, &mut first)?;
56        write!(writer, "level={}", record.metadata.level.as_str())?;
57        kv_prefix(writer, &mut first)?;
58        writer.write_str("target=")?;
59        write_logfmt_str(writer, record.metadata.target)?;
60        kv_prefix(writer, &mut first)?;
61        writer.write_str("message=")?;
62        write_logfmt_str(writer, record.message)?;
63        if let Some(file) = record.metadata.file {
64            kv_prefix(writer, &mut first)?;
65            writer.write_str("file=")?;
66            write_logfmt_str(writer, file)?;
67        }
68        if let Some(line) = record.metadata.line {
69            kv_prefix(writer, &mut first)?;
70            write!(writer, "line={line}")?;
71        }
72        for field in record.all_fields() {
73            kv_prefix(writer, &mut first)?;
74            write_logfmt_key(writer, field.key)?;
75            writer.write_char('=')?;
76            write_logfmt_value(writer, field.value)?;
77        }
78        writer.write_char('\n')
79    }
80}
81
82fn kv_prefix<W: fmt::Write + ?Sized>(w: &mut W, first: &mut bool) -> fmt::Result {
83    if *first {
84        *first = false;
85        Ok(())
86    } else {
87        w.write_char(' ')
88    }
89}
90
91fn needs_quotes(s: &str) -> bool {
92    s.is_empty()
93        || s.chars()
94            .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\') || (c as u32) < 0x20)
95}
96
97fn write_logfmt_str<W: fmt::Write + ?Sized>(w: &mut W, s: &str) -> fmt::Result {
98    if needs_quotes(s) {
99        w.write_char('"')?;
100        for c in s.chars() {
101            match c {
102                '"' => w.write_str("\\\"")?,
103                '\\' => w.write_str("\\\\")?,
104                '\n' => w.write_str("\\n")?,
105                '\r' => w.write_str("\\r")?,
106                '\t' => w.write_str("\\t")?,
107                c if (c as u32) < 0x20 => write!(w, "\\x{:02x}", c as u32)?,
108                c => w.write_char(c)?,
109            }
110        }
111        w.write_char('"')
112    } else {
113        w.write_str(s)
114    }
115}
116
117fn write_logfmt_key<W: fmt::Write + ?Sized>(w: &mut W, key: &str) -> fmt::Result {
118    // Keys with `=` or whitespace would break parsing. Replace with `_`
119    // rather than quote, because logfmt parsers never quote keys.
120    for c in key.chars() {
121        if c.is_whitespace() || c == '=' || c == '"' {
122            w.write_char('_')?;
123        } else {
124            w.write_char(c)?;
125        }
126    }
127    Ok(())
128}
129
130fn write_logfmt_value<W: fmt::Write + ?Sized>(w: &mut W, v: Value<'_>) -> fmt::Result {
131    match v {
132        Value::Null => Ok(()),
133        Value::Bool(true) => w.write_str("true"),
134        Value::Bool(false) => w.write_str("false"),
135        Value::I64(n) => write!(w, "{n}"),
136        Value::U64(n) => write!(w, "{n}"),
137        Value::F64(n) => write!(w, "{n}"),
138        Value::Str(s) => write_logfmt_str(w, s),
139        Value::Char(c) => {
140            let mut buf = [0u8; 4];
141            let s: &str = c.encode_utf8(&mut buf);
142            write_logfmt_str(w, s)
143        }
144    }
145}
146
147#[cfg(all(test, feature = "std"))]
148mod tests {
149    use super::*;
150    use crate::record::{Field, Metadata};
151    use crate::Level;
152
153    fn fmt_one(record: &Record<'_>) -> String {
154        let mut s = String::new();
155        LogfmtFormat::new().write_record(record, &mut s).unwrap();
156        s
157    }
158
159    #[test]
160    fn simple_record_format() {
161        let record = Record::new(Metadata::new(Level::Info, "app"), "hello", &[]);
162        let out = fmt_one(&record);
163        assert_eq!(out, "level=info target=app message=hello\n");
164    }
165
166    #[test]
167    fn message_with_spaces_is_quoted() {
168        let record = Record::new(Metadata::new(Level::Info, "app"), "hi there", &[]);
169        let out = fmt_one(&record);
170        assert!(out.contains(r#"message="hi there""#), "{out}");
171    }
172
173    #[test]
174    fn null_value_emits_empty() {
175        let fields = [Field::new("x", Value::Null)];
176        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
177        let out = fmt_one(&record);
178        assert!(out.contains("x="));
179        assert!(out.ends_with("x=\n"));
180    }
181
182    #[test]
183    fn fields_after_message() {
184        let fields = [Field::new("port", Value::U64(80))];
185        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
186        let out = fmt_one(&record);
187        let msg_pos = out.find("message=").unwrap();
188        let port_pos = out.find("port=").unwrap();
189        assert!(msg_pos < port_pos);
190    }
191
192    #[test]
193    fn quotes_in_value_are_escaped() {
194        let fields = [Field::new("data", Value::Str("a\"b"))];
195        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
196        let out = fmt_one(&record);
197        assert!(out.contains(r#"data="a\"b""#), "{out}");
198    }
199
200    #[test]
201    fn key_whitespace_replaced() {
202        let fields = [Field::new("the key", Value::U64(1))];
203        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
204        let out = fmt_one(&record);
205        assert!(out.contains("the_key=1"), "{out}");
206    }
207}