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 Some(address) => write!(f, "{address}"),
109 None => f.write_str("-"),
110 }
111 }
112}
113
114const ULID_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
116
117fn 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 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
180fn 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
206struct 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 fn replace_chain(user_agent: &str) -> String {
242 user_agent
243 .replace(' ', "_")
244 .replace('[', "{")
245 .replace(']', "}")
246 }
247
248 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 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}