diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7627a8..0f7cda5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,7 @@ jobs: run: | set -x + cargo run --example native cargo run --features="bridge-log" --example log_with_logger cargo run --features="starter-log" --example simple_stdout diff --git a/CHANGELOG.md b/CHANGELOG.md index 596655b..0b3c5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Improvements + +* Add instance-based native logging macros with lazy typed fields using the existing record and value model. +* Make native logging the primary documented API while retaining the optional `log` compatibility bridge. + ### Breaking changes * Bump minimum supported Rust version (MSRV) to 1.91.0. diff --git a/README.md b/README.md index 418bc8c..2878f7a 100644 --- a/README.md +++ b/README.md @@ -20,88 +20,85 @@ Logforth is a versatile, extensible, and easy-to-use logging framework for Rust ## Getting Started -Add `log` and `logforth` to your `Cargo.toml`: +Add Logforth to your application: ```shell -cargo add log -cargo add logforth -F starter-log +cargo add logforth ``` -## Simple Usage - -Set up a basic logger that outputs to stdout: +Build a logger and pass it to the native logging macros: ```rust +use logforth::{Level, LevelFilter, append}; + fn main() { - logforth::starter_log::stdout().apply(); + let logger = logforth::builder() + .dispatch(|d| d + .filter(LevelFilter::MoreSevereEqual(Level::Info)) + .append(append::Stderr::default())) + .build(); - log::error!("This is an error message."); - log::info!("This is an info message."); - log::debug!("This debug message will not be printed by default."); + logforth::info!(logger, "service started"); + logforth::debug!(logger, "this message is filtered out"); + logger.flush(); } ``` -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. +The native API is always available and does not depend on the `log` crate. There is no implicit global logger or global maximum level: each logger's dispatches decide what to emit. An unfiltered dispatch accepts every level; a logger without dispatches emits nothing. -## Advanced Usage +## Events and structured fields -Configure multiple dispatches with different filters and appenders: +Use `trace!`, `debug!`, `info!`, `warn!`, `error!`, or `fatal!`, or `log!(logger, level, ...)` for any Logforth `Level`. `fatal!` records severity; it does not terminate the process or flush. ```rust -fn main() { - logforth::starter_log::builder() - .dispatch(|d| d - .filter(LevelFilter::MoreSevereEqual(Level::Error)) - .append(append::Stderr::default())) - .dispatch(|d| d - .filter(LevelFilter::MoreSevereEqual(Level::Info)) - .append(append::Stdout::default())) - .apply(); - - log::error!("This error will be logged to stderr."); - log::info!("This info will be logged to stdout."); - log::debug!("This debug message will not be logged."); -} +use logforth::kv::Value; + +let logger = logforth::builder() + .dispatch(|d| d.append(logforth::append::Stderr::default())) + .build(); +let queue = String::from("background"); +logforth::info!(logger, { + "queue" => queue, + "completed" => 3u64, + "healthy" => true, + "details" => Value::debug(&[1, 2, 3]), +}, "batch complete"); +logforth::info!(logger, { "queue.depth" => 0 }); ``` -Configure OpenTelemetry appender to export logs to an OpenTelemetry backend ([full example](https://github.com/scopedb/percas/blob/d01db13b/crates/server/src/telemetry.rs#L131-L227)): +Fields use standard `From` conversions into the existing `kv::Value`: scalars retain their types, and strings and existing values are borrowed. Use `Value::none()` for an absent value. Use `Value::display` or `Value::debug` for text and `Value::list` or `Value::map` for nested values. No logging-specific conversion trait is required. + +Macros prefilter before evaluating messages, keys, fields, or conversions. Full-record filters may still reject an event afterward. The target defaults to the calling module, and source module/file/line/column identify the actual call site. Existing `RustLogFilter` module directives apply directly. Custom targets remain available through `RecordBuilder` and the `log` bridge. + +Event fields describe one event. Existing `Diagnostic` implementations supply dispatch, thread, task, or trace context. These macros do not introduce logger-bound fields, merging precedence, or a context propagation API. Appenders keep their existing field/context handling. For independent filtering or output policy, build independent loggers; use `Arc` to share a pipeline. + +See the [native example](examples/src/native.rs) for static context and typed fields. Existing text/JSON layouts, rolling files, async appenders, and OTLP consume the same records. [Appender documentation](https://docs.rs/logforth-append-opentelemetry) covers OTLP configuration. + +## Lifecycle and dependency logs + +Stop logging producers, call `logger.flush()`, and then tear down resources required by appenders. Flush does not disable the logger or provide a transactional delivery guarantee. Independent loggers must each be flushed. + +The optional `bridge-log` feature forwards dependency logs into a native logger. Install the bridge once at startup, retaining the `Arc` for native calls and shutdown: ```rust -fn main() { - let static_diagnostic = { - let mut static_diagnostic = StaticDiagnostic::default(); - static_diagnostic.insert("node_id", node_id); - static_diagnostic.insert("nodegroup", nodegroup); - static_diagnostic - }; - - let runtime = async_runtime(); - let filter = make_rust_log_filter(&opentelemetry.filter); - let appender = runtime.block_on(async { - let exporter = opentelemetry_otlp::LogExporter::builder() - .with_tonic() - .with_endpoint(&opentelemetry.otlp_endpoint) - .with_protocol(opentelemetry_otlp::Protocol::Grpc) - .build() - .expect("failed to initialize opentelemetry logger"); - - append::opentelemetry::OpentelemetryLogBuilder::new(service_name, exporter) - .label("service.name", service_name) - .build() - }); - - logforth::starter_log::builder() - .dispatch(|b| { - b.filter(filter) - .diagnostic(FastraceDiagnostic::default()) - .diagnostic(static_diagnostic) - .append(appender) - }) - .apply(); +use std::sync::Arc; +use logforth::append; +use logforth::bridge::log::LogBridge; + +fn main() -> Result<(), log::SetLoggerError> { + let logger = Arc::new(logforth::builder() + .dispatch(|d| d.append(append::Stderr::default())) + .build()); + log::set_boxed_logger(Box::new(LogBridge::new(logger.clone())))?; + log::set_max_level(log::LevelFilter::Trace); + logforth::info!(logger, "native event"); + log::info!("dependency event"); + logger.flush(); + Ok(()) } ``` -Read more demos under the [examples](examples) directory. +Add `log` and enable `logforth/bridge-log` for this example. `bridge-log-serde` continues to support Serde fields from the `log` facade. Native Serde capture is outside this API; the existing `Value` model supports explicit nested fields. The `starter-log` helpers remain available for applications using the compatibility facade. ## Features @@ -191,7 +188,7 @@ Users can also provide their own MDC by implementing the [`Diagnostic`] trait. ### Bridges -So far, Logforth provides out-of-the-box integration with the `log` crate. You can use Logforth as the backend for any crate that uses the `log` facade. +The optional `log` bridge forwards dependency logs into the same dispatch graph as native events. It preserves the original level, target, source location, and supported structured fields. ## Documentation diff --git a/core/src/filter/mod.rs b/core/src/filter/mod.rs index 3fc4786..1ef1207 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 an event by level and target before its payload is evaluated. + /// + /// Return `Neutral` if the decision needs message, fields, or source location, + /// and inspect those in [`Self::matches`]. `Reject` must mean no matching + /// record could be accepted with these criteria. An accepted prefilter does + /// not bypass the full-record filter pass. fn enabled(&self, criteria: &FilterCriteria, diags: &[Box]) -> FilterResult; /// Whether the record is filtered. diff --git a/core/src/kv.rs b/core/src/kv.rs index 4447f2b..ce7cc6b 100644 --- a/core/src/kv.rs +++ b/core/src/kv.rs @@ -14,6 +14,8 @@ //! Key-value pairs in a log record or a diagnostic context. +mod convert; + use std::borrow::Borrow; use std::borrow::Cow; use std::collections::HashMap; @@ -363,6 +365,10 @@ impl<'a> Iterator for MapValueIter<'a> { } /// A borrowed value in a key-value pair. +/// +/// Standard [`From`] conversions preserve scalar types and borrow strings, +/// and existing values. Use [`Self::display`] or [`Self::debug`] +/// to explicitly capture a value as text. #[derive(Debug, Clone, Copy)] pub struct Value<'a>(ValueState<'a>); @@ -382,6 +388,7 @@ enum ValueState<'a> { Map(&'a [(Key<'a>, Value<'a>)]), Debug(&'a dyn fmt::Debug), Display(&'a dyn fmt::Display), + Owned(&'a ValueOwned), } impl fmt::Debug for ValueState<'_> { @@ -401,6 +408,7 @@ impl fmt::Debug for ValueState<'_> { ValueState::Map(v) => v.fmt(f), ValueState::Debug(v) => fmt::Debug::fmt(v, f), ValueState::Display(v) => fmt::Display::fmt(v, f), + ValueState::Owned(v) => fmt::Debug::fmt(v, f), } } } @@ -431,11 +439,17 @@ impl Value<'_> { ValueState::Map(m) => ValueView::Map(MapValue(MapValueState::Borrowed(m))), ValueState::Debug(d) => ValueView::Debug(DebugValue(d)), ValueState::Display(d) => ValueView::Display(DisplayValue(d)), + ValueState::Owned(v) => v.view(), } } } impl<'a> Value<'a> { + /// Borrow an owned value without copying its strings or nested values. + pub fn borrowed(value: &'a ValueOwned) -> Self { + Self(ValueState::Owned(value)) + } + /// Create a value representing the absence of data. pub fn none() -> Value<'a> { Value(ValueState::None) diff --git a/core/src/kv/convert.rs b/core/src/kv/convert.rs new file mode 100644 index 0000000..3e6d11e --- /dev/null +++ b/core/src/kv/convert.rs @@ -0,0 +1,94 @@ +// 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 super::Value; +use super::ValueOwned; + +impl<'a> From<&&'a str> for Value<'a> { + fn from(value: &&'a str) -> Self { + Self::str(value) + } +} + +impl<'a> From<&'a Value<'_>> for Value<'a> { + fn from(value: &'a Value<'_>) -> Self { + *value + } +} + +impl<'a> From<&'a ValueOwned> for Value<'a> { + fn from(value: &'a ValueOwned) -> Self { + Self::borrowed(value) + } +} + +impl<'a> From<&'a str> for Value<'a> { + fn from(value: &'a str) -> Self { + Self::str(value) + } +} + +impl<'a> From<&'a String> for Value<'a> { + fn from(value: &'a String) -> Self { + Self::str(value) + } +} + +impl<'a> From<&&'a String> for Value<'a> { + fn from(value: &&'a String) -> Self { + Self::str(value) + } +} + +impl<'a> From<&&'a Value<'_>> for Value<'a> { + fn from(value: &&'a Value<'_>) -> Self { + **value + } +} + +impl<'a> From<&&'a ValueOwned> for Value<'a> { + fn from(value: &&'a ValueOwned) -> Self { + Self::borrowed(value) + } +} + +macro_rules! scalar { + ($constructor:ident, $repr:ty, $($ty:ty),+ $(,)?) => { + $(impl From<$ty> for Value<'_> { + fn from(value: $ty) -> Self { + Self::$constructor(value as $repr) + } + } + + impl From<&$ty> for Value<'_> { + fn from(value: &$ty) -> Self { + >::from(*value) + } + } + + impl From<&&$ty> for Value<'_> { + fn from(value: &&$ty) -> Self { + >::from(**value) + } + })+ + }; +} + +scalar!(bool, bool, bool); +scalar!(char, char, char); +scalar!(i64, i64, i8, i16, i32, i64, isize); +scalar!(u64, u64, u8, u16, u32, u64, usize); +scalar!(i128, i128, i128); +scalar!(u128, u128, u128); +scalar!(f64, f64, f32, f64); diff --git a/core/src/lib.rs b/core/src/lib.rs index dc5f75f..351c63a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -34,6 +34,8 @@ pub use self::trap::Trap; mod error; pub use self::error::*; +mod macros; + mod logger; pub use self::logger::*; diff --git a/core/src/logger/log_impl.rs b/core/src/logger/log_impl.rs index 9795c4d..f61e876 100644 --- a/core/src/logger/log_impl.rs +++ b/core/src/logger/log_impl.rs @@ -36,7 +36,10 @@ impl Logger { } impl Logger { - /// Determine if a log message with the specified metadata would be logged. + /// Test whether any dispatch might accept an event with this level and target. + /// + /// This is a conservative prefilter. Full-record filters can still reject + /// the event. Native macros call this before evaluating messages and fields. pub fn enabled(&self, criteria: &FilterCriteria) -> bool { self.dispatches .iter() @@ -52,7 +55,11 @@ impl Logger { } } - /// Flush any buffered records. + /// Flush buffered records through every configured appender. + /// + /// Stop producers before flushing during shutdown, and keep resources needed + /// by appenders alive until this returns. This does not disable the logger. + /// Errors use the existing stderr fallback. There is no implicit exit hook. pub fn flush(&self) { for dispatch in &self.dispatches { for err in dispatch.flush() { diff --git a/core/src/macros.rs b/core/src/macros.rs new file mode 100644 index 0000000..47c8e86 --- /dev/null +++ b/core/src/macros.rs @@ -0,0 +1,141 @@ +// 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. + +/// Emit a native log event through an explicit [`Logger`](crate::Logger). +/// +/// The logger is borrowed and evaluated once. An optional field map follows the +/// logger (and level for `log!`). Keys are string expressions; +/// values use the standard [`From`] conversions into [`Value`](crate::kv::Value). +/// A fields-only event has an empty message. The message uses the standard Rust +/// formatting syntax. +/// +/// Logger and level are evaluated once before metadata filtering. Message, key, +/// and value expressions are only evaluated if at least one dispatch may accept +/// the event. Full-record filters can still reject it. Formatting can happen more +/// than once when several appenders consume the same event. +/// +/// The target and source metadata identify the calling module and location. +/// Use [`Record::builder`](crate::record::Record::builder) for a custom target. +/// All native levels are supported, including `Level::Fatal`, which neither +/// terminates the process nor implicitly flushes the logger. +/// +/// ``` +/// use logforth_core::{builder, info, log}; +/// use logforth_core::kv::Value; +/// use logforth_core::record::Level; +/// +/// let logger = builder().build(); +/// let count = 3; +/// info!(logger, "processed {count} jobs"); +/// log!(logger, Level::Info2, { +/// "jobs.completed" => count, +/// "healthy" => true, +/// "details" => Value::debug(&[1, 2, 3]), +/// }, "batch complete"); +/// info!(logger, { "queue.depth" => 0 }); +/// logger.flush(); +/// ``` +#[macro_export] +macro_rules! log { + ($logger:expr, $level:expr, { $($key:expr => $value:expr),* $(,)? } $(, $($message:tt)*)?) => { + $crate::__log!($logger, $level, { $($key => $value),* }, $($($message)*)?) + }; + ($logger:expr, $level:expr, $($message:tt)+) => { + $crate::__log!($logger, $level, {}, $($message)+) + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __log { + ($logger:expr, $level:expr, { $($key:expr => $value:expr),* }, $($message:tt)+) => {{ + // The match keeps temporary logger expressions alive through dispatch. + match (&$logger, $level) { + (logger, level) => { + let logger: &$crate::Logger = logger; + let target = ::core::module_path!(); + let criteria = $crate::record::FilterCriteria::builder() + .level(level) + .target(target) + .build(); + if logger.enabled(&criteria) { + logger.log(&$crate::record::Record::builder() + .level(level) + .target_static(target) + .module_path_static(::core::module_path!()) + .file_static(::core::file!()) + .line(::core::option::Option::Some(::core::line!())) + .column(::core::option::Option::Some(::core::column!())) + .payload(::core::format_args!($($message)+)) + .key_values(&[ + $(($crate::kv::Key::borrowed(&$key), $crate::kv::Value::from(&$value))),* + ][..] as &[($crate::kv::Key<'_>, $crate::kv::Value<'_>)]) + .build()); + } + } + } + }}; + ($logger:expr, $level:expr, { $($key:expr => $value:expr),* },) => { + $crate::__log!($logger, $level, { $($key => $value),* }, "") + }; +} + +/// Emit a [`Trace`](crate::record::Level::Trace) event. See [`log!`](crate::log!) for syntax. +#[macro_export] +macro_rules! trace { + ($logger:expr, $($event:tt)+) => { + $crate::log!($logger, $crate::record::Level::Trace, $($event)+) + }; +} + +/// Emit a [`Debug`](crate::record::Level::Debug) event. See [`log!`](crate::log!) for syntax. +#[macro_export] +macro_rules! debug { + ($logger:expr, $($event:tt)+) => { + $crate::log!($logger, $crate::record::Level::Debug, $($event)+) + }; +} + +/// Emit a [`Info`](crate::record::Level::Info) event. See [`log!`](crate::log!) for syntax. +#[macro_export] +macro_rules! info { + ($logger:expr, $($event:tt)+) => { + $crate::log!($logger, $crate::record::Level::Info, $($event)+) + }; +} + +/// Emit a [`Warn`](crate::record::Level::Warn) event. See [`log!`](crate::log!) for syntax. +#[macro_export] +macro_rules! warn { + ($logger:expr, $($event:tt)+) => { + $crate::log!($logger, $crate::record::Level::Warn, $($event)+) + }; +} + +/// Emit a [`Error`](crate::record::Level::Error) event. See [`log!`](crate::log!) for syntax. +#[macro_export] +macro_rules! error { + ($logger:expr, $($event:tt)+) => { + $crate::log!($logger, $crate::record::Level::Error, $($event)+) + }; +} + +/// Emit a [`Fatal`](crate::record::Level::Fatal) event. See [`log!`](crate::log!) for syntax. +/// This records severity only; it does not terminate the process or flush. +#[macro_export] +macro_rules! fatal { + ($logger:expr, $($event:tt)+) => { + $crate::log!($logger, $crate::record::Level::Fatal, $($event)+) + }; +} diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 7b72c8d..317e2ae 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -148,3 +148,7 @@ required-features = [ "diagnostic-fastrace", "layout-google-cloud-logging", ] + +[[example]] +name = "native" +path = "src/native.rs" diff --git a/examples/src/native.rs b/examples/src/native.rs new file mode 100644 index 0000000..e36c6a4 --- /dev/null +++ b/examples/src/native.rs @@ -0,0 +1,44 @@ +// 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::Level; +use logforth::LevelFilter; +use logforth::append; +use logforth::diagnostic::StaticDiagnostic; +use logforth::kv::Value; + +fn main() { + let mut context = StaticDiagnostic::default(); + context.insert("service", "worker"); + let logger = logforth::builder() + .dispatch(|d| { + d.filter(LevelFilter::MoreSevereEqual(Level::Info)) + .diagnostic(context) + .append(append::Stderr::default()) + }) + .build(); + + let queue = String::from("background"); + logforth::info!(logger, { + "queue" => queue, + "completed" => 3u64, + "healthy" => true, + "details" => Value::debug(&[1, 2, 3]), + }, "batch complete"); + logforth::info!(logger, { "queue.depth" => 0 }); + logforth::debug!(logger, "this event is filtered out"); + + // Stop producers before flushing, while appender resources are still alive. + logger.flush(); +} diff --git a/logforth/src/lib.rs b/logforth/src/lib.rs index 668dcd5..1bab2c2 100644 --- a/logforth/src/lib.rs +++ b/logforth/src/lib.rs @@ -12,68 +12,66 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Logforth is a flexible logging framework for Rust applications, providing easy log dispatching -//! and configuration. +//! Native structured logging with configurable dispatches, filters, diagnostics, and appenders. //! -//! # Overview +//! Build an explicit [`Logger`] and use the native macros. They skip message and +//! field evaluation when metadata filtering rejects the event. //! -//! Logforth allows you to set up multiple log dispatches with different filters and appenders. You -//! can configure global log levels, use built-in appenders for stdout, stderr, files, or create -//! custom appenders. -//! -//! It provides out-of-the-box integrations with the `log` crate: -//! -//! ```shell -//! cargo add log -//! cargo add logforth -F starter-log //! ``` +//! use logforth::{Level, LevelFilter, append}; //! -//! # Examples -//! -//! Simple setup with a stderr appender: -//! +//! let logger = logforth::builder() +//! .dispatch(|d| d +//! .filter(LevelFilter::MoreSevereEqual(Level::Info)) +//! .append(append::Stderr::default())) +//! .build(); +//! logforth::info!(logger, { "jobs" => 3u64, "healthy" => true }, "batch complete"); +//! logger.flush(); //! ``` -//! logforth::starter_log::stderr().apply(); //! -//! log::info!("This is an info message."); -//! ``` +//! [`log!`] accepts every native severity; the convenience macros use the same +//! syntax. Fields use standard [`From`] conversions into [`kv::Value`], borrowing +//! strings and existing values. Use [`kv::Value::display`] / [`kv::Value::debug`] +//! for text, and [`kv::Value::list`] / [`kv::Value::map`] for structured values. +//! A fields-only event needs no message. //! -//! Advanced setup with custom filters and multiple appenders: +//! The target and source metadata come from the call site. Custom targets remain +//! available through [`record::RecordBuilder`] and compatibility bridges. +//! Diagnostics supply context through the existing dispatch API; these macros +//! do not bind context to loggers or change how appenders combine it with fields. +//! Stop producers and flush before shutting down resources used by appenders. //! -//! ``` -//! use logforth::append; -//! use logforth::record::Level; -//! use logforth::record::LevelFilter; -//! -//! logforth::starter_log::builder() -//! .dispatch(|d| { -//! d.filter(LevelFilter::MoreSevereEqual(Level::Error)) -//! .append(append::Stderr::default()) -//! }) -//! .dispatch(|d| { -//! d.filter(LevelFilter::MoreSevereEqual(Level::Info)) -//! .append(append::Stdout::default()) -//! }) -//! .apply(); -//! -//! log::error!("Error message."); -//! log::info!("Info message."); -//! ``` +//! The optional `bridge-log` feature forwards dependency logs to a native logger. +//! Existing `starter-log` helpers configure the `log` facade as a compatibility +//! entry point. Native logging does not require either feature. //! -//! See the [README] file for more details and examples. +//! See the [README] for setup, migration, and lifecycle details. //! -//! [README]: https://github.com/fast/logforth?tab=readme-ov-file +//! [README]: https://github.com/fast/logforth#readme #![cfg_attr(docsrs, feature(doc_cfg))] #![deny(missing_docs)] +pub use logforth_core::DispatchBuilder; pub use logforth_core::Error; +pub use logforth_core::Logger; +pub use logforth_core::LoggerBuilder; pub use logforth_core::append::Append; +pub use logforth_core::builder; +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::record; +pub use logforth_core::record::Level; +pub use logforth_core::record::LevelFilter; +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.rs b/logforth/tests/native.rs new file mode 100644 index 0000000..af544ab --- /dev/null +++ b/logforth/tests/native.rs @@ -0,0 +1,281 @@ +// 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::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use logforth::Append; +use logforth::Diagnostic; +use logforth::Error; +use logforth::Filter; +use logforth::Level; +use logforth::LevelFilter; +use logforth::filter::FilterResult; +use logforth::kv::KeyOwned; +use logforth::kv::Value; +use logforth::kv::ValueOwned; +use logforth::record::FilterCriteria; +use logforth::record::Record; +use logforth::record::RecordOwned; + +type Captured = (RecordOwned, Vec<(KeyOwned, ValueOwned)>); + +#[derive(Clone, Debug, Default)] +struct Capture { + events: Arc>>, + flushes: Arc, +} + +impl Append for Capture { + fn append(&self, record: &Record, diags: &[Box]) -> Result<(), Error> { + let mut fields = Vec::new(); + for diag in diags { + diag.visit(&mut |key: logforth::kv::KeyView<'_>, + value: logforth::kv::ValueView<'_>| { + fields.push((key.to_owned(), value.to_owned())); + Ok(()) + })?; + } + self.events + .lock() + .unwrap() + .push((record.to_owned(), fields)); + Ok(()) + } + + fn flush(&self) -> Result<(), Error> { + self.flushes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[test] +fn disabled_events_skip_payload_keys_and_conversions() { + struct Expensive; + impl From<&Expensive> for Value<'_> { + fn from(_: &Expensive) -> Self { + panic!("disabled field conversion"); + } + } + + let output = Capture::default(); + let logger = logforth::builder() + .dispatch(|d| d.filter(LevelFilter::Off).append(output.clone())) + .build(); + let probes = Cell::new(0); + let payloads = Cell::new(0); + let key = || { + payloads.set(payloads.get() + 1); + "field" + }; + let value = || { + payloads.set(payloads.get() + 1); + Expensive + }; + logforth::log!( + { probes.set(probes.get() + 1); &logger }, + { probes.set(probes.get() + 1); Level::Info }, + { key() => value() }, + "{}", { payloads.set(payloads.get() + 1); 42 }, + ); + assert_eq!(probes.get(), 2); + assert_eq!(payloads.get(), 0); + assert!(output.events.lock().unwrap().is_empty()); +} + +#[test] +fn fields_are_borrowed_typed_and_evaluated_once_across_dispatches() { + let output = Capture::default(); + let mut context = logforth::diagnostic::StaticDiagnostic::default(); + context.insert("name", "service"); + let nested = ValueOwned::map([("depth".into(), 2u64.into())]); + let logger = Arc::new( + logforth::builder() + .dispatch(|d| d.diagnostic(context.clone()).append(output.clone())) + .dispatch(|d| d.diagnostic(context.clone()).append(output.clone())) + .build(), + ); + let name = String::from("worker"); + let name_ref = &name; + let count = 3u32; + let count_ref = &count; + let nested_ref = &nested; + let ready = Value::from(true); + let ready_ref = &ready; + let calls = Cell::new(0); + let expected_line = line!() + 1; + logforth::info!(&logger, { + String::from("name") => name, + "count" => { calls.set(calls.get() + 1); count_ref }, + "borrowed_name" => name_ref, + "ready" => ready_ref, + "missing" => Value::none(), + "nested" => nested_ref, + "wide" => u128::MAX, + "debug" => Value::debug(&vec![1, 2]), + "display" => Value::display(&format_args!("{name}:{}", 3)), + }, "{name} finished {count}", count = { calls.set(calls.get() + 1); 3 }); + assert_eq!(calls.get(), 2); + assert_eq!(name, "worker"); + let events = output.events.lock().unwrap(); + assert_eq!(events.len(), 2); + for (event, diagnostics) in events.iter() { + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].0.view().as_str(), "name"); + assert_eq!(diagnostics[0].1.view().to_str(), Some("service")); + event.with(|r| { + assert_eq!(r.target_static(), Some(module_path!())); + assert_eq!(r.module_path_static(), Some(module_path!())); + assert_eq!(r.file_static(), Some(file!())); + assert_eq!(r.line(), Some(expected_line)); + assert_eq!(r.column(), Some(5)); + assert_eq!(r.payload().to_string(), "worker finished 3"); + let fields = r.key_values(); + assert_eq!(fields.get("name").unwrap().to_str(), Some("worker")); + assert_eq!(fields.get("count").unwrap().to_u64(), Some(3)); + assert_eq!( + fields.get("borrowed_name").unwrap().to_str(), + Some("worker") + ); + assert_eq!( + fields + .get("nested") + .unwrap() + .to_map() + .unwrap() + .get("depth") + .unwrap() + .to_u64(), + Some(2) + ); + assert_eq!(fields.get("ready").unwrap().to_bool(), Some(true)); + assert!(matches!( + fields.get("missing").unwrap(), + logforth::kv::ValueView::None + )); + assert_eq!(fields.get("wide").unwrap().to_u128(), Some(u128::MAX)); + assert_eq!(fields.get("debug").unwrap().to_str(), Some("[1, 2]")); + assert_eq!(fields.get("display").unwrap().to_str(), Some("worker:3")); + }); + } +} + +#[derive(Debug)] +struct MessageFilter; + +impl Filter for MessageFilter { + fn enabled(&self, _: &FilterCriteria, _: &[Box]) -> FilterResult { + FilterResult::Neutral + } + + fn matches(&self, record: &Record, _: &[Box]) -> FilterResult { + if record.payload().to_string().contains("keep") { + FilterResult::Accept + } else { + FilterResult::Reject + } + } +} + +#[test] +fn full_record_filtering_and_explicit_flush_keep_the_logger_usable() { + let output = Capture::default(); + let logger = logforth::builder() + .dispatch(|d| d.filter(MessageFilter).append(output.clone())) + .build(); + logforth::info!(logger, "discard"); + logforth::fatal!(logger, "keep going"); + logger.flush(); + logforth::log!(logger, Level::Warn4, "keep this too"); + let events = output.events.lock().unwrap(); + assert_eq!(events.len(), 2); + events[0].0.with(|r| assert_eq!(r.level(), Level::Fatal)); + events[1].0.with(|r| { + assert_eq!(r.level(), Level::Warn4); + assert_eq!(r.target(), module_path!()); + assert_eq!(r.module_path(), Some(module_path!())); + }); + assert_eq!(output.flushes.load(Ordering::SeqCst), 1); +} + +#[cfg(feature = "filter-rustlog")] +#[test] +fn module_filters_apply_before_fields_only_events_are_built() { + use logforth::filter::RustLogFilter; + + let output = Capture::default(); + let filter = format!("off,{}=info", module_path!()) + .parse::() + .unwrap(); + let logger = logforth::builder() + .dispatch(|d| d.filter(filter).append(output.clone())) + .build(); + let calls = Cell::new(0); + logforth::debug!(logger, { "units" => { calls.set(calls.get() + 1); 7u64 } }); + logforth::info!(logger, { "units" => 7u64 },); + assert_eq!(calls.get(), 0); + let events = output.events.lock().unwrap(); + assert_eq!(events.len(), 1); + events[0].0.with(|r| { + assert_eq!(r.target(), module_path!()); + assert_eq!(r.payload().to_string(), ""); + assert_eq!(r.key_values().get("units").unwrap().to_u64(), Some(7)); + }); +} + +#[cfg(feature = "bridge-log")] +#[test] +fn dependency_logs_and_native_events_share_dispatch_and_flush() { + let output = Capture::default(); + let logger = Arc::new( + logforth::builder() + .dispatch(|d| d.append(output.clone())) + .build(), + ); + log::set_boxed_logger(Box::new(logforth::bridge::log::LogBridge::new( + logger.clone(), + ))) + .unwrap(); + log::set_max_level(log::LevelFilter::Trace); + logforth::info!(logger, { "ready" => true }, "started"); + log::warn!(target: "dependency", attempts = 2u64; "retrying"); + logger.flush(); + let events = output.events.lock().unwrap(); + assert_eq!(events.len(), 2); + events[0].0.with(|r| assert_eq!(r.target(), module_path!())); + events[1].0.with(|r| { + assert_eq!(r.target(), "dependency"); + assert_eq!(r.key_values().get("attempts").unwrap().to_u64(), Some(2)); + }); + assert_eq!(output.flushes.load(Ordering::SeqCst), 1); +} + +#[test] +fn independent_loggers_keep_their_filtering_and_flush_policy() { + let output = Capture::default(); + let ordinary = logforth::builder() + .dispatch(|d| d.filter(LevelFilter::Off).append(output.clone())) + .build(); + let usage = logforth::builder() + .dispatch(|d| d.append(output.clone())) + .build(); + logforth::info!(ordinary, "disabled"); + logforth::info!(usage, { "resource" => "worker", "units" => 5i64 }); + usage.flush(); + assert_eq!(output.events.lock().unwrap().len(), 1); + assert_eq!(output.flushes.load(Ordering::SeqCst), 1); +}