1use 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#[derive(Clone, Debug)]
27pub struct LogEntry {
28 pub ts: String,
30 pub method: String,
31 pub url: String,
32 pub status: Option<u16>,
33 pub elapsed_ms: Option<u64>,
34 pub message: String,
37}
38
39#[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 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 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#[derive(Clone, Debug)]
75pub struct CockpitEntry {
76 pub ts: String,
77 pub level: String,
81 pub target: String,
84 pub message: String,
86}
87
88#[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
117pub 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 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
227pub fn install(capacity: usize) -> LogCapture {
231 GLOBAL.get_or_init(|| LogCapture::new(capacity)).clone()
232}
233
234pub fn install_cockpit(capacity: usize) -> CockpitCapture {
238 GLOBAL_COCKPIT
239 .get_or_init(|| CockpitCapture::new(capacity))
240 .clone()
241}
242
243pub fn handle() -> Option<LogCapture> {
246 GLOBAL.get().cloned()
247}
248
249pub 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 let a = install(123);
294 let b = install(456);
295 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}