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;
9
10/// Logging helpers for access logs and server info.
11///
12/// [`Log::combined`] produces standard Combined Log Format lines compatible with
13/// GoAccess, AWStats, and similar tools. All three server code paths (HTTP/1.1,
14/// HTTP/2, HTTP/3) use it.
15pub struct Log;
16
17impl Log {
18    pub fn request_response(request: &Request, response: &Response, peer_addr: &SocketAddr) -> String {
19        let mut request_headers = "".to_string();
20        for header in &request.headers {
21            if &header.name.chars().count() > &0 {
22                request_headers = [
23                    request_headers,
24                    "\n  ".to_string(),
25                    header.name.to_string(),
26                    ": ".to_string(),
27                    header.value.to_string()
28                ].join("");
29            }
30        }
31
32        let mut response_headers = "".to_string();
33        for header in &response.headers {
34            if &header.name.chars().count() > &0 {
35                response_headers = [
36                    response_headers,
37                    "\n  ".to_string(),
38                    header.name.to_string(),
39                    ": ".to_string(),
40                    header.value.to_string()
41                ].join("");
42            }
43        }
44
45        let mut response_body_length = 0;
46        let mut response_body_parts_number = 0;
47        for content_range in &response.content_range_list {
48            let boxed_parse = content_range.size.parse::<i32>();
49            if boxed_parse.is_ok() {
50                response_body_length += boxed_parse.unwrap();
51                response_body_parts_number += 1;
52            }
53        }
54
55        let current_thread = thread::current();
56        let thread_id = current_thread.name().unwrap();
57
58        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",
59                                           thread_id,
60                                           peer_addr,
61                                           &request.http_version,
62                                           &request.method,
63                                           &request.request_uri,
64                                           request_headers,
65                                           request.body.len(),
66
67                                           &response.status_code,
68                                           &response.reason_phrase,
69                                           response_headers,
70                                           response_body_parts_number,
71                                           response_body_length);
72
73        log_request_response
74    }
75
76    pub fn usage_information() -> String {
77        let mut log = "Usage:\n\n".to_string();
78        let command_line_arg_list = CommandLineArgument::get_command_line_arg_list();
79        for arg in command_line_arg_list {
80            let flags = if arg.short_form.is_empty() {
81                format!("--{}", arg.long_form)
82            } else {
83                format!("-{} or --{}", arg.short_form, arg.long_form)
84            };
85            let argument_info = format!("  {} environment variable\n  {} as command line line argument\n  {}\n\n", arg.environment_variable, flags, arg._hint.unwrap());
86            log = [log, argument_info].join("");
87        }
88        log = [log, "End of usage section\n\n".to_string()].join("");
89        log
90    }
91
92    /// The user rws runs as, for the startup banner: `$USER`, `$LOGNAME` or
93    /// `%USERNAME%`, else `"unknown"`. Deliberately not `whoami` (what
94    /// `FileExt::get_current_user` runs): minimal container images such as
95    /// distroless have no `whoami`, and the banner's `unwrap()` on that
96    /// error crashed the published Docker image at startup.
97    pub fn current_user() -> String {
98        ["USER", "LOGNAME", "USERNAME"]
99            .iter()
100            .find_map(|var| std::env::var(var).ok().filter(|v| !v.trim().is_empty()))
101            .unwrap_or_else(|| "unknown".to_string())
102    }
103
104    pub fn info(name: &str) -> String {
105        let mut log = format!("{}\n", name).to_string();
106        const VERSION: &str = env!("CARGO_PKG_VERSION");
107        const AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
108        const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
109        const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
110        const RUST_VERSION: &str = env!("CARGO_PKG_RUST_VERSION");
111        const LICENSE: &str = env!("CARGO_PKG_LICENSE");
112        let user = Log::current_user();
113        let working_directory = crate::app::controller::static_resource::working_directory().to_string();
114
115        let version = format!("Version:           {}\n", VERSION);
116        log = [log, version].join("");
117
118        let authors = format!("Authors:           {}\n", AUTHORS);
119        log = [log, authors].join("");
120
121        let repository = format!("Repository:        {}\n", REPOSITORY);
122        log = [log, repository].join("");
123
124        let description = format!("Description:       {}\n", DESCRIPTION);
125        log = [log, description].join("");
126
127        let rust_version = format!("Rust Version:      {}\n", RUST_VERSION);
128        log = [log, rust_version].join("");
129
130        let license = format!("License:           {}\n", LICENSE);
131        log = [log, license].join("");
132
133        let license = format!("User:              {}\n", user);
134        log = [log, license].join("");
135
136        let license = format!("Working Directory: {}\n", working_directory);
137        log = [log, license].join("");
138
139        log
140    }
141
142    /// Returns a structured JSON log line for one request/response pair.
143    ///
144    /// Enable with `RWS_CONFIG_LOG_FORMAT=json`. Useful for log aggregators
145    /// (Loki, Fluentd, Datadog) that ingest JSON from pod stdout.
146    pub fn json(request: &Request, response: &Response, peer_addr: &SocketAddr) -> String {
147        use std::time::{SystemTime, UNIX_EPOCH};
148
149        let body_size: usize = response.content_range_list.iter()
150            .map(|cr| cr.body.len())
151            .sum();
152
153        let secs = SystemTime::now()
154            .duration_since(UNIX_EPOCH)
155            .unwrap_or_default()
156            .as_secs();
157
158        let timestamp = Log::format_iso8601(secs);
159
160        fn escape(s: &str) -> String {
161            s.replace('\\', "\\\\")
162             .replace('"', "\\\"")
163             .replace('\n', "\\n")
164             .replace('\r', "\\r")
165        }
166
167        format!(
168            "{{\"time\":\"{}\",\"remote_addr\":\"{}\",\"method\":\"{}\",\"path\":\"{}\",\"protocol\":\"{}\",\"status\":{},\"bytes\":{}}}",
169            timestamp,
170            peer_addr.ip(),
171            escape(&request.method),
172            escape(&request.request_uri),
173            escape(&request.http_version),
174            response.status_code,
175            body_size,
176        )
177    }
178
179    /// Writes one access log line to stdout using the format configured by
180    /// `RWS_CONFIG_LOG_FORMAT` (`"combined"` or `"json"`). Call this instead of
181    /// `Log::combined` so that format selection is centralised.
182    pub fn log_access(request: &Request, response: &Response, peer_addr: &SocketAddr) {
183        if let Some(line) = Log::access_log_line(request, response, peer_addr) {
184            println!("{}", line);
185        }
186    }
187
188    /// The access-log line for `RWS_CONFIG_LOG_FORMAT`: `json` (the `rws`
189    /// binary's default), Combined Log Format for any other value (including
190    /// unset, in library use), or `None` for `off`/`none` — no per-request
191    /// line, e.g. behind a proxy that already logs or when benchmarking.
192    /// Taken from `ServerConfig::current()`, so it's hot-reloadable.
193    pub fn access_log_line(request: &Request, response: &Response, peer_addr: &SocketAddr) -> Option<String> {
194        let config = crate::server_config::ServerConfig::current();
195        let format = &config.log_format;
196        if format.eq_ignore_ascii_case("off") || format.eq_ignore_ascii_case("none") {
197            return None;
198        }
199        Some(if format == "json" {
200            Log::json(request, response, peer_addr)
201        } else {
202            Log::combined(request, response, peer_addr)
203        })
204    }
205
206    fn format_iso8601(secs: u64) -> String {
207        let sec = secs % 60;
208        let min = (secs / 60) % 60;
209        let hour = (secs / 3600) % 24;
210        let days = secs / 86400;
211        let (year, month, day) = Log::days_to_ymd(days);
212        format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hour, min, sec)
213    }
214
215    /// Returns a Combined Log Format (CLF) line for one request/response pair:
216    /// `IP - - [DD/Mon/YYYY:HH:MM:SS +0000] "METHOD URI VERSION" STATUS SIZE`
217    pub fn combined(request: &Request, response: &Response, peer_addr: &SocketAddr) -> String {
218        use std::time::{SystemTime, UNIX_EPOCH};
219
220        let body_size: usize = response.content_range_list.iter()
221            .map(|cr| cr.body.len())
222            .sum();
223
224        let secs = SystemTime::now()
225            .duration_since(UNIX_EPOCH)
226            .unwrap_or_default()
227            .as_secs();
228
229        let timestamp = Log::format_clf_timestamp(secs);
230
231        let body_str = if body_size > 0 { body_size.to_string() } else { "-".to_string() };
232
233        format!("{} - - [{}] \"{} {} {}\" {} {}",
234            peer_addr.ip(),
235            timestamp,
236            request.method,
237            request.request_uri,
238            request.http_version,
239            response.status_code,
240            body_str,
241        )
242    }
243
244    fn format_clf_timestamp(secs: u64) -> String {
245        let sec = secs % 60;
246        let min = (secs / 60) % 60;
247        let hour = (secs / 3600) % 24;
248        let days = secs / 86400;
249        let (year, month, day) = Log::days_to_ymd(days);
250        const MONTHS: [&str; 12] = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
251        format!("{:02}/{}/{:04}:{:02}:{:02}:{:02} +0000",
252            day, MONTHS[(month - 1) as usize], year, hour, min, sec)
253    }
254
255    fn days_to_ymd(days: u64) -> (u64, u64, u64) {
256        let z = days + 719468;
257        let era = z / 146097;
258        let doe = z % 146097;
259        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
260        let y = yoe + era * 400;
261        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
262        let mp = (5 * doy + 2) / 153;
263        let d = doy - (153 * mp + 2) / 5 + 1;
264        let m = if mp < 10 { mp + 3 } else { mp - 9 };
265        let y = if m <= 2 { y + 1 } else { y };
266        (y, m, d)
267    }
268
269    pub fn server_url_thread_count(protocol: &str, bind_addr: &String, thread_count: i32) -> String {
270        let url = format!("Server is up and running at: {}://{}\n", protocol, &bind_addr);
271        let thread_count = format!("Spawned {} thread(s) to handle incoming requests\n", thread_count);
272        [url, thread_count].join("")
273    }
274}