whiskers.git / web / worker / src / log.rs
log.rsannotatedlog.rssource194 lines · 6.6 KB · raw
1//! What the Worker writes to its log, and the rule that it never writes a visitor.
2//!
3//! A line is built only from the closed types below: the route (one of a fixed set, never the
4//! path the visitor asked for), the method, the status and how long it took. There is no way
5//! to hand `line` an address, a header, a query or a body, so none can be logged by accident.
6//! (The Worker never reads the client's address at all.) Cloudflare keeps its own request
7//! records for the account as any host does; this file is only about what the code writes.
8//!
9//! Levels: `error` for a response the site got wrong (5xx), `warn` for one the visitor got
10//! wrong or a file that should exist and does not (4xx), `info` for a page served, `debug`
11//! for the small things a page asks for.
12
13use std::fmt;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Route {
17    Page,
18    Script,
19    Style,
20    /// One of the shared fonts (`jev_ui::asset`).
21    Font,
22    Icon,
23    Mood,
24    Mascot,
25    Tick,
26    Tour,
27    Robots,
28    /// The link preview's video, which this Worker answers itself (`media.rs`).
29    Video,
30    /// The link preview's picture, which this Worker passes to the assets (`image` in `lib.rs`).
31    Image,
32    /// A file under `/preview/` that the assets did not serve: it was never generated.
33    PreviewMissing,
34    Other,
35}
36
37impl Route {
38    /// Which route a path is. The path itself goes no further than this function.
39    pub fn of(path: &str) -> Route {
40        match path {
41            "/" => Route::Page,
42            "/datastar.js" | "/ui.js" => Route::Script,
43            "/cat.css" | "/ui.css" => Route::Style,
44            p if p.starts_with("/fonts/") => Route::Font,
45            "/favicon.svg" => Route::Icon,
46            "/mood" => Route::Mood,
47            "/mascot" => Route::Mascot,
48            "/tick" => Route::Tick,
49            "/tour" => Route::Tour,
50            "/robots.txt" => Route::Robots,
51            crate::media::PATH => Route::Video,
52            "/preview/card.png" => Route::Image,
53            p if p.starts_with("/preview/") => Route::PreviewMissing,
54            _ => Route::Other,
55        }
56    }
57
58    fn name(self) -> &'static str {
59        match self {
60            Route::Page => "page",
61            Route::Script => "script",
62            Route::Style => "style",
63            Route::Font => "font",
64            Route::Icon => "icon",
65            Route::Mood => "mood",
66            Route::Mascot => "mascot",
67            Route::Tick => "tick",
68            Route::Tour => "tour",
69            Route::Robots => "robots",
70            Route::Video => "video",
71            Route::Image => "image",
72            Route::PreviewMissing => "preview-missing",
73            Route::Other => "other",
74        }
75    }
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum Level {
80    Debug,
81    Info,
82    Warn,
83    Error,
84}
85
86pub struct Served {
87    pub route: Route,
88    pub method: &'static str,
89    pub status: u16,
90    pub millis: u64,
91}
92
93impl Served {
94    pub fn level(&self) -> Level {
95        match (self.status, self.route) {
96            (500.., _) => Level::Error,
97            (_, Route::PreviewMissing) => Level::Warn,
98            (400..=499, Route::Other | Route::Font) | (416, Route::Video) => Level::Debug,
99            (400..=499, _) => Level::Warn,
100            (_, Route::Page) => Level::Info,
101            _ => Level::Debug,
102        }
103    }
104}
105
106impl fmt::Display for Served {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        write!(f, "{} {} -> {} in {} ms", self.method, self.route.name(), self.status, self.millis)?;
109        if self.route == Route::PreviewMissing {
110            write!(f, " (run web/tools/preview: the link preview's files were not built)")?;
111        }
112        Ok(())
113    }
114}
115
116/// The method as a fixed word, so a made-up one cannot reach the log.
117pub fn method(method: &http::Method) -> &'static str {
118    match *method {
119        http::Method::GET => "GET",
120        http::Method::HEAD => "HEAD",
121        http::Method::POST => "POST",
122        _ => "OTHER",
123    }
124}
125
126pub fn write(served: &Served) {
127    emit(served.level(), &served.to_string());
128}
129
130#[cfg(target_arch = "wasm32")]
131fn emit(level: Level, line: &str) {
132    match level {
133        Level::Debug => worker::console_debug!("{line}"),
134        Level::Info => worker::console_log!("{line}"),
135        Level::Warn => worker::console_warn!("{line}"),
136        Level::Error => worker::console_error!("{line}"),
137    }
138}
139
140/// Native builds (the tests) have no console to write to.
141#[cfg(not(target_arch = "wasm32"))]
142fn emit(_level: Level, _line: &str) {}
143
144/// Something the Worker itself got wrong, in words that carry no request.
145#[cfg(target_arch = "wasm32")]
146pub fn failure(what: &str) {
147    worker::console_error!("{what}");
148}
149
150#[cfg(not(target_arch = "wasm32"))]
151pub fn failure(_what: &str) {}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn served(route: Route, status: u16) -> Served {
158        Served { route, method: "GET", status, millis: 3 }
159    }
160
161    #[test]
162    fn a_path_is_reduced_to_a_route_and_goes_no_further() {
163        assert_eq!(Route::of("/"), Route::Page);
164        assert_eq!(Route::of("/mood"), Route::Mood);
165        assert_eq!(Route::of("/mascot"), Route::Mascot);
166        assert_eq!(Route::of("/preview/loop.mp4"), Route::Video);
167        assert_eq!(Route::of("/preview/card.png"), Route::Image);
168        assert_eq!(Route::of("/preview/other.png"), Route::PreviewMissing);
169        assert_eq!(Route::of("/wp-login.php?x=1.2.3.4"), Route::Other);
170        let line = served(Route::of("/some/secret/path"), 404).to_string();
171        assert!(!line.contains("secret"), "{line}");
172    }
173
174    #[test]
175    fn levels_follow_who_was_wrong() {
176        assert_eq!(served(Route::Page, 200).level(), Level::Info);
177        assert_eq!(served(Route::Style, 200).level(), Level::Debug);
178        assert_eq!(served(Route::Mood, 422).level(), Level::Warn);
179        assert_eq!(served(Route::Other, 404).level(), Level::Debug);
180        assert_eq!(served(Route::PreviewMissing, 404).level(), Level::Warn);
181        assert_eq!(served(Route::Image, 200).level(), Level::Debug);
182        assert_eq!(served(Route::Image, 404).level(), Level::Warn);
183        assert_eq!(served(Route::Video, 206).level(), Level::Debug);
184        assert_eq!(served(Route::Video, 416).level(), Level::Debug);
185        assert_eq!(served(Route::Video, 404).level(), Level::Warn);
186        assert_eq!(served(Route::Page, 500).level(), Level::Error);
187    }
188
189    #[test]
190    fn a_line_says_what_happened_and_nothing_about_who_asked() {
191        assert_eq!(served(Route::Tick, 200).to_string(), "GET tick -> 200 in 3 ms");
192        assert_eq!(method(&http::Method::from_bytes(b"BREW").unwrap()), "OTHER");
193    }
194}