tiny_tracing/config.rs
1use std::fs::{File, OpenOptions};
2use std::io::IsTerminal;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use crate::errors::LoggerError;
7use tracing::Level;
8use tracing_appender::non_blocking::{NonBlockingBuilder, WorkerGuard};
9use tracing_subscriber::fmt::time::UtcTime;
10use tracing_subscriber::{
11 EnvFilter, Layer, Registry,
12 filter::LevelFilter,
13 fmt::{MakeWriter, format::FmtSpan},
14 layer::SubscriberExt,
15 util::SubscriberInitExt,
16};
17
18/// Output format for log lines.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20#[non_exhaustive]
21pub enum LogFormat {
22 /// Human-readable single-line text output.
23 #[default]
24 Text,
25 /// One JSON object per log line.
26 Json,
27}
28
29impl LogFormat {
30 fn as_str(self) -> &'static str {
31 match self {
32 Self::Text => "text",
33 Self::Json => "json",
34 }
35 }
36}
37
38impl std::fmt::Display for LogFormat {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.write_str(self.as_str())
41 }
42}
43
44impl FromStr for LogFormat {
45 type Err = LoggerError;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_lowercase().as_str() {
49 "text" => Ok(Self::Text),
50 "json" => Ok(Self::Json),
51 other => Err(LoggerError::InvalidFormat(other.to_string())),
52 }
53 }
54}
55
56/// Where log lines are written.
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
58#[non_exhaustive]
59pub enum Output {
60 /// Standard output only. This is the default.
61 #[default]
62 Stdout,
63 /// Standard error only.
64 ///
65 /// Keeps stdout free for program output, which is what command-line tools
66 /// usually want. The automatic colour decision checks stderr, not stdout.
67 Stderr,
68 /// The given file only. Created if missing, appended otherwise.
69 File(PathBuf),
70 /// Both standard output and the given file.
71 ///
72 /// ANSI colours are kept on stdout but stripped from the file, so the
73 /// on-disk log stays free of escape codes.
74 Both(PathBuf),
75}
76
77/// Keeps background logging alive; returned by [`Logger::init`].
78///
79/// File output is written by a background thread. This guard owns that thread:
80/// when it is dropped, every line still queued is flushed to the file and the
81/// thread stops. Hold it until the program ends, typically as the first binding
82/// in `main`:
83///
84/// ```rust,no_run
85/// use tiny_tracing::{Logger, Output};
86///
87/// fn main() -> Result<(), Box<dyn std::error::Error>> {
88/// let _guard = Logger::new()
89/// .with_output(Output::File("app.log".into()))
90/// .init()?;
91///
92/// tiny_tracing::info!("this line is flushed when `_guard` is dropped");
93/// Ok(())
94/// }
95/// ```
96///
97/// Do not bind it to a bare `_`: `let _ = Logger::new().init()?;` drops it
98/// immediately and file output stops. Also note that `std::process::exit` skips
99/// destructors, so lines still queued at that point are lost.
100///
101/// For [`Output::Stdout`] and [`Output::Stderr`] the guard holds nothing, but
102/// keeping it costs nothing and keeps the code independent of the output.
103#[must_use = "dropping the guard stops file logging; bind it with `let _guard = ...` and keep it alive until the program ends"]
104#[derive(Debug)]
105pub struct LoggerGuard {
106 _file: Option<WorkerGuard>,
107}
108
109/// Builder for initializing the global tracing subscriber.
110///
111/// Uses a fluent builder pattern to configure log level, output format,
112/// and optional environment-based filtering before calling [`init`](Logger::init).
113///
114/// # Examples
115///
116/// Minimal setup (text output at INFO level):
117///
118/// ```rust
119/// use tiny_tracing::Logger;
120///
121/// let _guard = Logger::new().init().unwrap();
122/// tiny_tracing::info!("Ready");
123/// ```
124///
125/// JSON output with environment filter:
126///
127/// ```rust
128/// use tiny_tracing::{Logger, LogFormat, Level};
129///
130/// let _guard = Logger::new()
131/// .with_level(Level::DEBUG)
132/// .with_format(LogFormat::Json)
133/// .with_env_filter("info,my_crate=trace")
134/// .init()
135/// .unwrap();
136/// ```
137pub struct Logger {
138 level: Level,
139 format: LogFormat,
140 env_filter: Option<String>,
141 with_file: bool,
142 with_target: bool,
143 output: Output,
144 colored: Option<bool>,
145 timestamp: bool,
146}
147
148impl Logger {
149 /// Creates a new [`Logger`] with default values.
150 ///
151 /// Defaults: [`Level::INFO`], [`LogFormat::Text`], no env filter,
152 /// file location off, target on, timestamps on, output to stdout, colours
153 /// chosen automatically.
154 #[must_use]
155 pub fn new() -> Self {
156 Self {
157 level: Level::INFO,
158 format: LogFormat::Text,
159 env_filter: None,
160 with_file: false,
161 with_target: true,
162 output: Output::Stdout,
163 colored: None,
164 timestamp: true,
165 }
166 }
167
168 /// Sets the global (default) log level.
169 ///
170 /// This is the base level applied to every target. Per-target directives
171 /// passed to [`with_env_filter`](Self::with_env_filter) refine it on top —
172 /// the level is never silently ignored. A global directive inside the env
173 /// filter string (e.g. the `info` in `"info,my_crate=debug"`) does take
174 /// precedence over this value.
175 #[must_use]
176 pub fn with_level(mut self, level: Level) -> Self {
177 self.level = level;
178 self
179 }
180
181 /// Sets the output format.
182 #[must_use]
183 pub fn with_format(mut self, format: LogFormat) -> Self {
184 self.format = format;
185 self
186 }
187
188 /// Reads the filter directives from the `RUST_LOG` environment variable.
189 ///
190 /// When `RUST_LOG` is set (and valid UTF-8) it is used exactly as if it had
191 /// been passed to [`with_env_filter`](Self::with_env_filter), replacing any
192 /// filter set earlier. When it is not set, nothing changes and only the
193 /// [`with_level`](Self::with_level) value (plus any earlier env filter)
194 /// applies. A `RUST_LOG` that [`EnvFilter`] rejects makes
195 /// [`init`](Self::init) return [`LoggerError::InvalidEnvFilter`].
196 ///
197 /// The precedence rules of [`with_level`](Self::with_level) apply: a global
198 /// directive inside `RUST_LOG` (e.g. `RUST_LOG=warn`) takes precedence over
199 /// the configured level.
200 #[must_use]
201 pub fn with_env_filter_from_env(self) -> Self {
202 self.env_filter_from(std::env::var("RUST_LOG").ok())
203 }
204
205 /// Applies `value` as the env filter when present; keeps the current one otherwise.
206 fn env_filter_from(mut self, value: Option<String>) -> Self {
207 if let Some(filter) = value {
208 self.env_filter = Some(filter);
209 }
210 self
211 }
212
213 /// Forces ANSI colours on or off for stdout or stderr.
214 ///
215 /// By default colours are chosen automatically (on only when the stream
216 /// being written to, stdout or stderr, is a terminal). File output is never
217 /// coloured, regardless of this setting.
218 #[must_use]
219 pub fn colored(mut self, colored: bool) -> Self {
220 self.colored = Some(colored);
221 self
222 }
223
224 /// Adds environment-based, per-target filtering via [`EnvFilter`].
225 ///
226 /// The directives here are layered on top of the global
227 /// [`with_level`](Logger::with_level) value. Supported syntax: `"info"`,
228 /// `"info,my_crate=debug"`, etc.
229 ///
230 /// To take the directives from `RUST_LOG` instead of passing them in code, use
231 /// [`with_env_filter_from_env`](Logger::with_env_filter_from_env).
232 ///
233 /// If not called, only the static [`with_level`](Logger::with_level) value applies.
234 #[must_use]
235 pub fn with_env_filter(mut self, filter: impl Into<String>) -> Self {
236 self.env_filter = Some(filter.into());
237 self
238 }
239
240 /// Controls whether the source file name appears in log lines.
241 ///
242 /// Disabled by default.
243 #[must_use]
244 pub fn with_file(mut self, enabled: bool) -> Self {
245 self.with_file = enabled;
246 self
247 }
248
249 /// Controls whether each log line starts with a timestamp.
250 ///
251 /// Enabled by default. When on, timestamps are always UTC and formatted as
252 /// RFC 3339 with a sub-second fraction (e.g. `2026-10-01T12:00:00.123456Z`),
253 /// in both text and JSON output. Trailing zeros of the fraction are trimmed,
254 /// so its length varies. Turn it off when something else already adds
255 /// one, such as journald or a container runtime.
256 #[must_use]
257 pub fn with_timestamp(mut self, enabled: bool) -> Self {
258 self.timestamp = enabled;
259 self
260 }
261
262 /// Controls whether the module path (target) appears in log lines.
263 ///
264 /// Enabled by default.
265 #[must_use]
266 pub fn with_target(mut self, enabled: bool) -> Self {
267 self.with_target = enabled;
268 self
269 }
270
271 /// Sets where log lines are written: stdout, stderr, a file, or stdout and a file.
272 ///
273 /// Defaults to [`Output::Stdout`]. When a file is involved it is opened in
274 /// append mode (created if missing), so the call stays panic-free — an
275 /// unopenable path yields [`LoggerError::OpenLogFile`] from
276 /// [`init`](Self::init).
277 ///
278 /// File writes are non-blocking: lines are queued and written by a
279 /// background thread owned by the [`LoggerGuard`] that
280 /// [`init`](Self::init) returns, which must be kept alive. The queue holds
281 /// up to 128 000 lines and no line is ever dropped; if it fills up, the
282 /// logging call waits for room.
283 ///
284 /// Writes to stdout and stderr are synchronous: each log line is written on
285 /// the calling thread, which waits until the write finishes.
286 #[must_use]
287 pub fn with_output(mut self, output: Output) -> Self {
288 self.output = output;
289 self
290 }
291
292 /// Returns the configured log level.
293 #[must_use]
294 pub fn level(&self) -> Level {
295 self.level
296 }
297
298 /// Returns the configured output format.
299 #[must_use]
300 pub fn format(&self) -> LogFormat {
301 self.format
302 }
303
304 /// Initializes the global tracing subscriber.
305 ///
306 /// Consumes the builder. Must be called only once per process;
307 /// subsequent calls return [`LoggerError::TryInitError`].
308 ///
309 /// Returns a [`LoggerGuard`] that must be kept alive for as long as logging
310 /// is needed: dropping it flushes and stops the background file writer.
311 ///
312 /// # Errors
313 ///
314 /// - [`LoggerError::InvalidEnvFilter`] if an env filter was set and
315 /// [`EnvFilter`] rejects it.
316 /// - [`LoggerError::OpenLogFile`] if a file output was requested but the
317 /// path could not be opened for writing.
318 /// - [`LoggerError::TryInitError`] if a global subscriber is already set.
319 pub fn init(self) -> Result<LoggerGuard, LoggerError> {
320 let filter = EnvFilter::builder()
321 .with_default_directive(LevelFilter::from_level(self.level).into())
322 .parse(self.env_filter.as_deref().unwrap_or(""))
323 .map_err(|e| LoggerError::InvalidEnvFilter(e.to_string()))?;
324
325 let (to_stdout, to_stderr, file_path) = match &self.output {
326 Output::Stdout => (true, false, None),
327 Output::Stderr => (false, true, None),
328 Output::File(path) => (false, false, Some(path.clone())),
329 Output::Both(path) => (true, false, Some(path.clone())),
330 };
331
332 let mut layers: Vec<Box<dyn Layer<Registry> + Send + Sync>> = Vec::new();
333
334 if to_stdout {
335 let ansi = use_ansi(self.colored, std::io::stdout().is_terminal());
336 layers.push(self.fmt_layer(std::io::stdout, ansi));
337 }
338
339 if to_stderr {
340 let ansi = use_ansi(self.colored, std::io::stderr().is_terminal());
341 layers.push(self.fmt_layer(std::io::stderr, ansi));
342 }
343
344 let mut file_guard = None;
345 if let Some(path) = file_path {
346 let file = open_log_file(&path)?;
347 let (writer, guard) = NonBlockingBuilder::default().lossy(false).finish(file);
348 layers.push(self.fmt_layer(writer, false));
349 file_guard = Some(guard);
350 }
351
352 tracing_subscriber::registry()
353 .with(layers)
354 .with(filter)
355 .try_init()
356 .map_err(|e| LoggerError::TryInitError(e.to_string()))?;
357
358 Ok(LoggerGuard { _file: file_guard })
359 }
360
361 /// Builds a boxed `fmt` layer for the given writer, honouring the
362 /// configured format, field flags and timestamp setting. `ansi` toggles
363 /// colour output — enabled for terminals, disabled for files.
364 ///
365 /// The timer is a type parameter of the layer, so the timestamp on/off
366 /// choice cannot be a conditional inside the builder chain; each
367 /// format/timestamp combination is built in its own `match` arm.
368 fn fmt_layer<W>(&self, writer: W, ansi: bool) -> Box<dyn Layer<Registry> + Send + Sync>
369 where
370 W: for<'writer> MakeWriter<'writer> + Send + Sync + 'static,
371 {
372 let layer = tracing_subscriber::fmt::layer()
373 .with_writer(writer)
374 .with_ansi(ansi)
375 .with_thread_names(false)
376 .with_span_events(FmtSpan::NONE)
377 .with_file(self.with_file)
378 .with_target(self.with_target);
379
380 match (self.format, self.timestamp) {
381 (LogFormat::Json, true) => layer.with_timer(UtcTime::rfc_3339()).json().boxed(),
382 (LogFormat::Json, false) => layer.without_time().json().boxed(),
383 (LogFormat::Text, true) => layer.with_timer(UtcTime::rfc_3339()).boxed(),
384 (LogFormat::Text, false) => layer.without_time().boxed(),
385 }
386 }
387}
388
389/// Decides whether ANSI colours are emitted: an explicit `colored` setting wins,
390/// otherwise colours are on only when the stream being written to is a terminal.
391fn use_ansi(colored: Option<bool>, is_terminal: bool) -> bool {
392 colored.unwrap_or(is_terminal)
393}
394
395/// Opens `path` in append mode (creating it if absent) for log output.
396fn open_log_file(path: &Path) -> Result<File, LoggerError> {
397 OpenOptions::new()
398 .create(true)
399 .append(true)
400 .open(path)
401 .map_err(|e| LoggerError::OpenLogFile {
402 path: path.display().to_string(),
403 message: e.to_string(),
404 })
405}
406
407impl Default for Logger {
408 fn default() -> Self {
409 Self::new()
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn use_ansi_follows_the_terminal_when_not_forced() {
419 assert!(use_ansi(None, true));
420 assert!(!use_ansi(None, false));
421 }
422
423 #[test]
424 fn use_ansi_forced_on_ignores_the_terminal() {
425 assert!(use_ansi(Some(true), true));
426 assert!(use_ansi(Some(true), false));
427 }
428
429 #[test]
430 fn use_ansi_forced_off_ignores_the_terminal() {
431 assert!(!use_ansi(Some(false), true));
432 assert!(!use_ansi(Some(false), false));
433 }
434
435 #[test]
436 fn env_filter_from_uses_value_when_present() {
437 let logger = Logger::new().env_filter_from(Some("debug,my_crate=trace".to_string()));
438 assert_eq!(logger.env_filter.as_deref(), Some("debug,my_crate=trace"));
439 }
440
441 #[test]
442 fn env_filter_from_replaces_earlier_filter() {
443 let logger = Logger::new()
444 .with_env_filter("warn")
445 .env_filter_from(Some("debug".to_string()));
446 assert_eq!(logger.env_filter.as_deref(), Some("debug"));
447 }
448
449 #[test]
450 fn env_filter_from_keeps_current_filter_when_absent() {
451 let logger = Logger::new().env_filter_from(None);
452 assert_eq!(logger.env_filter, None);
453
454 let logger = Logger::new().with_env_filter("warn").env_filter_from(None);
455 assert_eq!(logger.env_filter.as_deref(), Some("warn"));
456 }
457}