Skip to main content

rust_web_server/log/
mod.rs

1use std::net::SocketAddr;
2use std::thread;
3use crate::entry_point::command_line_args::CommandLineArgument;
4use crate::request::Request;
5use crate::response::Response;
6
7#[cfg(test)]
8mod tests;
9pub(crate) mod access_sink;
10
11/// Logging helpers for access logs and server info.
12///
13/// [`Log::combined`] produces standard Combined Log Format lines compatible with
14/// GoAccess, AWStats, and similar tools. All three server code paths (HTTP/1.1,
15/// HTTP/2, HTTP/3) use it.
16pub struct Log;
17
18impl Log {
19    pub fn request_response(request: &Request, response: &Response, peer_addr: &SocketAddr) -> String {
20        let mut request_headers = "".to_string();
21        for header in &request.headers {
22            if &header.name.chars().count() > &0 {
23                request_headers = [
24                    request_headers,
25                    "\n  ".to_string(),
26                    header.name.to_string(),
27                    ": ".to_string(),
28                    header.value.to_string()
29                ].join("");
30            }
31        }
32
33        let mut response_headers = "".to_string();
34        for header in &response.headers {
35            if &header.name.chars().count() > &0 {
36                response_headers = [
37                    response_headers,
38                    "\n  ".to_string(),
39                    header.name.to_string(),
40                    ": ".to_string(),
41                    header.value.to_string()
42                ].join("");
43            }
44        }
45
46        let mut response_body_length = 0;
47        let mut response_body_parts_number = 0;
48        for content_range in &response.content_range_list {
49            let boxed_parse = content_range.size.parse::<i32>();
50            if boxed_parse.is_ok() {
51                response_body_length += boxed_parse.unwrap();
52                response_body_parts_number += 1;
53            }
54        }
55
56        let current_thread = thread::current();
57        let thread_id = current_thread.name().unwrap();
58
59        let log_request_response = format!("\n\nRequest (thread id: {} peer address is {}):\n  {} {} {}  {}\n  Body: {} byte(s) total (including default initialization vector)\nEnd of Request\nResponse:\n  {} {} {}\n\n  Body: {} part(s), {} byte(s) total\nEnd of Response",
60                                           thread_id,
61                                           peer_addr,
62                                           &request.http_version,
63                                           &request.method,
64                                           &request.request_uri,
65                                           request_headers,
66                                           request.body.len(),
67
68                                           &response.status_code,
69                                           &response.reason_phrase,
70                                           response_headers,
71                                           response_body_parts_number,
72                                           response_body_length);
73
74        log_request_response
75    }
76
77    pub fn usage_information() -> String {
78        let mut log = "Usage:\n\n".to_string();
79        let command_line_arg_list = CommandLineArgument::get_command_line_arg_list();
80        for arg in command_line_arg_list {
81            let flags = if arg.short_form.is_empty() {
82                format!("--{}", arg.long_form)
83            } else {
84                format!("-{} or --{}", arg.short_form, arg.long_form)
85            };
86            let argument_info = format!("  {} environment variable\n  {} as command line line argument\n  {}\n\n", arg.environment_variable, flags, arg._hint.unwrap());
87            log = [log, argument_info].join("");
88        }
89        log = [log, "End of usage section\n\n".to_string()].join("");
90        log
91    }
92
93    /// The user rws runs as, for the startup banner: `$USER`, `$LOGNAME` or
94    /// `%USERNAME%`, else `"unknown"`. Deliberately not `whoami` (what
95    /// `FileExt::get_current_user` runs): minimal container images such as
96    /// distroless have no `whoami`, and the banner's `unwrap()` on that
97    /// error crashed the published Docker image at startup.
98    pub fn current_user() -> String {
99        ["USER", "LOGNAME", "USERNAME"]
100            .iter()
101            .find_map(|var| std::env::var(var).ok().filter(|v| !v.trim().is_empty()))
102            .unwrap_or_else(|| "unknown".to_string())
103    }
104
105    pub fn info(name: &str) -> String {
106        let mut log = format!("{}\n", name).to_string();
107        const VERSION: &str = env!("CARGO_PKG_VERSION");
108        const AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
109        const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
110        const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
111        const RUST_VERSION: &str = env!("CARGO_PKG_RUST_VERSION");
112        const LICENSE: &str = env!("CARGO_PKG_LICENSE");
113        let user = Log::current_user();
114        let working_directory = crate::app::controller::static_resource::working_directory().to_string();
115
116        let version = format!("Version:           {}\n", VERSION);
117        log = [log, version].join("");
118
119        let authors = format!("Authors:           {}\n", AUTHORS);
120        log = [log, authors].join("");
121
122        let repository = format!("Repository:        {}\n", REPOSITORY);
123        log = [log, repository].join("");
124
125        let description = format!("Description:       {}\n", DESCRIPTION);
126        log = [log, description].join("");
127
128        let rust_version = format!("Rust Version:      {}\n", RUST_VERSION);
129        log = [log, rust_version].join("");
130
131        let license = format!("License:           {}\n", LICENSE);
132        log = [log, license].join("");
133
134        let license = format!("User:              {}\n", user);
135        log = [log, license].join("");
136
137        let license = format!("Working Directory: {}\n", working_directory);
138        log = [log, license].join("");
139
140        log
141    }
142
143    /// Returns a structured JSON log line for one request/response pair.
144    ///
145    /// Enable with `RWS_CONFIG_LOG_FORMAT=json`. Useful for log aggregators
146    /// (Loki, Fluentd, Datadog) that ingest JSON from pod stdout.
147    pub fn json(request: &Request, response: &Response, peer_addr: &SocketAddr) -> String {
148        let mut line = Vec::with_capacity(160);
149        Log::write_json(&mut line, request, response, peer_addr);
150        bytes_to_string(line)
151    }
152
153    /// [`Log::json`] appended to `out`, without intermediate strings.
154    fn write_json(out: &mut Vec<u8>, request: &Request, response: &Response, peer_addr: &SocketAddr) {
155        use std::io::Write;
156        let body_size: usize = response.content_range_list.iter().map(|cr| cr.body.len()).sum();
157        out.extend_from_slice(b"{\"time\":\"");
158        with_timestamps(|iso, _| out.extend_from_slice(iso.as_bytes()));
159        out.extend_from_slice(b"\",\"remote_addr\":\"");
160        let _ = write!(out, "{}", peer_addr.ip());
161        out.extend_from_slice(b"\",\"method\":\"");
162        push_json_escaped(out, &request.method);
163        out.extend_from_slice(b"\",\"path\":\"");
164        push_json_escaped(out, &request.request_uri);
165        out.extend_from_slice(b"\",\"protocol\":\"");
166        push_json_escaped(out, &request.http_version);
167        out.extend_from_slice(b"\",\"status\":");
168        let _ = write!(out, "{}", response.status_code);
169        out.extend_from_slice(b",\"bytes\":");
170        out.extend_from_slice(crate::ext::decimal::Decimal::new(body_size as u128).as_str().as_bytes());
171        out.push(b'}');
172    }
173
174    /// Writes one access log line to stdout using the format configured by
175    /// `RWS_CONFIG_LOG_FORMAT` (`"combined"` or `"json"`). Call this instead of
176    /// `Log::combined` so that format selection is centralised.
177    ///
178    /// Lines are buffered per thread and written by a background thread every
179    /// 100 ms (and when a thread's buffer passes 32 KiB, when the thread
180    /// exits, and at process exit); [`Log::flush_access_log`] writes them now.
181    pub fn log_access(request: &Request, response: &Response, peer_addr: &SocketAddr) {
182        let config = crate::server_config::ServerConfig::current();
183        let format = &config.log_format;
184        if format.eq_ignore_ascii_case("off") || format.eq_ignore_ascii_case("none") {
185            return;
186        }
187        let json = format == "json";
188        access_sink::write_line(|out| {
189            if json {
190                Log::write_json(out, request, response, peer_addr)
191            } else {
192                Log::write_combined(out, request, response, peer_addr)
193            }
194        });
195    }
196
197    /// Write out the buffered access-log lines now (they're otherwise written
198    /// within 100 ms) — e.g. before exiting without returning from `main`.
199    pub fn flush_access_log() {
200        access_sink::flush();
201    }
202
203    /// The access-log line for `RWS_CONFIG_LOG_FORMAT`: `json` (the `rws`
204    /// binary's default), Combined Log Format for any other value (including
205    /// unset, in library use), or `None` for `off`/`none` — no per-request
206    /// line, e.g. behind a proxy that already logs or when benchmarking.
207    /// Taken from `ServerConfig::current()`, so it's hot-reloadable.
208    pub fn access_log_line(request: &Request, response: &Response, peer_addr: &SocketAddr) -> Option<String> {
209        let config = crate::server_config::ServerConfig::current();
210        let format = &config.log_format;
211        if format.eq_ignore_ascii_case("off") || format.eq_ignore_ascii_case("none") {
212            return None;
213        }
214        Some(if format == "json" {
215            Log::json(request, response, peer_addr)
216        } else {
217            Log::combined(request, response, peer_addr)
218        })
219    }
220
221    fn format_iso8601(secs: u64) -> String {
222        let sec = secs % 60;
223        let min = (secs / 60) % 60;
224        let hour = (secs / 3600) % 24;
225        let days = secs / 86400;
226        let (year, month, day) = Log::days_to_ymd(days);
227        format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hour, min, sec)
228    }
229
230    /// Returns a Combined Log Format (CLF) line for one request/response pair:
231    /// `IP - - [DD/Mon/YYYY:HH:MM:SS +0000] "METHOD URI VERSION" STATUS SIZE`
232    pub fn combined(request: &Request, response: &Response, peer_addr: &SocketAddr) -> String {
233        let mut line = Vec::with_capacity(128);
234        Log::write_combined(&mut line, request, response, peer_addr);
235        bytes_to_string(line)
236    }
237
238    /// [`Log::combined`] appended to `out`, without intermediate strings.
239    fn write_combined(out: &mut Vec<u8>, request: &Request, response: &Response, peer_addr: &SocketAddr) {
240        use std::io::Write;
241        let body_size: usize = response.content_range_list.iter().map(|cr| cr.body.len()).sum();
242        let _ = write!(out, "{}", peer_addr.ip());
243        out.extend_from_slice(b" - - [");
244        with_timestamps(|_, clf| out.extend_from_slice(clf.as_bytes()));
245        out.extend_from_slice(b"] \"");
246        out.extend_from_slice(request.method.as_bytes());
247        out.push(b' ');
248        out.extend_from_slice(request.request_uri.as_bytes());
249        out.push(b' ');
250        out.extend_from_slice(request.http_version.as_bytes());
251        out.extend_from_slice(b"\" ");
252        let _ = write!(out, "{}", response.status_code);
253        out.push(b' ');
254        if body_size > 0 {
255            out.extend_from_slice(crate::ext::decimal::Decimal::new(body_size as u128).as_str().as_bytes());
256        } else {
257            out.push(b'-');
258        }
259    }
260
261    fn format_clf_timestamp(secs: u64) -> String {
262        let sec = secs % 60;
263        let min = (secs / 60) % 60;
264        let hour = (secs / 3600) % 24;
265        let days = secs / 86400;
266        let (year, month, day) = Log::days_to_ymd(days);
267        const MONTHS: [&str; 12] = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
268        format!("{:02}/{}/{:04}:{:02}:{:02}:{:02} +0000",
269            day, MONTHS[(month - 1) as usize], year, hour, min, sec)
270    }
271
272    fn days_to_ymd(days: u64) -> (u64, u64, u64) {
273        let z = days + 719468;
274        let era = z / 146097;
275        let doe = z % 146097;
276        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
277        let y = yoe + era * 400;
278        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
279        let mp = (5 * doy + 2) / 153;
280        let d = doy - (153 * mp + 2) / 5 + 1;
281        let m = if mp < 10 { mp + 3 } else { mp - 9 };
282        let y = if m <= 2 { y + 1 } else { y };
283        (y, m, d)
284    }
285
286    pub fn server_url_thread_count(protocol: &str, bind_addr: &String, thread_count: i32) -> String {
287        let url = format!("Server is up and running at: {}://{}\n", protocol, &bind_addr);
288        let thread_count = format!("Spawned {} thread(s) to handle incoming requests\n", thread_count);
289        [url, thread_count].join("")
290    }
291}
292/// The current second's ISO 8601 and Combined Log Format timestamps,
293/// formatted at most once per second per thread.
294fn with_timestamps(use_them: impl FnOnce(&str, &str)) {
295    use std::cell::RefCell;
296    use std::time::{SystemTime, UNIX_EPOCH};
297    thread_local! {
298        static CACHED: RefCell<(u64, String, String)> = const { RefCell::new((u64::MAX, String::new(), String::new())) };
299    }
300    let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
301    CACHED.with(|cached| {
302        let mut cached = cached.borrow_mut();
303        if cached.0 != secs {
304            *cached = (secs, Log::format_iso8601(secs), Log::format_clf_timestamp(secs));
305        }
306        use_them(&cached.1, &cached.2);
307    });
308}
309
310/// `s` JSON-escaped as the access log always did it: backslash, double
311/// quote, newline and carriage return.
312fn push_json_escaped(out: &mut Vec<u8>, s: &str) {
313    for b in s.bytes() {
314        match b {
315            b'\\' => out.extend_from_slice(b"\\\\"),
316            b'"' => out.extend_from_slice(b"\\\""),
317            b'\n' => out.extend_from_slice(b"\\n"),
318            b'\r' => out.extend_from_slice(b"\\r"),
319            _ => out.push(b),
320        }
321    }
322}
323
324/// A line built only from `&str` pieces is UTF-8.
325fn bytes_to_string(bytes: Vec<u8>) -> String {
326    String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
327}