Skip to main content

pmcore/
logs.rs

1use std::fs::{create_dir_all, OpenOptions};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::OnceLock;
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result};
8use tracing::Level;
9use tracing_subscriber::fmt;
10use tracing_subscriber::fmt::format::Writer;
11use tracing_subscriber::fmt::time::FormatTime;
12use tracing_subscriber::layer::SubscriberExt;
13use tracing_subscriber::util::SubscriberInitExt;
14use tracing_subscriber::{EnvFilter, Layer, Registry};
15
16/// Default directives appended to the level filter.
17///
18/// `diffsol` is silenced because it is very verbose at `INFO` and below and
19/// rarely useful for end users of PMcore.
20const DEFAULT_DIRECTIVES: &str = "diffsol=off";
21
22/// Builder for an opinionated `tracing` subscriber.
23///
24/// By default the subscriber writes `INFO` level events to stdout and does
25/// not write to a file. Each log line is prefixed with the elapsed wall-clock
26/// time since the subscriber was initialized, formatted as `HHh MMm SSs`. This
27/// column can be restarted at any time with [`Logger::reset_time`].
28#[derive(Debug, Clone)]
29pub struct Logger {
30    level: Level,
31    stdout: bool,
32    file: Option<PathBuf>,
33    extra_directives: Option<String>,
34}
35
36impl Default for Logger {
37    fn default() -> Self {
38        Self {
39            level: Level::INFO,
40            stdout: true,
41            file: None,
42            extra_directives: Some(DEFAULT_DIRECTIVES.to_string()),
43        }
44    }
45}
46
47impl Logger {
48    /// Create a new [`Logger`] builder with default settings (stdout at `INFO`).
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Set the minimum level of events to record.
54    pub fn level(mut self, level: Level) -> Self {
55        self.level = level;
56        self
57    }
58
59    /// Enable or disable the stdout layer. Enabled by default.
60    pub fn stdout(mut self, enable: bool) -> Self {
61        self.stdout = enable;
62        self
63    }
64
65    /// Write logs to the given file path. Parent directories are created as
66    /// needed and the file is truncated on open.
67    pub fn file<P: AsRef<Path>>(mut self, path: P) -> Self {
68        self.file = Some(path.as_ref().to_path_buf());
69        self
70    }
71
72    /// Disable writing logs to a file.
73    pub fn no_file(mut self) -> Self {
74        self.file = None;
75        self
76    }
77
78    /// Override the additional `EnvFilter` directives appended after the level.
79    ///
80    /// Defaults to `"diffsol=off"`. Pass an empty string to disable the
81    /// default directives entirely. The `RUST_LOG` environment variable, if
82    /// set, takes precedence over the configured level and directives.
83    pub fn directives(mut self, directives: impl Into<String>) -> Self {
84        let directives = directives.into();
85        self.extra_directives = if directives.is_empty() {
86            None
87        } else {
88            Some(directives)
89        };
90        self
91    }
92
93    /// Install the configured subscriber as the global default.
94    ///
95    /// Returns an error if a log file is configured but cannot be opened.
96    /// Silently does nothing if a global subscriber is already installed,
97    /// which makes the subscriber safe to reuse across back-to-back runs.
98    pub fn init(self) -> Result<()> {
99        let env_filter = self.build_env_filter();
100
101        // Anchor the process-start instant now so the elapsed-time column is
102        // measured from initialization. Every layer shares the same epoch.
103        let _ = process_start();
104        let timer = ElapsedTime;
105
106        let file_layer = match self.file.as_deref() {
107            Some(path) => Some(open_file_layer(path, timer)?),
108            None => None,
109        };
110
111        let stdout_layer = if self.stdout {
112            Some(
113                fmt::layer()
114                    .with_writer(std::io::stdout)
115                    .with_ansi(true)
116                    .with_target(false)
117                    .with_timer(timer)
118                    .boxed(),
119            )
120        } else {
121            None
122        };
123
124        let _ = Registry::default()
125            .with(env_filter)
126            .with(file_layer)
127            .with(stdout_layer)
128            .try_init();
129
130        Ok(())
131    }
132
133    /// Restart the elapsed-time column so subsequent log lines count from now.
134    ///
135    /// The leading `HHh MMm SSs` column is process-wide and shared by every
136    /// layer of the installed subscriber. Calling this resets that column to
137    /// `00h 00m 00s`, which is useful before a fresh run when a single
138    /// subscriber is reused across back-to-back fits.
139    ///
140    /// This is an associated function rather than a method because the column
141    /// lives in process-wide subscriber state, not on a [`Logger`] instance
142    /// (which is consumed by [`Logger::init`]).
143    pub fn reset_time() {
144        let offset = process_start().elapsed();
145        EPOCH_OFFSET_NANOS.store(offset.as_nanos() as u64, Ordering::Relaxed);
146    }
147
148    fn build_env_filter(&self) -> EnvFilter {
149        // Honor RUST_LOG when set, otherwise fall back to the configured level
150        // plus any extra directives.
151        if std::env::var("RUST_LOG").is_ok() {
152            return EnvFilter::from_default_env();
153        }
154
155        let mut directives = self.level.to_string().to_lowercase();
156        if let Some(extra) = &self.extra_directives {
157            directives.push(',');
158            directives.push_str(extra);
159        }
160        EnvFilter::new(directives)
161    }
162}
163
164fn open_file_layer<S>(
165    path: &Path,
166    timer: ElapsedTime,
167) -> Result<Box<dyn tracing_subscriber::Layer<S> + Send + Sync + 'static>>
168where
169    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
170{
171    if let Some(parent) = path.parent() {
172        if !parent.as_os_str().is_empty() {
173            create_dir_all(parent)
174                .with_context(|| format!("failed to create log directory {:?}", parent))?;
175        }
176    }
177
178    let file = OpenOptions::new()
179        .write(true)
180        .create(true)
181        .truncate(true)
182        .open(path)
183        .with_context(|| format!("failed to open log file {:?}", path))?;
184
185    Ok(fmt::layer()
186        .with_writer(file)
187        .with_ansi(false)
188        .with_timer(timer)
189        .boxed())
190}
191
192/// The fixed process-start reference instant, anchored on first use.
193fn process_start() -> Instant {
194    static START: OnceLock<Instant> = OnceLock::new();
195    *START.get_or_init(Instant::now)
196}
197
198/// Nanoseconds from [`process_start`] to the current logger epoch. Updated by
199/// [`Logger::reset_time`] to restart the elapsed-time column.
200static EPOCH_OFFSET_NANOS: AtomicU64 = AtomicU64::new(0);
201
202/// A [`FormatTime`] implementation that prefixes each log line with the elapsed
203/// wall-clock time since the current logger epoch, formatted as `HHh MMm SSs`,
204/// e.g. `00h 00m 03s  INFO {Cycle 288}: ...`.
205///
206/// This is a process-wide "uptime" column (equivalent in spirit to
207/// [`tracing_subscriber::fmt::time::uptime`], but using the compact
208/// `HHh MMm SSs` format). The epoch can be restarted with [`Logger::reset_time`].
209#[derive(Debug, Clone, Copy, Default)]
210struct ElapsedTime;
211
212impl FormatTime for ElapsedTime {
213    fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
214        let offset = Duration::from_nanos(EPOCH_OFFSET_NANOS.load(Ordering::Relaxed));
215        let elapsed = process_start().elapsed().saturating_sub(offset);
216        write!(w, "{}", format_elapsed(elapsed))
217    }
218}
219
220/// Format a [`Duration`] as a compact `HHh MMm SSs` string.
221///
222/// Intended for embedding elapsed-time information into log messages emitted
223/// by an algorithm that tracks its own start instant, e.g.:
224///
225/// ```no_run
226/// use std::time::Instant;
227/// use pmcore::logs::format_elapsed;
228///
229/// let start = Instant::now();
230/// // ... do work ...
231/// tracing::info!("cycle finished in {}", format_elapsed(start.elapsed()));
232/// ```
233pub fn format_elapsed(elapsed: Duration) -> String {
234    let secs = elapsed.as_secs();
235    let hours = secs / 3600;
236    let minutes = (secs % 3600) / 60;
237    let seconds = secs % 60;
238    format!("{:02}h {:02}m {:02}s", hours, minutes, seconds)
239}