Skip to main content

rws_server/request_log/
mod.rs

1//! In-memory ring buffer of recent HTTP requests.
2//!
3//! `LogLayer` middleware records each completed request. The global
4//! `RequestLog` holds the most recent N entries (default 1000). MCP tools
5//! `recent_requests` and `recent_errors` read from it.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! # extern crate rws_server as rust_web_server;
11//! use rust_web_server::app::App;
12//! use rust_web_server::core::New;
13//! use rust_web_server::request_log::{self, LogLayer};
14//!
15//! let app = App::new().wrap(LogLayer);
16//!
17//! let entries = request_log::global().recent(20);
18//! ```
19
20#[cfg(test)]
21mod tests;
22
23use std::collections::VecDeque;
24use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
25use std::sync::{Mutex, OnceLock};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use crate::application::Application;
29use crate::middleware::Middleware;
30use crate::request::Request;
31use crate::response::Response;
32use crate::server::ConnectionInfo;
33
34/// A single recorded request.
35#[derive(Clone)]
36pub struct LogEntry {
37    /// Unix timestamp (seconds) when the request completed.
38    pub timestamp: u64,
39    pub method: String,
40    pub path: String,
41    pub status: i16,
42    pub client_ip: String,
43    pub latency_ms: u64,
44}
45
46/// Shards in a [`RequestLog`]. Each thread appends to its own shard, so
47/// concurrent requests don't contend for one lock (a single global mutex
48/// here was the top lock wait under load).
49const SHARDS: usize = 16;
50
51/// Ring buffer of recent requests, sharded by thread; entries carry a global
52/// sequence number so readers can merge the shards in order. Each shard keeps
53/// up to `capacity` entries and readers keep the newest `capacity` overall —
54/// exactly what one ring buffer held, since the newest `capacity` entries
55/// overall are always among the newest `capacity` of their own shard.
56pub struct RequestLog {
57    shards: Vec<Mutex<VecDeque<(u64, LogEntry)>>>,
58    capacity: usize,
59    sequence: AtomicU64,
60}
61
62/// This thread's shard index, assigned round-robin on first use.
63fn shard_index() -> usize {
64    static NEXT: AtomicUsize = AtomicUsize::new(0);
65    thread_local! {
66        static INDEX: usize = NEXT.fetch_add(1, Ordering::Relaxed) % SHARDS;
67    }
68    INDEX.with(|i| *i)
69}
70
71impl RequestLog {
72    fn new(capacity: usize) -> Self {
73        RequestLog {
74            shards: (0..SHARDS).map(|_| Mutex::new(VecDeque::new())).collect(),
75            capacity: capacity.max(1),
76            sequence: AtomicU64::new(0),
77        }
78    }
79
80    #[cfg(test)]
81    fn push(&self, entry: LogEntry) {
82        self.record(entry.timestamp, &entry.method, &entry.path, entry.status, &entry.client_ip, entry.latency_ms);
83    }
84
85    /// Append one entry. A full shard reuses its oldest entry's strings, so
86    /// the steady state (every request once `capacity` is reached) allocates
87    /// nothing — it was three `String`s allocated and three freed per request.
88    fn record(&self, timestamp: u64, method: &str, path: &str, status: i16, client_ip: &str, latency_ms: u64) {
89        let seq = self.sequence.fetch_add(1, Ordering::Relaxed);
90        let mut shard = self.shards[shard_index()].lock().unwrap_or_else(|e| e.into_inner());
91        let entry = if shard.len() >= self.capacity {
92            shard.pop_front().map(|(_, mut old)| {
93                for (field, value) in [(&mut old.method, method), (&mut old.path, path), (&mut old.client_ip, client_ip)] {
94                    field.clear();
95                    field.push_str(value);
96                }
97                old.timestamp = timestamp;
98                old.status = status;
99                old.latency_ms = latency_ms;
100                old
101            })
102        } else {
103            None
104        };
105        let entry = entry.unwrap_or_else(|| LogEntry {
106            timestamp,
107            method: method.to_string(),
108            path: path.to_string(),
109            status,
110            client_ip: client_ip.to_string(),
111            latency_ms,
112        });
113        shard.push_back((seq, entry));
114    }
115
116    /// The newest `capacity` entries (oldest first) that match `keep`.
117    fn merged(&self, keep: impl Fn(&LogEntry) -> bool) -> Vec<LogEntry> {
118        let mut all: Vec<(u64, LogEntry)> = Vec::new();
119        for shard in &self.shards {
120            let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
121            all.extend(shard.iter().cloned());
122        }
123        all.sort_unstable_by_key(|(seq, _)| *seq);
124        let skip = all.len().saturating_sub(self.capacity);
125        all.into_iter().skip(skip).map(|(_, e)| e).filter(|e| keep(e)).collect()
126    }
127
128    /// Return up to `n` most recent entries (newest last).
129    pub fn recent(&self, n: usize) -> Vec<LogEntry> {
130        let all = self.merged(|_| true);
131        let skip = all.len().saturating_sub(n);
132        all.into_iter().skip(skip).collect()
133    }
134
135    /// Return up to `n` most recent entries with a 4xx or 5xx status.
136    pub fn recent_errors(&self, n: usize) -> Vec<LogEntry> {
137        let errors = self.merged(|e| e.status >= 400);
138        let skip = errors.len().saturating_sub(n);
139        errors.into_iter().skip(skip).collect()
140    }
141
142    /// Total number of entries currently held.
143    pub fn len(&self) -> usize {
144        let held: usize = self.shards.iter().map(|s| s.lock().unwrap_or_else(|e| e.into_inner()).len()).sum();
145        held.min(self.capacity)
146    }
147
148    pub fn is_empty(&self) -> bool {
149        self.len() == 0
150    }
151}
152
153static INSTANCE: OnceLock<RequestLog> = OnceLock::new();
154
155/// Return the process-wide `RequestLog` singleton (capacity 1000).
156pub fn global() -> &'static RequestLog {
157    INSTANCE.get_or_init(|| RequestLog::new(1000))
158}
159
160fn now_secs() -> u64 {
161    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
162}
163
164/// Middleware that records each request into the global [`RequestLog`].
165pub struct LogLayer;
166
167impl Middleware for LogLayer {
168    /// Headers only: a streamed body passes through untouched.
169    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
170        true
171    }
172
173
174    fn handle(
175        &self,
176        request: &Request,
177        connection: &ConnectionInfo,
178        next: &dyn Application,
179    ) -> Result<Response, String> {
180        let start = std::time::Instant::now();
181        let result = next.execute(request, connection);
182        let latency_ms = start.elapsed().as_millis() as u64;
183
184        let status = match &result {
185            Ok(r) => r.status_code,
186            Err(_) => 500,
187        };
188        let path = request.request_uri.split('?').next().unwrap_or(&request.request_uri);
189        global().record(now_secs(), &request.method, path, status, &crate::ip_filter::client_ip(request, &connection.client.ip), latency_ms);
190
191        result
192    }
193
194    /// In-memory only: safe on the event loop.
195    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
196        true
197    }
198}