Skip to main content

bhtune_runtime/
logging.rs

1//! Shared structured application logging, matching `opcda-bridge-gateway`'s own `tracing`
2//! stack and `log.*` configuration conventions (level/directory/format/rotation, resolved
3//! with the same `CLI flag > env var > config file > default` precedence as every other
4//! bhtune setting) -- see `crate::config::LogConfig`/`resolve_log_settings`.
5//!
6//! **Deliberately never writes to stdout.** `bhtune tune`/`simulate --output json` prints a
7//! single machine-readable JSON object to stdout as its whole documented contract; mirroring
8//! diagnostic log lines onto that same stream
9//! (as `opcda-bridge-gateway`'s equivalent does, safely, since it owns stdout outright) would
10//! risk interleaving free-form log text into a stream a scheduler parses as JSON. Log lines
11//! go to the rotating file always, and to **stderr** (never stdout) when a console is
12//! attached -- stderr can never corrupt stdout's contract, so mirroring there is free.
13//!
14//! This is diagnostic/operational logging only: CLI summaries and `history`/`export` output
15//! stay in the adapter's command code and are unaffected by this module.
16
17use std::path::{Path, PathBuf};
18use tracing_appender::non_blocking::WorkerGuard;
19use tracing_appender::rolling::{RollingFileAppender, Rotation};
20use tracing_subscriber::EnvFilter;
21
22use crate::config::LogConfig;
23
24/// Log file format.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum LogFormat {
27    /// Human-readable, ANSI-free (log files aren't a terminal).
28    Pretty,
29    /// Newline-delimited JSON, for log shippers.
30    Json,
31}
32
33/// Parse the configured log format. Defaults to `Pretty` for `None` or any unrecognized
34/// value -- a config typo in `log.format` should degrade gracefully rather than stop a tune
35/// from running.
36pub fn parse_log_format(value: Option<&str>) -> LogFormat {
37    match value {
38        Some(v) if v.eq_ignore_ascii_case("json") => LogFormat::Json,
39        _ => LogFormat::Pretty,
40    }
41}
42
43/// Parse the configured rotation policy. Defaults to `DAILY` for `None` or any unrecognized
44/// value, for the same reason as [`parse_log_format`].
45pub fn parse_rotation(value: Option<&str>) -> Rotation {
46    match value {
47        Some(v) if v.eq_ignore_ascii_case("hourly") => Rotation::HOURLY,
48        Some(v) if v.eq_ignore_ascii_case("never") => Rotation::NEVER,
49        _ => Rotation::DAILY,
50    }
51}
52
53/// Build an `EnvFilter` from an explicit level/directive spec (e.g. `"debug"` or
54/// `"bhtune_cli=debug,bhtune_runtime=debug,sqlx=warn"`), falling back to `info` if `level` is
55/// absent or fails to parse. Logging misconfiguration should degrade gracefully rather than
56/// stop a tune from running.
57pub fn build_env_filter(level: Option<&str>) -> EnvFilter {
58    level
59        .and_then(|spec| EnvFilter::try_new(spec).ok())
60        .unwrap_or_else(|| EnvFilter::new("info"))
61}
62
63/// Resolved logging settings, after applying `CLI flag > env var > config file > default`
64/// precedence to each individual field.
65#[derive(Debug, Clone, PartialEq)]
66pub struct LogSettings {
67    pub level: Option<String>,
68    pub dir: PathBuf,
69    pub format: LogFormat,
70    pub rotation: Rotation,
71}
72
73/// Resolve every logging setting. `cli_level` already has `RUST_LOG` folded in by clap's
74/// `env` attribute on `Cli::log_level`; `dir`/`format`/`rotation` have no env var, matching
75/// the rest of the config surface (see `crate::config`).
76pub fn resolve_log_settings(
77    cli_level: Option<String>,
78    cli_dir: Option<PathBuf>,
79    cli_format: Option<String>,
80    cli_rotation: Option<String>,
81    config: &LogConfig,
82    default_dir: &Path,
83) -> LogSettings {
84    let level = cli_level.or_else(|| config.level.clone());
85    let dir = cli_dir
86        .or_else(|| config.dir.clone().map(PathBuf::from))
87        .unwrap_or_else(|| default_dir.to_path_buf());
88    let format = parse_log_format(cli_format.as_deref().or(config.format.as_deref()));
89    let rotation = parse_rotation(cli_rotation.as_deref().or(config.rotation.as_deref()));
90    LogSettings {
91        level,
92        dir,
93        format,
94        rotation,
95    }
96}
97
98/// Initialize the process-global tracing subscriber: a non-blocking rolling file writer
99/// under `settings.dir`, plus a stderr writer when a console is actually attached (a
100/// scheduled/cron invocation typically has none). Never touches stdout -- see the module
101/// doc comment.
102///
103/// Returns the `WorkerGuard`, which the caller **must** hold for the process lifetime --
104/// dropping it early silently truncates buffered log lines that haven't yet been flushed to
105/// disk on exit. A logging setup failure (e.g. an unwritable log directory) is not fatal to
106/// the CLI's command or the server's primary service, so their startup paths treat the
107/// returned `Err` as best-effort rather than propagating it.
108pub fn init_tracing(settings: &LogSettings) -> anyhow::Result<WorkerGuard> {
109    use std::io::IsTerminal;
110    init_tracing_with_stderr(settings, std::io::stderr().is_terminal())
111}
112
113/// Same as [`init_tracing`], but with "is a console attached" passed in explicitly rather
114/// than detected, so tests can exercise both the stderr-attached and stderr-detached layer
115/// wiring deterministically.
116fn init_tracing_with_stderr(
117    settings: &LogSettings,
118    attach_stderr: bool,
119) -> anyhow::Result<WorkerGuard> {
120    use tracing_subscriber::Layer;
121    use tracing_subscriber::layer::SubscriberExt;
122    use tracing_subscriber::util::SubscriberInitExt;
123
124    std::fs::create_dir_all(&settings.dir)?;
125    let file_appender =
126        RollingFileAppender::new(settings.rotation.clone(), &settings.dir, "bhtune.log");
127    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
128    let filter = build_env_filter(settings.level.as_deref());
129
130    let file_layer = match settings.format {
131        LogFormat::Json => tracing_subscriber::fmt::layer()
132            .json()
133            .with_writer(non_blocking)
134            .boxed(),
135        LogFormat::Pretty => tracing_subscriber::fmt::layer()
136            .with_ansi(false)
137            .with_writer(non_blocking)
138            .boxed(),
139    };
140    let stderr_layer =
141        attach_stderr.then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr));
142
143    tracing_subscriber::registry()
144        .with(filter)
145        .with(file_layer)
146        .with(stderr_layer)
147        .try_init()?;
148    Ok(guard)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn parse_log_format_json() {
157        assert_eq!(parse_log_format(Some("json")), LogFormat::Json);
158        assert_eq!(parse_log_format(Some("JSON")), LogFormat::Json);
159    }
160
161    #[test]
162    fn parse_log_format_pretty() {
163        assert_eq!(parse_log_format(Some("pretty")), LogFormat::Pretty);
164    }
165
166    #[test]
167    fn parse_log_format_unknown_defaults_to_pretty() {
168        assert_eq!(parse_log_format(Some("yaml")), LogFormat::Pretty);
169    }
170
171    #[test]
172    fn parse_log_format_none_defaults_to_pretty() {
173        assert_eq!(parse_log_format(None), LogFormat::Pretty);
174    }
175
176    #[test]
177    fn parse_rotation_hourly() {
178        assert_eq!(parse_rotation(Some("hourly")), Rotation::HOURLY);
179        assert_eq!(parse_rotation(Some("HOURLY")), Rotation::HOURLY);
180    }
181
182    #[test]
183    fn parse_rotation_never() {
184        assert_eq!(parse_rotation(Some("never")), Rotation::NEVER);
185    }
186
187    #[test]
188    fn parse_rotation_daily() {
189        assert_eq!(parse_rotation(Some("daily")), Rotation::DAILY);
190    }
191
192    #[test]
193    fn parse_rotation_unknown_defaults_to_daily() {
194        assert_eq!(parse_rotation(Some("weekly")), Rotation::DAILY);
195    }
196
197    #[test]
198    fn parse_rotation_none_defaults_to_daily() {
199        assert_eq!(parse_rotation(None), Rotation::DAILY);
200    }
201
202    #[test]
203    fn build_env_filter_explicit_level() {
204        // `EnvFilter` has no public equality check, so assert indirectly via Debug output,
205        // which includes the directive spec.
206        let filter = build_env_filter(Some("debug"));
207        assert!(format!("{filter}").contains("debug"));
208    }
209
210    #[test]
211    fn build_env_filter_invalid_falls_back_to_info() {
212        // "level=notalevel" isn't one of the recognized level names/numbers, so this is a
213        // genuine parse failure (unlike e.g. "not a valid directive!!", which `EnvFilter`
214        // happily accepts as a target-name filter with an implicit "trace" level).
215        let filter = build_env_filter(Some("level=notalevel"));
216        assert!(format!("{filter}").contains("info"));
217    }
218
219    #[test]
220    fn build_env_filter_none_defaults_to_info() {
221        let filter = build_env_filter(None);
222        assert!(format!("{filter}").contains("info"));
223    }
224
225    #[test]
226    fn resolve_log_settings_cli_wins() {
227        let config = LogConfig {
228            level: Some("warn".to_string()),
229            dir: Some("/config/dir".to_string()),
230            format: Some("json".to_string()),
231            rotation: Some("hourly".to_string()),
232        };
233        let settings = resolve_log_settings(
234            Some("debug".to_string()),
235            Some(PathBuf::from("/cli/dir")),
236            Some("pretty".to_string()),
237            Some("never".to_string()),
238            &config,
239            Path::new("/default/dir"),
240        );
241        assert_eq!(settings.level, Some("debug".to_string()));
242        assert_eq!(settings.dir, PathBuf::from("/cli/dir"));
243        assert_eq!(settings.format, LogFormat::Pretty);
244        assert_eq!(settings.rotation, Rotation::NEVER);
245    }
246
247    #[test]
248    fn resolve_log_settings_config_wins_over_default() {
249        let config = LogConfig {
250            level: Some("warn".to_string()),
251            dir: Some("/config/dir".to_string()),
252            format: Some("json".to_string()),
253            rotation: Some("hourly".to_string()),
254        };
255        let settings =
256            resolve_log_settings(None, None, None, None, &config, Path::new("/default/dir"));
257        assert_eq!(settings.level, Some("warn".to_string()));
258        assert_eq!(settings.dir, PathBuf::from("/config/dir"));
259        assert_eq!(settings.format, LogFormat::Json);
260        assert_eq!(settings.rotation, Rotation::HOURLY);
261    }
262
263    #[test]
264    fn resolve_log_settings_defaults() {
265        let settings = resolve_log_settings(
266            None,
267            None,
268            None,
269            None,
270            &LogConfig::default(),
271            Path::new("/default/dir"),
272        );
273        assert_eq!(settings.level, None);
274        assert_eq!(settings.dir, PathBuf::from("/default/dir"));
275        assert_eq!(settings.format, LogFormat::Pretty);
276        assert_eq!(settings.rotation, Rotation::DAILY);
277    }
278
279    // `tracing_subscriber`'s global subscriber can only be installed once per process, and
280    // `cargo test` runs every unit test in this crate in one shared process across multiple
281    // threads. Exactly one call to `try_init()` anywhere in this binary can succeed; every
282    // other call (in this module or any other) observes an error. `run_with_cli` (unlike
283    // `opcda-bridge-gateway`'s `run_gateway`) deliberately never calls `init_tracing` itself
284    // -- only `run()` does, which has no direct unit test of its own (see `lib.rs`) -- so
285    // these are the *only* in-process calls to `init_tracing`/`init_tracing_with_stderr` in
286    // the whole `cargo test` binary. The tests below therefore never assert `Ok`/`Err` on the
287    // *outcome* of installing -- only that every line up to and including that call actually
288    // runs, which is all the 100%-line-coverage gate requires.
289
290    #[test]
291    fn init_tracing_with_stderr_covers_json_and_pretty_layers() {
292        let dir = tempfile::tempdir().unwrap();
293        // "off" rather than e.g. "debug": whichever of these calls wins the one-per-process
294        // global-install race stays installed for the rest of this shared test binary's run,
295        // so a permissive level here would otherwise leak unrelated crates' (e.g. `sqlx`'s)
296        // own debug/info-level tracing spans onto stderr for every later test -- harmless to
297        // correctness, but noisy. "off" exercises the exact same layer-construction code
298        // paths while guaranteeing this test can never become a noisy winner.
299        let json_settings = LogSettings {
300            level: Some("off".to_string()),
301            dir: dir.path().to_path_buf(),
302            format: LogFormat::Json,
303            rotation: Rotation::NEVER,
304        };
305        let pretty_settings = LogSettings {
306            level: Some("off".to_string()),
307            dir: dir.path().to_path_buf(),
308            format: LogFormat::Pretty,
309            rotation: Rotation::DAILY,
310        };
311        // Exercise both the JSON+stderr-attached and Pretty+stderr-detached combinations so
312        // every layer-construction branch runs regardless of which call (if any) wins the
313        // global-install race.
314        let _ = init_tracing_with_stderr(&json_settings, true);
315        let _ = init_tracing_with_stderr(&pretty_settings, false);
316    }
317
318    #[test]
319    fn init_tracing_wrapper_detects_terminal() {
320        let dir = tempfile::tempdir().unwrap();
321        // See the "off" rationale on `init_tracing_with_stderr_covers_json_and_pretty_layers`.
322        let settings = LogSettings {
323            level: Some("off".to_string()),
324            dir: dir.path().to_path_buf(),
325            format: LogFormat::Pretty,
326            rotation: Rotation::NEVER,
327        };
328        let _ = init_tracing(&settings);
329    }
330}