137 lines
3.9 KiB
Rust
137 lines
3.9 KiB
Rust
|
|
//! Bounded in-memory ring buffer for server log output.
|
||
|
|
//!
|
||
|
|
//! Use the [`slog!`] macro as a drop-in replacement for `eprintln!`. It writes
|
||
|
|
//! to stderr (same as before) and simultaneously appends the line to the global
|
||
|
|
//! ring buffer, making it retrievable via the `get_server_logs` MCP tool.
|
||
|
|
|
||
|
|
use std::collections::VecDeque;
|
||
|
|
use std::sync::{Mutex, OnceLock};
|
||
|
|
|
||
|
|
const CAPACITY: usize = 1000;
|
||
|
|
|
||
|
|
pub struct LogBuffer {
|
||
|
|
lines: Mutex<VecDeque<String>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl LogBuffer {
|
||
|
|
fn new() -> Self {
|
||
|
|
Self {
|
||
|
|
lines: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Append a log line, evicting the oldest entry when at capacity.
|
||
|
|
pub fn push(&self, line: String) {
|
||
|
|
if let Ok(mut buf) = self.lines.lock() {
|
||
|
|
if buf.len() >= CAPACITY {
|
||
|
|
buf.pop_front();
|
||
|
|
}
|
||
|
|
buf.push_back(line);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Return up to `count` recent lines, optionally filtered by a substring.
|
||
|
|
/// Lines are returned in chronological order (oldest first).
|
||
|
|
pub fn get_recent(&self, count: usize, filter: Option<&str>) -> Vec<String> {
|
||
|
|
let buf = match self.lines.lock() {
|
||
|
|
Ok(b) => b,
|
||
|
|
Err(_) => return vec![],
|
||
|
|
};
|
||
|
|
let filtered: Vec<&String> = buf
|
||
|
|
.iter()
|
||
|
|
.filter(|line| filter.is_none_or(|f| line.contains(f)))
|
||
|
|
.collect();
|
||
|
|
let start = filtered.len().saturating_sub(count);
|
||
|
|
filtered[start..].iter().map(|s| (*s).clone()).collect()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
|
||
|
|
|
||
|
|
/// Access the process-wide log ring buffer.
|
||
|
|
pub fn global() -> &'static LogBuffer {
|
||
|
|
GLOBAL.get_or_init(LogBuffer::new)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Write a formatted message to stderr **and** capture it in the ring buffer.
|
||
|
|
///
|
||
|
|
/// Usage is identical to `eprintln!`:
|
||
|
|
/// ```ignore
|
||
|
|
/// slog!("agent {} started", name);
|
||
|
|
/// ```
|
||
|
|
#[macro_export]
|
||
|
|
macro_rules! slog {
|
||
|
|
($($arg:tt)*) => {{
|
||
|
|
let _line = format!($($arg)*);
|
||
|
|
eprintln!("{}", _line);
|
||
|
|
$crate::log_buffer::global().push(_line);
|
||
|
|
}};
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
fn fresh_buffer() -> LogBuffer {
|
||
|
|
LogBuffer::new()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn push_and_retrieve() {
|
||
|
|
let buf = fresh_buffer();
|
||
|
|
buf.push("line one".into());
|
||
|
|
buf.push("line two".into());
|
||
|
|
let recent = buf.get_recent(10, None);
|
||
|
|
assert_eq!(recent, vec!["line one", "line two"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn evicts_oldest_at_capacity() {
|
||
|
|
let buf = LogBuffer {
|
||
|
|
lines: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
||
|
|
};
|
||
|
|
// Fill past capacity
|
||
|
|
for i in 0..=CAPACITY {
|
||
|
|
buf.push(format!("line {i}"));
|
||
|
|
}
|
||
|
|
let recent = buf.get_recent(CAPACITY + 1, None);
|
||
|
|
// Should have exactly CAPACITY lines
|
||
|
|
assert_eq!(recent.len(), CAPACITY);
|
||
|
|
// The oldest (line 0) should have been evicted
|
||
|
|
assert!(!recent.iter().any(|l| l == "line 0"));
|
||
|
|
// The newest should be present
|
||
|
|
assert!(recent.iter().any(|l| l == &format!("line {CAPACITY}")));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn filter_by_substring() {
|
||
|
|
let buf = fresh_buffer();
|
||
|
|
buf.push("watcher started".into());
|
||
|
|
buf.push("mcp call received".into());
|
||
|
|
buf.push("watcher event".into());
|
||
|
|
|
||
|
|
let filtered = buf.get_recent(100, Some("watcher"));
|
||
|
|
assert_eq!(filtered.len(), 2);
|
||
|
|
assert_eq!(filtered[0], "watcher started");
|
||
|
|
assert_eq!(filtered[1], "watcher event");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn count_limits_results() {
|
||
|
|
let buf = fresh_buffer();
|
||
|
|
for i in 0..10 {
|
||
|
|
buf.push(format!("line {i}"));
|
||
|
|
}
|
||
|
|
let recent = buf.get_recent(3, None);
|
||
|
|
assert_eq!(recent.len(), 3);
|
||
|
|
// Most recent 3
|
||
|
|
assert_eq!(recent, vec!["line 7", "line 8", "line 9"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn empty_buffer_returns_empty() {
|
||
|
|
let buf = fresh_buffer();
|
||
|
|
assert!(buf.get_recent(10, None).is_empty());
|
||
|
|
}
|
||
|
|
}
|