use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{Event, Subscriber};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
#[derive(Clone, Debug)]
pub struct LogEntry {
pub ts: String,
pub method: String,
pub url: String,
pub status: Option<u16>,
pub elapsed_ms: Option<u64>,
pub message: String,
}
#[derive(Clone, Debug)]
pub struct LogCapture {
inner: Arc<Mutex<VecDeque<LogEntry>>>,
capacity: usize,
}
impl LogCapture {
pub fn new(capacity: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(VecDeque::with_capacity(capacity))),
capacity,
}
}
pub fn push(&self, entry: LogEntry) {
let mut buf = self.inner.lock().expect("log capture mutex poisoned");
if buf.len() == self.capacity {
buf.pop_front();
}
buf.push_back(entry);
}
pub fn snapshot(&self) -> Vec<LogEntry> {
let buf = self.inner.lock().expect("log capture mutex poisoned");
buf.iter().cloned().collect()
}
}
pub struct CaptureLayer {
capture: LogCapture,
}
impl CaptureLayer {
pub fn new(capture: LogCapture) -> Self {
Self { capture }
}
}
impl<S> Layer<S> for CaptureLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if event.metadata().target() != "bee::http" {
return;
}
let mut v = FieldVisitor::default();
event.record(&mut v);
self.capture.push(LogEntry {
ts: format_now_hms(),
method: v.method.unwrap_or_default(),
url: v.url.unwrap_or_default(),
status: v.status,
elapsed_ms: v.elapsed_ms,
message: v.message.unwrap_or_default(),
});
}
}
#[derive(Default)]
struct FieldVisitor {
method: Option<String>,
url: Option<String>,
status: Option<u16>,
elapsed_ms: Option<u64>,
message: Option<String>,
}
impl tracing::field::Visit for FieldVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
match field.name() {
"method" => self.method = Some(value.to_string()),
"url" => self.url = Some(value.to_string()),
_ => {}
}
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
match field.name() {
"status" => self.status = Some(value as u16),
"elapsed_ms" => self.elapsed_ms = Some(value),
_ => {}
}
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
if value >= 0 {
self.record_u64(field, value as u64);
}
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = Some(format!("{value:?}").trim_matches('"').to_string());
} else if field.name() == "method" && self.method.is_none() {
self.method = Some(format!("{value:?}").trim_matches('"').to_string());
} else if field.name() == "url" && self.url.is_none() {
self.url = Some(format!("{value:?}").trim_matches('"').to_string());
}
}
}
static GLOBAL: OnceLock<LogCapture> = OnceLock::new();
pub fn install(capacity: usize) -> LogCapture {
GLOBAL.get_or_init(|| LogCapture::new(capacity)).clone()
}
pub fn handle() -> Option<LogCapture> {
GLOBAL.get().cloned()
}
fn format_now_hms() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let in_day = secs % 86_400;
let h = in_day / 3600;
let m = (in_day / 60) % 60;
let s = in_day % 60;
format!("{h:02}:{m:02}:{s:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_buffer_evicts_oldest() {
let cap = LogCapture::new(2);
for i in 0..3 {
cap.push(LogEntry {
ts: format!("00:00:{i:02}"),
method: "GET".into(),
url: format!("/{i}"),
status: Some(200),
elapsed_ms: Some(i),
message: "test".into(),
});
}
let snap = cap.snapshot();
assert_eq!(snap.len(), 2);
assert_eq!(snap[0].url, "/1");
assert_eq!(snap[1].url, "/2");
}
#[test]
fn install_returns_same_handle_on_second_call() {
let a = install(123);
let b = install(456);
assert!(Arc::ptr_eq(&a.inner, &b.inner));
}
#[test]
fn format_now_hms_is_eight_chars() {
assert_eq!(format_now_hms().len(), 8);
}
}