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, 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/// Layer plugged into `tracing-subscriber::registry()` from
72/// [`crate::logging::init`]. Only events at target `bee::http` are
73/// captured; everything else passes through untouched.
74pub struct CaptureLayer {
75    capture: LogCapture,
76}
77
78impl CaptureLayer {
79    pub fn new(capture: LogCapture) -> Self {
80        Self { capture }
81    }
82}
83
84impl<S> Layer<S> for CaptureLayer
85where
86    S: Subscriber + for<'a> LookupSpan<'a>,
87{
88    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
89        if event.metadata().target() != "bee::http" {
90            return;
91        }
92        let mut v = FieldVisitor::default();
93        event.record(&mut v);
94        self.capture.push(LogEntry {
95            ts: format_now_hms(),
96            method: v.method.unwrap_or_default(),
97            url: v.url.unwrap_or_default(),
98            status: v.status,
99            elapsed_ms: v.elapsed_ms,
100            message: v.message.unwrap_or_default(),
101        });
102    }
103}
104
105#[derive(Default)]
106struct FieldVisitor {
107    method: Option<String>,
108    url: Option<String>,
109    status: Option<u16>,
110    elapsed_ms: Option<u64>,
111    message: Option<String>,
112}
113
114impl tracing::field::Visit for FieldVisitor {
115    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
116        match field.name() {
117            "method" => self.method = Some(value.to_string()),
118            "url" => self.url = Some(value.to_string()),
119            _ => {}
120        }
121    }
122
123    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
124        match field.name() {
125            "status" => self.status = Some(value as u16),
126            "elapsed_ms" => self.elapsed_ms = Some(value),
127            _ => {}
128        }
129    }
130
131    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
132        if value >= 0 {
133            self.record_u64(field, value as u64);
134        }
135    }
136
137    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
138        if field.name() == "message" {
139            self.message = Some(format!("{value:?}").trim_matches('"').to_string());
140        } else if field.name() == "method" && self.method.is_none() {
141            self.method = Some(format!("{value:?}").trim_matches('"').to_string());
142        } else if field.name() == "url" && self.url.is_none() {
143            self.url = Some(format!("{value:?}").trim_matches('"').to_string());
144        }
145    }
146}
147
148static GLOBAL: OnceLock<LogCapture> = OnceLock::new();
149
150/// Construct the process-wide capture buffer and remember it. Called
151/// once during [`crate::logging::init`]. Subsequent calls return the
152/// already-installed capture.
153pub fn install(capacity: usize) -> LogCapture {
154    GLOBAL.get_or_init(|| LogCapture::new(capacity)).clone()
155}
156
157/// Borrow the installed capture, if any. Returns `None` before
158/// [`install`] has been called.
159pub fn handle() -> Option<LogCapture> {
160    GLOBAL.get().cloned()
161}
162
163fn format_now_hms() -> String {
164    let secs = SystemTime::now()
165        .duration_since(UNIX_EPOCH)
166        .map(|d| d.as_secs())
167        .unwrap_or_default();
168    let in_day = secs % 86_400;
169    let h = in_day / 3600;
170    let m = (in_day / 60) % 60;
171    let s = in_day % 60;
172    format!("{h:02}:{m:02}:{s:02}")
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn ring_buffer_evicts_oldest() {
181        let cap = LogCapture::new(2);
182        for i in 0..3 {
183            cap.push(LogEntry {
184                ts: format!("00:00:{i:02}"),
185                method: "GET".into(),
186                url: format!("/{i}"),
187                status: Some(200),
188                elapsed_ms: Some(i),
189                message: "test".into(),
190            });
191        }
192        let snap = cap.snapshot();
193        assert_eq!(snap.len(), 2);
194        assert_eq!(snap[0].url, "/1");
195        assert_eq!(snap[1].url, "/2");
196    }
197
198    #[test]
199    fn install_returns_same_handle_on_second_call() {
200        // Reset is hard for a OnceLock; rely on test ordering by using
201        // a fresh capacity that any previous test won't have set.
202        let a = install(123);
203        let b = install(456);
204        // Same Arc — installation is idempotent.
205        assert!(Arc::ptr_eq(&a.inner, &b.inner));
206    }
207
208    #[test]
209    fn format_now_hms_is_eight_chars() {
210        assert_eq!(format_now_hms().len(), 8);
211    }
212}