Skip to main content

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}