Skip to main content

rch_common/
logging.rs

1//! Structured logging initialization for RCH components.
2//!
3//! Provides a shared logging configuration and initialization routine for
4//! binaries (rch, rchd, rch-wkr) and libraries that need consistent output.
5
6use anyhow::Result;
7use std::collections::BTreeMap;
8use std::ffi::OsStr;
9use std::path::{Path, PathBuf};
10use tracing::Subscriber;
11use tracing_subscriber::{
12    EnvFilter, Layer, Registry, fmt,
13    fmt::writer::{BoxMakeWriter, MakeWriterExt},
14    layer::{Identity, SubscriberExt},
15    util::SubscriberInitExt,
16};
17
18/// Logging output format.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LogFormat {
21    /// Human-friendly, pretty-printed logs.
22    Pretty,
23    /// JSON-formatted logs for machine parsing.
24    Json,
25    /// Compact single-line logs.
26    Compact,
27}
28
29impl LogFormat {
30    fn parse(value: &str) -> Option<Self> {
31        match value.trim().to_lowercase().as_str() {
32            "pretty" => Some(Self::Pretty),
33            "json" => Some(Self::Json),
34            "compact" => Some(Self::Compact),
35            _ => None,
36        }
37    }
38}
39
40/// Configuration for logging initialization.
41#[derive(Debug, Clone)]
42pub struct LogConfig {
43    /// Base log level (trace, debug, info, warn, error, off).
44    pub level: String,
45    /// Output format.
46    pub format: LogFormat,
47    /// Optional file path for rotating logs.
48    pub file_path: Option<PathBuf>,
49    /// Per-target log level overrides.
50    pub targets: BTreeMap<String, String>,
51    /// Include target in log output.
52    pub with_target: bool,
53    /// Include thread IDs in log output.
54    pub with_thread_ids: bool,
55    /// Include file and line number in log output.
56    pub with_file_line: bool,
57    /// Write console logs to stderr instead of stdout.
58    pub use_stderr: bool,
59}
60
61impl Default for LogConfig {
62    fn default() -> Self {
63        Self {
64            level: "info".to_string(),
65            format: LogFormat::Pretty,
66            file_path: None,
67            targets: BTreeMap::new(),
68            with_target: true,
69            with_thread_ids: true,
70            with_file_line: true,
71            use_stderr: false,
72        }
73    }
74}
75
76impl LogConfig {
77    /// Build a logging configuration from environment variables.
78    ///
79    /// Supported environment variables:
80    /// - RCH_LOG_LEVEL
81    /// - RCH_LOG_FORMAT (pretty|json|compact)
82    /// - RCH_LOG_FILE (path to rotating log file)
83    /// - RCH_LOG_TARGETS (comma-separated target=level list)
84    pub fn from_env(default_level: &str) -> Self {
85        let mut config = Self {
86            level: std::env::var("RCH_LOG_LEVEL").unwrap_or_else(|_| default_level.to_string()),
87            ..Self::default()
88        };
89
90        if let Ok(format) = std::env::var("RCH_LOG_FORMAT")
91            && let Some(parsed) = LogFormat::parse(&format)
92        {
93            config.format = parsed;
94        }
95
96        if let Ok(path) = std::env::var("RCH_LOG_FILE")
97            && !path.trim().is_empty()
98        {
99            config.file_path = Some(PathBuf::from(path));
100        }
101
102        if let Ok(targets) = std::env::var("RCH_LOG_TARGETS") {
103            config.targets = parse_target_overrides(&targets);
104        }
105
106        config
107    }
108
109    /// Override the base log level.
110    pub fn with_level(mut self, level: impl Into<String>) -> Self {
111        self.level = level.into();
112        self
113    }
114
115    /// Write console logs to stderr.
116    pub fn with_stderr(mut self) -> Self {
117        self.use_stderr = true;
118        self
119    }
120
121    /// Build the effective EnvFilter, honoring RUST_LOG if set.
122    pub fn env_filter(&self) -> EnvFilter {
123        if std::env::var_os("RUST_LOG").is_some()
124            && let Ok(filter) = EnvFilter::try_from_default_env()
125        {
126            return filter;
127        }
128
129        let mut filter = self.level.clone();
130        for (target, level) in &self.targets {
131            filter.push_str(&format!(",{}={}", target, level));
132        }
133        EnvFilter::new(filter)
134    }
135}
136
137/// Guards required to keep background logging workers alive.
138pub struct LoggingGuards {
139    _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
140}
141
142/// Initialize tracing-based logging for the current process.
143///
144/// Returns guards that must be kept alive for the duration of the program
145/// (particularly when file logging is enabled).
146pub fn init_logging(config: &LogConfig) -> Result<LoggingGuards> {
147    init_with_layer(config, None::<Identity>, true)
148}
149
150/// Initialize logging with an independently filtered event or span layer.
151///
152/// The diagnostic `EnvFilter` applies only to formatted logs. The caller must
153/// apply any desired filtering to `layer` itself. Returns an error if another
154/// global subscriber is already installed: success means this layer was attached.
155pub fn init_logging_with_layer<L>(config: &LogConfig, layer: L) -> Result<LoggingGuards>
156where
157    L: Layer<Registry> + Send + Sync + 'static,
158{
159    init_with_layer(config, layer, false)
160}
161
162fn build_writer(
163    config: &LogConfig,
164) -> Result<(
165    BoxMakeWriter,
166    Option<tracing_appender::non_blocking::WorkerGuard>,
167)> {
168    let base_writer = if config.use_stderr {
169        BoxMakeWriter::new(std::io::stderr)
170    } else {
171        BoxMakeWriter::new(std::io::stdout)
172    };
173
174    if let Some(path) = config.file_path.as_ref() {
175        let dir = path
176            .parent()
177            .filter(|p| !p.as_os_str().is_empty())
178            .unwrap_or_else(|| Path::new("."));
179        let file_name = path.file_name().unwrap_or_else(|| OsStr::new("rch.log"));
180        // Retention cap for daily-rotated logs. The bare `rolling::daily`
181        // helper keeps every file forever, which is how a file-logging
182        // setup would silently fill the disk. Default to a week; allow
183        // operators to override via env without touching the public
184        // LogConfig struct (which would be a SemVer break since it has
185        // no #[non_exhaustive] attribute).
186        let max_files = std::env::var("RCH_LOG_MAX_FILES")
187            .ok()
188            .and_then(|s| s.trim().parse::<usize>().ok())
189            .filter(|n| *n > 0)
190            .unwrap_or(7);
191        let appender = tracing_appender::rolling::Builder::new()
192            .rotation(tracing_appender::rolling::Rotation::DAILY)
193            .filename_prefix(file_name.to_string_lossy().into_owned())
194            .max_log_files(max_files)
195            .build(dir)
196            .map_err(|e| anyhow::anyhow!("failed to build rolling log appender: {e}"))?;
197        let (non_blocking, guard) = tracing_appender::non_blocking(appender);
198        let writer = BoxMakeWriter::new(base_writer.and(non_blocking));
199        Ok((writer, Some(guard)))
200    } else {
201        Ok((base_writer, None))
202    }
203}
204
205fn init_with_layer<L>(
206    config: &LogConfig,
207    layer: L,
208    allow_already_initialized: bool,
209) -> Result<LoggingGuards>
210where
211    L: Layer<Registry> + Send + Sync + 'static,
212{
213    let filter = config.env_filter();
214    let (writer, file_guard) = build_writer(config)?;
215    let ansi = file_guard.is_none();
216    let subscriber = build_subscriber(config, writer, ansi, filter, layer);
217    finish_subscriber(subscriber, file_guard, allow_already_initialized)
218}
219
220fn build_subscriber<L>(
221    config: &LogConfig,
222    writer: BoxMakeWriter,
223    ansi: bool,
224    filter: EnvFilter,
225    layer: L,
226) -> impl Subscriber + Send + Sync + 'static
227where
228    L: Layer<Registry> + Send + Sync + 'static,
229{
230    let formatting: Box<dyn Layer<Registry> + Send + Sync> = match config.format {
231        LogFormat::Pretty => fmt::layer()
232            .with_writer(writer)
233            .with_target(config.with_target)
234            .with_thread_ids(config.with_thread_ids)
235            .with_file(config.with_file_line)
236            .with_line_number(config.with_file_line)
237            .with_ansi(ansi)
238            .pretty()
239            .with_filter(filter)
240            .boxed(),
241        LogFormat::Json => fmt::layer()
242            .with_writer(writer)
243            .with_target(config.with_target)
244            .with_thread_ids(config.with_thread_ids)
245            .with_file(config.with_file_line)
246            .with_line_number(config.with_file_line)
247            .with_ansi(false)
248            .json()
249            .with_filter(filter)
250            .boxed(),
251        LogFormat::Compact => fmt::layer()
252            .with_writer(writer)
253            .with_target(config.with_target)
254            .with_thread_ids(config.with_thread_ids)
255            .with_file(config.with_file_line)
256            .with_line_number(config.with_file_line)
257            .with_ansi(ansi)
258            .compact()
259            .with_filter(filter)
260            .boxed(),
261    };
262    // Keep these as sibling layers. In tracing-subscriber 0.3.23, composing
263    // Layer<Registry> with and_then can use only the outer layer's level hint,
264    // suppressing events that the caller's independently filtered layer needs.
265    tracing_subscriber::registry().with(vec![layer.boxed(), formatting])
266}
267
268fn finish_subscriber<S>(
269    subscriber: S,
270    file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
271    allow_already_initialized: bool,
272) -> Result<LoggingGuards>
273where
274    S: Subscriber + Send + Sync + 'static,
275{
276    if let Err(err) = subscriber.try_init() {
277        if allow_already_initialized && err.to_string().contains("already initialized") {
278            return Ok(LoggingGuards {
279                _file_guard: file_guard,
280            });
281        }
282        return Err(err.into());
283    }
284
285    Ok(LoggingGuards {
286        _file_guard: file_guard,
287    })
288}
289
290fn parse_target_overrides(value: &str) -> BTreeMap<String, String> {
291    let mut map = BTreeMap::new();
292    for entry in value.split(',') {
293        let entry = entry.trim();
294        if entry.is_empty() {
295            continue;
296        }
297        let Some((target, level)) = entry.split_once('=') else {
298            continue;
299        };
300        let target = target.trim();
301        let level = level.trim().to_lowercase();
302        if target.is_empty() || !is_valid_level(&level) {
303            continue;
304        }
305        map.insert(target.to_string(), level);
306    }
307    map
308}
309
310fn is_valid_level(level: &str) -> bool {
311    matches!(level, "trace" | "debug" | "info" | "warn" | "error" | "off")
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use std::io::Write;
318    use std::sync::{Arc, Mutex};
319
320    #[derive(Clone)]
321    struct CapturedWriter(Arc<Mutex<Vec<u8>>>);
322
323    impl Write for CapturedWriter {
324        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
325            self.0.lock().unwrap().extend_from_slice(bytes);
326            Ok(bytes.len())
327        }
328
329        fn flush(&mut self) -> std::io::Result<()> {
330            Ok(())
331        }
332    }
333
334    struct EventTargets(Arc<Mutex<Vec<&'static str>>>);
335
336    impl Layer<Registry> for EventTargets {
337        fn on_event(
338            &self,
339            event: &tracing::Event<'_>,
340            _context: tracing_subscriber::layer::Context<'_, Registry>,
341        ) {
342            self.0.lock().unwrap().push(event.metadata().target());
343        }
344    }
345
346    #[test]
347    fn attached_layer_receives_info_despite_error_only_diagnostics_in_all_formats() {
348        for format in [LogFormat::Pretty, LogFormat::Json, LogFormat::Compact] {
349            let output = Arc::new(Mutex::new(Vec::new()));
350            let writer = CapturedWriter(output.clone());
351            let observed = Arc::new(Mutex::new(Vec::new()));
352            let collector =
353                EventTargets(observed.clone()).with_filter(tracing_subscriber::filter::filter_fn(
354                    |metadata| metadata.target().starts_with("rch::doctor::"),
355                ));
356            let config = LogConfig {
357                format,
358                with_target: false,
359                with_thread_ids: false,
360                with_file_line: false,
361                ..LogConfig::default()
362            };
363            let subscriber = build_subscriber(
364                &config,
365                BoxMakeWriter::new(move || writer.clone()),
366                false,
367                EnvFilter::new("error"),
368                collector,
369            );
370            tracing::subscriber::with_default(subscriber, || {
371                tracing::info!(target: "rch::doctor::verdict", "quiet doctor verdict");
372                tracing::info!(target: "rch::unrelated", "irrelevant info");
373                tracing::error!(target: "rch::unrelated", "visible diagnostic");
374            });
375            assert_eq!(
376                *observed.lock().unwrap(),
377                ["rch::doctor::verdict"],
378                "caller filtering must remain independent of diagnostic filtering: {format:?}"
379            );
380            let text = String::from_utf8(output.lock().unwrap().clone()).unwrap();
381            assert!(text.contains("visible diagnostic"), "{format:?}: {text}");
382            assert!(!text.contains("quiet doctor verdict"), "{format:?}: {text}");
383            assert!(!text.contains("irrelevant info"), "{format:?}: {text}");
384            if format == LogFormat::Json {
385                let event: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
386                assert_eq!(event["level"], "ERROR");
387                assert_eq!(event["fields"]["message"], "visible diagnostic");
388            }
389        }
390    }
391
392    #[test]
393    fn absent_extra_layer_preserves_diagnostic_filtering_in_all_formats() {
394        for format in [LogFormat::Pretty, LogFormat::Json, LogFormat::Compact] {
395            let output = Arc::new(Mutex::new(Vec::new()));
396            let writer = CapturedWriter(output.clone());
397            let config = LogConfig {
398                format,
399                ..LogConfig::default()
400            };
401            let subscriber = build_subscriber(
402                &config,
403                BoxMakeWriter::new(move || writer.clone()),
404                false,
405                EnvFilter::new("warn"),
406                None::<Identity>,
407            );
408            tracing::subscriber::with_default(subscriber, || {
409                tracing::info!("filtered info");
410                tracing::warn!("visible warning");
411            });
412            let text = String::from_utf8(output.lock().unwrap().clone()).unwrap();
413            assert!(text.contains("visible warning"), "{format:?}: {text}");
414            assert!(!text.contains("filtered info"), "{format:?}: {text}");
415        }
416    }
417
418    #[test]
419    fn test_parse_targets() {
420        let targets = parse_target_overrides("rchd::workers=debug,hyper=warn,invalid");
421        assert_eq!(targets.get("rchd::workers"), Some(&"debug".to_string()));
422        assert_eq!(targets.get("hyper"), Some(&"warn".to_string()));
423        assert!(!targets.contains_key("invalid"));
424    }
425
426    #[test]
427    fn test_parse_targets_trims_and_filters_invalid_levels() {
428        let targets = parse_target_overrides(" rchd::api = DEBUG ,hyper=verbose,=warn,missing");
429        assert_eq!(targets.get("rchd::api"), Some(&"debug".to_string()));
430        assert!(!targets.contains_key("hyper"));
431        assert!(!targets.contains_key(""));
432        assert!(!targets.contains_key("missing"));
433    }
434
435    #[test]
436    fn test_log_format_parse() {
437        assert_eq!(LogFormat::parse("pretty"), Some(LogFormat::Pretty));
438        assert_eq!(LogFormat::parse("JSON"), Some(LogFormat::Json));
439        assert_eq!(LogFormat::parse("Compact"), Some(LogFormat::Compact));
440        assert_eq!(LogFormat::parse("invalid"), None);
441    }
442
443    #[test]
444    fn test_env_filter_builds_overrides() {
445        let mut config = LogConfig {
446            level: "info".to_string(),
447            ..LogConfig::default()
448        };
449        config
450            .targets
451            .insert("rchd::api".to_string(), "debug".to_string());
452        let filter = config.env_filter();
453        let filter_str = format!("{filter}");
454        assert!(filter_str.contains("info"));
455        assert!(filter_str.contains("rchd::api=debug"));
456    }
457
458    #[test]
459    fn test_log_config_default() {
460        let config = LogConfig::default();
461        assert_eq!(config.level, "info");
462        assert_eq!(config.format, LogFormat::Pretty);
463        assert!(config.file_path.is_none());
464        assert!(config.targets.is_empty());
465        assert!(config.with_target);
466        assert!(config.with_thread_ids);
467        assert!(config.with_file_line);
468        assert!(!config.use_stderr);
469    }
470
471    #[test]
472    fn test_log_config_with_level() {
473        let config = LogConfig::default().with_level("debug");
474        assert_eq!(config.level, "debug");
475    }
476
477    #[test]
478    fn test_log_config_with_level_owned_string() {
479        let config = LogConfig::default().with_level(String::from("trace"));
480        assert_eq!(config.level, "trace");
481    }
482
483    #[test]
484    fn test_log_config_with_stderr() {
485        let config = LogConfig::default().with_stderr();
486        assert!(config.use_stderr);
487    }
488
489    #[test]
490    fn test_log_config_chained_builders() {
491        let config = LogConfig::default().with_level("warn").with_stderr();
492        assert_eq!(config.level, "warn");
493        assert!(config.use_stderr);
494    }
495
496    #[test]
497    fn test_log_format_equality() {
498        assert_eq!(LogFormat::Pretty, LogFormat::Pretty);
499        assert_eq!(LogFormat::Json, LogFormat::Json);
500        assert_eq!(LogFormat::Compact, LogFormat::Compact);
501        assert_ne!(LogFormat::Pretty, LogFormat::Json);
502        assert_ne!(LogFormat::Json, LogFormat::Compact);
503    }
504
505    #[test]
506    fn test_log_format_copy() {
507        let format = LogFormat::Json;
508        let copy = format; // Copy trait
509        assert_eq!(format, copy);
510    }
511
512    #[test]
513    fn test_log_format_clone() {
514        fn assert_clone<T: Clone>() {}
515        assert_clone::<LogFormat>();
516    }
517
518    #[test]
519    fn test_log_format_debug() {
520        let format = LogFormat::Pretty;
521        let debug = format!("{:?}", format);
522        assert!(debug.contains("Pretty"));
523    }
524
525    #[test]
526    fn test_log_format_parse_whitespace() {
527        assert_eq!(LogFormat::parse("  pretty  "), Some(LogFormat::Pretty));
528        assert_eq!(LogFormat::parse("\tjson\t"), Some(LogFormat::Json));
529        assert_eq!(LogFormat::parse(" compact "), Some(LogFormat::Compact));
530    }
531
532    #[test]
533    fn test_log_format_parse_mixed_case() {
534        assert_eq!(LogFormat::parse("PRETTY"), Some(LogFormat::Pretty));
535        assert_eq!(LogFormat::parse("JsOn"), Some(LogFormat::Json));
536        assert_eq!(LogFormat::parse("cOmPaCt"), Some(LogFormat::Compact));
537    }
538
539    #[test]
540    fn test_log_format_parse_empty() {
541        assert_eq!(LogFormat::parse(""), None);
542        assert_eq!(LogFormat::parse("   "), None);
543    }
544
545    #[test]
546    fn test_is_valid_level_all_valid() {
547        assert!(is_valid_level("trace"));
548        assert!(is_valid_level("debug"));
549        assert!(is_valid_level("info"));
550        assert!(is_valid_level("warn"));
551        assert!(is_valid_level("error"));
552        assert!(is_valid_level("off"));
553    }
554
555    #[test]
556    fn test_is_valid_level_invalid() {
557        assert!(!is_valid_level(""));
558        assert!(!is_valid_level("DEBUG")); // Case sensitive
559        assert!(!is_valid_level("warning"));
560        assert!(!is_valid_level("fatal"));
561        assert!(!is_valid_level("verbose"));
562    }
563
564    #[test]
565    fn test_parse_target_overrides_empty() {
566        let targets = parse_target_overrides("");
567        assert!(targets.is_empty());
568    }
569
570    #[test]
571    fn test_parse_target_overrides_whitespace_only() {
572        let targets = parse_target_overrides("   ,  ,  ");
573        assert!(targets.is_empty());
574    }
575
576    #[test]
577    fn test_parse_target_overrides_single_entry() {
578        let targets = parse_target_overrides("my_crate=debug");
579        assert_eq!(targets.len(), 1);
580        assert_eq!(targets.get("my_crate"), Some(&"debug".to_string()));
581    }
582
583    #[test]
584    fn test_parse_target_overrides_multiple_entries() {
585        let targets = parse_target_overrides("a=trace,b=debug,c=info,d=warn,e=error,f=off");
586        assert_eq!(targets.len(), 6);
587        assert_eq!(targets.get("a"), Some(&"trace".to_string()));
588        assert_eq!(targets.get("b"), Some(&"debug".to_string()));
589        assert_eq!(targets.get("c"), Some(&"info".to_string()));
590        assert_eq!(targets.get("d"), Some(&"warn".to_string()));
591        assert_eq!(targets.get("e"), Some(&"error".to_string()));
592        assert_eq!(targets.get("f"), Some(&"off".to_string()));
593    }
594
595    #[test]
596    fn test_parse_target_overrides_empty_target() {
597        let targets = parse_target_overrides("=debug");
598        assert!(targets.is_empty());
599    }
600
601    #[test]
602    fn test_parse_target_overrides_no_equals() {
603        let targets = parse_target_overrides("nodebug");
604        assert!(targets.is_empty());
605    }
606
607    #[test]
608    fn test_parse_target_overrides_duplicate_target() {
609        // Later entry should win (BTreeMap behavior)
610        let targets = parse_target_overrides("crate=debug,crate=warn");
611        assert_eq!(targets.len(), 1);
612        assert_eq!(targets.get("crate"), Some(&"warn".to_string()));
613    }
614
615    #[test]
616    fn test_log_config_clone() {
617        let mut config = LogConfig {
618            level: "debug".to_string(),
619            format: LogFormat::Json,
620            file_path: Some(PathBuf::from("/tmp/test.log")),
621            ..LogConfig::default()
622        };
623        config.targets.insert("a".to_string(), "trace".to_string());
624
625        let cloned = config.clone();
626        assert_eq!(config.level, cloned.level);
627        assert_eq!(config.format, cloned.format);
628        assert_eq!(config.file_path, cloned.file_path);
629        assert_eq!(config.targets, cloned.targets);
630    }
631
632    #[test]
633    fn test_log_config_debug() {
634        let config = LogConfig::default();
635        let debug = format!("{:?}", config);
636        assert!(debug.contains("LogConfig"));
637        assert!(debug.contains("info"));
638    }
639
640    #[test]
641    fn test_env_filter_no_targets() {
642        let config = LogConfig {
643            level: "warn".to_string(),
644            ..LogConfig::default()
645        };
646        let filter = config.env_filter();
647        let filter_str = format!("{filter}");
648        assert!(filter_str.contains("warn"));
649    }
650
651    #[test]
652    fn test_env_filter_multiple_targets() {
653        let mut config = LogConfig {
654            level: "error".to_string(),
655            ..LogConfig::default()
656        };
657        config
658            .targets
659            .insert("mod_a".to_string(), "debug".to_string());
660        config
661            .targets
662            .insert("mod_b".to_string(), "trace".to_string());
663        let filter = config.env_filter();
664        let filter_str = format!("{filter}");
665        assert!(filter_str.contains("error"));
666        assert!(filter_str.contains("mod_a=debug"));
667        assert!(filter_str.contains("mod_b=trace"));
668    }
669}