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}