penna 0.1.0

Structured JSON logging for tracing, in one line per event, without a regex engine underneath
Documentation
//! Rendering fields as JSON.
//!
//! A `tracing` field arrives already typed, so there is nothing to serialise
//! generically: each visit writes one `"key":value` pair. Strings are escaped
//! exactly as `serde_json` escapes them β€” the quote, the backslash and the
//! control characters, nothing else β€” which a test checks against
//! `serde_json` itself.

use std::fmt;

use tracing_core::field::{Field, Visit};

/// Writes `"key":value` pairs into a buffer, separated by commas.
pub(crate) struct FieldWriter<'a> {
    out: &'a mut String,
    first: bool,
}

impl<'a> FieldWriter<'a> {
    /// Starts a fresh object body.
    pub(crate) fn new(out: &'a mut String) -> Self {
        Self { out, first: true }
    }

    /// Continues an object body that already holds pairs, for a span whose
    /// fields are recorded after it was created.
    pub(crate) fn appending(out: &'a mut String) -> Self {
        let first = out.is_empty();
        Self { out, first }
    }

    fn key(&mut self, field: &Field) {
        if !self.first {
            self.out.push(',');
        }
        self.first = false;
        write_string(self.out, field.name());
        self.out.push(':');
    }
}

impl Visit for FieldWriter<'_> {
    fn record_str(&mut self, field: &Field, value: &str) {
        self.key(field);
        write_string(self.out, value);
    }

    fn record_bool(&mut self, field: &Field, value: bool) {
        self.key(field);
        self.out.push_str(if value { "true" } else { "false" });
    }

    fn record_i64(&mut self, field: &Field, value: i64) {
        self.key(field);
        self.out.push_str(itoa(value as i128).as_str());
    }

    fn record_u64(&mut self, field: &Field, value: u64) {
        self.key(field);
        self.out.push_str(itoa(value as i128).as_str());
    }

    fn record_i128(&mut self, field: &Field, value: i128) {
        self.key(field);
        self.out.push_str(itoa(value).as_str());
    }

    fn record_u128(&mut self, field: &Field, value: u128) {
        self.key(field);
        // Beyond i128's range this would truncate, so print it directly.
        self.out.push_str(&value.to_string());
    }

    /// A non-finite float is `null`, which is what JSON has instead of NaN and
    /// what `serde_json` writes for one.
    fn record_f64(&mut self, field: &Field, value: f64) {
        self.key(field);
        if value.is_finite() {
            let mut buffer = ryu_like(value);
            self.out.push_str(buffer.as_mut_str());
        } else {
            self.out.push_str("null");
        }
    }

    /// An error is rendered by its `Display`, and its sources are appended the
    /// way a reader expects to see a chain.
    fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
        let mut rendered = value.to_string();
        let mut source = value.source();
        while let Some(error) = source {
            rendered.push_str(": ");
            rendered.push_str(&error.to_string());
            source = error.source();
        }
        self.key(field);
        write_string(self.out, &rendered);
    }

    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
        self.key(field);
        write_string(self.out, &format!("{value:?}"));
    }
}

fn itoa(value: i128) -> String {
    value.to_string()
}

/// Prints a float the way JSON wants it: a whole number keeps a `.0` so it
/// stays a float on the way back in.
fn ryu_like(value: f64) -> String {
    if value == value.trunc() && value.abs() < 1e16 {
        format!("{value:.1}")
    } else {
        format!("{value}")
    }
}

/// Writes a quoted, escaped JSON string.
pub(crate) fn write_string(out: &mut String, value: &str) {
    out.push('"');
    let mut start = 0;
    for (index, byte) in value.bytes().enumerate() {
        let escape = match byte {
            b'"' => "\\\"",
            b'\\' => "\\\\",
            0x08 => "\\b",
            0x09 => "\\t",
            0x0A => "\\n",
            0x0C => "\\f",
            0x0D => "\\r",
            0x00..=0x1F => {
                out.push_str(&value[start..index]);
                const HEX: &[u8; 16] = b"0123456789abcdef";
                out.push_str("\\u00");
                out.push(HEX[usize::from(byte >> 4)] as char);
                out.push(HEX[usize::from(byte & 0xF)] as char);
                start = index + 1;
                continue;
            }
            _ => continue,
        };
        out.push_str(&value[start..index]);
        out.push_str(escape);
        start = index + 1;
    }
    out.push_str(&value[start..]);
    out.push('"');
}

#[cfg(test)]
mod tests {
    use super::*;

    fn escaped(value: &str) -> String {
        let mut out = String::new();
        write_string(&mut out, value);
        out
    }

    #[test]
    fn strings_escape_exactly_as_serde_json_does() {
        for case in [
            "",
            "plain",
            "quote\" and back\\slash",
            "tab\tnewline\ncr\rbs\u{8}ff\u{c}",
            "\u{1}\u{1f} control and \u{7f} del",
            "unicode Γ©δΈ­πŸ™‚ kept",
            "slash / not escaped",
        ] {
            assert_eq!(
                escaped(case),
                serde_json::to_string(case).unwrap(),
                "{case:?}"
            );
        }
    }

    #[test]
    fn floats_stay_floats() {
        assert_eq!(ryu_like(1.0), "1.0");
        assert_eq!(ryu_like(166.6), "166.6");
        assert_eq!(ryu_like(-0.5), "-0.5");
    }
}