Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 positional logger instances, optional stable logger names, fine-grained levels, lazy evaluation, and structured key-value fields.

## [0.30.1] 2026-06-03

### Improvements
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,44 @@ 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, request_id = 42_u64; "request accepted");
logforth::log!(logger, Level::Info2, "request details");
}
```

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.

## Advanced Usage

Configure multiple dispatches with different filters and appenders:
Expand Down
7 changes: 6 additions & 1 deletion core/src/filter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Diagnostic>]) -> FilterResult;

/// Whether the record is filtered.
Expand Down
140 changes: 140 additions & 0 deletions core/src/kv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u8> {
fn to_value(&self) -> Value<'_> {
Value::bytes(self)
}
}

impl<T> ToValue for Option<T>
where
T: ToValue,
{
fn to_value(&self) -> Value<'_> {
match self {
Some(value) => value.to_value(),
None => Value::none(),
}
}
}

impl<T> ToValue for &T
where
T: ToValue + ?Sized,
{
fn to_value(&self) -> Value<'_> {
(*self).to_value()
}
}

impl<T> 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);
Expand Down
2 changes: 2 additions & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 38 additions & 2 deletions core/src/logger/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Dispatch>,
}

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
Expand Down Expand Up @@ -85,7 +121,7 @@ impl LoggerBuilder {
/// l.log(&r);
/// ```
pub fn build(self) -> Logger {
Logger::new(self.dispatches)
Logger::new(self.name, self.dispatches)
}
}

Expand Down
22 changes: 19 additions & 3 deletions core/src/logger/log_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,33 @@ 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<Dispatch>,
}

impl Logger {
pub(super) fn new(dispatches: Vec<Dispatch>) -> Self {
Self { dispatches }
pub(super) fn new(name: Option<&'static str>, dispatches: Vec<Dispatch>) -> Self {
Self { name, dispatches }
}
}

impl Logger {
/// Determine if a log message with the specified metadata would be logged.
/// 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
/// 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()
Expand Down
Loading