lib.rsannotatedlib.rssource157 lines · 4.8 KB · raw

What a site writes to its log, and the rule that it never writes a visitor.

A line is built only from closed types: the route (one of a fixed set the site declares, never the path that was asked for), the method (one of four words), the status and how long it took. There is no way to hand [Served] an address, a header, a query or a body, so none can be logged by accident. A site never reads the client's address for its log at all.

Levels follow who was wrong: Error for a response the site got wrong (5xx), Warn for one the visitor got wrong or a file that should exist and does not (4xx), Info for a page served and Debug for the small things a page asks for. A route may say otherwise for a status.

No runtime: an adapter writes [Served] to the console (the Worker's) or to a logger.

14use std::fmt;

A site's routes: a fixed set of names a path is reduced to, once, at the edge.

17pub trait Route: Copy {

The name written to the log.

19    fn name(self) -> &'static str;

A level this route gives this status, overriding the default; None for the default.

22    fn level(self, _status: u16) -> Option<Level> {
23        None
24    }

Words appended to the line for this route and status, for a failure only the site's own build can cause (a file that was never generated). Never a request's content.

28    fn note(self, _status: u16) -> Option<&'static str> {
29        None
30    }
31}
33#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub enum Level {
35    Debug,
36    Info,
37    Warn,
38    Error,
39}

The method as one of four words, so a made-up one cannot reach the log.

42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Method {
44    Get,
45    Head,
46    Post,
47    Other,
48}
50impl Method {
51    pub fn parse(method: &str) -> Method {
52        match method {
53            "GET" => Method::Get,
54            "HEAD" => Method::Head,
55            "POST" => Method::Post,
56            _ => Method::Other,
57        }
58    }
59
60    fn word(self) -> &'static str {
61        match self {
62            Method::Get => "GET",
63            Method::Head => "HEAD",
64            Method::Post => "POST",
65            Method::Other => "OTHER",
66        }
67    }
68}

One request served.

71#[derive(Clone, Copy, Debug)]
72pub struct Served<R: Route> {
73    pub route: R,
74    pub method: Method,
75    pub status: u16,
76    pub millis: u64,
77}
79impl<R: Route> Served<R> {
80    pub fn level(&self) -> Level {
81        if self.status >= 500 {
82            return Level::Error;
83        }
84        if let Some(level) = self.route.level(self.status) {
85            return level;
86        }
87        if (400..=499).contains(&self.status) { Level::Warn } else { Level::Debug }
88    }
89}
90
91impl<R: Route> fmt::Display for Served<R> {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "{} {} -> {} in {} ms", self.method.word(), self.route.name(), self.status, self.millis)?;
94        if let Some(note) = self.route.note(self.status) {
95            write!(f, " ({note})")?;
96        }
97        Ok(())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[derive(Clone, Copy)]
106    enum R {
107        Page,
108        Asset,
109        Missing,
110    }
111
112    impl Route for R {
113        fn name(self) -> &'static str {
114            match self {
115                R::Page => "page",
116                R::Asset => "asset",
117                R::Missing => "missing",
118            }
119        }
120
121        fn level(self, status: u16) -> Option<Level> {
122            match (self, status) {
123                (R::Page, 200) => Some(Level::Info),
124                (R::Asset, 404) => Some(Level::Debug),
125                (R::Missing, _) => Some(Level::Warn),
126                _ => None,
127            }
128        }
129
130        fn note(self, _status: u16) -> Option<&'static str> {
131            matches!(self, R::Missing).then_some("run the build")
132        }
133    }
134
135    fn served(route: R, status: u16) -> Served<R> {
136        Served { route, method: Method::Get, status, millis: 3 }
137    }
138
139    #[test]
140    fn levels_follow_who_was_wrong() {
141        assert_eq!(served(R::Page, 200).level(), Level::Info);
142        assert_eq!(served(R::Asset, 200).level(), Level::Debug);
143        assert_eq!(served(R::Asset, 404).level(), Level::Debug);
144        assert_eq!(served(R::Page, 404).level(), Level::Warn);
145        assert_eq!(served(R::Missing, 404).level(), Level::Warn);
146        assert_eq!(served(R::Page, 500).level(), Level::Error);
147        assert_eq!(served(R::Missing, 503).level(), Level::Error, "a 5xx is the site's, whatever the route says");
148    }
149
150    #[test]
151    fn a_line_says_what_happened_and_nothing_about_who_asked() {
152        assert_eq!(served(R::Asset, 200).to_string(), "GET asset -> 200 in 3 ms");
153        assert_eq!(served(R::Missing, 404).to_string(), "GET missing -> 404 in 3 ms (run the build)");
154        assert_eq!(Method::parse("BREW"), Method::Other);
155        assert_eq!(Method::parse("POST"), Method::Post);
156    }
157}