Skip to main content

aster_forge_logging/
lib.rs

1//! Shared tracing subscriber setup for Aster services.
2//!
3//! This crate provides the complete logging initialization behavior used by the application
4//! repositories: stdout or file output, optional daily rotation, bounded retained log files,
5//! `RUST_LOG` precedence over configured levels, text or JSON formatting, debug-build file and line
6//! annotations, and a non-blocking writer guard. Applications can use [`LoggingConfig`] directly
7//! in their deployment configuration schema.
8#![cfg_attr(
9    not(test),
10    deny(
11        clippy::unwrap_used,
12        clippy::unreachable,
13        clippy::expect_used,
14        clippy::panic,
15        clippy::unimplemented,
16        clippy::todo
17    )
18)]
19
20use std::ffi::OsStr;
21use std::io::Write;
22use std::path::Path;
23
24use tracing_appender::non_blocking::WorkerGuard;
25use tracing_appender::rolling;
26use tracing_subscriber::EnvFilter;
27
28const DEFAULT_TEXT_FORMAT: &str = "text";
29const DEFAULT_JSON_FORMAT: &str = "json";
30const DEFAULT_LOG_FILENAME: &str = "aster.log";
31
32/// Logging initialization options.
33#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
34pub struct LoggingConfig {
35    /// Tracing filter directive used when `RUST_LOG` is not set.
36    #[serde(default = "LoggingConfig::default_level")]
37    pub level: String,
38    /// Output format. `"json"` enables JSON logs; every other value uses text formatting.
39    #[serde(default = "LoggingConfig::default_format")]
40    pub format: String,
41    /// Log file path. Empty values write to stdout.
42    #[serde(default)]
43    pub file: String,
44    /// Enables daily rolling files when `file` is non-empty.
45    #[serde(default = "LoggingConfig::default_enable_rotation")]
46    pub enable_rotation: bool,
47    /// Maximum number of rotated log files to retain.
48    #[serde(default = "LoggingConfig::default_max_backups")]
49    pub max_backups: u32,
50}
51
52impl Default for LoggingConfig {
53    fn default() -> Self {
54        Self {
55            level: Self::default_level(),
56            format: Self::default_format(),
57            file: String::new(),
58            enable_rotation: Self::default_enable_rotation(),
59            max_backups: Self::default_max_backups(),
60        }
61    }
62}
63
64impl LoggingConfig {
65    fn default_level() -> String {
66        "info".to_string()
67    }
68
69    fn default_format() -> String {
70        DEFAULT_TEXT_FORMAT.to_string()
71    }
72
73    const fn default_enable_rotation() -> bool {
74        true
75    }
76
77    const fn default_max_backups() -> u32 {
78        5
79    }
80}
81
82/// Result returned after installing the tracing subscriber.
83pub struct LoggingInitResult {
84    /// Guard that keeps the non-blocking logging worker alive.
85    pub guard: WorkerGuard,
86    /// Startup warnings produced while selecting writer or filter settings.
87    pub warning: Option<String>,
88}
89
90/// Initializes global tracing subscriber using Aster's standard runtime logging behavior.
91///
92/// File writer failures fall back to stdout and are reported through [`LoggingInitResult::warning`].
93/// `RUST_LOG` takes precedence over [`LoggingConfig::level`] and also produces a warning so startup
94/// logs can surface which setting actually won; an invalid `RUST_LOG` value warns and falls back to
95/// the configured level instead of being silently ignored. Installing a second global subscriber
96/// (embedded runtimes, shared test processes) keeps the existing subscriber and warns rather than
97/// panicking.
98pub fn init_logging(config: &LoggingConfig) -> LoggingInitResult {
99    let (writer, warning) = build_writer(config);
100    let (non_blocking_writer, guard) = tracing_appender::non_blocking(writer);
101
102    let mut warning = warning;
103    let filter = build_filter(&config.level, &mut warning);
104    let is_stdout = config.file.is_empty();
105
106    let builder = tracing_subscriber::fmt()
107        .with_writer(non_blocking_writer)
108        .with_env_filter(filter)
109        .with_level(true)
110        .with_ansi(is_stdout);
111
112    #[cfg(debug_assertions)]
113    let builder = builder.with_file(true).with_line_number(true);
114
115    let init_result = if config.format == DEFAULT_JSON_FORMAT {
116        builder.json().try_init()
117    } else {
118        builder.try_init()
119    };
120    if let Err(error) = init_result {
121        // A global subscriber already exists (embedded runtimes, tests sharing
122        // a process). Panicking would break the crate's graceful-degradation
123        // contract, so degrade to a startup warning like the other fallbacks.
124        push_warning(
125            &mut warning,
126            format!(
127                "Global tracing subscriber was already installed; keeping the existing subscriber: {error}"
128            ),
129        );
130    }
131
132    LoggingInitResult { guard, warning }
133}
134
135fn build_writer(config: &LoggingConfig) -> (Box<dyn Write + Send + Sync>, Option<String>) {
136    if config.file.is_empty() {
137        return (Box::new(std::io::stdout()), None);
138    }
139
140    if config.enable_rotation {
141        build_rolling_writer(config)
142    } else {
143        build_file_writer(&config.file)
144    }
145}
146
147fn build_rolling_writer(config: &LoggingConfig) -> (Box<dyn Write + Send + Sync>, Option<String>) {
148    let path = Path::new(&config.file);
149    let dir = path.parent().unwrap_or_else(|| Path::new("."));
150    let filename = path
151        .file_name()
152        .unwrap_or_else(|| OsStr::new(DEFAULT_LOG_FILENAME));
153    let filename = filename.to_str().unwrap_or(DEFAULT_LOG_FILENAME);
154    let max_log_files = usize::try_from(config.max_backups).unwrap_or(usize::MAX);
155
156    match rolling::Builder::new()
157        .rotation(rolling::Rotation::DAILY)
158        .filename_prefix(filename.trim_end_matches(".log"))
159        .filename_suffix("log")
160        .max_log_files(max_log_files)
161        .build(dir)
162    {
163        Ok(appender) => (Box::new(appender), None),
164        Err(error) => (
165            Box::new(std::io::stdout()),
166            Some(format!(
167                "Failed to create rolling log appender for '{}': {}. Falling back to stdout.",
168                config.file, error
169            )),
170        ),
171    }
172}
173
174fn build_file_writer(file: &str) -> (Box<dyn Write + Send + Sync>, Option<String>) {
175    match std::fs::OpenOptions::new()
176        .create(true)
177        .append(true)
178        .open(file)
179    {
180        Ok(file) => (Box::new(file), None),
181        Err(error) => (
182            Box::new(std::io::stdout()),
183            Some(format!(
184                "Failed to open log file '{}': {}. Falling back to stdout.",
185                file, error
186            )),
187        ),
188    }
189}
190
191fn build_filter(level: &str, warning: &mut Option<String>) -> EnvFilter {
192    // Probe RUST_LOG explicitly: `EnvFilter::try_from_default_env` cannot
193    // distinguish "unset" from "invalid", and silently falling back to the
194    // config level would leave operators believing their override worked.
195    match std::env::var("RUST_LOG") {
196        Ok(value) => match EnvFilter::try_new(&value) {
197            Ok(filter) => {
198                push_warning(
199                    warning,
200                    format!(
201                        "RUST_LOG environment variable detected; config.toml logging.level='{level}' is overridden by RUST_LOG"
202                    ),
203                );
204                filter
205            }
206            Err(error) => {
207                push_warning(
208                    warning,
209                    format!(
210                        "Invalid RUST_LOG value '{value}': {error}. Falling back to logging.level='{level}'."
211                    ),
212                );
213                build_config_filter(level, warning)
214            }
215        },
216        Err(std::env::VarError::NotPresent) => build_config_filter(level, warning),
217        Err(std::env::VarError::NotUnicode(value)) => {
218            push_warning(
219                warning,
220                format!(
221                    "Invalid RUST_LOG value '{}': not valid Unicode. Falling back to logging.level='{level}'.",
222                    value.to_string_lossy()
223                ),
224            );
225            build_config_filter(level, warning)
226        }
227    }
228}
229
230fn build_config_filter(level: &str, warning: &mut Option<String>) -> EnvFilter {
231    match EnvFilter::try_new(level) {
232        Ok(filter) => filter,
233        Err(error) => {
234            push_warning(
235                warning,
236                format!("Invalid logging.level '{level}': {error}. Falling back to 'info'."),
237            );
238            EnvFilter::new("info")
239        }
240    }
241}
242
243fn push_warning(warning: &mut Option<String>, message: String) {
244    if let Some(existing) = warning.as_mut() {
245        existing.push(' ');
246        existing.push_str(&message);
247    } else {
248        *warning = Some(message);
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::{
255        DEFAULT_JSON_FORMAT, LoggingConfig, build_file_writer, build_filter, build_rolling_writer,
256        build_writer, init_logging,
257    };
258    use aster_forge_test::temp::TestTempDir;
259    use std::io::Write;
260
261    #[test]
262    fn build_writer_uses_stdout_for_empty_file() {
263        let (_writer, warning) = build_writer(&LoggingConfig {
264            file: String::new(),
265            ..LoggingConfig::default()
266        });
267
268        assert!(warning.is_none());
269    }
270
271    #[test]
272    fn logging_config_deserializes_missing_fields_with_defaults() {
273        let config: LoggingConfig =
274            serde_json::from_str("{}").expect("empty logging config should use field defaults");
275
276        assert_eq!(config, LoggingConfig::default());
277    }
278
279    #[test]
280    fn build_writer_reports_invalid_file_and_falls_back_to_stdout() {
281        let directory = TestTempDir::new("logging-invalid-file");
282        let parent_file = directory.join("not-a-directory");
283        std::fs::write(&parent_file, "fixture").expect("parent fixture should be writable");
284        let (_writer, warning) = build_writer(&LoggingConfig {
285            file: parent_file.join("aster.log").to_string_lossy().into_owned(),
286            enable_rotation: false,
287            ..LoggingConfig::default()
288        });
289
290        let warning = warning.expect("invalid file path should report warning");
291        assert!(warning.contains("Failed to open log file"));
292        assert!(warning.contains("Falling back to stdout"));
293    }
294
295    #[test]
296    fn build_file_writer_creates_file_and_appends_bytes() {
297        let directory = TestTempDir::new("logging-file-writer");
298        let path = directory.join("logs").join("aster.log");
299        std::fs::create_dir_all(path.parent().expect("test path has parent"))
300            .expect("fixture parent should be created");
301
302        let (mut writer, warning) =
303            build_file_writer(path.to_str().expect("test path should be utf-8"));
304        assert!(warning.is_none());
305        writer
306            .write_all(b"first line\n")
307            .expect("file writer should accept first write");
308        drop(writer);
309
310        let (mut writer, warning) =
311            build_file_writer(path.to_str().expect("test path should be utf-8"));
312        assert!(warning.is_none());
313        writer
314            .write_all(b"second line\n")
315            .expect("file writer should append second write");
316        drop(writer);
317
318        let contents =
319            std::fs::read_to_string(&path).expect("file writer output should be readable");
320        assert_eq!(contents, "first line\nsecond line\n");
321    }
322
323    #[test]
324    fn build_rolling_writer_creates_daily_appender_for_valid_directory() {
325        let directory = TestTempDir::new("logging-rolling-writer");
326        let file = directory.join("service.log");
327
328        let (mut writer, warning) = build_rolling_writer(&LoggingConfig {
329            file: file.to_string_lossy().into_owned(),
330            max_backups: 2,
331            ..LoggingConfig::default()
332        });
333
334        assert!(warning.is_none());
335        writer
336            .write_all(b"rolling line\n")
337            .expect("rolling writer should accept writes");
338    }
339
340    #[test]
341    fn build_rolling_writer_reports_invalid_directory_and_falls_back_to_stdout() {
342        let directory = TestTempDir::new("logging-invalid-rolling-directory");
343        let parent_file = directory.join("not-a-directory");
344        std::fs::write(&parent_file, "not a directory")
345            .expect("parent-file fixture should be writable");
346        let file = parent_file.join("aster.log");
347
348        let (_writer, warning) = build_rolling_writer(&LoggingConfig {
349            file: file.to_string_lossy().into_owned(),
350            ..LoggingConfig::default()
351        });
352
353        let warning = warning.expect("invalid rolling log path should report warning");
354        assert!(warning.contains("Failed to create rolling log appender"));
355        assert!(warning.contains("Falling back to stdout"));
356    }
357
358    #[test]
359    fn build_filter_reports_invalid_level_warning() {
360        let mut warning = None;
361        let _filter = build_filter("aster=not-a-level", &mut warning);
362
363        let warning = warning.expect("invalid level should report warning");
364        assert!(
365            warning.contains("Invalid logging.level")
366                || warning.contains("RUST_LOG environment variable detected"),
367            "{warning}"
368        );
369    }
370
371    #[test]
372    fn init_logging_warns_instead_of_panicking_when_subscriber_already_installed() {
373        // The first install wins (this is the only in-process global init in
374        // the test binary; the other init test uses a child process).
375        let _first = init_logging(&LoggingConfig::default());
376
377        let second = init_logging(&LoggingConfig::default());
378        let warning = second
379            .warning
380            .expect("re-initializing should report a warning instead of panicking");
381        assert!(
382            warning.contains("already installed"),
383            "unexpected warning: {warning}"
384        );
385    }
386
387    #[test]
388    fn build_filter_warns_when_rust_log_is_invalid_in_child_process() {
389        if std::env::var("ASTER_FORGE_LOGGING_FILTER_CHILD").is_ok() {
390            run_build_filter_child();
391            return;
392        }
393
394        let current_exe = std::env::current_exe().expect("current test executable should resolve");
395        let output = std::process::Command::new(current_exe)
396            .arg("--exact")
397            .arg("tests::build_filter_warns_when_rust_log_is_invalid_in_child_process")
398            .arg("--nocapture")
399            .env("ASTER_FORGE_LOGGING_FILTER_CHILD", "1")
400            .env("RUST_LOG", "aster=not-a-level")
401            .output()
402            .expect("build_filter child process should run");
403
404        assert!(
405            output.status.success(),
406            "child process failed\nstdout:\n{}\nstderr:\n{}",
407            String::from_utf8_lossy(&output.stdout),
408            String::from_utf8_lossy(&output.stderr)
409        );
410    }
411
412    fn run_build_filter_child() {
413        let mut warning = None;
414        let _filter = build_filter("debug", &mut warning);
415
416        let warning = warning.expect("invalid RUST_LOG should report warning");
417        assert!(
418            warning.contains("Invalid RUST_LOG"),
419            "unexpected warning: {warning}"
420        );
421        assert!(
422            warning.contains("logging.level='debug'"),
423            "fallback should name the config level: {warning}"
424        );
425    }
426
427    #[test]
428    fn init_logging_can_initialize_global_subscriber_in_child_process() {
429        if std::env::var("ASTER_FORGE_LOGGING_INIT_CHILD").is_ok() {
430            run_init_logging_child();
431            return;
432        }
433
434        let current_exe = std::env::current_exe().expect("current test executable should resolve");
435        let output = std::process::Command::new(current_exe)
436            .arg("--exact")
437            .arg("tests::init_logging_can_initialize_global_subscriber_in_child_process")
438            .arg("--nocapture")
439            .env("ASTER_FORGE_LOGGING_INIT_CHILD", "1")
440            .env_remove("RUST_LOG")
441            .output()
442            .expect("init_logging child process should run");
443
444        assert!(
445            output.status.success(),
446            "child process failed\nstdout:\n{}\nstderr:\n{}",
447            String::from_utf8_lossy(&output.stdout),
448            String::from_utf8_lossy(&output.stderr)
449        );
450    }
451
452    fn run_init_logging_child() {
453        let directory = TestTempDir::new("logging-init-child");
454        let log_path = directory.join("logs").join("aster.log");
455        std::fs::create_dir_all(log_path.parent().expect("test path has parent"))
456            .expect("fixture parent should be created");
457
458        let result = init_logging(&LoggingConfig {
459            level: "aster=not-a-level".to_string(),
460            format: DEFAULT_JSON_FORMAT.to_string(),
461            file: log_path.to_string_lossy().into_owned(),
462            enable_rotation: false,
463            max_backups: 1,
464        });
465
466        let warning = result
467            .warning
468            .expect("invalid logging level should report startup warning");
469        assert!(warning.contains("Invalid logging.level"));
470        drop(result.guard);
471    }
472}