use std::collections::VecDeque;
use std::fmt::Write as _;
use std::sync::{Arc, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use parking_lot::RwLock;
use serde::Serialize;
use tracing::field::{Field, Visit};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::Layer;
pub const DEFAULT_CAPACITY: usize = 2_000;
pub const CAPACITY_ENV_VAR: &str = "FEAGI_LOG_RING_BUFFER_CAPACITY";
#[derive(Debug, Clone, Serialize)]
pub struct LogRecord {
pub timestamp_ms: i64,
pub level: String,
pub target: String,
pub file: String,
pub line: u32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<serde_json::Value>,
}
pub struct LogRingBuffer {
inner: RwLock<VecDeque<LogRecord>>,
capacity: usize,
}
impl LogRingBuffer {
pub fn new(capacity: usize) -> Self {
let initial_capacity = capacity.min(64);
Self {
inner: RwLock::new(VecDeque::with_capacity(initial_capacity)),
capacity,
}
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn push(&self, record: LogRecord) {
if self.capacity == 0 {
return;
}
let mut inner = self.inner.write();
if inner.len() == self.capacity {
inner.pop_front();
}
inner.push_back(record);
}
pub fn snapshot(
&self,
since_ts_ms: Option<i64>,
min_level: Option<&str>,
target_prefix: Option<&str>,
limit: Option<usize>,
) -> Vec<LogRecord> {
let inner = self.inner.read();
let min_rank = min_level.and_then(level_rank);
let mut filtered: Vec<LogRecord> = inner
.iter()
.filter(|r| match since_ts_ms {
Some(ts) => r.timestamp_ms >= ts,
None => true,
})
.filter(|r| match (min_rank, level_rank(&r.level)) {
(Some(min), Some(level)) => level >= min,
_ => true,
})
.filter(|r| match target_prefix {
Some(prefix) => r.target.starts_with(prefix),
None => true,
})
.cloned()
.collect();
if let Some(n) = limit {
if filtered.len() > n {
let skip = filtered.len() - n;
filtered.drain(0..skip);
}
}
filtered
}
pub fn clear(&self) {
self.inner.write().clear();
}
}
fn level_rank(level: &str) -> Option<u8> {
match level.to_ascii_uppercase().as_str() {
"TRACE" => Some(0),
"DEBUG" => Some(1),
"INFO" => Some(2),
"WARN" | "WARNING" => Some(3),
"ERROR" => Some(4),
_ => None,
}
}
fn level_to_str(level: &Level) -> &'static str {
match *level {
Level::TRACE => "TRACE",
Level::DEBUG => "DEBUG",
Level::INFO => "INFO",
Level::WARN => "WARN",
Level::ERROR => "ERROR",
}
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub struct RingBufferLayer {
buffer: Arc<LogRingBuffer>,
}
impl RingBufferLayer {
pub fn new(buffer: Arc<LogRingBuffer>) -> Self {
Self { buffer }
}
}
impl<S: Subscriber> Layer<S> for RingBufferLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if self.buffer.capacity() == 0 {
return;
}
let metadata = event.metadata();
let mut visitor = MessageVisitor::default();
event.record(&mut visitor);
let fields = if visitor.extra_fields.is_empty() {
None
} else {
Some(serde_json::Value::Object(visitor.extra_fields))
};
let record = LogRecord {
timestamp_ms: now_unix_ms(),
level: level_to_str(metadata.level()).to_string(),
target: metadata.target().to_string(),
file: metadata.file().unwrap_or_default().to_string(),
line: metadata.line().unwrap_or(0),
message: visitor.message,
fields,
};
self.buffer.push(record);
}
}
#[derive(Default)]
struct MessageVisitor {
message: String,
extra_fields: serde_json::Map<String, serde_json::Value>,
}
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
let _ = write!(&mut self.message, "{:?}", value);
} else {
self.extra_fields.insert(
field.name().to_string(),
serde_json::Value::String(format!("{:?}", value)),
);
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.message.push_str(value);
} else {
self.extra_fields.insert(
field.name().to_string(),
serde_json::Value::String(value.to_string()),
);
}
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.extra_fields
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.extra_fields
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_f64(&mut self, field: &Field, value: f64) {
self.extra_fields
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.extra_fields
.insert(field.name().to_string(), serde_json::json!(value));
}
}
static GLOBAL_RING: OnceLock<Arc<LogRingBuffer>> = OnceLock::new();
pub fn install_global_ring(capacity: usize) -> Arc<LogRingBuffer> {
GLOBAL_RING
.get_or_init(|| Arc::new(LogRingBuffer::new(capacity)))
.clone()
}
pub fn capacity_from_env() -> usize {
std::env::var(CAPACITY_ENV_VAR)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(DEFAULT_CAPACITY)
}
pub fn global_ring() -> Option<Arc<LogRingBuffer>> {
GLOBAL_RING.get().cloned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_buffer_respects_capacity() {
let ring = LogRingBuffer::new(3);
for i in 0..5 {
ring.push(LogRecord {
timestamp_ms: i as i64,
level: "INFO".into(),
target: "test".into(),
file: String::new(),
line: 0,
message: format!("msg-{}", i),
fields: None,
});
}
let snap = ring.snapshot(None, None, None, None);
assert_eq!(snap.len(), 3);
assert_eq!(snap[0].message, "msg-2");
assert_eq!(snap[2].message, "msg-4");
}
#[test]
fn snapshot_filters_by_level_and_target() {
let ring = LogRingBuffer::new(10);
ring.push(LogRecord {
timestamp_ms: 100,
level: "DEBUG".into(),
target: "feagi-api".into(),
file: String::new(),
line: 0,
message: "debug".into(),
fields: None,
});
ring.push(LogRecord {
timestamp_ms: 200,
level: "ERROR".into(),
target: "feagi-burst-engine".into(),
file: String::new(),
line: 0,
message: "error".into(),
fields: None,
});
ring.push(LogRecord {
timestamp_ms: 300,
level: "WARN".into(),
target: "feagi-api".into(),
file: String::new(),
line: 0,
message: "warn".into(),
fields: None,
});
let warnings = ring.snapshot(None, Some("warn"), None, None);
assert_eq!(warnings.len(), 2);
let api_only = ring.snapshot(None, None, Some("feagi-api"), None);
assert_eq!(api_only.len(), 2);
assert!(api_only.iter().all(|r| r.target.starts_with("feagi-api")));
let recent = ring.snapshot(Some(250), None, None, None);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].timestamp_ms, 300);
let limited = ring.snapshot(None, None, None, Some(2));
assert_eq!(limited.len(), 2);
assert_eq!(limited.last().unwrap().timestamp_ms, 300);
}
#[test]
fn capacity_zero_disables_buffer() {
let ring = LogRingBuffer::new(0);
ring.push(LogRecord {
timestamp_ms: 0,
level: "INFO".into(),
target: "test".into(),
file: String::new(),
line: 0,
message: "x".into(),
fields: None,
});
assert!(ring.snapshot(None, None, None, None).is_empty());
}
}