diff --git a/src/lib.rs b/src/lib.rs index dad5a40..beb47cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,9 +26,29 @@ pub use timestamp::TSFormat; /// * `report_caller`: setting to true will output the filename and line number /// where the logging call was made /// * `time_format`: custom time format string (chrono format) +/// * `module_filters`: per-module log level overrides (see below) /// /// With the options set, call the setup function, passing the opts as the argument. /// +/// # Per-Module Log Level Filtering +/// +/// You can suppress noisy third-party crates while keeping verbose logging for +/// your own code using `module_filter()`: +/// +/// ```rust +/// use twyg::{self, LogLevel, OptsBuilder}; +/// +/// let opts = OptsBuilder::new() +/// .level(LogLevel::Trace) +/// .module_filter("tokenizers", LogLevel::Warn) +/// .module_filter("hyper", LogLevel::Info) +/// .build() +/// .unwrap(); +/// ``` +/// +/// Module filters match against the log record's target (module path) by prefix. +/// The first matching prefix wins. Unmatched modules use the global `level`. +/// /// # Structured Logging Support /// /// Twyg supports structured logging with key-value pairs using the log crate's diff --git a/src/logger.rs b/src/logger.rs index 87a3392..60fa852 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -65,6 +65,7 @@ struct LoggerConfig { msg_separator: String, arrow_char: String, colors: Colors, + module_filters: Vec<(String, LevelFilter)>, } /// Logger implementation that directly implements log::Log trait. @@ -92,6 +93,11 @@ impl TwygLogger { let msg_separator = opts.msg_separator().to_string(); let arrow_char = opts.arrow_char().to_string(); let colors = opts.colors().clone(); + let module_filters: Vec<(String, LevelFilter)> = opts + .module_filters() + .iter() + .map(|(prefix, level)| (prefix.clone(), LevelFilter::from(*level))) + .collect(); TwygLogger { output: Arc::new(Mutex::new(output)), @@ -106,6 +112,7 @@ impl TwygLogger { msg_separator, arrow_char, colors, + module_filters, }, } } @@ -261,6 +268,12 @@ impl TwygLogger { impl Log for TwygLogger { #[inline] fn enabled(&self, metadata: &Metadata) -> bool { + let target = metadata.target(); + for (prefix, level_filter) in &self.config.module_filters { + if target.starts_with(prefix.as_str()) { + return metadata.level() <= *level_filter; + } + } metadata.level() <= self.config.max_level } @@ -460,7 +473,16 @@ impl Logger { // Create and install the logger let logger = TwygLogger::new(&self.opts, output_writer); log::set_boxed_logger(Box::new(logger)).map_err(|_| super::error::TwygError::InitError)?; - log::set_max_level(LevelFilter::from(self.opts.level())); + + // Compute effective max level: the most permissive among global + all module filters. + // This ensures filtered messages reach TwygLogger::enabled() for per-module checks. + let effective_max = self + .opts + .module_filters() + .iter() + .map(|(_, l)| LevelFilter::from(*l)) + .fold(LevelFilter::from(self.opts.level()), std::cmp::max); + log::set_max_level(effective_max); Ok(()) } @@ -662,6 +684,7 @@ mod tests { msg_separator: opts.msg_separator().to_string(), arrow_char: opts.arrow_char().to_string(), colors: opts.colors().clone(), + module_filters: Vec::new(), }; assert_eq!(collector.format_pairs(&config), ""); @@ -689,6 +712,7 @@ mod tests { msg_separator: opts.msg_separator().to_string(), arrow_char: opts.arrow_char().to_string(), colors: opts.colors().clone(), + module_filters: Vec::new(), }; let formatted = collector.format_pairs(&config); @@ -723,6 +747,7 @@ mod tests { msg_separator: opts.msg_separator().to_string(), arrow_char: opts.arrow_char().to_string(), colors: opts.colors().clone(), + module_filters: Vec::new(), }; let formatted = collector.format_pairs(&config); @@ -912,6 +937,7 @@ mod tests { msg_separator: opts.msg_separator().to_string(), arrow_char: opts.arrow_char().to_string(), colors: opts.colors().clone(), + module_filters: Vec::new(), }; let formatted = collector.format_pairs(&config); @@ -952,6 +978,7 @@ mod tests { msg_separator: ": ".to_string(), arrow_char: "▶".to_string(), colors: empty_colors, + module_filters: Vec::new(), }; let formatted = collector.format_pairs(&config); @@ -1205,4 +1232,123 @@ mod tests { assert!(result.is_ok()); } } + + #[test] + fn test_twyg_logger_enabled_with_module_filter_suppresses() { + let opts = OptsBuilder::new() + .level(LogLevel::Trace) + .module_filter("tokenizers", LogLevel::Warn) + .build() + .unwrap(); + + let output = OutputWriter::Stdout(io::stdout()); + let logger = TwygLogger::new(&opts, output); + + // TRACE from tokenizers should be suppressed + let metadata = Metadata::builder() + .level(Level::Trace) + .target("tokenizers::tokenizer::normalizer") + .build(); + assert!(!logger.enabled(&metadata)); + + // DEBUG from tokenizers should be suppressed + let metadata = Metadata::builder() + .level(Level::Debug) + .target("tokenizers::models") + .build(); + assert!(!logger.enabled(&metadata)); + } + + #[test] + fn test_twyg_logger_enabled_with_module_filter_allows() { + let opts = OptsBuilder::new() + .level(LogLevel::Trace) + .module_filter("tokenizers", LogLevel::Warn) + .build() + .unwrap(); + + let output = OutputWriter::Stdout(io::stdout()); + let logger = TwygLogger::new(&opts, output); + + // WARN from tokenizers should be allowed + let metadata = Metadata::builder() + .level(Level::Warn) + .target("tokenizers::tokenizer") + .build(); + assert!(logger.enabled(&metadata)); + + // ERROR from tokenizers should be allowed + let metadata = Metadata::builder() + .level(Level::Error) + .target("tokenizers") + .build(); + assert!(logger.enabled(&metadata)); + } + + #[test] + fn test_twyg_logger_enabled_unmatched_uses_global() { + let opts = OptsBuilder::new() + .level(LogLevel::Info) + .module_filter("tokenizers", LogLevel::Warn) + .build() + .unwrap(); + + let output = OutputWriter::Stdout(io::stdout()); + let logger = TwygLogger::new(&opts, output); + + // INFO from an unmatched module should use global level (Info) + let metadata = Metadata::builder() + .level(Level::Info) + .target("my_app::handlers") + .build(); + assert!(logger.enabled(&metadata)); + + // DEBUG from an unmatched module should be suppressed by global level + let metadata = Metadata::builder() + .level(Level::Debug) + .target("my_app::handlers") + .build(); + assert!(!logger.enabled(&metadata)); + } + + #[test] + fn test_twyg_logger_enabled_first_prefix_match_wins() { + let opts = OptsBuilder::new() + .level(LogLevel::Trace) + .module_filter("hyper", LogLevel::Warn) + .module_filter("hyper::proto", LogLevel::Trace) + .build() + .unwrap(); + + let output = OutputWriter::Stdout(io::stdout()); + let logger = TwygLogger::new(&opts, output); + + // "hyper::proto" matches "hyper" first, so Warn applies (not Trace) + let metadata = Metadata::builder() + .level(Level::Debug) + .target("hyper::proto::h1") + .build(); + assert!(!logger.enabled(&metadata)); + } + + #[test] + fn test_dispatch_max_level_includes_module_filters() { + // We can't actually call dispatch() in tests (global logger), + // but we can verify the effective max level computation logic. + let opts = OptsBuilder::new() + .level(LogLevel::Warn) + .module_filter("my_app", LogLevel::Trace) + .build() + .unwrap(); + + // The effective max should be Trace (most permissive) + let global_level = LevelFilter::from(opts.level()); + let effective_max = opts + .module_filters() + .iter() + .map(|(_, l)| LevelFilter::from(*l)) + .fold(global_level, std::cmp::max); + + assert_eq!(effective_max, LevelFilter::Trace); + } } diff --git a/src/opts.rs b/src/opts.rs index 1387c7c..6e03619 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -83,6 +83,15 @@ pub struct Opts { /// Fine-grained color configuration. #[serde(default)] colors: Colors, + + /// Per-module log level overrides. + /// + /// Keys are module path prefixes (e.g., `"tokenizers"`, `"hyper::proto"`). + /// Values are the maximum log level for that module. + /// Modules not matching any prefix use the global `level`. + /// Prefixes are matched in order; the first match wins. + #[serde(default)] + module_filters: Vec<(String, LogLevel)>, } // Default value functions for serde @@ -112,6 +121,7 @@ impl Default for Opts { msg_separator: ": ".to_string(), arrow_char: "▶".to_string(), colors: Colors::default(), + module_filters: Vec::new(), } } } @@ -185,6 +195,11 @@ impl Opts { &self.colors } + /// Returns the per-module log level filters. + pub fn module_filters(&self) -> &[(String, LogLevel)] { + &self.module_filters + } + /// Returns the time format string (deprecated, for backward compatibility). #[deprecated(since = "0.6.1", note = "Use timestamp_format() instead")] pub fn time_format(&self) -> Option<&str> { @@ -222,6 +237,7 @@ pub struct OptsBuilder { msg_separator: String, arrow_char: String, colors: Colors, + module_filters: Vec<(String, LogLevel)>, } impl Default for OptsBuilder { @@ -245,6 +261,7 @@ impl OptsBuilder { msg_separator: ": ".to_string(), arrow_char: "▶".to_string(), colors: Colors::default(), + module_filters: Vec::new(), } } @@ -327,6 +344,36 @@ impl OptsBuilder { self } + /// Add a per-module log level filter. + /// + /// The prefix is matched against the log record's target (module path). + /// The first matching prefix wins. Unmatched modules use the global level. + /// + /// # Examples + /// + /// ``` + /// use twyg::{LogLevel, OptsBuilder}; + /// + /// let opts = OptsBuilder::new() + /// .level(LogLevel::Trace) + /// .module_filter("tokenizers", LogLevel::Warn) + /// .module_filter("hyper", LogLevel::Info) + /// .build() + /// .unwrap(); + /// ``` + pub fn module_filter(mut self, prefix: impl Into, level: LogLevel) -> Self { + self.module_filters.push((prefix.into(), level)); + self + } + + /// Set all per-module log level filters at once. + /// + /// Replaces any previously added filters. + pub fn module_filters(mut self, filters: Vec<(String, LogLevel)>) -> Self { + self.module_filters = filters; + self + } + /// Set a custom time format string (deprecated). /// /// The format string uses chrono's format syntax. @@ -370,6 +417,7 @@ impl OptsBuilder { msg_separator: self.msg_separator, arrow_char: self.arrow_char, colors: self.colors, + module_filters: self.module_filters, }) } } @@ -793,4 +841,91 @@ mod tests { assert_eq!(default_msg_separator(), ": "); assert_eq!(default_arrow_char(), "▶"); } + + #[test] + fn test_opts_module_filters_default_empty() { + let opts = Opts::default(); + assert!(opts.module_filters().is_empty()); + + let opts = Opts::new(); + assert!(opts.module_filters().is_empty()); + } + + #[test] + fn test_opts_builder_with_module_filter() { + let opts = OptsBuilder::new() + .level(LogLevel::Trace) + .module_filter("tokenizers", LogLevel::Warn) + .module_filter("hyper", LogLevel::Info) + .build() + .unwrap(); + + let filters = opts.module_filters(); + assert_eq!(filters.len(), 2); + assert_eq!(filters[0].0, "tokenizers"); + assert_eq!(filters[0].1, LogLevel::Warn); + assert_eq!(filters[1].0, "hyper"); + assert_eq!(filters[1].1, LogLevel::Info); + } + + #[test] + fn test_opts_builder_with_module_filters() { + let filters = vec![ + ("tokenizers".to_string(), LogLevel::Warn), + ("hyper".to_string(), LogLevel::Info), + ]; + let opts = OptsBuilder::new() + .level(LogLevel::Trace) + .module_filters(filters.clone()) + .build() + .unwrap(); + + assert_eq!(opts.module_filters(), &filters[..]); + } + + #[test] + fn test_opts_module_filters_serde_roundtrip() { + let opts = OptsBuilder::new() + .level(LogLevel::Trace) + .module_filter("tokenizers", LogLevel::Warn) + .module_filter("hyper::proto", LogLevel::Error) + .build() + .unwrap(); + + let serialized = serde_json::to_string(&opts).unwrap(); + let deserialized: Opts = serde_json::from_str(&serialized).unwrap(); + + assert_eq!( + opts.module_filters().len(), + deserialized.module_filters().len() + ); + assert_eq!( + opts.module_filters()[0].0, + deserialized.module_filters()[0].0 + ); + assert_eq!( + opts.module_filters()[0].1, + deserialized.module_filters()[0].1 + ); + assert_eq!( + opts.module_filters()[1].0, + deserialized.module_filters()[1].0 + ); + assert_eq!( + opts.module_filters()[1].1, + deserialized.module_filters()[1].1 + ); + } + + #[test] + fn test_opts_builder_module_filters_replaces_previous() { + let opts = OptsBuilder::new() + .module_filter("a", LogLevel::Warn) + .module_filters(vec![("b".to_string(), LogLevel::Error)]) + .build() + .unwrap(); + + assert_eq!(opts.module_filters().len(), 1); + assert_eq!(opts.module_filters()[0].0, "b"); + } }