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}