//! In-process ring buffer of log lines, so the UI can show what the daemon is doing. //! //! Tailing a file would not survive Docker, where logs go to stdout and there is no file //! to read. Capturing inside the tracing pipeline works the same either way. use std::collections::VecDeque; use std::sync::{LazyLock, Mutex}; use tracing::field::{Field, Visit}; use tracing_subscriber::Layer; use tracing_subscriber::layer::Context; /// Kept small enough to be cheap to hold and to serialise in one response. const CAPACITY: usize = 5000; #[derive(Clone, Debug, serde::Serialize)] pub struct LogLine { /// Monotonic, so a client can ask for "everything after N" without duplicates. pub seq: u64, pub ts: i64, pub level: String, pub target: String, pub msg: String, } struct Ring { lines: VecDeque, next_seq: u64, } static BUF: LazyLock> = LazyLock::new(|| { Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 }) }); pub fn push(level: &str, target: &str, msg: String) { let mut ring = match BUF.lock() { Ok(r) => r, Err(p) => p.into_inner(), // a poisoned log buffer must not take the process down }; let seq = ring.next_seq; ring.next_seq += 1; if ring.lines.len() == CAPACITY { ring.lines.pop_front(); } ring.lines.push_back(LogLine { seq, ts: crate::db::now(), level: level.to_owned(), target: target.to_owned(), msg, }); } /// Lines newer than `after`, oldest first, plus the highest seq now held. pub fn since(after: u64, limit: usize) -> (Vec, u64) { let ring = match BUF.lock() { Ok(r) => r, Err(p) => p.into_inner(), }; let latest = ring.next_seq.saturating_sub(1); let mut out: Vec = ring .lines .iter() .filter(|l| l.seq > after) .cloned() .collect(); // On a first load (after = 0) the tail is what matters, not the head. if out.len() > limit { out.drain(..out.len() - limit); } (out, latest) } /// A tracing layer that mirrors every event into the ring. pub struct RingLayer; impl Layer for RingLayer { fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { let mut v = Collect::default(); event.record(&mut v); let meta = event.metadata(); push(meta.level().as_str(), meta.target(), v.finish()); } } #[derive(Default)] struct Collect { message: String, fields: Vec, } impl Collect { fn finish(self) -> String { if self.fields.is_empty() { self.message } else if self.message.is_empty() { self.fields.join(" ") } else { format!("{} {}", self.message, self.fields.join(" ")) } } fn add(&mut self, field: &Field, value: String) { if field.name() == "message" { self.message = value; } else { self.fields.push(format!("{}={}", field.name(), value)); } } } impl Visit for Collect { fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { self.add(field, format!("{value:?}")); } // Numbers and bools reach record_debug through the trait's defaults, which prints them the // same way. A string would print quoted there, hence its own method. fn record_str(&mut self, field: &Field, value: &str) { self.add(field, value.to_owned()); } } #[cfg(test)] mod tests { use super::*; #[test] fn the_ring_drops_oldest_and_keeps_sequence_stable() { for i in 0..(CAPACITY + 50) { push("INFO", "t", format!("line {i}")); } let (all, latest) = since(0, CAPACITY * 2); assert_eq!(all.len(), CAPACITY, "bounded"); assert!(latest >= (CAPACITY + 50) as u64); assert!( all.first().unwrap().seq < all.last().unwrap().seq, "oldest first" ); // "everything after the last one I saw" must return nothing new. let (none, _) = since(latest, 100); assert!(none.is_empty()); // A first load takes the tail, not the head. let (tail, _) = since(0, 5); assert_eq!(tail.len(), 5); assert_eq!(tail.last().unwrap().seq, latest); } }