Skip to main content

bee_tui/
log_capture.rs

1//! In-memory ring buffer of HTTP-traffic log events, fed by a custom
2//! `tracing-subscriber` layer that filters to the `bee::http` target.
3//!
4//! `bee::Inner::send` (in bee-rs ≥ 1.3.0) emits a single
5//! `tracing::debug!` event per request carrying `method`, `url`,
6//! `status`, and `elapsed_ms`. The S10 Command-log pane subscribes
7//! to this buffer to render the lazygit-style request tail without
8//! re-instrumenting any code.
9//!
10//! Architecture
11//! - One process-wide [`LogCapture`] (initialised via [`install`]).
12//! - `LogCapture::push` is called by [`CaptureLayer::on_event`].
13//! - `LogCapture::snapshot` returns a `Vec<LogEntry>` cheap clone for
14//!   rendering — the lock is held only to `clone` the deque.
15
16use std::collections::VecDeque;
17use std::sync::{Arc, Mutex, OnceLock};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use tracing::{Event, Level, Subscriber};
21use tracing_subscriber::Layer;
22use tracing_subscriber::layer::Context;
23use tracing_subscriber::registry::LookupSpan;
24
25/// One captured `bee::http` event.
26#[derive(Clone, Debug)]
27pub struct LogEntry {
28    /// Pre-formatted UTC `HH:MM:SS` of capture (cheap to render).
29    pub ts: String,
30    pub method: String,
31    pub url: String,
32    pub status: Option<u16>,
33    pub elapsed_ms: Option<u64>,
34    /// Free-form message text from the event (e.g. "bee api request",
35    /// "bee api error response").
36    pub message: String,
37}
38
39/// Bounded ring buffer; cloning shares the underlying `Arc`.
40#[derive(Clone, Debug)]
41pub struct LogCapture {
42    inner: Arc<Mutex<VecDeque<LogEntry>>>,
43    capacity: usize,
44}
45
46impl LogCapture {
47    pub fn new(capacity: usize) -> Self {
48        Self {
49            inner: Arc::new(Mutex::new(VecDeque::with_capacity(capacity))),
50            capacity,
51        }
52    }
53
54    /// Append one entry; evict oldest when full.
55    pub fn push(&self, entry: LogEntry) {
56        let mut buf = self.inner.lock().expect("log capture mutex poisoned");
57        if buf.len() == self.capacity {
58            buf.pop_front();
59        }
60        buf.push_back(entry);
61    }
62
63    /// Cheap snapshot for rendering — collects entries to a Vec under
64    /// a brief lock and returns it.
65    pub fn snapshot(&self) -> Vec<LogEntry> {
66        let buf = self.inner.lock().expect("log capture mutex poisoned");
67        buf.iter().cloned().collect()
68    }
69}
70
71/// One captured cockpit-internal event (anything that is NOT
72/// `bee::http`). Lives in a separate ring from [`LogEntry`] so the
73/// cockpit-log tab and the bee::http tab are non-overlapping.
74#[derive(Clone, Debug)]
75pub struct CockpitEntry {
76    pub ts: String,
77    /// Severity level rendered as a fixed token (`ERROR`/`WARN`/`INFO`
78    /// /`DEBUG`/`TRACE`). Pre-formatted so the renderer doesn't have
79    /// to map levels.
80    pub level: String,
81    /// Tracing target — the module path the event came from. Helps
82    /// readers tell `bee_tui::watch` from `bee_tui::supervisor` etc.
83    pub target: String,
84    /// Free-form message text.
85    pub message: String,
86}
87
88/// Bounded ring buffer for cockpit-internal events.
89#[derive(Clone, Debug)]
90pub struct CockpitCapture {
91    inner: Arc<Mutex<VecDeque<CockpitEntry>>>,
92    capacity: usize,
93}
94
95impl CockpitCapture {
96    pub fn new(capacity: usize) -> Self {
97        Self {
98            inner: Arc::new(Mutex::new(VecDeque::with_capacity(capacity))),
99            capacity,
100        }
101    }
102
103    pub fn push(&self, entry: CockpitEntry) {
104        let mut buf = self.inner.lock().expect("cockpit capture mutex poisoned");
105        if buf.len() == self.capacity {
106            buf.pop_front();
107        }
108        buf.push_back(entry);
109    }
110
111    pub fn snapshot(&self) -> Vec<CockpitEntry> {
112        let buf = self.inner.lock().expect("cockpit capture mutex poisoned");
113        buf.iter().cloned().collect()
114    }
115}
116
117/// Layer plugged into `tracing-subscriber::registry()` from
118/// [`crate::logging::init`]. Splits events by target:
119///   * `bee::http` → [`LogCapture`] (the bee::http tab).
120///   * everything else → [`CockpitCapture`] (the new Cockpit tab).
121///
122/// One layer instead of two so a single `tracing` event walks the
123/// subscriber chain just once.
124pub struct CaptureLayer {
125    capture: LogCapture,
126    cockpit: CockpitCapture,
127}
128
129impl CaptureLayer {
130    pub fn new(capture: LogCapture, cockpit: CockpitCapture) -> Self {
131        Self { capture, cockpit }
132    }
133}
134
135impl<S> Layer<S> for CaptureLayer
136where
137    S: Subscriber + for<'a> LookupSpan<'a>,
138{
139    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
140        let target = event.metadata().target();
141        if target == "bee::http" {
142            let mut v = FieldVisitor::default();
143            event.record(&mut v);
144            self.capture.push(LogEntry {
145                ts: format_now_hms(),
146                method: v.method.unwrap_or_default(),
147                url: v.url.unwrap_or_default(),
148                status: v.status,
149                elapsed_ms: v.elapsed_ms,
150                message: v.message.unwrap_or_default(),
151            });
152            return;
153        }
154        // Everything else lands on the Cockpit tab. Filter out events
155        // emitted by tracing's own internals so the tab doesn't fill
156        // up with subscriber-bookkeeping noise.
157        if target.starts_with("tracing") {
158            return;
159        }
160        let mut v = FieldVisitor::default();
161        event.record(&mut v);
162        self.cockpit.push(CockpitEntry {
163            ts: format_now_hms(),
164            level: format_level(*event.metadata().level()),
165            target: target.to_string(),
166            message: v.message.unwrap_or_default(),
167        });
168    }
169}
170
171fn format_level(l: Level) -> String {
172    match l {
173        Level::ERROR => "ERROR".into(),
174        Level::WARN => "WARN".into(),
175        Level::INFO => "INFO".into(),
176        Level::DEBUG => "DEBUG".into(),
177        Level::TRACE => "TRACE".into(),
178    }
179}
180
181#[derive(Default)]
182struct FieldVisitor {
183    method: Option<String>,
184    url: Option<String>,
185    status: Option<u16>,
186    elapsed_ms: Option<u64>,
187    message: Option<String>,
188}
189
190impl tracing::field::Visit for FieldVisitor {
191    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
192        match field.name() {
193            "method" => self.method = Some(value.to_string()),
194            "url" => self.url = Some(value.to_string()),
195            _ => {}
196        }
197    }
198
199    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
200        match field.name() {
201            "status" => self.status = Some(value as u16),
202            "elapsed_ms" => self.elapsed_ms = Some(value),
203            _ => {}
204        }
205    }
206
207    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
208        if value >= 0 {
209            self.record_u64(field, value as u64);
210        }
211    }
212
213    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
214        if field.name() == "message" {
215            self.message = Some(format!("{value:?}").trim_matches('"').to_string());
216        } else if field.name() == "method" && self.method.is_none() {
217            self.method = Some(format!("{value:?}").trim_matches('"').to_string());
218        } else if field.name() == "url" && self.url.is_none() {
219            self.url = Some(format!("{value:?}").trim_matches('"').to_string());
220        }
221    }
222}
223
224static GLOBAL: OnceLock<LogCapture> = OnceLock::new();
225static GLOBAL_COCKPIT: OnceLock<CockpitCapture> = OnceLock::new();
226
227/// Construct the process-wide bee::http capture buffer and remember
228/// it. Called once during [`crate::logging::init`]. Subsequent calls
229/// return the already-installed capture.
230pub fn install(capacity: usize) -> LogCapture {
231    GLOBAL.get_or_init(|| LogCapture::new(capacity)).clone()
232}
233
234/// Construct the process-wide cockpit-internal capture buffer.
235/// Capacity defaults large because cockpit events are bounded mostly
236/// by the operator's own action volume, not by network chatter.
237pub fn install_cockpit(capacity: usize) -> CockpitCapture {
238    GLOBAL_COCKPIT
239        .get_or_init(|| CockpitCapture::new(capacity))
240        .clone()
241}
242
243/// Borrow the installed capture, if any. Returns `None` before
244/// [`install`] has been called.
245pub fn handle() -> Option<LogCapture> {
246    GLOBAL.get().cloned()
247}
248
249/// Borrow the installed cockpit-capture, if any.
250pub fn cockpit_handle() -> Option<CockpitCapture> {
251    GLOBAL_COCKPIT.get().cloned()
252}
253
254fn format_now_hms() -> String {
255    let secs = SystemTime::now()
256        .duration_since(UNIX_EPOCH)
257        .map(|d| d.as_secs())
258        .unwrap_or_default();
259    let in_day = secs % 86_400;
260    let h = in_day / 3600;
261    let m = (in_day / 60) % 60;
262    let s = in_day % 60;
263    format!("{h:02}:{m:02}:{s:02}")
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn ring_buffer_evicts_oldest() {
272        let cap = LogCapture::new(2);
273        for i in 0..3 {
274            cap.push(LogEntry {
275                ts: format!("00:00:{i:02}"),
276                method: "GET".into(),
277                url: format!("/{i}"),
278                status: Some(200),
279                elapsed_ms: Some(i),
280                message: "test".into(),
281            });
282        }
283        let snap = cap.snapshot();
284        assert_eq!(snap.len(), 2);
285        assert_eq!(snap[0].url, "/1");
286        assert_eq!(snap[1].url, "/2");
287    }
288
289    #[test]
290    fn install_returns_same_handle_on_second_call() {
291        // Reset is hard for a OnceLock; rely on test ordering by using
292        // a fresh capacity that any previous test won't have set.
293        let a = install(123);
294        let b = install(456);
295        // Same Arc — installation is idempotent.
296        assert!(Arc::ptr_eq(&a.inner, &b.inner));
297    }
298
299    #[test]
300    fn format_now_hms_is_eight_chars() {
301        assert_eq!(format_now_hms().len(), 8);
302    }
303
304    #[test]
305    fn cockpit_ring_evicts_oldest() {
306        let cap = CockpitCapture::new(2);
307        for i in 0..3 {
308            cap.push(CockpitEntry {
309                ts: format!("00:00:{i:02}"),
310                level: "INFO".into(),
311                target: "bee_tui::test".into(),
312                message: format!("msg {i}"),
313            });
314        }
315        let snap = cap.snapshot();
316        assert_eq!(snap.len(), 2);
317        assert!(snap[0].message.contains("msg 1"));
318        assert!(snap[1].message.contains("msg 2"));
319    }
320
321    #[test]
322    fn install_cockpit_returns_same_handle() {
323        let a = install_cockpit(321);
324        let b = install_cockpit(654);
325        assert!(Arc::ptr_eq(&a.inner, &b.inner));
326    }
327
328    #[test]
329    fn format_level_round_trip() {
330        assert_eq!(format_level(Level::ERROR), "ERROR");
331        assert_eq!(format_level(Level::WARN), "WARN");
332        assert_eq!(format_level(Level::INFO), "INFO");
333        assert_eq!(format_level(Level::DEBUG), "DEBUG");
334        assert_eq!(format_level(Level::TRACE), "TRACE");
335    }
336}