Skip to content

feat: add native logging macros - #239

Draft
tisonkun wants to merge 5 commits into
mainfrom
codex/native-log-macros
Draft

feat: add native logging macros#239
tisonkun wants to merge 5 commits into
mainfrom
codex/native-log-macros

Conversation

@tisonkun

@tisonkun tisonkun commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #205.

Summary

  • add instance-first native logging macros: log!, fatal!, error!, warn!, info!, debug!, and trace!
  • take the required logger as the first positional argument instead of copying log's optional logger: control syntax
  • use the call-site module path as the target for unnamed loggers, and support a stable target through LoggerBuilder::name
  • keep the real call-site module in Record::module_path even when a logger has a different name
  • avoid a per-record target: branch while preserving target-based RustLogFilter directives and log bridge compatibility
  • support every Logforth severity, including the fine-grained OpenTelemetry levels, through the generic log! entry point
  • preserve typed structured fields with shorthand keys, expression keys, :? debug capture, and :% display capture
  • avoid evaluating the message and fields when metadata prefiltering rejects the record
  • document Logger::enabled as a low-level, conservative prefilter and deliberately do not expose a log_enabled! macro
  • capture module/file/line/column metadata and re-export the logging macros from the logforth facade
  • continue recommending the log facade for reusable libraries

API

The common case uses an unnamed logger. Its target is the call-site module path:

logforth::info!(
    logger,
    request_id,
    elapsed_ms = elapsed.as_millis();
    "request completed"
);

logforth::log!(
    logger,
    Level::Info2,
    actor = user_id;
    "permission granted"
);

A stable application channel uses a dedicated named logger:

let metering = logforth::core::builder()
    .name("metering")
    .dispatch(|d| d.append(metering_otlp))
    .build();

logforth::info!(
    metering,
    tenant_id,
    metering_kind = "compute",
    compute_time_ms;
);

Here target == "metering", while module_path still identifies the module containing the macro invocation. A directive such as metering=info therefore remains valid for RustLogFilter.

The first argument accepts a Logger, &Logger, or a dereferenceable owner such as Arc<Logger>.

Why this design

The logger is positional because it is required

Most logging APIs bind the logger as the method receiver:

  • Go: logger.Info(...)
  • Python: logger.info(...)
  • Java/Log4j: logger.info(...)
  • Pino and Zap: logger.info(...) / logger.Info(...)
  • .NET: logger.LogInformation(...)

Rust declarative macros cannot be invoked as methods. The closest Rust representation is therefore a required first argument, which is also the convention established by slog::info!(logger, ...).

The log crate's logger: form has a different purpose: it is an optional override for an API whose default is the global logger. Logforth removed its core global logger in #226, so retaining an override-shaped pseudo-keyword would add syntax without communicating a choice.

Target is a logger scope; module path is source location

target has historically carried two meanings in Rust logging: source identity and an ad hoc routing tag. Treating it as unconditionally equal to module_path!() loses legitimate stable channels, while allowing an arbitrary target: on every call encourages dynamic application data to leak into a filtering namespace.

This PR separates the concepts:

  • Record::module_path is always the actual Rust source module captured at the call site.
  • An unnamed logger uses that module path as Record::target, preserving ordinary RUST_LOG=my_crate::module=debug behavior.
  • A named logger uses its static name as Record::target. The name is configured once on the logger, not repeated on events.
  • Records forwarded by the log bridge retain their original target, so existing facade users are unaffected.

This matches the dominant instance-oriented model. Log4j, Python, and .NET attach a category/name to a logger instance; Zap provides Logger.Named; OpenTelemetry binds a stable instrumentation scope when a logger is obtained. Per-event meaning remains in structured attributes or an event identifier/name.

Logger names take &'static str deliberately: they define a bounded application namespace such as metering, audit, or query, not tenant IDs or other high-cardinality data.

A dedicated logger routes a dedicated channel

A metering or audit stream usually has a different reliability policy and destination from diagnostic logs. With an explicit logger API, selecting the logger instance is already the earliest and cheapest routing decision:

ordinary logger  -> RUST_LOG filter -> stderr/file/general OTLP
metering logger  -> metering policy -> billing OTLP

The stable logger name remains useful for filtering, layout output, and migration compatibility, but it is not required to discover the destination: the dedicated logger owns that dispatch graph.

This is preferable to the alternatives:

  • A structured field such as metering_kind describes the event and should still be emitted, but routing on it happens after the complete record and fields have been constructed. It also makes metadata prefiltering impossible with the current FilterCriteria contract.
  • A diagnostic is ambient context contributed by a dispatch, thread, task, or trace. It is suitable for node IDs and trace IDs, not for declaring that one particular event belongs to a billing stream.
  • A per-call target: is early enough to filter, but repeats an untyped string at every call and can drift independently of the logger's dispatch topology.

If a dedicated event must also reach general logs, that is expressed explicitly by adding another dispatch/appender to that logger. Routing policy remains at construction time rather than hidden in each call site.

RustLogFilter compatibility

No directive syntax changes are required:

  • unnamed native logger: my_crate::module=debug matches the call-site module target
  • named native logger: metering=info matches the logger name
  • log facade through LogBridge: the original log::Record::target() is preserved

The test suite exercises a named native logger with off,metering=info, proving that debug is rejected during prefiltering and info is accepted. This provides an incremental migration path: applications can move dedicated target calls to a named logger without immediately rewriting their filter specifications.

No log_enabled! application API

The native macro itself performs metadata prefiltering before evaluating:

  • format arguments
  • structured field expressions
  • ToValue conversions
  • the complete Record

This makes the normal call a single operation with lazy arguments.

A separate enabled probe creates a two-step check-then-emit protocol. Its result may change before emission, it duplicates filtering work, and it cannot precisely represent filters that require the complete record. The ecosystem evidence is explicit:

OpenTelemetry still recommends a low-level Logger.Enabled operation for instrumentation implementations, while explicitly describing it as an optional optimization whose result can become stale: https://opentelemetry.io/docs/specs/otel/logs/api/#enabled

Logforth therefore retains Logger::enabled(&FilterCriteria) for filters, bridges, and macro internals, but does not promote it as a normal application macro. Its documentation calls it a conservative prefilter rather than a promise. The Filter::enabled contract also states that filters requiring message/field data must return Neutral and decide in matches.

One generic level macro plus familiar conveniences

The six convenience macros cover the common path. The generic log! accepts a level expression and makes all 24 Logforth/OpenTelemetry severities usable without adding trace2!, trace3!, and so on.

This shape follows Go's slog.Logger.Log, Python's Logger.log, and Log4j's Logger.log(Level, ...). It also addresses repeated Rust requests for Notice, Critical, Fatal, or otherwise extensible levels:

fatal! records severity only; it does not terminate the process or imply a flush.

Structured fields retain their types

logforth::info!(
    logger,
    user_id,
    "http.status_code" = status,
    (dynamic_key) = value,
    error:? = error,
    latency:% = latency;
    "request failed"
);

Plain values use the public ToValue conversion trait and retain supported scalar types instead of becoming formatted strings. :? and :% are explicit, lazily formatted escape hatches for Debug and Display. Structured-only records may omit the text message.

Declarative macros in core, with no feature gate

The implementation uses hygienic macro_rules! macros in logforth-core, then re-exports them from logforth.

This avoids:

  • a proc-macro crate and its compile-time/dependency cost
  • an optional feature that splits the public API across dependency graphs
  • another global or Cargo-feature-driven maximum level that overrides per-Logger filtering

The macros are always available, introduce no new dependency, and use $crate paths so facade re-exports remain hygienic.

Non-goals

  • replacing the log facade for reusable libraries
  • adding an implicit Logforth global logger
  • supporting arbitrary per-call target overrides
  • using dynamic/high-cardinality logger names as event data
  • adding convenience macros for every fine-grained severity
  • adding a Cargo-feature-driven compile-time maximum level
  • providing spans or implicit context propagation

Validation

  • cargo x test
  • workspace nightly Clippy for all targets and features with warnings denied
  • rustfmt, Taplo, typos, and HawkEye
  • targeted named-logger, RustLogFilter, metadata, typed-field, disabled-evaluation, evaluate-once, structured-only, and cross-platform source-path tests
  • cargo-semver-checks for logforth-core and logforth against origin/main
  • Linux, macOS, and Windows CI on stable and MSRV

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add log macros as APIs

1 participant