use std::fmt::Write as _;
use std::io::{BufWriter, IsTerminal, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use chrono::Utc;
use env_filter::Filter;
use log::{Level, LevelFilter, Log, Metadata, Record};
const POLL_INTERVAL: Duration = Duration::from_millis(1);
const QUEUE_CAPACITY: usize = 16 * 1024;
pub struct AsyncStderrLogger {
filter: Filter,
tx: Mutex<SyncSender<String>>,
dropped: Arc<AtomicU64>,
color: bool,
}
impl AsyncStderrLogger {
fn with_filter(filter: Filter) -> (Self, Receiver<String>, Arc<AtomicU64>) {
let (tx, rx) = sync_channel(QUEUE_CAPACITY);
let dropped = Arc::new(AtomicU64::new(0));
(
Self {
filter,
tx: Mutex::new(tx),
dropped: Arc::clone(&dropped),
color: std::io::stderr().is_terminal(),
},
rx,
dropped,
)
}
}
impl Log for AsyncStderrLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
self.filter.enabled(metadata)
}
fn log(&self, record: &Record) {
if !self.filter.matches(record) {
return;
}
let mut line = String::with_capacity(160);
format_line(&mut line, record, self.color);
let tx = self
.tx
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Err(TrySendError::Full(_)) = tx.try_send(line) {
self.dropped.fetch_add(1, Ordering::Relaxed);
}
}
fn flush(&self) {
}
}
fn format_line(out: &mut String, record: &Record, color: bool) {
out.push('[');
let _ = write!(out, "{}", Utc::now().format("%Y-%m-%dT%H:%M:%SZ"));
out.push(' ');
let level = record.level().as_str();
if color {
out.push_str(level_ansi(record.level()));
}
out.push_str(level);
for _ in level.len()..5 {
out.push(' ');
}
if color {
out.push_str("\x1b[0m");
}
out.push(' ');
out.push_str(record.target());
out.push_str("] ");
let _ = write!(out, "{}", record.args());
}
fn level_ansi(level: Level) -> &'static str {
match level {
Level::Error => "\x1b[31m",
Level::Warn => "\x1b[33m",
Level::Info => "\x1b[32m",
Level::Debug => "\x1b[34m",
Level::Trace => "\x1b[36m",
}
}
fn run_writer(rx: Receiver<String>, dropped: Arc<AtomicU64>) {
let stderr = std::io::stderr();
let mut out = BufWriter::with_capacity(128 * 1024, stderr.lock());
let mut reported: u64 = 0;
loop {
thread::sleep(POLL_INTERVAL);
let mut wrote = false;
while let Ok(line) = rx.try_recv() {
let _ = out.write_all(line.as_bytes());
let _ = out.write_all(b"\n");
wrote = true;
}
let current = dropped.load(Ordering::Relaxed);
if current > reported {
let _ = writeln!(
out,
"[{} WARN httpcan::logging] {} log lines dropped (stderr writer behind)",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
current - reported
);
wrote = true;
reported = current;
}
if wrote {
let _ = out.flush();
}
}
}
pub fn init() {
let mut builder = env_filter::Builder::new();
match std::env::var("RUST_LOG") {
Ok(spec) if !spec.is_empty() => {
builder.parse(&spec);
}
_ => {
builder.filter_level(LevelFilter::Info);
}
}
let filter = builder.build();
let max_level = filter.filter();
let (logger, rx, dropped) = AsyncStderrLogger::with_filter(filter);
thread::Builder::new()
.name("httpcan-log-writer".to_owned())
.spawn(move || run_writer(rx, dropped))
.expect("spawn log writer thread");
log::set_boxed_logger(Box::new(logger)).expect("install httpcan logger once");
log::set_max_level(max_level);
}
#[cfg(test)]
mod tests {
use super::*;
use log::Level;
#[test]
fn format_line_matches_env_logger_shape() {
let record = Record::builder()
.args(format_args!("time=x level=info"))
.level(Level::Info)
.target("httpcan::middleware")
.build();
let mut line = String::new();
format_line(&mut line, &record, false);
assert!(line.starts_with('['), "{line}");
let (ts, rest) = line[1..].split_once(' ').expect("space after timestamp");
assert_eq!(ts.len(), 20, "RFC3339 seconds timestamp, got {ts}");
assert_eq!(
rest, "INFO httpcan::middleware] time=x level=info",
"level padded to 5, single space before target"
);
}
#[test]
fn format_line_pads_short_levels() {
let record = Record::builder()
.args(format_args!("m"))
.level(Level::Warn)
.target("t")
.build();
let mut line = String::new();
format_line(&mut line, &record, false);
assert!(line.contains(" WARN t] m"), "WARN padded to 5: {line}");
}
#[test]
fn filter_honors_rust_log_directives() {
let error_only = env_filter::Builder::new().parse("error").build();
let info = Record::builder()
.args(format_args!("x"))
.level(Level::Info)
.target("httpcan")
.build();
let error = Record::builder()
.args(format_args!("x"))
.level(Level::Error)
.target("httpcan")
.build();
assert!(!error_only.matches(&info));
assert!(error_only.matches(&error));
let module = env_filter::Builder::new()
.parse("info,actix_server=off")
.build();
let actix = Record::builder()
.args(format_args!("x"))
.level(Level::Error)
.target("actix_server::server")
.build();
assert!(module.matches(&info));
assert!(!module.matches(&actix), "module directives must apply");
}
#[test]
fn queue_overflow_drops_instead_of_blocking() {
let filter = env_filter::Builder::new().parse("trace").build();
let (logger, _rx, dropped) = AsyncStderrLogger::with_filter(filter);
for _ in 0..QUEUE_CAPACITY {
let record = Record::builder()
.args(format_args!("filler"))
.level(Level::Info)
.target("t")
.build();
logger.log(&record);
}
assert_eq!(dropped.load(Ordering::Relaxed), 0, "queue absorbs a full batch");
for _ in 0..2 {
let record = Record::builder()
.args(format_args!("filler"))
.level(Level::Info)
.target("t")
.build();
logger.log(&record);
}
assert_eq!(dropped.load(Ordering::Relaxed), 2, "overflow is dropped, not blocked");
}
}