use crate::recorder::Recorder;
use std::{
io::Write as _,
path::{Path, PathBuf},
sync::{Condvar, Mutex, OnceLock},
time::{Duration, Instant},
};
const DEFAULT_BYTES_PER_SHARD: usize = 16 << 20;
const DEFAULT_THROTTLE_MS: u64 = 1000;
const DEFAULT_MAX_DUMPS: u64 = 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum LimitPolicy {
KeepOldest,
KeepNewest,
}
impl LimitPolicy {
fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"keep-oldest" | "oldest" => Some(Self::KeepOldest),
"keep-newest" | "newest" => Some(Self::KeepNewest),
_ => None,
}
}
}
const MAX_DEFAULT_SHARDS: usize = 16;
struct Global {
recorder: Recorder,
dumper: Dumper,
host: String,
}
struct Dumper {
state: Mutex<bool>,
condvar: Condvar,
path: PathBuf,
throttle: Duration,
max_dumps: Option<u64>,
limit_policy: LimitPolicy,
}
static GLOBAL: OnceLock<Global> = OnceLock::new();
pub fn recorder() -> &'static Recorder {
&global().recorder
}
fn global() -> &'static Global {
GLOBAL.get_or_init(build)
}
#[inline]
pub fn record<E: crate::event::Event>(event: &E) {
global().recorder.record(event);
}
pub fn enable() {
global().recorder.set_enabled(true);
}
pub fn disable() {
global().recorder.set_enabled(false);
}
pub fn is_enabled() -> bool {
global().recorder.is_enabled()
}
pub fn trigger() {
let g = global();
let mut requested = g.dumper.state.lock().unwrap_or_else(|e| e.into_inner());
*requested = true;
g.dumper.condvar.notify_one();
}
fn build() -> Global {
let shards = env_usize("BACKBEAT_SHARDS")
.unwrap_or_else(default_shards)
.max(1);
let bytes = env_usize("BACKBEAT_BYTES")
.unwrap_or(DEFAULT_BYTES_PER_SHARD)
.max(4096);
let recorder = Recorder::new(shards, bytes);
let path = std::env::var_os("BACKBEAT_PATH")
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::temp_dir().join(format!("backbeat.{}.bb", std::process::id()))
});
let throttle_ms = env_u64("BACKBEAT_THROTTLE_MS").unwrap_or(DEFAULT_THROTTLE_MS);
let max_dumps = match env_u64("BACKBEAT_MAX_DUMPS") {
None => Some(DEFAULT_MAX_DUMPS),
Some(0) => None,
Some(n) => Some(n),
};
let limit_policy = std::env::var("BACKBEAT_LIMIT_POLICY")
.ok()
.and_then(|s| LimitPolicy::parse(&s))
.unwrap_or(LimitPolicy::KeepOldest);
let host = std::env::var("BACKBEAT_HOST")
.ok()
.filter(|h| !h.is_empty())
.unwrap_or_else(hostname);
let dumper = Dumper {
state: Mutex::new(false),
condvar: Condvar::new(),
path,
throttle: Duration::from_millis(throttle_ms),
max_dumps,
limit_policy,
};
let g = Global {
recorder,
dumper,
host,
};
if env_truthy("BACKBEAT_ENABLE") {
g.recorder.set_enabled(true);
}
spawn_dumper();
if env_truthy("BACKBEAT_DUMP_ON_PANIC") {
install_panic_hook();
}
#[cfg(unix)]
if let Some(sig) = std::env::var("BACKBEAT_SIGNAL")
.ok()
.and_then(|s| signal::parse(&s))
{
signal::install_full(sig);
}
g
}
fn default_shards() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(MAX_DEFAULT_SHARDS)
}
fn hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME").or_else(|_| std::env::var("COMPUTERNAME")) {
if !h.is_empty() {
return h;
}
}
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
fn env_usize(key: &str) -> Option<usize> {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.filter(|v| *v > 0)
}
fn env_u64(key: &str) -> Option<u64> {
std::env::var(key).ok().and_then(|v| v.parse().ok())
}
fn env_truthy(key: &str) -> bool {
std::env::var(key)
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
fn spawn_dumper() {
let _ = std::thread::Builder::new()
.name("backbeat::dumper".into())
.spawn(move || {
let g = global();
let base = &g.dumper.path;
let mut live: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
let mut last_stamp = String::new();
let mut last_dump: Option<Instant> = None;
let mut warned_cap = false;
loop {
{
let mut requested = g.dumper.state.lock().unwrap();
while !*requested {
requested = g.dumper.condvar.wait(requested).unwrap();
}
*requested = false;
}
match limit_action(live.len(), g.dumper.max_dumps, g.dumper.limit_policy) {
LimitAction::Stop => {
if !warned_cap {
warned_cap = true;
let limit = g.dumper.max_dumps.unwrap_or(0);
eprintln!(
"backbeat: dump limit reached ({limit} files kept, policy \
keep-oldest); further dumps are being dropped. Raise \
BACKBEAT_MAX_DUMPS, set it to 0 for unlimited, or use \
BACKBEAT_LIMIT_POLICY=keep-newest to keep the most recent instead."
);
}
continue;
}
LimitAction::Write { evict } => {
if throttled(last_dump.map(|l| l.elapsed()), g.dumper.throttle) {
continue;
}
let stamp = unique_stamp(now_unix_millis(), &last_stamp);
let path = stamped_path(base, &stamp);
last_stamp = stamp;
let bytes = g.recorder.dump(
crate::registry::schemas(),
core::iter::empty(),
crate::registry::views(),
&g.host,
);
if write_dump(&path, &bytes).is_ok() {
live.push_back(path);
}
if evict {
if let Some(old) = live.pop_front() {
let _ = std::fs::remove_file(old);
}
}
last_dump = Some(Instant::now());
}
}
}
});
}
#[derive(Debug, PartialEq, Eq)]
enum LimitAction {
Write { evict: bool },
Stop,
}
fn limit_action(live: usize, max_dumps: Option<u64>, policy: LimitPolicy) -> LimitAction {
let Some(limit) = max_dumps else {
return LimitAction::Write { evict: false };
};
let full = live as u64 >= limit;
match policy {
LimitPolicy::KeepOldest => {
if full {
LimitAction::Stop
} else {
LimitAction::Write { evict: false }
}
}
LimitPolicy::KeepNewest => LimitAction::Write { evict: full },
}
}
fn throttled(since_last: Option<Duration>, throttle: Duration) -> bool {
matches!(since_last, Some(elapsed) if elapsed < throttle)
}
fn stamped_path(base: &Path, stamp: &str) -> PathBuf {
let stem = base.file_stem().map(|s| s.to_string_lossy().into_owned());
let ext = base.extension().map(|s| s.to_string_lossy().into_owned());
let name = match (stem, ext) {
(Some(stem), Some(ext)) => format!("{stem}.{stamp}.{ext}"),
(Some(stem), None) => format!("{stem}.{stamp}"),
_ => format!("backbeat.{stamp}.bb"),
};
base.with_file_name(name)
}
fn now_unix_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn unique_stamp(unix_millis: u64, prev: &str) -> String {
let base = format_utc_millis(unix_millis);
if base != prev && !prev.starts_with(&format!("{base}-")) {
return base;
}
let next = prev
.rsplit_once('-')
.and_then(|(stem, n)| (stem == base).then(|| n.parse::<u64>().ok()).flatten())
.unwrap_or(0)
+ 1;
format!("{base}-{next}")
}
fn format_utc_millis(unix_millis: u64) -> String {
let secs = unix_millis / 1000;
let millis = unix_millis % 1000;
let days = (secs / 86_400) as i64;
let secs_of_day = secs % 86_400;
let (hour, min, sec) = (
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let year = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if month <= 2 { year + 1 } else { year };
format!("{year:04}{month:02}{day:02}T{hour:02}{min:02}{sec:02}{millis:03}Z",)
}
fn write_dump(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = std::io::BufWriter::new(std::fs::File::create(path)?);
file.write_all(bytes)?;
file.flush()
}
fn install_panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
trigger();
std::thread::sleep(Duration::from_millis(100));
prev(info);
}));
}
#[doc(hidden)]
pub fn dump_path() -> PathBuf {
global().dumper.path.clone()
}
#[doc(hidden)]
pub fn host() -> &'static str {
&global().host
}
#[cfg(unix)]
mod signal {
pub fn parse(s: &str) -> Option<i32> {
match s.trim().to_ascii_lowercase().as_str() {
"usr1" | "sigusr1" => Some(libc::SIGUSR1),
"usr2" | "sigusr2" => Some(libc::SIGUSR2),
other => other.parse::<i32>().ok().filter(|n| *n > 0 && *n < 64),
}
}
fn install(sig: i32) {
unsafe {
let mut action: libc::sigaction = core::mem::zeroed();
action.sa_sigaction = handler as *const () as usize;
action.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut action.sa_mask);
libc::sigaction(sig, &action, core::ptr::null_mut());
}
}
extern "C" fn handler(_sig: i32) {
PENDING.store(true, core::sync::atomic::Ordering::Relaxed);
let fd = PIPE_WRITE.load(core::sync::atomic::Ordering::Relaxed);
if fd >= 0 {
let byte = [1u8];
unsafe {
libc::write(fd, byte.as_ptr() as *const libc::c_void, 1);
}
}
}
use core::sync::atomic::{AtomicBool, AtomicI32, Ordering};
static PENDING: AtomicBool = AtomicBool::new(false);
static PIPE_WRITE: AtomicI32 = AtomicI32::new(-1);
fn setup_forwarder() {
let mut fds = [0i32; 2];
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return;
}
let (read_fd, write_fd) = (fds[0], fds[1]);
PIPE_WRITE.store(write_fd, Ordering::Relaxed);
let _ = std::thread::Builder::new()
.name("backbeat::signal".into())
.spawn(move || {
let mut buf = [0u8; 64];
loop {
let n = unsafe {
libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
};
if n <= 0 {
continue;
}
if PENDING.swap(false, Ordering::Relaxed) {
super::trigger();
}
}
});
}
pub fn install_full(sig: i32) {
setup_forwarder();
install(sig);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn throttle_drops_only_within_window() {
let throttle = Duration::from_millis(1000);
assert!(!throttled(None, throttle));
assert!(throttled(Some(Duration::from_millis(10)), throttle));
assert!(throttled(Some(Duration::from_millis(999)), throttle));
assert!(!throttled(Some(Duration::from_millis(1000)), throttle));
assert!(!throttled(Some(Duration::from_millis(5000)), throttle));
assert!(!throttled(Some(Duration::ZERO), Duration::ZERO));
}
#[test]
fn limit_action_unlimited_always_writes() {
for live in [0, 1, 100, 10_000] {
assert_eq!(
limit_action(live, None, LimitPolicy::KeepOldest),
LimitAction::Write { evict: false }
);
assert_eq!(
limit_action(live, None, LimitPolicy::KeepNewest),
LimitAction::Write { evict: false }
);
}
}
#[test]
fn limit_action_keep_oldest_stops_when_full() {
let limit = Some(3);
assert_eq!(
limit_action(0, limit, LimitPolicy::KeepOldest),
LimitAction::Write { evict: false }
);
assert_eq!(
limit_action(2, limit, LimitPolicy::KeepOldest),
LimitAction::Write { evict: false }
);
assert_eq!(
limit_action(3, limit, LimitPolicy::KeepOldest),
LimitAction::Stop
);
assert_eq!(
limit_action(9, limit, LimitPolicy::KeepOldest),
LimitAction::Stop
);
}
#[test]
fn limit_action_keep_newest_evicts_when_full() {
let limit = Some(3);
assert_eq!(
limit_action(0, limit, LimitPolicy::KeepNewest),
LimitAction::Write { evict: false }
);
assert_eq!(
limit_action(2, limit, LimitPolicy::KeepNewest),
LimitAction::Write { evict: false }
);
assert_eq!(
limit_action(3, limit, LimitPolicy::KeepNewest),
LimitAction::Write { evict: true }
);
assert_eq!(
limit_action(4, limit, LimitPolicy::KeepNewest),
LimitAction::Write { evict: true }
);
}
#[test]
fn limit_policy_parses() {
assert_eq!(
LimitPolicy::parse("keep-oldest"),
Some(LimitPolicy::KeepOldest)
);
assert_eq!(LimitPolicy::parse("OLDEST"), Some(LimitPolicy::KeepOldest));
assert_eq!(
LimitPolicy::parse("keep-newest"),
Some(LimitPolicy::KeepNewest)
);
assert_eq!(
LimitPolicy::parse(" newest "),
Some(LimitPolicy::KeepNewest)
);
assert_eq!(LimitPolicy::parse("sideways"), None);
}
#[test]
fn stamped_path_inserts_timestamp_before_extension() {
let base = PathBuf::from("/tmp/backbeat.bb");
assert_eq!(
stamped_path(&base, "20260623T142530123Z"),
PathBuf::from("/tmp/backbeat.20260623T142530123Z.bb")
);
let base = PathBuf::from("/tmp/trace");
assert_eq!(
stamped_path(&base, "20260623T142530123Z"),
PathBuf::from("/tmp/trace.20260623T142530123Z")
);
}
#[test]
fn format_utc_millis_matches_known_instants() {
assert_eq!(format_utc_millis(0), "19700101T000000000Z");
assert_eq!(format_utc_millis(1_782_224_730_123), "20260623T142530123Z");
assert_eq!(format_utc_millis(1_709_164_800_000), "20240229T000000000Z");
}
#[test]
fn unique_stamp_disambiguates_same_millisecond() {
let ms = 1_782_224_730_123;
let base = format_utc_millis(ms); let s0 = unique_stamp(ms, "");
assert_eq!(s0, base);
let s1 = unique_stamp(ms, &s0);
assert_eq!(s1, format!("{base}-1"));
let s2 = unique_stamp(ms, &s1);
assert_eq!(s2, format!("{base}-2"));
let later = format_utc_millis(ms + 1);
assert_eq!(unique_stamp(ms + 1, &s2), later);
}
#[test]
fn env_truthy_parses_common_forms() {
for (val, want) in [
("1", true),
("true", true),
("TRUE", true),
("yes", true),
("on", true),
("0", false),
("false", false),
("", false),
("nope", false),
] {
std::env::set_var("BACKBEAT_TEST_TRUTHY", val);
assert_eq!(env_truthy("BACKBEAT_TEST_TRUTHY"), want, "value {val:?}");
}
std::env::remove_var("BACKBEAT_TEST_TRUTHY");
}
}