use std::collections::VecDeque;
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
Trace,
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Breadcrumb {
pub ts_ms: u128,
pub level: Level,
pub category: String,
pub message: String,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub fields: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
}
pub struct Recorder {
ring: Mutex<VecDeque<Breadcrumb>>,
capacity: usize,
}
impl Recorder {
fn record(&self, crumb: Breadcrumb) {
if let Ok(mut ring) = self.ring.lock() {
if ring.len() == self.capacity {
ring.pop_front();
}
ring.push_back(crumb);
}
}
#[must_use]
pub fn snapshot(&self) -> Vec<Breadcrumb> {
self.ring
.lock()
.map(|r| r.iter().cloned().collect())
.unwrap_or_default()
}
}
static RECORDER: OnceLock<Recorder> = OnceLock::new();
pub(crate) fn init(capacity: usize) {
let _ = RECORDER.set(Recorder {
ring: Mutex::new(VecDeque::with_capacity(capacity.max(1))),
capacity: capacity.max(1),
});
}
#[must_use]
pub fn recorder() -> Option<&'static Recorder> {
RECORDER.get()
}
pub fn record(
level: Level,
category: impl Into<String>,
message: impl Into<String>,
fields: serde_json::Value,
) {
let Some(rec) = RECORDER.get() else { return };
let category = category.into();
let message = message.into();
#[cfg(feature = "shared-ring")]
if let Some(cfg) = crate::config()
&& let Some(ring) = cfg.shared_ring.as_ref()
{
let redacted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cfg.redactor.redact(&message)
}));
if let Ok(redacted) = redacted {
ring.record(level, &category, &redacted);
}
}
rec.record(Breadcrumb {
ts_ms: crate::now_ms(),
level,
category,
message,
fields,
pid: None,
});
}
#[must_use]
pub fn snapshot() -> Vec<Breadcrumb> {
RECORDER.get().map(Recorder::snapshot).unwrap_or_default()
}
#[macro_export]
macro_rules! breadcrumb {
($level:ident, $category:expr, $message:expr, $fields:tt) => {
$crate::breadcrumbs::record(
$crate::breadcrumbs::Level::$level,
$category,
$message,
$crate::serde_json::json!($fields),
)
};
($level:ident, $category:expr, $message:expr) => {
$crate::breadcrumbs::record(
$crate::breadcrumbs::Level::$level,
$category,
$message,
$crate::serde_json::Value::Null,
)
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_is_bounded_and_evicts_oldest() {
let rec = Recorder {
ring: Mutex::new(VecDeque::new()),
capacity: 3,
};
for i in 0..5 {
rec.record(Breadcrumb {
ts_ms: i as u128,
level: Level::Info,
category: "t".to_owned(),
message: format!("m{i}"),
fields: serde_json::Value::Null,
pid: None,
});
}
let snap = rec.snapshot();
assert_eq!(snap.len(), 3, "capacity bound holds");
assert_eq!(snap[0].message, "m2");
assert_eq!(snap[2].message, "m4");
}
#[test]
fn record_is_a_noop_when_uninitialized() {
record(Level::Error, "x", "y", serde_json::Value::Null);
}
}