Skip to main content

trusty_common/
log_buffer.rs

1//! Bounded in-memory ring buffer of recent tracing log lines.
2//!
3//! Why: Operators debugging a running daemon want the last N log lines
4//!      without SSHing to the box, tailing a file, or restarting with a
5//!      different `RUST_LOG`. A small in-process ring buffer lets the daemon
6//!      serve recent logs over HTTP (`GET /logs/tail`) at near-zero cost and
7//!      with no file I/O. The cap keeps memory bounded on a long-running
8//!      process.
9//! What: [`LogBuffer`] is a thread-safe `VecDeque<String>` capped at a fixed
10//!      capacity; the oldest line is evicted on overflow. [`LogBufferLayer`]
11//!      is a `tracing_subscriber::Layer` that formats every event into one
12//!      line and pushes it onto the buffer. The HTTP handler drains the tail.
13//! Test: see the `tests` module — capacity eviction, tail semantics, and a
14//!      layer-integration test that emits events through a real subscriber.
15
16use std::collections::VecDeque;
17use std::fmt::Write as _;
18use std::sync::{Arc, Mutex};
19
20use tracing::field::{Field, Visit};
21use tracing_subscriber::Layer;
22use tracing_subscriber::layer::Context;
23
24/// Default ring-buffer capacity (lines). Sized so a daemon retains a few
25/// minutes of INFO-level chatter while costing well under 1 MB of RAM.
26pub const DEFAULT_LOG_CAPACITY: usize = 1000;
27
28/// Thread-safe, bounded ring buffer of formatted log lines.
29///
30/// Why: shared between the tracing `Layer` (writer) and the HTTP handler
31///      (reader); both hold cheap `Arc` clones of the same underlying deque.
32/// What: wraps `Arc<Mutex<VecDeque<String>>>`. `push` appends and evicts the
33///      oldest line past capacity; `tail` snapshots the most recent N lines.
34/// Test: `capacity_evicts_oldest`, `tail_returns_last_n`.
35#[derive(Clone, Debug)]
36pub struct LogBuffer {
37    inner: Arc<Mutex<VecDeque<String>>>,
38    capacity: usize,
39}
40
41impl LogBuffer {
42    /// Create an empty buffer with the given line capacity.
43    ///
44    /// Why: callers (daemon startup) choose the cap; tests use a tiny one.
45    /// What: allocates a `VecDeque` with `capacity.max(1)` reserved slots so a
46    ///      zero capacity is treated as one (a zero-cap ring is useless and
47    ///      would panic on the eviction arithmetic).
48    /// Test: `capacity_evicts_oldest`.
49    #[must_use]
50    pub fn new(capacity: usize) -> Self {
51        let capacity = capacity.max(1);
52        Self {
53            inner: Arc::new(Mutex::new(VecDeque::with_capacity(capacity))),
54            capacity,
55        }
56    }
57
58    /// Append a line, evicting the oldest entry when at capacity.
59    ///
60    /// Why: a tracing `Layer` calls this on every event; it must never panic
61    ///      or block long. A poisoned mutex (a prior panic while logging) is
62    ///      recovered via `into_inner` so logging itself never cascades a
63    ///      panic into the daemon.
64    /// What: pushes `line` to the back; if length now exceeds `capacity`,
65    ///      pops the front.
66    /// Test: `capacity_evicts_oldest`.
67    pub fn push(&self, line: String) {
68        let mut guard = match self.inner.lock() {
69            Ok(g) => g,
70            Err(poisoned) => poisoned.into_inner(),
71        };
72        guard.push_back(line);
73        while guard.len() > self.capacity {
74            guard.pop_front();
75        }
76    }
77
78    /// Snapshot the most recent `n` lines (or all, when `n` exceeds the
79    /// current length).
80    ///
81    /// Why: the `/logs/tail` handler returns these as a JSON array. Cloning
82    ///      under the lock keeps the critical section short and lets the
83    ///      caller serialise without holding the mutex.
84    /// What: returns a `Vec<String>` of at most `n` lines, oldest-first.
85    /// Test: `tail_returns_last_n`, `tail_all_when_n_exceeds_len`.
86    #[must_use]
87    pub fn tail(&self, n: usize) -> Vec<String> {
88        let guard = match self.inner.lock() {
89            Ok(g) => g,
90            Err(poisoned) => poisoned.into_inner(),
91        };
92        let skip = guard.len().saturating_sub(n);
93        guard.iter().skip(skip).cloned().collect()
94    }
95
96    /// Total number of lines currently buffered.
97    ///
98    /// Why: the `/logs/tail` response reports `total` so callers can tell
99    ///      whether the buffer has wrapped.
100    /// What: returns the deque length.
101    /// Test: `tail_returns_last_n` asserts `len` after pushes.
102    #[must_use]
103    pub fn len(&self) -> usize {
104        match self.inner.lock() {
105            Ok(g) => g.len(),
106            Err(poisoned) => poisoned.into_inner().len(),
107        }
108    }
109
110    /// Whether the buffer holds no lines.
111    ///
112    /// Why: clippy requires `is_empty` alongside `len`; also a convenient
113    ///      readiness check in tests.
114    /// What: returns `len() == 0`.
115    /// Test: covered by `capacity_evicts_oldest`.
116    #[must_use]
117    pub fn is_empty(&self) -> bool {
118        self.len() == 0
119    }
120}
121
122/// `tracing_subscriber::Layer` that mirrors every event into a [`LogBuffer`].
123///
124/// Why: wiring this layer into the subscriber means the daemon's normal
125///      `tracing::info!` / `warn!` calls are captured for `/logs/tail` with
126///      no extra call sites — the buffer stays in lock-step with stderr.
127/// What: on each event, formats `[<level> <target>] <message> k=v …` into a
128///      single line and pushes it. Level/target/fields are collected via a
129///      lightweight `Visit` implementation.
130/// Test: `layer_captures_events` installs the layer on a real subscriber and
131///      asserts an emitted event lands in the buffer.
132pub struct LogBufferLayer {
133    buffer: LogBuffer,
134}
135
136impl LogBufferLayer {
137    /// Wrap a [`LogBuffer`] as a tracing layer.
138    ///
139    /// Why: the daemon constructs the buffer first (so it can also hand a
140    ///      clone to its HTTP state) and then builds the layer around it.
141    /// What: stores a clone of the buffer handle.
142    /// Test: `layer_captures_events`.
143    #[must_use]
144    pub fn new(buffer: LogBuffer) -> Self {
145        Self { buffer }
146    }
147}
148
149/// Field visitor that accumulates an event's message and key/value fields
150/// into a single human-readable string.
151///
152/// Why: tracing events expose their data only through the `Visit` callback;
153///      we render it to text once so the buffer stores plain `String`s.
154/// What: the canonical `message` field becomes the line body; every other
155///      field is appended as ` key=value`.
156/// Test: exercised indirectly by `layer_captures_events`.
157struct LineVisitor {
158    message: String,
159    fields: String,
160}
161
162impl LineVisitor {
163    fn new() -> Self {
164        Self {
165            message: String::new(),
166            fields: String::new(),
167        }
168    }
169}
170
171impl Visit for LineVisitor {
172    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
173        if field.name() == "message" {
174            // `{:?}` on the message preserves it without surrounding quotes
175            // for string payloads in practice; use Display-ish formatting.
176            let _ = write!(self.message, "{value:?}");
177        } else {
178            let _ = write!(self.fields, " {}={value:?}", field.name());
179        }
180    }
181
182    fn record_str(&mut self, field: &Field, value: &str) {
183        if field.name() == "message" {
184            self.message.push_str(value);
185        } else {
186            let _ = write!(self.fields, " {}={value}", field.name());
187        }
188    }
189}
190
191impl<S: tracing::Subscriber> Layer<S> for LogBufferLayer {
192    fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
193        let meta = event.metadata();
194        let mut visitor = LineVisitor::new();
195        event.record(&mut visitor);
196        // Trim the leading `"` artefact that `{:?}` adds for the message when
197        // the payload was a quoted string literal — keep lines readable.
198        let message = visitor.message.trim_matches('"');
199        let line = format!(
200            "[{} {}] {}{}",
201            meta.level(),
202            meta.target(),
203            message,
204            visitor.fields
205        );
206        self.buffer.push(line);
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn capacity_evicts_oldest() {
216        let buf = LogBuffer::new(3);
217        assert!(buf.is_empty());
218        for i in 0..5 {
219            buf.push(format!("line {i}"));
220        }
221        // Capacity 3 → only the last three survive.
222        assert_eq!(buf.len(), 3);
223        assert_eq!(buf.tail(10), vec!["line 2", "line 3", "line 4"]);
224    }
225
226    #[test]
227    fn tail_returns_last_n() {
228        let buf = LogBuffer::new(100);
229        for i in 0..10 {
230            buf.push(format!("l{i}"));
231        }
232        assert_eq!(buf.len(), 10);
233        assert_eq!(buf.tail(3), vec!["l7", "l8", "l9"]);
234    }
235
236    #[test]
237    fn tail_all_when_n_exceeds_len() {
238        let buf = LogBuffer::new(100);
239        buf.push("only".to_string());
240        assert_eq!(buf.tail(50), vec!["only"]);
241        assert_eq!(buf.tail(0), Vec::<String>::new());
242    }
243
244    #[test]
245    fn zero_capacity_treated_as_one() {
246        let buf = LogBuffer::new(0);
247        buf.push("a".to_string());
248        buf.push("b".to_string());
249        assert_eq!(buf.tail(10), vec!["b"]);
250    }
251
252    #[test]
253    fn layer_captures_events() {
254        use tracing_subscriber::layer::SubscriberExt;
255
256        let buffer = LogBuffer::new(10);
257        let subscriber = tracing_subscriber::registry().with(LogBufferLayer::new(buffer.clone()));
258        tracing::subscriber::with_default(subscriber, || {
259            tracing::info!(answer = 42, "hello from test");
260        });
261        let lines = buffer.tail(10);
262        assert_eq!(lines.len(), 1, "expected one captured line, got {lines:?}");
263        let line = &lines[0];
264        assert!(line.contains("hello from test"), "line was: {line}");
265        assert!(line.contains("answer=42"), "line was: {line}");
266        assert!(line.contains("INFO"), "line was: {line}");
267    }
268}