From a7a5be618e0f77957072a9711c6fdeb112d3191a Mon Sep 17 00:00:00 2001 From: tison Date: Mon, 10 Aug 2026 03:15:32 +0800 Subject: [PATCH 1/4] feat: add native logging macros --- CHANGELOG.md | 4 + README.md | 20 ++ core/src/kv.rs | 140 +++++++++++++ core/src/lib.rs | 2 + core/src/macros.rs | 332 +++++++++++++++++++++++++++++ core/tests/macros.rs | 361 ++++++++++++++++++++++++++++++++ logforth/src/lib.rs | 23 ++ logforth/tests/native_macros.rs | 24 +++ 8 files changed, 906 insertions(+) create mode 100644 core/src/macros.rs create mode 100644 core/tests/macros.rs create mode 100644 logforth/tests/native_macros.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 596655b..9e16a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. * Bump minimum supported Rust version (MSRV) to 1.91.0. +### New features + +* Add native Logforth logging macros with explicit logger instances, fine-grained levels, and structured key-value fields. + ## [0.30.1] 2026-06-03 ### Improvements diff --git a/README.md b/README.md index 418bc8c..4c3c85c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,26 @@ fn main() { By default, all logging except the `error` level is disabled. You can enable logging at other levels by setting the [`RUST_LOG`](https://docs.rs/logforth-filter-rustlog/*/logforth_filter_rustlog/index.html) environment variable. For example, `RUST_LOG=all cargo run` will print all logs. +### Native Logforth macros + +Applications can use Logforth's native macros when they need fine-grained OpenTelemetry severity levels or want to avoid the `log` facade. Native macros take an explicit logger instance instead of using a second global logger: + +```rust +use logforth::append; +use logforth::record::Level; + +fn main() { + let logger = logforth::core::builder() + .dispatch(|d| d.append(append::Stdout::default())) + .build(); + + logforth::info!(logger: logger, request_id = 42_u64; "request accepted"); + logforth::log!(logger: logger, Level::Info2, "request details"); +} +``` + +The `log` facade remains the recommended API for libraries because it lets the final application choose its logging implementation. + ## Advanced Usage Configure multiple dispatches with different filters and appenders: diff --git a/core/src/kv.rs b/core/src/kv.rs index 4447f2b..9a4fdff 100644 --- a/core/src/kv.rs +++ b/core/src/kv.rs @@ -384,6 +384,16 @@ enum ValueState<'a> { Display(&'a dyn fmt::Display), } +/// Convert a value into its structured logging representation. +/// +/// Implementations are provided for primitive scalar values, strings, byte slices, [`Option`], +/// references, and [`Value`] itself. Other values can implement this trait or use the `:?` and `:%` +/// capture modifiers in Logforth's logging macros. +pub trait ToValue { + /// Convert this value into a borrowed [`Value`]. + fn to_value(&self) -> Value<'_>; +} + impl fmt::Debug for ValueState<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -512,6 +522,136 @@ impl<'a> Value<'a> { } } +impl ToValue for Value<'_> { + fn to_value(&self) -> Value<'_> { + *self + } +} + +impl ToValue for bool { + fn to_value(&self) -> Value<'_> { + Value::bool(*self) + } +} + +macro_rules! impl_to_value_signed { + ($($ty:ty),+ $(,)?) => { + $( + impl ToValue for $ty { + fn to_value(&self) -> Value<'_> { + Value::i64(*self as i64) + } + } + )+ + }; +} + +impl_to_value_signed!(i8, i16, i32, i64, isize); + +impl ToValue for i128 { + fn to_value(&self) -> Value<'_> { + Value::i128(*self) + } +} + +macro_rules! impl_to_value_unsigned { + ($($ty:ty),+ $(,)?) => { + $( + impl ToValue for $ty { + fn to_value(&self) -> Value<'_> { + Value::u64(*self as u64) + } + } + )+ + }; +} + +impl_to_value_unsigned!(u8, u16, u32, u64, usize); + +impl ToValue for u128 { + fn to_value(&self) -> Value<'_> { + Value::u128(*self) + } +} + +impl ToValue for f32 { + fn to_value(&self) -> Value<'_> { + Value::f64((*self).into()) + } +} + +impl ToValue for f64 { + fn to_value(&self) -> Value<'_> { + Value::f64(*self) + } +} + +impl ToValue for char { + fn to_value(&self) -> Value<'_> { + Value::char(*self) + } +} + +impl ToValue for str { + fn to_value(&self) -> Value<'_> { + Value::str(self) + } +} + +impl ToValue for String { + fn to_value(&self) -> Value<'_> { + Value::str(self) + } +} + +impl ToValue for Cow<'_, str> { + fn to_value(&self) -> Value<'_> { + Value::str(self) + } +} + +impl ToValue for [u8] { + fn to_value(&self) -> Value<'_> { + Value::bytes(self) + } +} + +impl ToValue for Vec { + fn to_value(&self) -> Value<'_> { + Value::bytes(self) + } +} + +impl ToValue for Option +where + T: ToValue, +{ + fn to_value(&self) -> Value<'_> { + match self { + Some(value) => value.to_value(), + None => Value::none(), + } + } +} + +impl ToValue for &T +where + T: ToValue + ?Sized, +{ + fn to_value(&self) -> Value<'_> { + (*self).to_value() + } +} + +impl ToValue for &mut T +where + T: ToValue + ?Sized, +{ + fn to_value(&self) -> Value<'_> { + (**self).to_value() + } +} + /// An owned value in a key-value pair. #[derive(Debug, Clone)] pub struct ValueOwned(ValueOwnedState); diff --git a/core/src/lib.rs b/core/src/lib.rs index dc5f75f..07ad0df 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -25,6 +25,8 @@ pub mod layout; pub mod record; pub mod trap; +mod macros; + pub use self::append::Append; pub use self::diagnostic::Diagnostic; pub use self::filter::Filter; diff --git a/core/src/macros.rs b/core/src/macros.rs new file mode 100644 index 0000000..5cf69a8 --- /dev/null +++ b/core/src/macros.rs @@ -0,0 +1,332 @@ +// Copyright 2024 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Log a message at a dynamically selected level. +/// +/// A logger instance is required. The target defaults to the caller's module path and can be +/// overridden with `target:`. Structured key-value pairs precede the message and are separated +/// from it by a semicolon. Values retain their native type by default; use `:?` or `:%` to capture +/// a value with [`Debug`](std::fmt::Debug) or [`Display`](std::fmt::Display). +/// +/// Keys can be identifiers, string literals, or parenthesized string expressions. An identifier +/// without `= value` captures the variable with the same name. The message can be omitted for a +/// structured-only record by ending the fields with a semicolon. +/// +/// The message and structured fields are not evaluated when the logger disables the level and +/// target. +/// +/// # Examples +/// +/// ``` +/// use logforth_core::record::Level; +/// +/// let logger = logforth_core::builder().build(); +/// let request_id = 42_u64; +/// logforth_core::log!( +/// logger: logger, +/// target: "http", +/// Level::Info2, +/// request_id, +/// peer:% = "127.0.0.1"; +/// "request accepted" +/// ); +/// ``` +#[macro_export] +#[clippy::format_args] +macro_rules! log { + (logger: $logger:expr, target: $target:expr, $level:expr, $($args:tt)+) => {{ + $crate::__log!(logger: $logger, target: $target, target_method: target, $level, $($args)+) + }}; + (logger: $logger:expr, $level:expr, $($args:tt)+) => {{ + $crate::__log!(logger: $logger, target: ::std::module_path!(), target_method: target_static, $level, $($args)+) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +/// Log a message at the fatal level. +/// +/// This macro records severity only; it does not terminate the process or flush the logger. +/// +/// # Examples +/// +/// ``` +/// let logger = logforth_core::builder().build(); +/// logforth_core::fatal!(logger: logger, "unrecoverable failure"); +/// ``` +#[macro_export] +#[clippy::format_args] +macro_rules! fatal { + (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, target: $target, $crate::record::Level::Fatal, $($args)+) + }}; + (logger: $logger:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, $crate::record::Level::Fatal, $($args)+) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +/// Log a message at the error level. +/// +/// # Examples +/// +/// ``` +/// let logger = logforth_core::builder().build(); +/// logforth_core::error!(logger: logger, "operation failed"); +/// ``` +#[macro_export] +#[clippy::format_args] +macro_rules! error { + (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, target: $target, $crate::record::Level::Error, $($args)+) + }}; + (logger: $logger:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, $crate::record::Level::Error, $($args)+) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +/// Log a message at the warn level. +/// +/// # Examples +/// +/// ``` +/// let logger = logforth_core::builder().build(); +/// logforth_core::warn!(logger: logger, "retrying operation"); +/// ``` +#[macro_export] +#[clippy::format_args] +macro_rules! warn { + (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, target: $target, $crate::record::Level::Warn, $($args)+) + }}; + (logger: $logger:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, $crate::record::Level::Warn, $($args)+) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +/// Log a message at the info level. +/// +/// # Examples +/// +/// ``` +/// let logger = logforth_core::builder().build(); +/// logforth_core::info!(logger: logger, user_id = 42_u64; "user connected"); +/// ``` +#[macro_export] +#[clippy::format_args] +macro_rules! info { + (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, target: $target, $crate::record::Level::Info, $($args)+) + }}; + (logger: $logger:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, $crate::record::Level::Info, $($args)+) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +/// Log a message at the debug level. +/// +/// # Examples +/// +/// ``` +/// let logger = logforth_core::builder().build(); +/// logforth_core::debug!(logger: logger, "state updated"); +/// ``` +#[macro_export] +#[clippy::format_args] +macro_rules! debug { + (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, target: $target, $crate::record::Level::Debug, $($args)+) + }}; + (logger: $logger:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, $crate::record::Level::Debug, $($args)+) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +/// Log a message at the trace level. +/// +/// # Examples +/// +/// ``` +/// let logger = logforth_core::builder().build(); +/// logforth_core::trace!(logger: logger, "entered operation"); +/// ``` +#[macro_export] +#[clippy::format_args] +macro_rules! trace { + (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, target: $target, $crate::record::Level::Trace, $($args)+) + }}; + (logger: $logger:expr, $($args:tt)+) => {{ + $crate::log!(logger: $logger, $crate::record::Level::Trace, $($args)+) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +/// Determine whether a level and target are enabled for a logger. +/// +/// The target defaults to the caller's module path. +/// +/// # Examples +/// +/// ``` +/// use logforth_core::record::Level; +/// +/// let logger = logforth_core::builder().build(); +/// if logforth_core::log_enabled!(logger: logger, Level::Debug) { +/// // Perform expensive diagnostic work. +/// } +/// ``` +#[macro_export] +macro_rules! log_enabled { + (logger: $logger:expr, target: $target:expr, $level:expr) => {{ + let __logforth_logger: &$crate::Logger = &$logger; + let __logforth_level = $level; + let __logforth_target = $target; + let __logforth_criteria = $crate::record::FilterCriteria::builder() + .level(__logforth_level) + .target(__logforth_target) + .build(); + __logforth_logger.enabled(&__logforth_criteria) + }}; + (logger: $logger:expr, $level:expr) => {{ + $crate::log_enabled!(logger: $logger, target: ::std::module_path!(), $level) + }}; + ($($args:tt)*) => {{ + ::std::compile_error!("Logforth logging macros require `logger: `") + }}; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __log { + (logger: $logger:expr, target: $target:expr, target_method: $target_method:ident, $level:expr, $($key:tt $(:$capture:tt)? $(= $value:expr)?),+; $($message:tt)+) => {{ + let __logforth_logger: &$crate::Logger = &$logger; + let __logforth_level = $level; + let __logforth_target = $target; + let __logforth_criteria = $crate::record::FilterCriteria::builder() + .level(__logforth_level) + .target(__logforth_target) + .build(); + if __logforth_logger.enabled(&__logforth_criteria) { + __logforth_logger.log( + &$crate::record::Record::builder() + .level(__logforth_level) + .$target_method(__logforth_target) + .module_path_static(::std::module_path!()) + .file_static(::std::file!()) + .line(::std::option::Option::Some(::std::line!())) + .column(::std::option::Option::Some(::std::column!())) + .payload(::std::format_args!($($message)+)) + .key_values(&[ + $(( + $crate::__log_key!($key), + $crate::__log_value!($key $(:$capture)? $(= $value)?), + )),+ + ][..]) + .build(), + ); + } + }}; + (logger: $logger:expr, target: $target:expr, target_method: $target_method:ident, $level:expr, $($key:tt $(:$capture:tt)? $(= $value:expr)?),+;) => {{ + $crate::__log!(logger: $logger, target: $target, target_method: $target_method, $level, $($key $(:$capture)? $(= $value)?),+; "") + }}; + (logger: $logger:expr, target: $target:expr, target_method: $target_method:ident, $level:expr, $($message:tt)+) => {{ + let __logforth_logger: &$crate::Logger = &$logger; + let __logforth_level = $level; + let __logforth_target = $target; + let __logforth_criteria = $crate::record::FilterCriteria::builder() + .level(__logforth_level) + .target(__logforth_target) + .build(); + if __logforth_logger.enabled(&__logforth_criteria) { + __logforth_logger.log( + &$crate::record::Record::builder() + .level(__logforth_level) + .$target_method(__logforth_target) + .module_path_static(::std::module_path!()) + .file_static(::std::file!()) + .line(::std::option::Option::Some(::std::line!())) + .column(::std::option::Option::Some(::std::column!())) + .payload(::std::format_args!($($message)+)) + .build(), + ); + } + }}; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __log_key { + ($key:ident) => { + $crate::kv::Key::new(::std::stringify!($key)) + }; + ($key:literal) => { + $crate::kv::Key::new($key) + }; + (($key:expr)) => { + $crate::kv::Key::borrowed($key) + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __log_value { + ($key:tt = $value:expr) => { + $crate::kv::ToValue::to_value(&$value) + }; + ($key:tt :? = $value:expr) => { + $crate::kv::Value::debug(&$value) + }; + ($key:tt :debug = $value:expr) => { + $crate::kv::Value::debug(&$value) + }; + ($key:tt :% = $value:expr) => { + $crate::kv::Value::display(&$value) + }; + ($key:tt :display = $value:expr) => { + $crate::kv::Value::display(&$value) + }; + ($key:ident) => { + $crate::kv::ToValue::to_value(&$key) + }; + ($key:ident :?) => { + $crate::kv::Value::debug(&$key) + }; + ($key:ident :debug) => { + $crate::kv::Value::debug(&$key) + }; + ($key:ident :%) => { + $crate::kv::Value::display(&$key) + }; + ($key:ident :display) => { + $crate::kv::Value::display(&$key) + }; +} diff --git a/core/tests/macros.rs b/core/tests/macros.rs new file mode 100644 index 0000000..2a589b3 --- /dev/null +++ b/core/tests/macros.rs @@ -0,0 +1,361 @@ +// Copyright 2024 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::cell::Cell; +use std::fmt; +use std::sync::Arc; +use std::sync::Mutex; + +use logforth_core::Append; +use logforth_core::Diagnostic; +use logforth_core::Error; +use logforth_core::Logger; +use logforth_core::kv::KeyView; +use logforth_core::kv::ToValue; +use logforth_core::kv::Value; +use logforth_core::kv::ValueView; +use logforth_core::record::Level; +use logforth_core::record::LevelFilter; +use logforth_core::record::Record; + +#[derive(Debug, PartialEq)] +enum CapturedValue { + None, + Bool(bool), + I64(i64), + U64(u64), + F64(f64), + I128(i128), + U128(u128), + Char(char), + String(String), + Bytes(Vec), + Debug(String), + Display(String), + Other(String), +} + +impl CapturedValue { + fn from_view(value: ValueView<'_>) -> Self { + match value { + ValueView::None => CapturedValue::None, + ValueView::BorrowedStr(value) | ValueView::StaticStr(value) => { + CapturedValue::String(value.to_owned()) + } + ValueView::Bytes(value) => CapturedValue::Bytes(value.to_vec()), + ValueView::Bool(value) => CapturedValue::Bool(value), + ValueView::I64(value) => CapturedValue::I64(value), + ValueView::U64(value) => CapturedValue::U64(value), + ValueView::F64(value) => CapturedValue::F64(value), + ValueView::I128(value) => CapturedValue::I128(value), + ValueView::U128(value) => CapturedValue::U128(value), + ValueView::Char(value) => CapturedValue::Char(value), + ValueView::Debug(value) => CapturedValue::Debug(format!("{value:?}")), + ValueView::Display(value) => CapturedValue::Display(format!("{value}")), + value => CapturedValue::Other(format!("{value:?}")), + } + } +} + +#[derive(Debug, PartialEq)] +struct CapturedRecord { + level: Level, + target: String, + target_static: Option, + module_path: Option, + file: Option, + line: Option, + column: Option, + payload: String, + key_values: Vec<(String, CapturedValue)>, +} + +impl CapturedRecord { + fn from_record(record: &Record<'_>) -> Result { + let mut key_values = Vec::new(); + record + .key_values() + .visit(&mut |key: KeyView<'_>, value: ValueView<'_>| { + key_values.push((key.as_str().to_owned(), CapturedValue::from_view(value))); + Ok(()) + })?; + + Ok(Self { + level: record.level(), + target: record.target().to_owned(), + target_static: record.target_static().map(str::to_owned), + module_path: record.module_path().map(str::to_owned), + file: record.file().map(str::to_owned), + line: record.line(), + column: record.column(), + payload: record.payload().to_string(), + key_values, + }) + } +} + +#[derive(Clone, Debug, Default)] +struct Capture { + records: Arc>>, +} + +impl Capture { + fn take(&self) -> Vec { + std::mem::take(&mut *self.records.lock().unwrap()) + } +} + +impl Append for Capture { + fn append(&self, record: &Record<'_>, _: &[Box]) -> Result<(), Error> { + self.records + .lock() + .unwrap() + .push(CapturedRecord::from_record(record)?); + Ok(()) + } + + fn flush(&self) -> Result<(), Error> { + Ok(()) + } +} + +fn make_logger(capture: Capture) -> Logger { + logforth_core::builder() + .dispatch(|dispatch| dispatch.append(capture)) + .build() +} + +fn make_filtered_logger(capture: Capture) -> Logger { + logforth_core::builder() + .dispatch(|dispatch| { + dispatch + .filter(LevelFilter::MoreSevereEqual(Level::Error)) + .append(capture) + }) + .build() +} + +#[test] +fn captures_fine_grained_level_metadata_and_typed_fields() { + struct DebugOnly(u8); + + impl fmt::Debug for DebugOnly { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "DebugOnly({})", self.0) + } + } + + struct DisplayOnly(u8); + + impl fmt::Display for DisplayOnly { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "display({})", self.0) + } + } + + struct CustomValue(u8); + + impl ToValue for CustomValue { + fn to_value(&self) -> Value<'_> { + Value::u64(self.0.into()) + } + } + + let capture = Capture::default(); + let logger = make_logger(capture.clone()); + let shorthand = 7_u32; + let text = String::from("hello"); + let absent: Option = None; + let dynamic_key = String::from("dynamic.key"); + let expected_line = line!() + 1; + logforth_core::log!( + logger: logger, + target: "custom.target", + Level::Info2, + shorthand, + signed = -2_i32, + unsigned = 3_usize, + wide_signed = -4_i128, + wide_unsigned = 5_u128, + float = 1.5_f32, + character = 'x', + text = text, + absent = absent, + bytes = &b"bytes"[..], + "literal.key" = true, + (dynamic_key.as_str()) = 9_u8, + debug:? = DebugOnly(10), + display:% = DisplayOnly(11), + custom = CustomValue(13); + "accepted {}", + 12 + ); + + let records = capture.take(); + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record.level, Level::Info2); + assert_eq!(record.target, "custom.target"); + assert_eq!(record.target_static, None); + assert_eq!(record.module_path.as_deref(), Some("macros")); + assert!(record.file.as_deref().unwrap().ends_with("tests/macros.rs")); + assert_eq!(record.line, Some(expected_line)); + assert!(record.column.unwrap() > 0); + assert_eq!(record.payload, "accepted 12"); + assert_eq!( + record.key_values, + [ + ("shorthand".to_owned(), CapturedValue::U64(7)), + ("signed".to_owned(), CapturedValue::I64(-2)), + ("unsigned".to_owned(), CapturedValue::U64(3)), + ("wide_signed".to_owned(), CapturedValue::I128(-4)), + ("wide_unsigned".to_owned(), CapturedValue::U128(5)), + ("float".to_owned(), CapturedValue::F64(1.5)), + ("character".to_owned(), CapturedValue::Char('x')), + ("text".to_owned(), CapturedValue::String("hello".to_owned())), + ("absent".to_owned(), CapturedValue::None), + ("bytes".to_owned(), CapturedValue::Bytes(b"bytes".to_vec())), + ("literal.key".to_owned(), CapturedValue::Bool(true)), + ("dynamic.key".to_owned(), CapturedValue::U64(9)), + ( + "debug".to_owned(), + CapturedValue::Debug("DebugOnly(10)".to_owned()) + ), + ( + "display".to_owned(), + CapturedValue::Display("display(11)".to_owned()) + ), + ("custom".to_owned(), CapturedValue::U64(13)), + ] + ); +} + +#[test] +fn convenience_macros_cover_standard_levels() { + let capture = Capture::default(); + let logger = Arc::new(make_logger(capture.clone())); + + logforth_core::fatal!(logger: logger, "fatal"); + logforth_core::error!(logger: logger, target: "error.target", "error"); + logforth_core::warn!(logger: &logger, "warn"); + logforth_core::info!(logger: logger, "info"); + logforth_core::debug!(logger: logger, "debug"); + logforth_core::trace!(logger: logger, "trace"); + + let records = capture.take(); + assert_eq!( + records + .iter() + .map(|record| (record.level, record.payload.as_str())) + .collect::>(), + [ + (Level::Fatal, "fatal"), + (Level::Error, "error"), + (Level::Warn, "warn"), + (Level::Info, "info"), + (Level::Debug, "debug"), + (Level::Trace, "trace"), + ] + ); + assert_eq!(records[1].target, "error.target"); + assert!( + records + .iter() + .enumerate() + .all(|(index, record)| index == 1 || record.target == "macros") + ); + assert!( + records + .iter() + .enumerate() + .all(|(index, record)| index == 1 || record.target_static.as_deref() == Some("macros")) + ); + assert_eq!(records[1].target_static, None); +} + +#[test] +fn disabled_records_do_not_evaluate_payload_or_fields() { + let capture = Capture::default(); + let logger = make_filtered_logger(capture.clone()); + let evaluations = Cell::new(0); + let expensive = || { + evaluations.set(evaluations.get() + 1); + 42_u64 + }; + + logforth_core::info!( + logger: logger, + value = expensive(); + "value is {}", + expensive() + ); + + assert_eq!(evaluations.get(), 0); + assert!(capture.take().is_empty()); + assert!(!logforth_core::log_enabled!(logger: logger, Level::Info)); + assert!(logforth_core::log_enabled!( + logger: logger, + target: "custom.target", + Level::Error + )); +} + +#[test] +fn macro_inputs_are_evaluated_once() { + let capture = Capture::default(); + let logger = make_logger(capture.clone()); + let logger_evaluations = Cell::new(0); + let level_evaluations = Cell::new(0); + let target_evaluations = Cell::new(0); + + let logger_expression = || { + logger_evaluations.set(logger_evaluations.get() + 1); + &logger + }; + let level_expression = || { + level_evaluations.set(level_evaluations.get() + 1); + Level::Debug3 + }; + let target_expression = || { + target_evaluations.set(target_evaluations.get() + 1); + "evaluated.once" + }; + + logforth_core::log!( + logger: logger_expression(), + target: target_expression(), + level_expression(), + "once" + ); + + assert_eq!(logger_evaluations.get(), 1); + assert_eq!(level_evaluations.get(), 1); + assert_eq!(target_evaluations.get(), 1); + assert_eq!(capture.take()[0].level, Level::Debug3); +} + +#[test] +fn structured_record_may_omit_message() { + let capture = Capture::default(); + let logger = make_logger(capture.clone()); + + logforth_core::info!(logger: logger, answer = 42_u64;); + + let records = capture.take(); + assert_eq!(records[0].payload, ""); + assert_eq!( + records[0].key_values, + [("answer".to_owned(), CapturedValue::U64(42))] + ); +} diff --git a/logforth/src/lib.rs b/logforth/src/lib.rs index 668dcd5..a872e67 100644 --- a/logforth/src/lib.rs +++ b/logforth/src/lib.rs @@ -60,6 +60,21 @@ //! log::info!("Info message."); //! ``` //! +//! Applications that need Logforth's fine-grained severity levels can keep a [`core::Logger`] +//! instance and use the native macros directly, without installing a second global logger: +//! +//! ``` +//! use logforth::append; +//! use logforth::record::Level; +//! +//! let logger = logforth::core::builder() +//! .dispatch(|d| d.append(append::Stdout::default())) +//! .build(); +//! +//! logforth::info!(logger: logger, request_id = 42_u64; "request accepted"); +//! logforth::log!(logger: logger, Level::Info2, "request details"); +//! ``` +//! //! See the [README] file for more details and examples. //! //! [README]: https://github.com/fast/logforth?tab=readme-ov-file @@ -69,11 +84,19 @@ pub use logforth_core::Error; pub use logforth_core::append::Append; +pub use logforth_core::debug; pub use logforth_core::diagnostic::Diagnostic; +pub use logforth_core::error; +pub use logforth_core::fatal; pub use logforth_core::filter::Filter; +pub use logforth_core::info; pub use logforth_core::kv; pub use logforth_core::layout::Layout; +pub use logforth_core::log; +pub use logforth_core::log_enabled; pub use logforth_core::record; +pub use logforth_core::trace; +pub use logforth_core::warn; /// Dispatch log records to various targets. pub mod append { diff --git a/logforth/tests/native_macros.rs b/logforth/tests/native_macros.rs new file mode 100644 index 0000000..5fd2467 --- /dev/null +++ b/logforth/tests/native_macros.rs @@ -0,0 +1,24 @@ +// Copyright 2024 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use logforth::record::Level; + +#[test] +fn native_macros_are_reexported() { + let logger = logforth::core::builder().build(); + + logforth::info!(logger: logger, answer = 42_u64; "hello"); + logforth::log!(logger: logger, Level::Info2, "fine-grained"); + assert!(!logforth::log_enabled!(logger: logger, Level::Info)); +} From 1ad4c0fab53c356fe72892dc64896be1d31ed66f Mon Sep 17 00:00:00 2001 From: tison Date: Mon, 10 Aug 2026 03:21:09 +0800 Subject: [PATCH 2/4] test: make macro source path assertion portable --- core/tests/macros.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/tests/macros.rs b/core/tests/macros.rs index 2a589b3..49ec0ec 100644 --- a/core/tests/macros.rs +++ b/core/tests/macros.rs @@ -209,7 +209,10 @@ fn captures_fine_grained_level_metadata_and_typed_fields() { assert_eq!(record.target, "custom.target"); assert_eq!(record.target_static, None); assert_eq!(record.module_path.as_deref(), Some("macros")); - assert!(record.file.as_deref().unwrap().ends_with("tests/macros.rs")); + assert!( + std::path::Path::new(record.file.as_deref().unwrap()) + .ends_with(std::path::Path::new("tests").join("macros.rs")) + ); assert_eq!(record.line, Some(expected_line)); assert!(record.column.unwrap() > 0); assert_eq!(record.payload, "accepted 12"); From 85396d6618db710e8b50662545e88e668a46e53b Mon Sep 17 00:00:00 2001 From: tison Date: Mon, 24 Aug 2026 02:03:06 +0800 Subject: [PATCH 3/4] refactor: simplify native logging macro API --- CHANGELOG.md | 2 +- README.md | 8 +- core/src/filter/mod.rs | 7 +- core/src/logger/log_impl.rs | 8 +- core/src/macros.rs | 136 ++++++++++---------------------- core/tests/macros.rs | 55 ++++--------- logforth/src/lib.rs | 5 +- logforth/tests/native_macros.rs | 5 +- 8 files changed, 77 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e16a8a..1be04fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. ### New features -* Add native Logforth logging macros with explicit logger instances, fine-grained levels, and structured key-value fields. +* Add native Logforth logging macros with positional logger instances, fine-grained levels, lazy evaluation, and structured key-value fields. ## [0.30.1] 2026-06-03 diff --git a/README.md b/README.md index 4c3c85c..6180bf4 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,14 @@ fn main() { .dispatch(|d| d.append(append::Stdout::default())) .build(); - logforth::info!(logger: logger, request_id = 42_u64; "request accepted"); - logforth::log!(logger: logger, Level::Info2, "request details"); + logforth::info!(logger, request_id = 42_u64; "request accepted"); + logforth::log!(logger, Level::Info2, "request details"); } ``` -The `log` facade remains the recommended API for libraries because it lets the final application choose its logging implementation. +The logger is the first argument, following the same instance-first convention as `slog`. A record's target is its call-site module path; application-specific classifications belong in structured fields. + +The macros check the logger before evaluating the message or its fields, so an extra enabled check is unnecessary. The `log` facade remains the recommended API for libraries because it lets the final application choose its logging implementation. ## Advanced Usage diff --git a/core/src/filter/mod.rs b/core/src/filter/mod.rs index 3fc4786..2be1190 100644 --- a/core/src/filter/mod.rs +++ b/core/src/filter/mod.rs @@ -34,7 +34,12 @@ pub enum FilterResult { /// A filter that can be applied to log records. pub trait Filter: fmt::Debug + Send + Sync + 'static { - /// Whether the record is filtered by its given metadata. + /// Prefilter a record using criteria available before the complete record is constructed. + /// + /// A filter that needs the message or structured fields to decide must return + /// [`FilterResult::Neutral`] here and make that decision in [`Filter::matches`]. Returning + /// [`FilterResult::Reject`] promises that every record with these criteria can be rejected + /// without constructing it. fn enabled(&self, criteria: &FilterCriteria, diags: &[Box]) -> FilterResult; /// Whether the record is filtered. diff --git a/core/src/logger/log_impl.rs b/core/src/logger/log_impl.rs index 9795c4d..769e7d0 100644 --- a/core/src/logger/log_impl.rs +++ b/core/src/logger/log_impl.rs @@ -36,7 +36,13 @@ impl Logger { } impl Logger { - /// Determine if a log message with the specified metadata would be logged. + /// Determine whether any dispatch may log a record with the specified criteria. + /// + /// This is a prefiltering hint, not a promise that a subsequent record will be logged. Filters + /// may make their final decision from the complete [`Record`], and configuration may change + /// between this call and [`Logger::log`]. Calling this method before `log` is optional; the + /// native logging macros already avoid evaluating messages and fields when prefiltering rejects + /// them. pub fn enabled(&self, criteria: &FilterCriteria) -> bool { self.dispatches .iter() diff --git a/core/src/macros.rs b/core/src/macros.rs index 5cf69a8..903e6cc 100644 --- a/core/src/macros.rs +++ b/core/src/macros.rs @@ -14,10 +14,10 @@ /// Log a message at a dynamically selected level. /// -/// A logger instance is required. The target defaults to the caller's module path and can be -/// overridden with `target:`. Structured key-value pairs precede the message and are separated -/// from it by a semicolon. Values retain their native type by default; use `:?` or `:%` to capture -/// a value with [`Debug`](std::fmt::Debug) or [`Display`](std::fmt::Display). +/// A logger instance is required as the first argument. The target is the caller's module path. +/// Structured key-value pairs precede the message and are separated from it by a semicolon. Values +/// retain their native type by default; use `:?` or `:%` to capture a value with +/// [`Debug`](std::fmt::Debug) or [`Display`](std::fmt::Display). /// /// Keys can be identifiers, string literals, or parenthesized string expressions. An identifier /// without `= value` captures the variable with the same name. The message can be omitted for a @@ -34,8 +34,7 @@ /// let logger = logforth_core::builder().build(); /// let request_id = 42_u64; /// logforth_core::log!( -/// logger: logger, -/// target: "http", +/// logger, /// Level::Info2, /// request_id, /// peer:% = "127.0.0.1"; @@ -45,14 +44,11 @@ #[macro_export] #[clippy::format_args] macro_rules! log { - (logger: $logger:expr, target: $target:expr, $level:expr, $($args:tt)+) => {{ - $crate::__log!(logger: $logger, target: $target, target_method: target, $level, $($args)+) - }}; - (logger: $logger:expr, $level:expr, $($args:tt)+) => {{ - $crate::__log!(logger: $logger, target: ::std::module_path!(), target_method: target_static, $level, $($args)+) + ($logger:expr, $level:expr, $($args:tt)+) => {{ + $crate::__log!($logger, $level, $($args)+) }}; ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") + ::std::compile_error!("Logforth logging macros require a logger as the first argument") }}; } @@ -64,19 +60,16 @@ macro_rules! log { /// /// ``` /// let logger = logforth_core::builder().build(); -/// logforth_core::fatal!(logger: logger, "unrecoverable failure"); +/// logforth_core::fatal!(logger, "unrecoverable failure"); /// ``` #[macro_export] #[clippy::format_args] macro_rules! fatal { - (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, target: $target, $crate::record::Level::Fatal, $($args)+) - }}; - (logger: $logger:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, $crate::record::Level::Fatal, $($args)+) + ($logger:expr, $($args:tt)+) => {{ + $crate::log!($logger, $crate::record::Level::Fatal, $($args)+) }}; ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") + ::std::compile_error!("Logforth logging macros require a logger as the first argument") }}; } @@ -86,19 +79,16 @@ macro_rules! fatal { /// /// ``` /// let logger = logforth_core::builder().build(); -/// logforth_core::error!(logger: logger, "operation failed"); +/// logforth_core::error!(logger, "operation failed"); /// ``` #[macro_export] #[clippy::format_args] macro_rules! error { - (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, target: $target, $crate::record::Level::Error, $($args)+) - }}; - (logger: $logger:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, $crate::record::Level::Error, $($args)+) + ($logger:expr, $($args:tt)+) => {{ + $crate::log!($logger, $crate::record::Level::Error, $($args)+) }}; ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") + ::std::compile_error!("Logforth logging macros require a logger as the first argument") }}; } @@ -108,19 +98,16 @@ macro_rules! error { /// /// ``` /// let logger = logforth_core::builder().build(); -/// logforth_core::warn!(logger: logger, "retrying operation"); +/// logforth_core::warn!(logger, "retrying operation"); /// ``` #[macro_export] #[clippy::format_args] macro_rules! warn { - (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, target: $target, $crate::record::Level::Warn, $($args)+) - }}; - (logger: $logger:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, $crate::record::Level::Warn, $($args)+) + ($logger:expr, $($args:tt)+) => {{ + $crate::log!($logger, $crate::record::Level::Warn, $($args)+) }}; ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") + ::std::compile_error!("Logforth logging macros require a logger as the first argument") }}; } @@ -130,19 +117,16 @@ macro_rules! warn { /// /// ``` /// let logger = logforth_core::builder().build(); -/// logforth_core::info!(logger: logger, user_id = 42_u64; "user connected"); +/// logforth_core::info!(logger, user_id = 42_u64; "user connected"); /// ``` #[macro_export] #[clippy::format_args] macro_rules! info { - (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, target: $target, $crate::record::Level::Info, $($args)+) - }}; - (logger: $logger:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, $crate::record::Level::Info, $($args)+) + ($logger:expr, $($args:tt)+) => {{ + $crate::log!($logger, $crate::record::Level::Info, $($args)+) }}; ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") + ::std::compile_error!("Logforth logging macros require a logger as the first argument") }}; } @@ -152,19 +136,16 @@ macro_rules! info { /// /// ``` /// let logger = logforth_core::builder().build(); -/// logforth_core::debug!(logger: logger, "state updated"); +/// logforth_core::debug!(logger, "state updated"); /// ``` #[macro_export] #[clippy::format_args] macro_rules! debug { - (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, target: $target, $crate::record::Level::Debug, $($args)+) - }}; - (logger: $logger:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, $crate::record::Level::Debug, $($args)+) + ($logger:expr, $($args:tt)+) => {{ + $crate::log!($logger, $crate::record::Level::Debug, $($args)+) }}; ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") + ::std::compile_error!("Logforth logging macros require a logger as the first argument") }}; } @@ -174,63 +155,26 @@ macro_rules! debug { /// /// ``` /// let logger = logforth_core::builder().build(); -/// logforth_core::trace!(logger: logger, "entered operation"); +/// logforth_core::trace!(logger, "entered operation"); /// ``` #[macro_export] #[clippy::format_args] macro_rules! trace { - (logger: $logger:expr, target: $target:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, target: $target, $crate::record::Level::Trace, $($args)+) - }}; - (logger: $logger:expr, $($args:tt)+) => {{ - $crate::log!(logger: $logger, $crate::record::Level::Trace, $($args)+) - }}; - ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") - }}; -} - -/// Determine whether a level and target are enabled for a logger. -/// -/// The target defaults to the caller's module path. -/// -/// # Examples -/// -/// ``` -/// use logforth_core::record::Level; -/// -/// let logger = logforth_core::builder().build(); -/// if logforth_core::log_enabled!(logger: logger, Level::Debug) { -/// // Perform expensive diagnostic work. -/// } -/// ``` -#[macro_export] -macro_rules! log_enabled { - (logger: $logger:expr, target: $target:expr, $level:expr) => {{ - let __logforth_logger: &$crate::Logger = &$logger; - let __logforth_level = $level; - let __logforth_target = $target; - let __logforth_criteria = $crate::record::FilterCriteria::builder() - .level(__logforth_level) - .target(__logforth_target) - .build(); - __logforth_logger.enabled(&__logforth_criteria) - }}; - (logger: $logger:expr, $level:expr) => {{ - $crate::log_enabled!(logger: $logger, target: ::std::module_path!(), $level) + ($logger:expr, $($args:tt)+) => {{ + $crate::log!($logger, $crate::record::Level::Trace, $($args)+) }}; ($($args:tt)*) => {{ - ::std::compile_error!("Logforth logging macros require `logger: `") + ::std::compile_error!("Logforth logging macros require a logger as the first argument") }}; } #[doc(hidden)] #[macro_export] macro_rules! __log { - (logger: $logger:expr, target: $target:expr, target_method: $target_method:ident, $level:expr, $($key:tt $(:$capture:tt)? $(= $value:expr)?),+; $($message:tt)+) => {{ + ($logger:expr, $level:expr, $($key:tt $(:$capture:tt)? $(= $value:expr)?),+; $($message:tt)+) => {{ let __logforth_logger: &$crate::Logger = &$logger; let __logforth_level = $level; - let __logforth_target = $target; + let __logforth_target = ::std::module_path!(); let __logforth_criteria = $crate::record::FilterCriteria::builder() .level(__logforth_level) .target(__logforth_target) @@ -239,7 +183,7 @@ macro_rules! __log { __logforth_logger.log( &$crate::record::Record::builder() .level(__logforth_level) - .$target_method(__logforth_target) + .target_static(__logforth_target) .module_path_static(::std::module_path!()) .file_static(::std::file!()) .line(::std::option::Option::Some(::std::line!())) @@ -255,13 +199,13 @@ macro_rules! __log { ); } }}; - (logger: $logger:expr, target: $target:expr, target_method: $target_method:ident, $level:expr, $($key:tt $(:$capture:tt)? $(= $value:expr)?),+;) => {{ - $crate::__log!(logger: $logger, target: $target, target_method: $target_method, $level, $($key $(:$capture)? $(= $value)?),+; "") + ($logger:expr, $level:expr, $($key:tt $(:$capture:tt)? $(= $value:expr)?),+;) => {{ + $crate::__log!($logger, $level, $($key $(:$capture)? $(= $value)?),+; "") }}; - (logger: $logger:expr, target: $target:expr, target_method: $target_method:ident, $level:expr, $($message:tt)+) => {{ + ($logger:expr, $level:expr, $($message:tt)+) => {{ let __logforth_logger: &$crate::Logger = &$logger; let __logforth_level = $level; - let __logforth_target = $target; + let __logforth_target = ::std::module_path!(); let __logforth_criteria = $crate::record::FilterCriteria::builder() .level(__logforth_level) .target(__logforth_target) @@ -270,7 +214,7 @@ macro_rules! __log { __logforth_logger.log( &$crate::record::Record::builder() .level(__logforth_level) - .$target_method(__logforth_target) + .target_static(__logforth_target) .module_path_static(::std::module_path!()) .file_static(::std::file!()) .line(::std::option::Option::Some(::std::line!())) diff --git a/core/tests/macros.rs b/core/tests/macros.rs index 49ec0ec..130492a 100644 --- a/core/tests/macros.rs +++ b/core/tests/macros.rs @@ -180,8 +180,7 @@ fn captures_fine_grained_level_metadata_and_typed_fields() { let dynamic_key = String::from("dynamic.key"); let expected_line = line!() + 1; logforth_core::log!( - logger: logger, - target: "custom.target", + logger, Level::Info2, shorthand, signed = -2_i32, @@ -206,8 +205,8 @@ fn captures_fine_grained_level_metadata_and_typed_fields() { assert_eq!(records.len(), 1); let record = &records[0]; assert_eq!(record.level, Level::Info2); - assert_eq!(record.target, "custom.target"); - assert_eq!(record.target_static, None); + assert_eq!(record.target, "macros"); + assert_eq!(record.target_static.as_deref(), Some("macros")); assert_eq!(record.module_path.as_deref(), Some("macros")); assert!( std::path::Path::new(record.file.as_deref().unwrap()) @@ -249,12 +248,12 @@ fn convenience_macros_cover_standard_levels() { let capture = Capture::default(); let logger = Arc::new(make_logger(capture.clone())); - logforth_core::fatal!(logger: logger, "fatal"); - logforth_core::error!(logger: logger, target: "error.target", "error"); - logforth_core::warn!(logger: &logger, "warn"); - logforth_core::info!(logger: logger, "info"); - logforth_core::debug!(logger: logger, "debug"); - logforth_core::trace!(logger: logger, "trace"); + logforth_core::fatal!(logger, "fatal"); + logforth_core::error!(logger, "error"); + logforth_core::warn!(&logger, "warn"); + logforth_core::info!(logger, "info"); + logforth_core::debug!(logger, "debug"); + logforth_core::trace!(logger, "trace"); let records = capture.take(); assert_eq!( @@ -271,20 +270,12 @@ fn convenience_macros_cover_standard_levels() { (Level::Trace, "trace"), ] ); - assert_eq!(records[1].target, "error.target"); + assert!(records.iter().all(|record| record.target == "macros")); assert!( records .iter() - .enumerate() - .all(|(index, record)| index == 1 || record.target == "macros") + .all(|record| record.target_static.as_deref() == Some("macros")) ); - assert!( - records - .iter() - .enumerate() - .all(|(index, record)| index == 1 || record.target_static.as_deref() == Some("macros")) - ); - assert_eq!(records[1].target_static, None); } #[test] @@ -298,7 +289,7 @@ fn disabled_records_do_not_evaluate_payload_or_fields() { }; logforth_core::info!( - logger: logger, + logger, value = expensive(); "value is {}", expensive() @@ -306,12 +297,6 @@ fn disabled_records_do_not_evaluate_payload_or_fields() { assert_eq!(evaluations.get(), 0); assert!(capture.take().is_empty()); - assert!(!logforth_core::log_enabled!(logger: logger, Level::Info)); - assert!(logforth_core::log_enabled!( - logger: logger, - target: "custom.target", - Level::Error - )); } #[test] @@ -320,7 +305,6 @@ fn macro_inputs_are_evaluated_once() { let logger = make_logger(capture.clone()); let logger_evaluations = Cell::new(0); let level_evaluations = Cell::new(0); - let target_evaluations = Cell::new(0); let logger_expression = || { logger_evaluations.set(logger_evaluations.get() + 1); @@ -330,21 +314,10 @@ fn macro_inputs_are_evaluated_once() { level_evaluations.set(level_evaluations.get() + 1); Level::Debug3 }; - let target_expression = || { - target_evaluations.set(target_evaluations.get() + 1); - "evaluated.once" - }; - - logforth_core::log!( - logger: logger_expression(), - target: target_expression(), - level_expression(), - "once" - ); + logforth_core::log!(logger_expression(), level_expression(), "once"); assert_eq!(logger_evaluations.get(), 1); assert_eq!(level_evaluations.get(), 1); - assert_eq!(target_evaluations.get(), 1); assert_eq!(capture.take()[0].level, Level::Debug3); } @@ -353,7 +326,7 @@ fn structured_record_may_omit_message() { let capture = Capture::default(); let logger = make_logger(capture.clone()); - logforth_core::info!(logger: logger, answer = 42_u64;); + logforth_core::info!(logger, answer = 42_u64;); let records = capture.take(); assert_eq!(records[0].payload, ""); diff --git a/logforth/src/lib.rs b/logforth/src/lib.rs index a872e67..64f4c68 100644 --- a/logforth/src/lib.rs +++ b/logforth/src/lib.rs @@ -71,8 +71,8 @@ //! .dispatch(|d| d.append(append::Stdout::default())) //! .build(); //! -//! logforth::info!(logger: logger, request_id = 42_u64; "request accepted"); -//! logforth::log!(logger: logger, Level::Info2, "request details"); +//! logforth::info!(logger, request_id = 42_u64; "request accepted"); +//! logforth::log!(logger, Level::Info2, "request details"); //! ``` //! //! See the [README] file for more details and examples. @@ -93,7 +93,6 @@ pub use logforth_core::info; pub use logforth_core::kv; pub use logforth_core::layout::Layout; pub use logforth_core::log; -pub use logforth_core::log_enabled; pub use logforth_core::record; pub use logforth_core::trace; pub use logforth_core::warn; diff --git a/logforth/tests/native_macros.rs b/logforth/tests/native_macros.rs index 5fd2467..50871d7 100644 --- a/logforth/tests/native_macros.rs +++ b/logforth/tests/native_macros.rs @@ -18,7 +18,6 @@ use logforth::record::Level; fn native_macros_are_reexported() { let logger = logforth::core::builder().build(); - logforth::info!(logger: logger, answer = 42_u64; "hello"); - logforth::log!(logger: logger, Level::Info2, "fine-grained"); - assert!(!logforth::log_enabled!(logger: logger, Level::Info)); + logforth::info!(logger, answer = 42_u64; "hello"); + logforth::log!(logger, Level::Info2, "fine-grained"); } From 73c2e11bcbc108c97035ab996abbd81bd561b0df Mon Sep 17 00:00:00 2001 From: tison Date: Mon, 24 Aug 2026 02:27:14 +0800 Subject: [PATCH 4/4] feat: add stable names to logger instances --- CHANGELOG.md | 2 +- README.md | 18 +++++++++++++++- core/src/logger/builder.rs | 40 ++++++++++++++++++++++++++++++++++-- core/src/logger/log_impl.rs | 14 +++++++++++-- core/src/macros.rs | 17 +++++++++------ core/src/record.rs | 10 +++++---- core/tests/macros.rs | 17 +++++++++++++++ filters/rustlog/src/lib.rs | 9 ++++++-- filters/rustlog/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++ logforth/src/lib.rs | 8 ++++++++ 10 files changed, 157 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be04fe..432d2de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. ### New features -* Add native Logforth logging macros with positional logger instances, fine-grained levels, lazy evaluation, and structured key-value fields. +* Add native Logforth logging macros with positional logger instances, optional stable logger names, fine-grained levels, lazy evaluation, and structured key-value fields. ## [0.30.1] 2026-06-03 diff --git a/README.md b/README.md index 6180bf4..05bcb67 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,23 @@ fn main() { } ``` -The logger is the first argument, following the same instance-first convention as `slog`. A record's target is its call-site module path; application-specific classifications belong in structured fields. +The logger is the first argument, following the same instance-first convention as `slog`. An unnamed logger uses the call-site module path as its target. A dedicated logger can instead carry one stable name, which keeps target-based `RustLogFilter` directives without repeating `target:` at every call site: + +```rust +let metering = logforth::core::builder() + .name("metering") + .dispatch(|d| d.append(logforth::append::Stdout::default())) + .build(); + +logforth::info!( + metering, + tenant_id = "acme", + metering_kind = "compute", + compute_time_ms = 42_u64; +); +``` + +The logger name is a stable channel or source scope, so a directive such as `RUST_LOG=metering=info` keeps working. Event kinds, tenant IDs, and other varying classifications remain structured fields. The record still carries the call-site module path separately. The macros check the logger before evaluating the message or its fields, so an extra enabled check is unnecessary. The `log` facade remains the recommended API for libraries because it lets the final application choose its logging implementation. diff --git a/core/src/logger/builder.rs b/core/src/logger/builder.rs index 7ec73af..315a799 100644 --- a/core/src/logger/builder.rs +++ b/core/src/logger/builder.rs @@ -30,7 +30,10 @@ use crate::logger::log_impl::Dispatch; /// .build(); /// ``` pub fn builder() -> LoggerBuilder { - LoggerBuilder { dispatches: vec![] } + LoggerBuilder { + name: None, + dispatches: vec![], + } } /// A builder for configuring log dispatching. @@ -47,11 +50,44 @@ pub fn builder() -> LoggerBuilder { #[must_use = "call `build` to construct a logger instance"] #[derive(Debug)] pub struct LoggerBuilder { + // optional stable logger name + name: Option<&'static str>, + // stashed dispatches dispatches: Vec, } impl LoggerBuilder { + /// Assign a stable name to the logger. + /// + /// Native logging macros use this name as the record target. An unnamed logger instead uses + /// the call-site module path. The source module is recorded separately in both cases. + /// + /// A name is useful for a dedicated event channel such as `metering` or `audit`: routing is + /// selected by the logger instance, while target-based filters such as `RustLogFilter` can + /// retain the same stable namespace. Per-event classifications should remain structured + /// fields rather than logger names. + /// + /// Names must be static because they describe a bounded, application-defined namespace rather + /// than dynamic event data. + /// + /// # Examples + /// + /// ``` + /// use logforth_core::append; + /// + /// let metering = logforth_core::builder() + /// .name("metering") + /// .dispatch(|d| d.append(append::Stdout::default())) + /// .build(); + /// + /// assert_eq!(metering.name(), Some("metering")); + /// ``` + pub fn name(mut self, name: &'static str) -> Self { + self.name = Some(name); + self + } + /// Register a new dispatch with the [`LoggerBuilder`]. /// /// # Examples @@ -85,7 +121,7 @@ impl LoggerBuilder { /// l.log(&r); /// ``` pub fn build(self) -> Logger { - Logger::new(self.dispatches) + Logger::new(self.name, self.dispatches) } } diff --git a/core/src/logger/log_impl.rs b/core/src/logger/log_impl.rs index 769e7d0..460ec27 100644 --- a/core/src/logger/log_impl.rs +++ b/core/src/logger/log_impl.rs @@ -26,16 +26,26 @@ use crate::record::Record; /// A logger that dispatches log records to one or more dispatcher. #[derive(Debug)] pub struct Logger { + name: Option<&'static str>, dispatches: Vec, } impl Logger { - pub(super) fn new(dispatches: Vec) -> Self { - Self { dispatches } + pub(super) fn new(name: Option<&'static str>, dispatches: Vec) -> Self { + Self { name, dispatches } } } impl Logger { + /// Return the logger's stable name, if configured. + /// + /// Native logging macros use a named logger's name as the record target. For an unnamed logger, + /// they use the call-site module path instead. [`Record::module_path`] continues to identify + /// the source module independently of this name. + pub const fn name(&self) -> Option<&'static str> { + self.name + } + /// Determine whether any dispatch may log a record with the specified criteria. /// /// This is a prefiltering hint, not a promise that a subsequent record will be logged. Filters diff --git a/core/src/macros.rs b/core/src/macros.rs index 903e6cc..b64e2d1 100644 --- a/core/src/macros.rs +++ b/core/src/macros.rs @@ -14,10 +14,11 @@ /// Log a message at a dynamically selected level. /// -/// A logger instance is required as the first argument. The target is the caller's module path. -/// Structured key-value pairs precede the message and are separated from it by a semicolon. Values -/// retain their native type by default; use `:?` or `:%` to capture a value with -/// [`Debug`](std::fmt::Debug) or [`Display`](std::fmt::Display). +/// A logger instance is required as the first argument. The target is the logger's configured name, +/// or the caller's module path when the logger is unnamed. Structured key-value pairs precede the +/// message and are separated from it by a semicolon. Values retain their native type by default; +/// use `:?` or `:%` to capture a value with [`Debug`](std::fmt::Debug) or +/// [`Display`](std::fmt::Display). /// /// Keys can be identifiers, string literals, or parenthesized string expressions. An identifier /// without `= value` captures the variable with the same name. The message can be omitted for a @@ -174,7 +175,9 @@ macro_rules! __log { ($logger:expr, $level:expr, $($key:tt $(:$capture:tt)? $(= $value:expr)?),+; $($message:tt)+) => {{ let __logforth_logger: &$crate::Logger = &$logger; let __logforth_level = $level; - let __logforth_target = ::std::module_path!(); + let __logforth_target = __logforth_logger + .name() + .unwrap_or(::std::module_path!()); let __logforth_criteria = $crate::record::FilterCriteria::builder() .level(__logforth_level) .target(__logforth_target) @@ -205,7 +208,9 @@ macro_rules! __log { ($logger:expr, $level:expr, $($message:tt)+) => {{ let __logforth_logger: &$crate::Logger = &$logger; let __logforth_level = $level; - let __logforth_target = ::std::module_path!(); + let __logforth_target = __logforth_logger + .name() + .unwrap_or(::std::module_path!()); let __logforth_criteria = $crate::record::FilterCriteria::builder() .level(__logforth_level) .target(__logforth_target) diff --git a/core/src/record.rs b/core/src/record.rs index 01c6db6..61eeaf5 100644 --- a/core/src/record.rs +++ b/core/src/record.rs @@ -58,16 +58,18 @@ impl<'a> Record<'a> { self.level } - /// The name of the target of the directive. + /// The stable namespace used for target-based filtering. /// - /// This is typically the same as the module path, but can be set explicitly. + /// Native macros use the call-site module path for an unnamed logger and the logger name for a + /// named logger. Bridges preserve the target supplied by the source logging facade. The actual + /// source module, when known, is available separately from [`Record::module_path`]. pub fn target(&self) -> &'a str { self.target.get() } - /// The name of the target of the directive, if it is a `'static` str. + /// The stable namespace used for target-based filtering, if it is a `'static` str. /// - /// This is typically the same as the module path, but can be set explicitly. + /// See [`Record::target`] for target semantics. pub fn target_static(&self) -> Option<&'static str> { self.target.get_static() } diff --git a/core/tests/macros.rs b/core/tests/macros.rs index 130492a..c5fe353 100644 --- a/core/tests/macros.rs +++ b/core/tests/macros.rs @@ -243,6 +243,23 @@ fn captures_fine_grained_level_metadata_and_typed_fields() { ); } +#[test] +fn named_logger_changes_target_without_hiding_source_module() { + let capture = Capture::default(); + let logger = logforth_core::builder() + .name("metering") + .dispatch(|dispatch| dispatch.append(capture.clone())) + .build(); + + logforth_core::info!(logger, metering_kind = "compute";); + + let records = capture.take(); + assert_eq!(logger.name(), Some("metering")); + assert_eq!(records[0].target, "metering"); + assert_eq!(records[0].target_static.as_deref(), Some("metering")); + assert_eq!(records[0].module_path.as_deref(), Some("macros")); +} + #[test] fn convenience_macros_cover_standard_levels() { let capture = Capture::default(); diff --git a/filters/rustlog/src/lib.rs b/filters/rustlog/src/lib.rs index 44feae5..bddda05 100644 --- a/filters/rustlog/src/lib.rs +++ b/filters/rustlog/src/lib.rs @@ -14,7 +14,7 @@ //! A filter that follows the famous `RUST_LOG` directive pattern. //! -//! Log levels are controlled on a per-module basis, and by default all logging is disabled except +//! Log levels are controlled on a per-target basis, and by default all logging is disabled except //! for the `error` level. //! //! You can use [`RustLogFilterBuilder::from_default_env`] to configure the filter from the @@ -28,7 +28,10 @@ //! target=level //! ``` //! -//! `target` is typically `path::to::module`, but it may also be set manually via the log macros. +//! For Logforth's native macros, `target` is the call-site module path when the logger is unnamed, +//! or the stable logger name configured with [`LoggerBuilder::name`]. Records forwarded from the +//! `log` facade retain that facade's target. This lets existing target directives keep working +//! during incremental migration while new code avoids repeating a target at every call site. //! //! The path to the module is rooted in the name of the crate it was compiled for. Thus, if your //! program is contained in a file `hello.rs`, for example, to turn on logging for this file you @@ -69,6 +72,8 @@ //! * `error,hello=off` turns on global error logging, but turn off logging for hello //! * `off` turns off all logging for the application //! * `OFF` turns off all logging for the application (same as previous) +//! +//! [`LoggerBuilder::name`]: logforth_core::LoggerBuilder::name #![cfg_attr(docsrs, feature(doc_cfg))] #![deny(missing_docs)] diff --git a/filters/rustlog/src/tests.rs b/filters/rustlog/src/tests.rs index 522fbe1..62d6c98 100644 --- a/filters/rustlog/src/tests.rs +++ b/filters/rustlog/src/tests.rs @@ -12,12 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + use insta::assert_snapshot; +use logforth_core::Append; +use logforth_core::Diagnostic; +use logforth_core::Error; use logforth_core::Filter; use logforth_core::filter::FilterResult; use logforth_core::record::FilterCriteria; use logforth_core::record::Level; use logforth_core::record::LevelFilter; +use logforth_core::record::Record; use crate::Directive; use crate::ParseResult; @@ -36,6 +44,38 @@ impl RustLogFilter { } } +#[derive(Debug)] +struct CountAppend(Arc); + +impl Append for CountAppend { + fn append(&self, _: &Record<'_>, _: &[Box]) -> Result<(), Error> { + self.0.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + fn flush(&self) -> Result<(), Error> { + Ok(()) + } +} + +#[test] +fn named_native_logger_matches_target_directive() { + let count = Arc::new(AtomicUsize::new(0)); + let logger = logforth_core::builder() + .name("metering") + .dispatch(|dispatch| { + dispatch + .filter(RustLogFilterBuilder::from_spec("off,metering=info").build()) + .append(CountAppend(Arc::clone(&count))) + }) + .build(); + + logforth_core::debug!(logger, "disabled by the metering directive"); + logforth_core::info!(logger, "accepted by the metering directive"); + + assert_eq!(count.load(Ordering::Relaxed), 1); +} + #[test] fn parse_spec_valid() { let ParseResult { diff --git a/logforth/src/lib.rs b/logforth/src/lib.rs index 64f4c68..1261c1e 100644 --- a/logforth/src/lib.rs +++ b/logforth/src/lib.rs @@ -75,6 +75,14 @@ //! logforth::log!(logger, Level::Info2, "request details"); //! ``` //! +//! Dedicated channels can use a named logger. Its name becomes the native record target while the +//! call-site module remains available as source metadata: +//! +//! ``` +//! let metering = logforth::core::builder().name("metering").build(); +//! logforth::info!(metering, tenant_id = "acme", compute_time_ms = 42_u64;); +//! ``` +//! //! See the [README] file for more details and examples. //! //! [README]: https://github.com/fast/logforth?tab=readme-ov-file