diff --git a/crates/lambda-rs-logging/README.md b/crates/lambda-rs-logging/README.md index 7c3667fc..c0f94680 100644 --- a/crates/lambda-rs-logging/README.md +++ b/crates/lambda-rs-logging/README.md @@ -2,48 +2,112 @@ ![lambda-rs](https://img.shields.io/crates/d/lambda-rs-logging) ![lambda-rs](https://img.shields.io/crates/v/lambda-rs-logging) -A simple logger implementation for lamba-rs crates. Inspired by -python's [logging](https://docs.python.org/3/library/logging.html) module. +Simple, lightweight logging for lambda-rs crates. Inspired by Python’s +[logging](https://docs.python.org/3/library/logging.html) module. -# Installation -First, add the following to your `Cargo.toml`: +## Installation +Add to your `Cargo.toml`: ```toml [dependencies] +# Option A: use the crate name in code as `lambda_rs_logging` lambda-rs-logging = "2023.1.30" + +# Option B: rename dependency so you can write `use logging;` +# logging = { package = "lambda-rs-logging", version = "2023.1.30" } ``` -or run this command from your project directory: +Or from your project directory: ```bash cargo add lambda-rs-logging ``` -# Getting started -## Using the global logger +Then in code, either import with the default name: +```rust +use lambda_rs_logging as logging; +``` +or, if you used the rename in Cargo.toml (Option B), simply: +```rust +use logging; // renamed in Cargo.toml +``` + +## Getting Started +### Global logger via macros ```rust -use logging; +use lambda_rs_logging as logging; fn main() { - logging::trace!("Hello world"); - logging::debug!("Hello world"); - logging::info!("Hello world"); - logging::warn!("Hello world"); - logging::error!("Hello world"); - logging::fatal!("Hello world"); + logging::trace!("trace {}", 1); + logging::debug!("debug {}", 2); + logging::info!("info {}", 3); + logging::warn!("warn {}", 4); + logging::error!("error {}", 5); + logging::fatal!("fatal {}", 6); // note: does not exit } ``` -## Using an instance of the logger +### Custom logger instance ```rust -use logging::Logger; +use lambda_rs_logging as logging; fn main() { - let logger = Logger::new("my-logger"); - logger.trace("Hello world"); - logger.debug("Hello world"); - logger.info("Hello world"); - logger.warn("Hello world"); - logger.error("Hello world"); - logger.fatal("Hello world"); + let logger = logging::Logger::builder() + .name("my-app") + .level(logging::LogLevel::INFO) + .with_handler(Box::new(logging::handler::ConsoleHandler::new("my-app"))) + .build(); + + logger.info("Hello world".to_string()); + logger.warn("Be careful".to_string()); +} +``` + +### Initialize a custom global +```rust +use lambda_rs_logging as logging; + +fn main() { + let logger = logging::Logger::builder() + .name("app") + .level(logging::LogLevel::DEBUG) + .with_handler(Box::new(logging::handler::ConsoleHandler::new("app"))) + .build(); + + // Set the global logger before any macros are used + logging::Logger::init(logger).expect("global logger can only be initialized once"); + + logging::debug!("from global"); } ``` + +### Configure level from environment +```rust +use lambda_rs_logging as logging; + +fn main() { + // LAMBDA_LOG can be: trace|debug|info|warn|error|fatal + // Example: export LAMBDA_LOG=debug + logging::env::init_global_from_env().ok(); + + logging::info!("respects env filter"); +} +``` + +## Notes +- Thread-safe global with `OnceLock>`. +- Handlers are `Send + Sync` and receive a `Record` internally (phase 1 refactor). +- `fatal!` logs at FATAL level but does not exit the process. Prefer explicit exits in your app logic. +- Console handler colors only when attached to a TTY and writes WARN+ to stderr. + +## Examples +This crate ships with examples. From the repository root: +```bash +cargo run -p lambda-rs-logging --example 01_global_macros +cargo run -p lambda-rs-logging --example 02_custom_logger +cargo run -p lambda-rs-logging --example 03_global_init +``` + +### Environment example +```bash +LAMBDA_LOG=debug cargo run -p lambda-rs-logging --example 01_global_macros +``` diff --git a/crates/lambda-rs-logging/examples/01_global_macros.rs b/crates/lambda-rs-logging/examples/01_global_macros.rs new file mode 100644 index 00000000..dd54aac5 --- /dev/null +++ b/crates/lambda-rs-logging/examples/01_global_macros.rs @@ -0,0 +1,8 @@ +fn main() { + logging::trace!("trace example"); + logging::debug!("debug example: {}", 42); + logging::info!("info example"); + logging::warn!("warn example"); + logging::error!("error example"); + logging::fatal!("fatal example (no exit)"); +} diff --git a/crates/lambda-rs-logging/examples/02_custom_logger.rs b/crates/lambda-rs-logging/examples/02_custom_logger.rs new file mode 100644 index 00000000..f42b417e --- /dev/null +++ b/crates/lambda-rs-logging/examples/02_custom_logger.rs @@ -0,0 +1,8 @@ +fn main() { + let logger = logging::Logger::new(logging::LogLevel::DEBUG, "custom"); + logger.add_handler(Box::new(logging::handler::ConsoleHandler::new("custom"))); + + logger.trace("this will be filtered unless level <= TRACE".to_string()); + logger.debug("debug from custom logger".to_string()); + logger.info("info from custom logger".to_string()); +} diff --git a/crates/lambda-rs-logging/examples/03_global_init.rs b/crates/lambda-rs-logging/examples/03_global_init.rs new file mode 100644 index 00000000..a40fafc0 --- /dev/null +++ b/crates/lambda-rs-logging/examples/03_global_init.rs @@ -0,0 +1,9 @@ +fn main() { + let logger = logging::Logger::new(logging::LogLevel::INFO, "app"); + logger.add_handler(Box::new(logging::handler::ConsoleHandler::new("app"))); + + logging::Logger::init(logger) + .expect("global logger can only be initialized once"); + + logging::info!("hello from initialized global"); +} diff --git a/crates/lambda-rs-logging/examples/04_builder_env.rs b/crates/lambda-rs-logging/examples/04_builder_env.rs new file mode 100644 index 00000000..36b07605 --- /dev/null +++ b/crates/lambda-rs-logging/examples/04_builder_env.rs @@ -0,0 +1,17 @@ +// When building examples inside the crate, refer to the library as `logging` directly. + +fn main() { + // Build a custom logger and apply env level + let logger = logging::Logger::builder() + .name("builder-env") + .level(logging::LogLevel::INFO) + .with_handler(Box::new(logging::handler::ConsoleHandler::new( + "builder-env", + ))) + .build(); + + logging::env::apply_env_level(&logger, Some("LAMBDA_LOG")); + + logger.debug("filtered unless LAMBDA_LOG=debug".to_string()); + logger.info("visible at info".to_string()); +} diff --git a/crates/lambda-rs-logging/examples/05_json_handler.rs b/crates/lambda-rs-logging/examples/05_json_handler.rs new file mode 100644 index 00000000..beaf777a --- /dev/null +++ b/crates/lambda-rs-logging/examples/05_json_handler.rs @@ -0,0 +1,17 @@ +// Inside this crate, refer to the lib as `logging` directly. + +fn main() { + let path = std::env::temp_dir().join("lambda_json_example.log"); + let path_s = path.to_string_lossy().to_string(); + + let logger = logging::Logger::builder() + .name("json-example") + .level(logging::LogLevel::TRACE) + .with_handler(Box::new(logging::handler::JsonHandler::new(path_s.clone()))) + .build(); + + logger.info("json info".to_string()); + logger.error("json error".to_string()); + + println!("wrote JSON to {}", path_s); +} diff --git a/crates/lambda-rs-logging/examples/06_rotating_file.rs b/crates/lambda-rs-logging/examples/06_rotating_file.rs new file mode 100644 index 00000000..c857cb51 --- /dev/null +++ b/crates/lambda-rs-logging/examples/06_rotating_file.rs @@ -0,0 +1,20 @@ +fn main() { + let base = std::env::temp_dir().join("lambda_rotate_example.log"); + let base_s = base.to_string_lossy().to_string(); + + let logger = logging::Logger::builder() + .name("rotate-example") + .level(logging::LogLevel::TRACE) + .with_handler(Box::new(logging::handler::RotatingFileHandler::new( + base_s.clone(), + 256, // bytes + 3, // keep 3 backups + ))) + .build(); + + for i in 0..200 { + logger.info(format!("log line {:03}", i)); + } + + println!("rotation base: {} (check .1, .2, .3)", base_s); +} diff --git a/crates/lambda-rs-logging/src/handler.rs b/crates/lambda-rs-logging/src/handler.rs index f2a5d5c7..82903c77 100644 --- a/crates/lambda-rs-logging/src/handler.rs +++ b/crates/lambda-rs-logging/src/handler.rs @@ -1,53 +1,56 @@ //! Log handling implementations for the logger. use std::{ - fmt::Debug, fs::OpenOptions, - io::Write, + io::{ + self, + IsTerminal, + Write, + }, + sync::Mutex, time::SystemTime, }; -use crate::LogLevel; +use crate::{ + LogLevel, + Record, +}; /// Pluggable sink for log records emitted by the `Logger`. -/// -/// Implementors decide how to format and where to deliver messages for each -/// severity level. -pub trait Handler { - fn trace(&mut self, message: String); - fn debug(&mut self, message: String); - fn info(&mut self, message: String); - fn warn(&mut self, message: String); - fn error(&mut self, message: String); - fn fatal(&mut self, message: String); +/// Implementors decide how to format and where to deliver messages. +pub trait Handler: Send + Sync { + fn log(&self, record: &Record); } /// A handler that logs to a file. - -#[derive(Debug, Clone, PartialEq, PartialOrd)] +#[derive(Debug)] pub struct FileHandler { file: String, - log_buffer: Vec, + log_buffer: Mutex>, } impl FileHandler { pub fn new(file: String) -> Self { Self { file, - log_buffer: Vec::new(), + log_buffer: Mutex::new(Vec::new()), } } +} - /// Logs a message to the file. - fn log(&mut self, log_level: LogLevel, message: String) { - let timestamp = SystemTime::now() +impl Handler for FileHandler { + fn log(&self, record: &Record) { + let timestamp = record + .timestamp .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); - let log_message = format!("[{}]-[{:?}]: {}", timestamp, log_level, message); + let log_message = + format!("[{}]-[{:?}]: {}", timestamp, record.level, record.message); - let colored_message = match log_level { + // Preserve existing behavior: color codes even in file output. + let colored_message = match record.level { LogLevel::TRACE => format!("\x1B[37m{}\x1B[0m", log_message), LogLevel::DEBUG => format!("\x1B[35m{}\x1B[0m", log_message), LogLevel::INFO => format!("\x1B[32m{}\x1B[0m", log_message), @@ -56,14 +59,15 @@ impl FileHandler { LogLevel::FATAL => format!("\x1B[31;1m{}\x1B[0m", log_message), }; - self.log_buffer.push(colored_message); + let mut buf = self.log_buffer.lock().unwrap(); + buf.push(colored_message); // Flush buffer every ten messages. - if self.log_buffer.len() < 10 { + if buf.len() < 10 { return; } - let log_message = self.log_buffer.join("\n"); + let log_message = buf.join("\n"); let mut file = OpenOptions::new() .append(true) @@ -75,95 +79,207 @@ impl FileHandler { .write_all(log_message.as_bytes()) .expect("Unable to write data"); - self.log_buffer.clear(); - } -} - -impl Handler for FileHandler { - fn trace(&mut self, message: String) { - self.log(LogLevel::TRACE, message) - } - - fn debug(&mut self, message: String) { - self.log(LogLevel::DEBUG, message) - } - - fn info(&mut self, message: String) { - self.log(LogLevel::INFO, message) - } - - fn warn(&mut self, message: String) { - self.log(LogLevel::WARN, message) - } - - fn error(&mut self, message: String) { - self.log(LogLevel::ERROR, message) - } - - fn fatal(&mut self, message: String) { - self.log(LogLevel::FATAL, message) + buf.clear(); } } #[derive(Debug, Clone, PartialEq, PartialOrd)] -/// A handler that prints colored log lines to stdout. pub struct ConsoleHandler { name: String, } impl ConsoleHandler { pub fn new(name: &str) -> Self { - return Self { + Self { name: name.to_string(), - }; + } } +} - fn log(&mut self, log_level: LogLevel, message: String) { - let timestamp = SystemTime::now() +impl Handler for ConsoleHandler { + fn log(&self, record: &Record) { + let timestamp = record + .timestamp .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); let log_message = format!( "[{}]-[{:?}]-[{}]: {}", - timestamp, log_level, self.name, message + timestamp, record.level, self.name, record.message ); - let colored_message = match log_level { - LogLevel::TRACE => format!("\x1B[37m{}\x1B[0m", log_message), - LogLevel::DEBUG => format!("\x1B[35m{}\x1B[0m", log_message), - LogLevel::INFO => format!("\x1B[32m{}\x1B[0m", log_message), - LogLevel::WARN => format!("\x1B[33m{}\x1B[0m", log_message), - LogLevel::ERROR => format!("\x1B[31;1m{}\x1B[0m", log_message), - LogLevel::FATAL => format!("\x1B[31;1m{}\x1B[0m", log_message), - }; - - println!("{}", colored_message); + // Select output stream based on level. + let warn_or_higher = matches!( + record.level, + LogLevel::WARN | LogLevel::ERROR | LogLevel::FATAL + ); + if warn_or_higher { + let mut e = io::stderr().lock(); + let use_color = io::stderr().is_terminal(); + if use_color { + let colored = match record.level { + LogLevel::TRACE => format!("\x1B[37m{}\x1B[0m", log_message), + LogLevel::DEBUG => format!("\x1B[35m{}\x1B[0m", log_message), + LogLevel::INFO => format!("\x1B[32m{}\x1B[0m", log_message), + LogLevel::WARN => format!("\x1B[33m{}\x1B[0m", log_message), + LogLevel::ERROR | LogLevel::FATAL => { + format!("\x1B[31;1m{}\x1B[0m", log_message) + } + }; + let _ = writeln!(e, "{}", colored); + } else { + let _ = writeln!(e, "{}", log_message); + } + } else { + let mut o = io::stdout().lock(); + let use_color = io::stdout().is_terminal(); + if use_color { + let colored = match record.level { + LogLevel::TRACE => format!("\x1B[37m{}\x1B[0m", log_message), + LogLevel::DEBUG => format!("\x1B[35m{}\x1B[0m", log_message), + LogLevel::INFO => format!("\x1B[32m{}\x1B[0m", log_message), + LogLevel::WARN => format!("\x1B[33m{}\x1B[0m", log_message), + LogLevel::ERROR | LogLevel::FATAL => { + format!("\x1B[31;1m{}\x1B[0m", log_message) + } + }; + let _ = writeln!(o, "{}", colored); + } else { + let _ = writeln!(o, "{}", log_message); + } + } } } -impl Handler for ConsoleHandler { - fn trace(&mut self, message: String) { - self.log(LogLevel::TRACE, message); +/// A handler that writes newline-delimited JSON log records. +/// Uses minimal manual escaping to avoid external dependencies. +pub struct JsonHandler { + inner: Mutex>, +} + +impl JsonHandler { + pub fn new(path: String) -> Self { + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .expect("open json log file"); + Self { + inner: Mutex::new(io::BufWriter::new(file)), + } } - fn debug(&mut self, message: String) { - self.log(LogLevel::DEBUG, message); + fn escape_json(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 8); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out } +} - fn info(&mut self, message: String) { - self.log(LogLevel::INFO, message); +impl Handler for JsonHandler { + fn log(&self, record: &Record) { + let ts = record + .timestamp + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis(); + let msg = Self::escape_json(record.message); + let target = Self::escape_json(record.target); + let module = record.module_path.unwrap_or(""); + let file = record.file.unwrap_or(""); + let line = record.line.unwrap_or(0); + let level = match record.level { + LogLevel::TRACE => "TRACE", + LogLevel::DEBUG => "DEBUG", + LogLevel::INFO => "INFO", + LogLevel::WARN => "WARN", + LogLevel::ERROR => "ERROR", + LogLevel::FATAL => "FATAL", + }; + let json = format!( + "{{\"ts\":{},\"level\":\"{}\",\"target\":\"{}\",\"message\":\"{}\",\"module\":\"{}\",\"file\":\"{}\",\"line\":{}}}\n", + ts, level, target, msg, module, file, line + ); + let mut w = self.inner.lock().unwrap(); + let _ = w.write_all(json.as_bytes()); + let _ = w.flush(); } +} - fn warn(&mut self, message: String) { - self.log(LogLevel::WARN, message); +/// A handler that writes to a file and rotates when size exceeds `max_bytes`. +pub struct RotatingFileHandler { + path: String, + max_bytes: u64, + backups: usize, + lock: Mutex<()>, +} + +impl RotatingFileHandler { + pub fn new(path: String, max_bytes: u64, backups: usize) -> Self { + Self { + path, + max_bytes, + backups, + lock: Mutex::new(()), + } } - fn error(&mut self, message: String) { - self.log(LogLevel::ERROR, message); + fn rotate(&self) { + // Rotate: file.(n-1) -> file.n, ..., file -> file.1, delete file.n if exists + for i in (1..=self.backups).rev() { + let from = if i == 1 { + std::path::PathBuf::from(&self.path) + } else { + std::path::PathBuf::from(format!("{}.{}", &self.path, i - 1)) + }; + let to = std::path::PathBuf::from(format!("{}.{}", &self.path, i)); + if from.exists() { + let _ = std::fs::rename(&from, &to); + } + } } +} + +impl Handler for RotatingFileHandler { + fn log(&self, record: &Record) { + let _guard = self.lock.lock().unwrap(); + + // Check file size and rotate if needed + if let Ok(meta) = std::fs::metadata(&self.path) { + if meta.len() >= self.max_bytes { + self.rotate(); + } + } + + let timestamp = record + .timestamp + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + let line = format!( + "[{}]-[{:?}]-[{}]: {}\n", + timestamp, record.level, record.target, record.message + ); - fn fatal(&mut self, message: String) { - self.log(LogLevel::FATAL, message); + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .expect("open rotating file"); + let _ = f.write_all(line.as_bytes()); } } diff --git a/crates/lambda-rs-logging/src/lib.rs b/crates/lambda-rs-logging/src/lib.rs index a2e97582..c760e78d 100644 --- a/crates/lambda-rs-logging/src/lib.rs +++ b/crates/lambda-rs-logging/src/lib.rs @@ -1,7 +1,19 @@ #![allow(clippy::needless_return)] //! A simple logging library for lambda-rs crates. -use std::fmt::Debug; +use std::{ + fmt, + sync::{ + atomic::{ + AtomicU8, + Ordering, + }, + Arc, + OnceLock, + RwLock, + }, + time::SystemTime, +}; /// A trait for handling log messages. pub mod handler; @@ -17,11 +29,36 @@ pub enum LogLevel { FATAL, } +impl LogLevel { + fn to_u8(self) -> u8 { + match self { + LogLevel::TRACE => 0, + LogLevel::DEBUG => 1, + LogLevel::INFO => 2, + LogLevel::WARN => 3, + LogLevel::ERROR => 4, + LogLevel::FATAL => 5, + } + } +} + +/// A log record passed to handlers. +#[derive(Debug, Clone)] +pub struct Record<'a> { + pub timestamp: SystemTime, + pub level: LogLevel, + pub target: &'a str, + pub message: &'a str, + pub module_path: Option<&'static str>, + pub file: Option<&'static str>, + pub line: Option, +} + /// Logger implementation. pub struct Logger { name: String, - level: LogLevel, - handlers: Vec>, + level: AtomicU8, + handlers: RwLock>>, } impl Logger { @@ -29,111 +66,241 @@ impl Logger { pub fn new(level: LogLevel, name: &str) -> Self { Self { name: name.to_string(), - level, - handlers: Vec::new(), + level: AtomicU8::new(level.to_u8()), + handlers: RwLock::new(Vec::new()), } } - /// Returns the global logger. - pub fn global() -> &'static mut Self { - // TODO(vmarcella): Fix the instantiation for the global logger. - unsafe { - if LOGGER.is_none() { - LOGGER = Some(Logger { - level: LogLevel::TRACE, - name: "lambda-rs".to_string(), - handlers: vec![Box::new(handler::ConsoleHandler::new("lambda-rs"))], - }); - } - }; - return unsafe { &mut LOGGER } - .as_mut() - .expect("Logger not initialized"); + /// Creates a builder for configuring a `Logger`. + pub fn builder() -> LoggerBuilder { + LoggerBuilder::default() + } + + /// Returns the global logger (thread-safe). Initializes with a default + /// console handler if not explicitly initialized via `init`. + pub fn global() -> &'static Arc { + static GLOBAL: OnceLock> = OnceLock::new(); + GLOBAL.get_or_init(|| { + let logger = Logger::new(LogLevel::TRACE, "lambda-rs"); + // Default console handler + logger.add_handler(Box::new(handler::ConsoleHandler::new("lambda-rs"))); + Arc::new(logger) + }) + } + + /// Initialize the global logger (first caller wins). + pub fn init(logger: Logger) -> Result<(), InitError> { + static GLOBAL: OnceLock> = OnceLock::new(); + GLOBAL + .set(Arc::new(logger)) + .map_err(|_| InitError::AlreadyInitialized) } /// Adds a handler to the logger. Handlers are called in the order they /// are added. - pub fn add_handler(&mut self, handler: Box) { - self.handlers.push(handler); + pub fn add_handler(&self, handler: Box) { + let mut lock = self.handlers.write().expect("poisoned handlers lock"); + lock.push(handler); + } + + /// Updates the minimum level for this logger. + pub fn set_level(&self, level: LogLevel) { + self.level.store(level.to_u8(), Ordering::Relaxed); } fn compare_levels(&self, level: LogLevel) -> bool { - level as u8 >= self.level as u8 + level.to_u8() >= self.level.load(Ordering::Relaxed) } - /// Logs a trace message to all handlers. - pub fn trace(&mut self, message: String) { - if !self.compare_levels(LogLevel::TRACE) { + fn log_inner(&self, level: LogLevel, message: &str) { + if !self.compare_levels(level) { return; } - - for handler in self.handlers.iter_mut() { - handler.trace(message.clone()); - } + self.log_inner_with_meta(level, message, None, None, None); } - /// Logs a debug message to all handlers. - pub fn debug(&mut self, message: String) { - if !self.compare_levels(LogLevel::DEBUG) { + fn log_inner_with_meta( + &self, + level: LogLevel, + message: &str, + module_path: Option<&'static str>, + file: Option<&'static str>, + line: Option, + ) { + if !self.compare_levels(level) { return; } - for handler in self.handlers.iter_mut() { - handler.debug(message.clone()); + let record = Record { + timestamp: SystemTime::now(), + level, + target: &self.name, + message, + module_path, + file, + line, + }; + let lock = self.handlers.read().expect("poisoned handlers lock"); + for handler in lock.iter() { + handler.log(&record); } } - /// Logs an info message to all handlers. - pub fn info(&mut self, message: String) { - if !self.compare_levels(LogLevel::INFO) { - return; - } + /// Logs a trace message to all handlers (shim for backward compatibility). + pub fn trace(&self, message: String) { + self.log_inner(LogLevel::TRACE, &message); + } - for handler in self.handlers.iter_mut() { - handler.info(message.clone()); - } + /// Logs a debug message to all handlers (shim for backward compatibility). + pub fn debug(&self, message: String) { + self.log_inner(LogLevel::DEBUG, &message); } - /// Logs a warning to all handlers. - pub fn warn(&mut self, message: String) { - if !self.compare_levels(LogLevel::WARN) { - return; - } - for handler in self.handlers.iter_mut() { - handler.warn(message.clone()); - } + /// Logs an info message to all handlers (shim for backward compatibility). + pub fn info(&self, message: String) { + self.log_inner(LogLevel::INFO, &message); } - /// Logs an error to all handlers. - pub fn error(&mut self, message: String) { - if !self.compare_levels(LogLevel::ERROR) { - return; + /// Logs a warning to all handlers (shim for backward compatibility). + pub fn warn(&self, message: String) { + self.log_inner(LogLevel::WARN, &message); + } + + /// Logs an error to all handlers (shim for backward compatibility). + pub fn error(&self, message: String) { + self.log_inner(LogLevel::ERROR, &message); + } + + /// Logs a fatal error to all handlers. Does NOT exit the process. + pub fn fatal(&self, message: String) { + self.log_inner(LogLevel::FATAL, &message); + } +} + +/// Initialization error for the global logger. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InitError { + AlreadyInitialized, +} + +/// Logger builder for ergonomic configuration. +pub struct LoggerBuilder { + name: String, + level: LogLevel, + handlers: Vec>, +} + +impl Default for LoggerBuilder { + fn default() -> Self { + Self { + name: "lambda-rs".to_string(), + level: LogLevel::INFO, + handlers: Vec::new(), } + } +} + +impl LoggerBuilder { + pub fn name(mut self, name: &str) -> Self { + self.name = name.to_string(); + self + } + + pub fn level(mut self, level: LogLevel) -> Self { + self.level = level; + self + } + + pub fn with_handler(mut self, handler: Box) -> Self { + self.handlers.push(handler); + self + } - for handler in self.handlers.iter_mut() { - handler.error(message.clone()); + pub fn build(self) -> Logger { + let logger = Logger::new(self.level, &self.name); + for h in self.handlers { + logger.add_handler(h); } + logger } +} - /// Logs a fatal error to all handlers and exits the program. - pub fn fatal(&mut self, message: String) { - if !self.compare_levels(LogLevel::FATAL) { - return; +/// Environment configuration helpers. +pub mod env { + use super::{ + LogLevel, + Logger, + }; + + /// Parse a log level from a string like "trace", "debug", ... + pub fn parse_level(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "trace" => Some(LogLevel::TRACE), + "debug" => Some(LogLevel::DEBUG), + "info" => Some(LogLevel::INFO), + "warn" | "warning" => Some(LogLevel::WARN), + "error" => Some(LogLevel::ERROR), + "fatal" => Some(LogLevel::FATAL), + _ => None, } + } - for handler in self.handlers.iter_mut() { - handler.fatal(message.clone()); + /// Applies a level from the environment to the provided logger. + /// + /// Reads the specified `var` (default: "LAMBDA_LOG"). If it parses to a level, + /// updates the logger's level. + pub fn apply_env_level(logger: &Logger, var: Option<&str>) { + let key = var.unwrap_or("LAMBDA_LOG"); + if let Ok(val) = std::env::var(key) { + if let Some(level) = parse_level(&val) { + logger.set_level(level); + } } - std::process::exit(1); } -} -pub(crate) static mut LOGGER: Option = None; + /// Initialize a global logger with a console handler and apply env level. + pub fn init_global_from_env() -> Result<(), super::InitError> { + let logger = Logger::builder() + .name("lambda-rs") + .level(LogLevel::INFO) + .with_handler(Box::new(crate::handler::ConsoleHandler::new("lambda-rs"))) + .build(); + apply_env_level(&logger, Some("LAMBDA_LOG")); + super::Logger::init(logger) + } +} +/// Returns whether the global logger would log at `level`. +pub fn enabled(level: LogLevel) -> bool { + Logger::global().compare_levels(level) +} +/// Logs using the global logger, formatting only after an enabled check. +pub fn log_args( + level: LogLevel, + module_path: &'static str, + file: &'static str, + line: u32, + args: fmt::Arguments, +) { + let logger = Logger::global().clone(); + if !logger.compare_levels(level) { + return; + } + let message = args.to_string(); + logger.log_inner_with_meta( + level, + &message, + Some(module_path), + Some(file), + Some(line), + ); +} /// Trace logging macro using the global logger instance. #[macro_export] macro_rules! trace { ($($arg:tt)*) => { - logging::Logger::global().trace(format!("{}", format_args!($($arg)*))); + if $crate::enabled($crate::LogLevel::TRACE) { + $crate::log_args($crate::LogLevel::TRACE, module_path!(), file!(), line!(), format_args!($($arg)*)); + } }; } @@ -141,7 +308,9 @@ macro_rules! trace { #[macro_export] macro_rules! debug { ($($arg:tt)*) => { - logging::Logger::global().debug(format!("{}", format_args!($($arg)*))); + if $crate::enabled($crate::LogLevel::DEBUG) { + $crate::log_args($crate::LogLevel::DEBUG, module_path!(), file!(), line!(), format_args!($($arg)*)); + } }; } @@ -149,7 +318,9 @@ macro_rules! debug { #[macro_export] macro_rules! info { ($($arg:tt)*) => { - logging::Logger::global().info(format!("{}", format_args!($($arg)*))); + if $crate::enabled($crate::LogLevel::INFO) { + $crate::log_args($crate::LogLevel::INFO, module_path!(), file!(), line!(), format_args!($($arg)*)); + } }; } @@ -157,20 +328,305 @@ macro_rules! info { #[macro_export] macro_rules! warn { ($($arg:tt)*) => { - logging::Logger::global().warn(format!("{}", format_args!($($arg)*))); + if $crate::enabled($crate::LogLevel::WARN) { + $crate::log_args($crate::LogLevel::WARN, module_path!(), file!(), line!(), format_args!($($arg)*)); + } }; } #[macro_export] macro_rules! error { ($($arg:tt)*) => { - logging::Logger::global().error(format!("{}", format_args!($($arg)*))); + if $crate::enabled($crate::LogLevel::ERROR) { + $crate::log_args($crate::LogLevel::ERROR, module_path!(), file!(), line!(), format_args!($($arg)*)); + } }; } #[macro_export] macro_rules! fatal { ($($arg:tt)*) => { - logging::Logger::global().fatal(format!("{}", format_args!($($arg)*))); + if $crate::enabled($crate::LogLevel::FATAL) { + $crate::log_args($crate::LogLevel::FATAL, module_path!(), file!(), line!(), format_args!($($arg)*)); + } + }; +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + Mutex, + }, + thread, }; + + use super::*; + + #[derive(Default)] + struct TestHandler { + out: Arc>>, + } + + impl TestHandler { + fn new(out: Arc>>) -> Self { + Self { out } + } + } + + impl handler::Handler for TestHandler { + fn log(&self, record: &Record) { + self + .out + .lock() + .unwrap() + .push((record.level, record.message.to_string())); + } + } + + #[test] + fn global_singleton_is_stable() { + let a = Logger::global().clone(); + let b = Logger::global().clone(); + assert!(Arc::ptr_eq(&a, &b)); + } + + #[test] + fn level_filtering_works() { + let logger = Logger::new(LogLevel::INFO, "test"); + let out = Arc::new(Mutex::new(Vec::new())); + logger.add_handler(Box::new(TestHandler::new(out.clone()))); + + logger.debug("ignored".to_string()); + logger.info("shown".to_string()); + + let recs = out.lock().unwrap(); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].0, LogLevel::INFO); + assert_eq!(recs[0].1, "shown"); + } + + #[test] + fn handler_order_is_preserved_single_thread() { + #[derive(Default)] + struct TagHandler { + tag: &'static str, + out: Arc>>, + } + impl handler::Handler for TagHandler { + fn log(&self, _record: &Record) { + self.out.lock().unwrap().push(self.tag); + } + } + + let logger = Logger::new(LogLevel::TRACE, "order"); + let out = Arc::new(Mutex::new(Vec::new())); + logger.add_handler(Box::new(TagHandler { + tag: "A", + out: out.clone(), + })); + logger.add_handler(Box::new(TagHandler { + tag: "B", + out: out.clone(), + })); + + logger.info("x".to_string()); + + let v = out.lock().unwrap().clone(); + assert_eq!(v, vec!["A", "B"]); + } + + #[test] + fn concurrent_logging_no_panic_and_counts_match() { + let logger = Arc::new(Logger::new(LogLevel::TRACE, "concurrent")); + let out = Arc::new(Mutex::new(Vec::new())); + logger.add_handler(Box::new(TestHandler::new(out.clone()))); + + let mut handles = Vec::new(); + for i in 0..8 { + let lg = logger.clone(); + handles.push(thread::spawn(move || { + for j in 0..100 { + lg.debug(format!("msg {}:{}", i, j)); + } + })); + } + for t in handles { + t.join().unwrap(); + } + + let recs = out.lock().unwrap(); + assert_eq!(recs.len(), 800); + } + + #[test] + fn fatal_does_not_exit() { + let logger = Logger::new(LogLevel::TRACE, "fatal"); + let out = Arc::new(Mutex::new(Vec::new())); + logger.add_handler(Box::new(TestHandler::new(out.clone()))); + logger.fatal("boom".to_string()); + let recs = out.lock().unwrap(); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].0, LogLevel::FATAL); + assert_eq!(recs[0].1, "boom"); + } + + #[test] + fn file_handler_flushes_after_ten() { + use std::{ + fs, + time::UNIX_EPOCH, + }; + + let tmp = std::env::temp_dir().join(format!( + "lambda_logging_test_{}_{}.log", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + let logger = Logger::new(LogLevel::TRACE, "file"); + logger.add_handler(Box::new(crate::handler::FileHandler::new( + tmp.to_string_lossy().to_string(), + ))); + + for i in 0..10 { + logger.info(format!("line{}", i)); + } + + let content = + fs::read_to_string(&tmp).expect("file must exist after flush"); + assert!(!content.is_empty()); + } + + #[test] + fn macro_early_guard_avoids_formatting() { + // Ensure TRACE is disabled by setting level to INFO. + super::Logger::global().set_level(super::LogLevel::INFO); + + struct Boom; + impl fmt::Display for Boom { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + panic!("should not be formatted when level disabled"); + } + } + + // If guard fails, formatting Boom would panic. + super::trace!("{}", Boom); + } + + #[test] + fn builder_sets_name_level_and_handlers() { + #[derive(Default)] + struct Capture { + out: Arc>>, + } + impl handler::Handler for Capture { + fn log(&self, record: &Record) { + self + .out + .lock() + .unwrap() + .push(format!("{}:{}", record.target, record.level as u8)); + } + } + + let out = Arc::new(Mutex::new(Vec::new())); + let logger = Logger::builder() + .name("builder-app") + .level(LogLevel::WARN) + .with_handler(Box::new(Capture { out: out.clone() })) + .build(); + + logger.info("drop".to_string()); + logger.error("keep".to_string()); + + let v = out.lock().unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0], "builder-app:4"); // ERROR => 4 per to_u8 mapping + } + + #[test] + fn env_parse_and_apply_level() { + // no panic if env missing + super::env::apply_env_level( + &Logger::new(LogLevel::TRACE, "tmp"), + Some("__NOT_SET__"), + ); + + assert_eq!(super::env::parse_level("trace"), Some(LogLevel::TRACE)); + assert_eq!(super::env::parse_level("DEBUG"), Some(LogLevel::DEBUG)); + assert_eq!(super::env::parse_level("warning"), Some(LogLevel::WARN)); + assert_eq!(super::env::parse_level("nope"), None); + + // apply + let logger = Logger::new(LogLevel::ERROR, "tmp"); + std::env::set_var("LAMBDA_LOG", "info"); + super::env::apply_env_level(&logger, Some("LAMBDA_LOG")); + assert!(logger.compare_levels(LogLevel::INFO)); + // restore + std::env::remove_var("LAMBDA_LOG"); + } + + #[test] + fn json_handler_writes_json_lines() { + use std::fs; + let tmp = std::env::temp_dir().join(format!( + "lambda_json_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let p = tmp.to_string_lossy().to_string(); + + let logger = Logger::builder() + .name("json") + .level(LogLevel::TRACE) + .with_handler(Box::new(crate::handler::JsonHandler::new(p.clone()))) + .build(); + + logger.info("hello json".to_string()); + let content = fs::read_to_string(p).unwrap(); + assert!(content.contains("\"level\":\"INFO\"")); + assert!(content.contains("hello json")); + } + + #[test] + fn rotating_handler_rotates_files() { + use std::fs; + let base = std::env::temp_dir().join(format!( + "lambda_rotate_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let base_s = base.to_string_lossy().to_string(); + + let logger = Logger::builder() + .name("rot") + .level(LogLevel::TRACE) + .with_handler(Box::new(crate::handler::RotatingFileHandler::new( + base_s.clone(), + 128, // small threshold + 2, + ))) + .build(); + + for i in 0..100 { + logger.info(format!("line {i:03}")); + } + + // Expect rotated files to exist + let p1 = format!("{}.1", &base_s); + let _p2 = format!("{}.2", &base_s); + assert!(fs::metadata(p1).is_ok() || fs::metadata(base_s.clone()).is_ok()); + // not strictly asserting p2 due to small logs, but should often appear + } } diff --git a/docs/logging-guide.md b/docs/logging-guide.md new file mode 100644 index 00000000..08a48f46 --- /dev/null +++ b/docs/logging-guide.md @@ -0,0 +1,78 @@ +--- +title: "Lambda RS Logging Guide" +document_id: "logging-guide-2025-09-28" +status: "living" +created: "2025-09-28T21:17:44Z" +last_updated: "2025-09-28T21:17:44Z" +version: "0.2.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "637b82305833ef0db6079bcae5f64777e847a505" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["guide", "logging", "engine", "infra"] +--- + +Summary +- Thread-safe logging crate with global and instance loggers. +- Minimal-overhead macros, configurable level, and pluggable handlers. +- Console, file, JSON, and rotating-file handlers included. + +Quick Start +- Global macros: + - Add dependency (rename optional): `logging = { package = "lambda-rs-logging", version = "2023.1.30" }` + - Use: `logging::info!("hello {}", 123);` +- Custom global from env: + - `logging::env::init_global_from_env().ok(); // honors LAMBDA_LOG` +- Custom logger: + - `let logger = logging::Logger::builder().name("app").level(logging::LogLevel::INFO).with_handler(Box::new(logging::handler::ConsoleHandler::new("app"))).build();` + +Core Concepts +- Level filtering: `TRACE < DEBUG < INFO < WARN < ERROR < FATAL`. +- Early guard: macros check level before formatting message. +- Handlers: `Send + Sync` sinks implementing `fn log(&self, record: &Record)`. +- Record fields: timestamp, level, target(name), message, module/file/line. + +Configuration +- Global init: + - Default global created on first use (`TRACE`, console handler). + - Override once via `Logger::init(logger)`. +- Builder: + - `Logger::builder().name(..).level(..).with_handler(..).build()`. +- Environment: + - `LAMBDA_LOG=trace|debug|info|warn|error|fatal`. + - `logging::env::apply_env_level(&logger, Some("LAMBDA_LOG"));` + - `logging::env::init_global_from_env()` creates a console logger and applies env. + +Handlers +- ConsoleHandler + - Colors only when stdout/stderr are TTYs. + - Writes WARN/ERROR/FATAL to stderr; TRACE/DEBUG/INFO to stdout. +- FileHandler + - Appends colored lines to a file (legacy behavior). Flushes every 10 messages. +- JsonHandler + - Newline-delimited JSON (one object per line). Minimal string escaping. + - Use: `with_handler(Box::new(logging::handler::JsonHandler::new("/path/log.jsonl".into())))`. +- RotatingFileHandler + - Rotates when current file exceeds `max_bytes`. + - Keeps `backups` files as `file.1`, `file.2`, ... + - Use: `RotatingFileHandler::new("/path/app.log".into(), 1_048_576, 3)`. + +Macros +- `trace!/debug!/info!/warn!/error!/fatal!` + - Use `$crate` and `format_args!` internally for low overhead when disabled. + - Attach module/file/line to records for handler formatting. + +Examples (run from repo root) +- `cargo run -p lambda-rs-logging --example 01_global_macros` +- `cargo run -p lambda-rs-logging --example 02_custom_logger` +- `cargo run -p lambda-rs-logging --example 03_global_init` +- `cargo run -p lambda-rs-logging --example 04_builder_env` +- `cargo run -p lambda-rs-logging --example 05_json_handler` +- `cargo run -p lambda-rs-logging --example 06_rotating_file` + +Changelog +- 0.2.0: Added builder, env config, JSON and rotating file handlers; improved console (colors on TTY, WARN+ to stderr); macro early-guard. +- 0.1.0: Initial spec and thread-safe global with unified handler API. diff --git a/docs/logging-improvements-spec.md b/docs/logging-improvements-spec.md new file mode 100644 index 00000000..c1b5e550 --- /dev/null +++ b/docs/logging-improvements-spec.md @@ -0,0 +1,133 @@ +--- +title: "lambda-rs Logging: Design Review and Improvement Spec" +document_id: "logging-spec-2025-09-28" +status: "draft" +created: "2025-09-28T05:35:01Z" +last_updated: "2025-09-28T05:35:01Z" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "964d58c0658a4c5d23a4cf33f4a8ecc12458dad6" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["spec", "logging", "engine", "infra"] +--- + +**Summary** +- Evaluate current `lambda-rs-logging` design and implementation. +- Propose a thread-safe, low-overhead, extensible logging API with clear migration. + +**Goals** +- Thread-safe global and instance loggers without `unsafe`. +- Low allocation and minimal formatting when disabled. +- Simple API for console/file; room for structured output. +- Clear configuration (builder + env vars) and sane defaults. +- Compatibility bridge with the broader Rust ecosystem (`log`/`tracing`). + +**Current State** +- Files: `crates/lambda-rs-logging/src/lib.rs`, `crates/lambda-rs-logging/src/handler.rs`. +- API: `Logger { name, level, handlers }` with per-level methods that take `&mut self` and `String`. +- Global: `pub(crate) static mut LOGGER: Option` initialized in `Logger::global()` via `unsafe`. +- Handlers: `ConsoleHandler`, `FileHandler`; both colorize; file buffers 10 messages then writes. +- Macros: `trace!/debug!/…` call `logging::Logger::global()` and eagerly `format!(...)`. + +**Key Issues** +- Unsound global singleton + - `static mut` + `&'static mut` return is not thread-safe and is UB under concurrency. +- Macro design and overhead + - Always allocates due to `format!` before level check; path uses `logging::` instead of `$crate`. +- Concurrency and ergonomics + - `Logger` methods require `&mut self`; `Handler` requires `&mut self`, preventing concurrent logging; no `Send + Sync` guarantees. +- Fatal behavior + - `Logger::fatal` calls `std::process::exit(1)` in a library crate; surprising and hard to test. +- File handler + - Color codes in files; buffered writes with no `Drop` flush; potential data loss at shutdown; no newline guarantee. +- Output streams + - All levels write to stdout; `WARN+` should prefer stderr. +- Timestamps and format + - Seconds-since-epoch, not human-friendly; no timezone; no module/file/line metadata. +- Documentation drift + - README example does not match API (`Logger::new` signature); crate/lib naming is confusing. + +**Proposed Design** + +- Core types + - `enum Level { Trace, Debug, Info, Warn, Error }` plus optional `Off`. + - `struct Record<'a> { ts: SystemTime, level: Level, target: &'a str, message: std::fmt::Arguments<'a>, module_path: Option<&'static str>, file: Option<&'static str>, line: Option }`. + +- Handler trait + - `pub trait Handler: Send + Sync { fn enabled(&self, level: Level, target: &str) -> bool { true } fn log(&self, record: &Record); }` + - Rationale: single entry point, shared across levels; concurrency via `&self` with interior mutability where needed. + +- Logger internals + - `pub struct Logger { name: String, level: AtomicLevel, handlers: RwLock>> }` + - `AtomicLevel` is a thin wrapper over `AtomicU8` with `Level` conversions. + - `impl Logger` exposes `builder()`, `level()`, `set_level()`, `add_handler()`, `clear_handlers()`. + +- Global initialization + - `static LOGGER: std::sync::OnceLock>`. + - `pub fn init(logger: Logger) -> Result<(), InitError>`; first caller wins; no `unsafe`. + - `pub fn global() -> &'static std::sync::Arc` returns initialized default (console info) if `init` not called. + +- Macros (zero/low-cost when disabled) + - Use `$crate` for paths and `format_args!` to avoid allocation: + - `macro_rules! log { ($lvl:expr, $($arg:tt)*) => {{ if $crate::enabled($lvl) { $crate::log_args($lvl, module_path!(), file!(), line!(), format_args!($($arg)*)); } }} }` + - Define `trace!/debug!/info!/warn!/error!` in terms of `log!`. + - Early guard using an atomic global level to skip work before formatting. + +- Console handler + - Color only when writing to a TTY (use `atty` or equivalent); write `WARN+` to `stderr`. + - Default format: `YYYY-MM-DDTHH:MM:SS.sssZ level target: message`. + +- File handler + - No color; use `BufWriter` behind a `Mutex` to ensure thread-safety. + - Flush on newline or on `Drop`; optional size-based rotation later. + +- Configuration + - Builder: `Logger::builder().name("lambda-rs").level(Level::Info).with_handler(Arc::default()).build()`. + - Env var parser (opt-in): `LAMBDA_LOG="info,lambda_rs_render=debug"` to set per-target levels. + - Feature flags: `color` (default on), `env` (enable env parsing), `log-bridge`, `tracing-bridge`. + +- Ecosystem bridges (optional, but recommended) + - `log` facade: implement `log::Log` for a thin adapter that forwards to our `Logger`; provide `init_log_bridge()`. + - `tracing` bridge (future): implement a basic `tracing_subscriber::Layer` emitting to handlers. + +- API surface (sketch) + - `pub fn enabled(level: Level) -> bool` + - `pub fn log_args(level: Level, target: &str, file: &'static str, line: u32, args: Arguments)` + - `pub mod macros { pub use crate::{trace, debug, info, warn, error}; }` + +**Migration Plan** +- Phase 1: Internals refactor (Record, Handler::log, OnceLock global); keep old methods/macros working via shims. +- Phase 2: Macro rewrite with `$crate` and early guard; mark old `String`-based methods `#[deprecated]`. +- Phase 3: Introduce builder and env config; update examples/README. +- Phase 4: Optional bridges (`log` facade) and additional handlers (JSON, rotating file). + +**Backwards Compatibility** +- Maintain `trace!/debug!/…` macro names; forward to new implementation. +- Keep `Logger::{trace,debug,…}(String)` for one release, delegating to the new `Arguments`-based path; deprecate with guidance. +- Remove `fatal` auto-exit; add `fatal_and_exit!` macro behind a `danger-exit` feature flag for explicit opt-in. + +**Testing** +- Unit tests: level filtering, macro early-exit (no formatting when disabled), handler invocation order, builder config. +- Concurrency tests: multi-threaded logging to console/file without panics or data races. +- Integration tests: env var parsing; bridge to `log` (if feature enabled). +- Snapshot tests for formatted output (normalize timestamps via injection). + +**Documentation Updates** +- Fix README examples to match signatures and import paths; clarify crate name vs lib name. +- Add a usage guide with builder, env config, and handler customization. +- Document migration notes and deprecations. + +**Risks & Trade-offs** +- Adding `OnceLock`, `RwLock`, and atomics slightly increases complexity but removes UB and makes logging robust under concurrency. +- Bridging to `log`/`tracing` introduces optional deps; gate behind features to keep core lightweight. + +**Open Questions** +- Do we want hierarchical targets (e.g., `engine.render` inheritance) in v1, or keep flat target-level map? +- Should we default to integrating with `log` to reduce duplicate macros across crates? + +**Changelog** +- 0.1.0 (draft): Initial spec, proposes thread-safe global, unified handler API, macro rewrite, configuration, and ecosystem bridges.