Skip to main content

sozu_command_lib/logging/
display.rs

1use std::fmt;
2
3use rusty_ulid::Ulid;
4
5use crate::{
6    AsStr,
7    logging::{
8        EndpointRecord, FullTags, LogAddress, LogContext, LogDuration, LogLevel, LogMessage,
9        LoggerBackend, Rfc3339Time,
10    },
11};
12
13impl LogLevel {
14    pub const fn as_str(&self, access: bool, colored: bool) -> &'static str {
15        match (self, access, colored) {
16            (LogLevel::Error, false, false) => "ERROR",
17            (LogLevel::Warn, false, false) => "WARN ",
18            (LogLevel::Info, false, false) => "INFO ",
19            (LogLevel::Debug, false, false) => "DEBUG",
20            (LogLevel::Trace, false, false) => "TRACE",
21
22            (LogLevel::Error, false, true) => "\x1b[;31;1mERROR",
23            (LogLevel::Warn, false, true) => "\x1b[;33;1mWARN ",
24            (LogLevel::Info, false, true) => "\x1b[;32;1mINFO ",
25            (LogLevel::Debug, false, true) => "\x1b[;36mDEBUG",
26            (LogLevel::Trace, false, true) => "\x1b[;90mTRACE",
27
28            (LogLevel::Error, true, false) => "ERROR-ACCESS",
29            (LogLevel::Info, true, false) => "INFO-ACCESS ",
30            (_, true, false) => "???",
31
32            (LogLevel::Error, true, true) => "\x1b[;35;1mERROR-ACCESS",
33            (LogLevel::Info, true, true) => "\x1b[;35;1mINFO-ACCESS ",
34            (_, true, true) => "\x1b[;35;1m???",
35        }
36    }
37}
38
39impl AsRef<str> for LoggerBackend {
40    fn as_ref(&self) -> &str {
41        match self {
42            LoggerBackend::Stdout(_) => "stdout",
43            LoggerBackend::Unix(_) => "UNIX socket",
44            LoggerBackend::Udp(_, _) => "UDP socket",
45            LoggerBackend::Tcp(_) => "TCP socket",
46            LoggerBackend::File(_) => "file",
47        }
48    }
49}
50
51impl fmt::Display for Rfc3339Time {
52    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
53        let t = self.inner;
54        write!(
55            f,
56            "{}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
57            t.year(),
58            t.month() as u8,
59            t.day(),
60            t.hour(),
61            t.minute(),
62            t.second(),
63            t.microsecond()
64        )
65    }
66}
67
68impl fmt::Display for LogMessage<'_> {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self.0 {
71            Some(message) => write!(f, " | {message}"),
72            None => Ok(()),
73        }
74    }
75}
76
77impl fmt::Display for LogDuration {
78    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
79        match self.0 {
80            None => write!(f, "-"),
81            Some(duration) => {
82                let secs = duration.as_secs();
83                if secs >= 10 {
84                    return write!(f, "{secs}s");
85                }
86
87                let ms = duration.as_millis();
88                if ms < 10 {
89                    let us = duration.as_micros();
90                    if us >= 10 {
91                        return write!(f, "{us}μs");
92                    }
93
94                    let ns = duration.as_nanos();
95                    return write!(f, "{ns}ns");
96                }
97
98                write!(f, "{ms}ms")
99            }
100        }
101    }
102}
103
104impl fmt::Display for LogAddress {
105    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
106        match self.0 {
107            // `SocketAddr`'s own `Display` formats on the stack.
108            Some(address) => write!(f, "{address}"),
109            None => f.write_str("-"),
110        }
111    }
112}
113
114/// Crockford base32 alphabet of a ULID, the one `rusty_ulid` encodes with.
115const ULID_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
116
117/// Write `ulid` in its 26-character canonical form.
118///
119/// `rusty_ulid`'s `Display` is `f.write_str(&self.to_string())`, one `String`
120/// per ULID, and every log line carrying a [`LogContext`] renders one or two
121/// of them. This encodes into a stack array instead, with the same alphabet
122/// and the same bit layout: the first character carries the top 3 bits of the
123/// 128-bit value, each of the other 25 the next 5.
124fn write_ulid(f: &mut fmt::Formatter, ulid: Ulid) -> fmt::Result {
125    let value = u128::from(ulid);
126    let mut encoded = [0u8; 26];
127    for (index, digit) in encoded.iter_mut().enumerate() {
128        let shift = 125 - 5 * index;
129        *digit = ULID_ALPHABET[((value >> shift) & 0x1f) as usize];
130    }
131    // Every byte comes from the ASCII alphabet above, so this never fails.
132    f.write_str(std::str::from_utf8(&encoded).map_err(|_| fmt::Error)?)
133}
134
135impl fmt::Display for LogContext<'_> {
136    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
137        f.write_str("[")?;
138        write_ulid(f, self.session_id)?;
139        f.write_str(" ")?;
140        match self.request_id {
141            Some(id) => write_ulid(f, id)?,
142            None => f.write_str("-")?,
143        }
144        write!(
145            f,
146            " {} {}]",
147            self.cluster_id.unwrap_or("-"),
148            self.backend_id.unwrap_or("-")
149        )
150    }
151}
152
153impl fmt::Display for EndpointRecord<'_> {
154    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
155        match self {
156            Self::Http {
157                method,
158                authority,
159                path,
160                status,
161                ..
162            } => {
163                let pretty = f.alternate();
164                write!(
165                    f,
166                    "{} {} {} ",
167                    authority.as_str_or("-"),
168                    method.as_str_or("-"),
169                    path.as_str_or("-"),
170                )?;
171                write_status(f, *status, pretty)
172            }
173            Self::Tcp => {
174                write!(f, "-")
175            }
176        }
177    }
178}
179
180/// Write the status code, colored by class when `pretty`, straight into the
181/// formatter rather than through an intermediate `String`.
182fn write_status(f: &mut fmt::Formatter, status: Option<u16>, pretty: bool) -> fmt::Result {
183    match (status, pretty) {
184        (Some(s @ 200..=299), true) => write!(f, "\x1b[32m{s}"),
185        (Some(s @ 300..=399), true) => write!(f, "\x1b[34m{s}"),
186        (Some(s @ 400..=499), true) => write!(f, "\x1b[33m{s}"),
187        (Some(s @ 500..=599), true) => write!(f, "\x1b[31m{s}"),
188        (Some(s), _) => write!(f, "{s}"),
189        (None, _) => f.write_str("-"),
190    }
191}
192
193impl fmt::Display for FullTags<'_> {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        match (self.concatenated, self.user_agent) {
196            (None, None) => Ok(()),
197            (Some(tags), None) => write!(f, "{tags}"),
198            (Some(tags), Some(ua)) if !tags.is_empty() => {
199                write!(f, "{tags}, user-agent={}", EscapedUserAgent(ua))
200            }
201            (_, Some(ua)) => write!(f, "user-agent={}", EscapedUserAgent(ua)),
202        }
203    }
204}
205
206/// A user agent as the access log renders it: every space becomes `_`, `[`
207/// becomes `{` and `]` becomes `}`, so the value can neither split the line
208/// into more columns nor close the bracketed tag block early. Every other
209/// byte is copied verbatim.
210///
211/// The spans between two replaced bytes are written straight into the
212/// formatter. The three replaced bytes are ASCII and never occur inside a
213/// multi-byte UTF-8 sequence, so splitting on them keeps every span valid.
214struct EscapedUserAgent<'a>(&'a str);
215
216impl fmt::Display for EscapedUserAgent<'_> {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        let mut rest = self.0;
219        while let Some(index) = rest.find([' ', '[', ']']) {
220            f.write_str(&rest[..index])?;
221            f.write_str(match rest.as_bytes()[index] {
222                b' ' => "_",
223                b'[' => "{",
224                _ => "}",
225            })?;
226            rest = &rest[index + 1..];
227        }
228        f.write_str(rest)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use rusty_ulid::Ulid;
235
236    use super::EscapedUserAgent;
237    use crate::logging::{EndpointRecord, FullTags, LogAddress, LogContext};
238
239    /// The allocating renderer this module used before it wrote into the
240    /// formatter, kept as the byte-for-byte oracle of the escaping.
241    fn replace_chain(user_agent: &str) -> String {
242        user_agent
243            .replace(' ', "_")
244            .replace('[', "{")
245            .replace(']', "}")
246    }
247
248    /// Edge cases of the user-agent escaping, each with its expected
249    /// rendering. Only space, `[` and `]` are rewritten: control bytes,
250    /// quotes, tabs, other whitespace and non-ASCII pass through unchanged.
251    const USER_AGENTS: &[(&str, &str)] = &[
252        ("", ""),
253        ("curl/8.18.0", "curl/8.18.0"),
254        (" ", "_"),
255        ("[", "{"),
256        ("]", "}"),
257        ("  [[]]  ", "__{{}}__"),
258        (
259            "Mozilla/5.0 (X11; Linux x86_64) [en]",
260            "Mozilla/5.0_(X11;_Linux_x86_64)_{en}",
261        ),
262        ("leading and trailing ", "leading_and_trailing_"),
263        ("tab\there", "tab\there"),
264        ("nul\0byte esc\x1b[31m", "nul\0byte_esc\x1b{31m"),
265        ("cr\r\nlf del\x7f", "cr\r\nlf_del\x7f"),
266        ("\"double\" 'single'", "\"double\"_'single'"),
267        ("back\\slash", "back\\slash"),
268        ("nbsp\u{a0}kept", "nbsp\u{a0}kept"),
269        ("ideographic\u{3000}space", "ideographic\u{3000}space"),
270        (
271            "caf\u{e9} [\u{65e5}\u{672c}] \u{1f980}",
272            "caf\u{e9}_{\u{65e5}\u{672c}}_\u{1f980}",
273        ),
274        ("{already} braces_", "{already}_braces_"),
275    ];
276
277    #[test]
278    fn user_agent_escaping_matches_the_expected_rendering() {
279        for (input, expected) in USER_AGENTS {
280            assert_eq!(
281                EscapedUserAgent(input).to_string(),
282                *expected,
283                "user agent {input:?}"
284            );
285        }
286    }
287
288    #[test]
289    fn user_agent_escaping_is_byte_identical_to_the_replace_chain() {
290        for (input, _) in USER_AGENTS {
291            assert_eq!(
292                EscapedUserAgent(input).to_string().as_bytes(),
293                replace_chain(input).as_bytes(),
294                "user agent {input:?}"
295            );
296        }
297    }
298
299    #[test]
300    fn full_tags_render_the_escaped_user_agent() {
301        let ua = Some("a b [c]");
302        let render = |concatenated| {
303            FullTags {
304                concatenated,
305                user_agent: ua,
306            }
307            .to_string()
308        };
309        assert_eq!(render(Some("k=v")), "k=v, user-agent=a_b_{c}");
310        assert_eq!(render(Some("")), "user-agent=a_b_{c}");
311        assert_eq!(render(None), "user-agent=a_b_{c}");
312    }
313
314    #[test]
315    fn ulid_rendering_is_byte_identical_to_rusty_ulid() {
316        let values = [
317            0u128,
318            1,
319            0x1f,
320            0x20,
321            u128::MAX,
322            u128::MAX >> 3,
323            1 << 125,
324            0x0123_4567_89AB_CDEF_FEDC_BA98_7654_3210,
325            0x0192_3A4B_5C6D_7E8F_9012_3456_7890_ABCD,
326        ];
327        for value in values {
328            let ulid = Ulid::from(value);
329            let ctx = LogContext {
330                session_id: ulid,
331                request_id: Some(ulid),
332                cluster_id: None,
333                backend_id: None,
334            };
335            let expected = ulid.to_string();
336            assert_eq!(
337                ctx.to_string(),
338                format!("[{expected} {expected} - -]"),
339                "ULID {value:#x}"
340            );
341        }
342        // A generated ULID carries a real timestamp and random bits.
343        for _ in 0..64 {
344            let ulid = Ulid::generate();
345            let ctx = LogContext {
346                session_id: ulid,
347                request_id: None,
348                cluster_id: None,
349                backend_id: None,
350            };
351            assert_eq!(ctx.to_string(), format!("[{ulid} - - -]"));
352        }
353    }
354
355    #[test]
356    fn socket_addresses_render_like_their_display_or_a_dash() {
357        let v4: std::net::SocketAddr = "127.0.0.1:49312".parse().unwrap();
358        let v6: std::net::SocketAddr = "[2001:db8::1]:8443".parse().unwrap();
359        assert_eq!(LogAddress(Some(v4)).to_string(), "127.0.0.1:49312");
360        assert_eq!(LogAddress(Some(v6)).to_string(), "[2001:db8::1]:8443");
361        assert_eq!(LogAddress(None).to_string(), "-");
362    }
363
364    #[test]
365    fn endpoint_status_renders_plain_and_colored() {
366        let endpoint = |status| EndpointRecord::Http {
367            method: Some("GET"),
368            authority: Some("example.com"),
369            path: Some("/"),
370            status,
371            reason: None,
372        };
373        assert_eq!(format!("{}", endpoint(Some(200))), "example.com GET / 200");
374        assert_eq!(format!("{}", endpoint(Some(101))), "example.com GET / 101");
375        assert_eq!(format!("{}", endpoint(None)), "example.com GET / -");
376        assert_eq!(
377            format!("{:#}", endpoint(Some(204))),
378            "example.com GET / \x1b[32m204"
379        );
380        assert_eq!(
381            format!("{:#}", endpoint(Some(302))),
382            "example.com GET / \x1b[34m302"
383        );
384        assert_eq!(
385            format!("{:#}", endpoint(Some(404))),
386            "example.com GET / \x1b[33m404"
387        );
388        assert_eq!(
389            format!("{:#}", endpoint(Some(503))),
390            "example.com GET / \x1b[31m503"
391        );
392        assert_eq!(
393            format!("{:#}", endpoint(Some(101))),
394            "example.com GET / 101"
395        );
396        assert_eq!(format!("{:#}", endpoint(None)), "example.com GET / -");
397        assert_eq!(format!("{}", EndpointRecord::Tcp), "-");
398    }
399
400    #[test]
401    fn log_context_display_all_fields_present() {
402        let session = Ulid::from(0x01_23_45_67_89_AB_CD_EF_FE_DC_BA_98_76_54_32_10_u128);
403        let request = Ulid::from(0x01_23_45_67_89_AB_CD_EF_FE_DC_BA_98_76_54_32_11_u128);
404        let ctx = LogContext {
405            session_id: session,
406            request_id: Some(request),
407            cluster_id: Some("cluster-abc"),
408            backend_id: Some("backend-1"),
409        };
410        let rendered = format!("{ctx}");
411        assert_eq!(
412            rendered,
413            format!("[{session} {request} cluster-abc backend-1]")
414        );
415    }
416
417    #[test]
418    fn log_context_display_dashes_when_missing() {
419        let session = Ulid::from(0xABCDu128);
420        let ctx = LogContext {
421            session_id: session,
422            request_id: None,
423            cluster_id: None,
424            backend_id: None,
425        };
426        assert_eq!(format!("{ctx}"), format!("[{session} - - -]"));
427    }
428
429    #[test]
430    fn log_context_display_partial() {
431        let session = Ulid::from(0x42u128);
432        let request = Ulid::from(0x43u128);
433        let ctx = LogContext {
434            session_id: session,
435            request_id: Some(request),
436            cluster_id: None,
437            backend_id: Some("backend-2"),
438        };
439        assert_eq!(
440            format!("{ctx}"),
441            format!("[{session} {request} - backend-2]")
442        );
443    }
444}