Skip to main content

santh_tracing/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3#![warn(clippy::pedantic)]
4#![cfg_attr(
5    not(test),
6    deny(
7        clippy::unwrap_used,
8        clippy::expect_used,
9        clippy::todo,
10        clippy::unimplemented,
11        clippy::panic
12    )
13)]
14#![allow(
15    clippy::module_name_repetitions,
16    clippy::must_use_candidate,
17    clippy::missing_errors_doc
18)]
19//! Consistent tracing shape for Santh CLI tools.
20//!
21//! # Quick start
22//!
23//! ```
24//! use santh_tracing::{init, LogLevel};
25//!
26//! let _guard = init("mytool", LogLevel::Info);
27//! santh_tracing::tracing::info!("ready");
28//! ```
29//!
30//! # Safe-defaults answers
31//!
32//! - Input size: log-line size tracks what the caller emits; the optional
33//!   [`RedactingWriter`] buffers one line at a time, never the whole stream.
34//! - Recursion depth: operation spans nest only as deep as the caller's
35//!   [`with_op`] calls; the library itself adds no recursion.
36//! - Outbound network: none. The crate performs no network access.
37//! - Process spawning: none. The crate spawns no child processes.
38//! - Filesystem writes: none by default (logs go to stderr). A file is opened
39//!   for append only when the caller explicitly sets
40//!   [`InitConfig::file_sink`], which validates the path up front.
41//! - Credential exposure: the default sink does not redact. Wrap a sink in
42//!   [`RedactingWriter`] to mask secrets line-by-line through `santh-error`'s
43//!   redactor before they reach the destination.
44
45mod error;
46pub mod metrics;
47mod redacting_writer;
48
49use std::cell::RefCell;
50use std::path::PathBuf;
51use std::sync::{Mutex, OnceLock};
52
53use tracing_subscriber::fmt::writer::BoxMakeWriter;
54use tracing_subscriber::{
55    fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, Registry,
56};
57
58pub use error::Error;
59pub use metrics::*;
60pub use redacting_writer::RedactingWriter;
61
62/// Log level configured at init time.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum LogLevel {
65    /// Trace-level diagnostics.
66    Trace,
67    /// Debug-level diagnostics.
68    Debug,
69    /// Info-level diagnostics.
70    Info,
71    /// Warning-level diagnostics.
72    Warn,
73    /// Error-level diagnostics.
74    Error,
75}
76
77/// Guard returned by [`init`]; dropping does not uninstall the global subscriber.
78pub struct InitGuard;
79
80static INIT: OnceLock<()> = OnceLock::new();
81thread_local! {
82    static TOOL: RefCell<String> = const { RefCell::new(String::new()) };
83    static OP_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
84}
85
86/// Install the global Santh tracing subscriber for a tool process.
87///
88/// Equivalent to `InitConfig::new(tool, level).init()`: the filter comes from
89/// `RUST_LOG` when set, otherwise `level`. Logs are written to **stderr** so
90/// machine-readable findings on stdout are never corrupted (the Santh logging
91/// contract). For JSON output, a log file, or a custom filter, use
92/// [`init_with`] with an [`InitConfig`].
93pub fn init(tool: &str, level: LogLevel) -> InitGuard {
94    init_with(InitConfig::new(tool, level))
95}
96
97/// Declarative configuration for [`init_with`].
98///
99/// One entry point, many shapes: every tool configures tracing by chaining
100/// builder methods rather than reaching for `tracing_subscriber` directly.
101/// The default is the Santh house style - stderr, full human format, target
102/// suppressed, `RUST_LOG`-or-`level` filtering.
103#[derive(Debug, Clone)]
104pub struct InitConfig {
105    tool: String,
106    level: LogLevel,
107    deny_by_default: bool,
108    format: OutputFormat,
109    without_time: bool,
110    file_path: Option<PathBuf>,
111    default_filter: Option<String>,
112    env_var: Option<String>,
113}
114
115/// Human/JSON/compact event format. The three are mutually exclusive, so a
116/// single field captures the choice (the last builder call wins) instead of a
117/// pair of `bool`s that could both be set.
118#[derive(Debug, Clone, Copy)]
119enum OutputFormat {
120    Human,
121    Json,
122    Compact,
123}
124
125impl InitConfig {
126    /// Start a configuration for `tool`, defaulting to `level` when the
127    /// environment does not specify a filter.
128    #[must_use]
129    pub fn new(tool: impl Into<String>, level: LogLevel) -> Self {
130        Self {
131            tool: tool.into(),
132            level,
133            deny_by_default: false,
134            format: OutputFormat::Human,
135            without_time: false,
136            file_path: None,
137            default_filter: None,
138            env_var: None,
139        }
140    }
141
142    /// Emit nothing when the filter environment variable is unset, instead of
143    /// falling back to `level`. For tools that must stay silent unless the
144    /// operator opts in (e.g. via `RUST_LOG`).
145    #[must_use]
146    pub fn deny_by_default(mut self) -> Self {
147        self.deny_by_default = true;
148        self
149    }
150
151    /// Emit newline-delimited JSON (one object per event) instead of the
152    /// human format. Mutually exclusive with [`compact`](Self::compact); the
153    /// last one set wins.
154    #[must_use]
155    pub fn json_output(mut self) -> Self {
156        self.format = OutputFormat::Json;
157        self
158    }
159
160    /// Use the compact single-line human format. Mutually exclusive with
161    /// [`json_output`](Self::json_output); the last one set wins.
162    #[must_use]
163    pub fn compact(mut self) -> Self {
164        self.format = OutputFormat::Compact;
165        self
166    }
167
168    /// Suppress the timestamp field. Useful for deterministic test output and
169    /// for tools whose host (journald, the TUI) already timestamps lines.
170    #[must_use]
171    pub fn without_time(mut self) -> Self {
172        self.without_time = true;
173        self
174    }
175
176    /// Set a default filter directive string (e.g. `"info,chromiumoxide=error"`)
177    /// used when the filter environment variable is unset. Takes precedence
178    /// over the bare `level` fallback.
179    #[must_use]
180    pub fn default_filter(mut self, directives: impl Into<String>) -> Self {
181        self.default_filter = Some(directives.into());
182        self
183    }
184
185    /// Read the filter from a tool-specific environment variable name (e.g.
186    /// `"WARPSCAN_LOG"`) instead of the default `RUST_LOG`.
187    #[must_use]
188    pub fn env_var(mut self, name: impl Into<String>) -> Self {
189        self.env_var = Some(name.into());
190        self
191    }
192
193    /// Write logs to `path` (opened for append, ANSI colour off) instead of
194    /// stderr. Intended for TUI tools whose terminal is owned by the UI.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`std::io::Error`] if `path` cannot be opened for append, so the
199    /// caller can fall back loudly rather than silently losing logs.
200    pub fn file_sink(mut self, path: impl Into<PathBuf>) -> std::io::Result<Self> {
201        let path = path.into();
202        // Validate up front so the caller learns of the failure here, not at
203        // the first dropped log line.
204        std::fs::OpenOptions::new()
205            .create(true)
206            .append(true)
207            .open(&path)?;
208        self.file_path = Some(path);
209        Ok(self)
210    }
211
212    /// Install this configuration as the global subscriber, returning the
213    /// process-lifetime [`InitGuard`]. Idempotent: only the first call in a
214    /// process installs a subscriber.
215    pub fn init(self) -> InitGuard {
216        init_with(self)
217    }
218
219    /// Resolve the [`EnvFilter`]: the configured environment variable (or
220    /// `RUST_LOG`) wins; otherwise `default_filter`, else silence when
221    /// `deny_by_default`, else the bare `level`.
222    fn build_filter(&self) -> EnvFilter {
223        let from_env = match &self.env_var {
224            Some(name) => EnvFilter::try_from_env(name),
225            None => EnvFilter::try_from_default_env(),
226        };
227        from_env.unwrap_or_else(|_| {
228            if let Some(directives) = &self.default_filter {
229                EnvFilter::new(directives.clone())
230            } else if self.deny_by_default {
231                // "off" disables every level; an empty filter would still
232                // admit ERROR (EnvFilter's default directive).
233                EnvFilter::new("off")
234            } else {
235                level_filter(self.level)
236            }
237        })
238    }
239
240    /// Build the boxed fmt layer for the selected format, time, and writer.
241    fn fmt_layer(
242        &self,
243        writer: BoxMakeWriter,
244        ansi: bool,
245    ) -> Box<dyn Layer<Registry> + Send + Sync> {
246        let base = fmt::layer()
247            .with_target(false)
248            .with_ansi(ansi)
249            .with_writer(writer);
250        match self.format {
251            OutputFormat::Json => {
252                let layer = base.json();
253                if self.without_time {
254                    layer.without_time().boxed()
255                } else {
256                    layer.boxed()
257                }
258            }
259            OutputFormat::Compact => {
260                let layer = base.compact();
261                if self.without_time {
262                    layer.without_time().boxed()
263                } else {
264                    layer.boxed()
265                }
266            }
267            OutputFormat::Human => {
268                if self.without_time {
269                    base.without_time().boxed()
270                } else {
271                    base.boxed()
272                }
273            }
274        }
275    }
276
277    /// Build and globally install the subscriber for this configuration.
278    fn install(self) {
279        let filter = self.build_filter();
280        let (writer, ansi): (BoxMakeWriter, bool) = match &self.file_path {
281            Some(path) => match std::fs::OpenOptions::new()
282                .create(true)
283                .append(true)
284                .open(path)
285            {
286                Ok(file) => (BoxMakeWriter::new(Mutex::new(file)), false),
287                // file_sink() validated openability; if it has since become
288                // unopenable, fall back loudly to stderr rather than panic.
289                Err(_) => (BoxMakeWriter::new(std::io::stderr), true),
290            },
291            None => (BoxMakeWriter::new(std::io::stderr), true),
292        };
293        let layer = self.fmt_layer(writer, ansi);
294        // fmt layer at the base (it is typed `Layer<Registry>`); the global
295        // EnvFilter layers on top and filters events for the whole stack.
296        Registry::default().with(layer).with(filter).init();
297    }
298}
299
300/// Install the global Santh tracing subscriber from an [`InitConfig`].
301///
302/// Idempotent via a process-global [`OnceLock`]: the first call installs the
303/// subscriber and wins; later calls are no-ops except that they update the
304/// current thread's tool name (so [`with_op`] spans are labelled correctly).
305pub fn init_with(config: InitConfig) -> InitGuard {
306    let tool = config.tool.clone();
307    // First call installs the subscriber and wins; the returned `&()` is not
308    // needed (`get_or_init` is not `#[must_use]`).
309    INIT.get_or_init(move || config.install());
310    TOOL.with(|name| *name.borrow_mut() = tool);
311    InitGuard
312}
313
314/// The tool name recorded by the most recent [`init`]/[`init_with`] on this
315/// thread. Used by the Prometheus metrics facade to namespace metric names
316/// under `santh_<tool>_`; the no-op facade never reads it, so the function
317/// only exists in builds that can call it.
318#[cfg(feature = "prometheus")]
319pub(crate) fn get_tool_name() -> String {
320    TOOL.with(|name| name.borrow().clone())
321}
322
323/// Re-export for tool code and tests.
324pub mod tracing {
325    pub use tracing::*;
326}
327
328/// Run `body` inside a nested operation span.
329pub fn with_op<F, R>(op: &str, body: F) -> R
330where
331    F: FnOnce() -> R,
332{
333    OP_STACK.with(|stack| stack.borrow_mut().push(op.to_string()));
334    let tool = TOOL.with(|t| t.borrow().clone());
335    let ops_chain = OP_STACK.with(|stack| {
336        stack
337            .borrow()
338            .iter()
339            .map(|name| format!("op=\"{name}\""))
340            .collect::<Vec<_>>()
341            .join(" ")
342    });
343    let span = tracing::info_span!(
344        parent: tracing::Span::current(),
345        "santh_op",
346        tool = %tool,
347        op = op,
348        ops = %ops_chain
349    );
350    let result = span.in_scope(body);
351    OP_STACK.with(|stack| {
352        stack.borrow_mut().pop();
353    });
354    result
355}
356
357/// Run `body` inside a span with explicit tool, operation, and target fields.
358#[macro_export]
359macro_rules! santh_span {
360    ($tool:expr, $op:expr, $target:expr, $body:block) => {{
361        let span = $crate::tracing::info_span!("santh", tool = $tool, op = $op, target = $target);
362        let _enter = span.enter();
363        $body
364    }};
365}
366
367fn level_filter(level: LogLevel) -> EnvFilter {
368    use tracing::Level;
369    let level = match level {
370        LogLevel::Trace => Level::TRACE,
371        LogLevel::Debug => Level::DEBUG,
372        LogLevel::Info => Level::INFO,
373        LogLevel::Warn => Level::WARN,
374        LogLevel::Error => Level::ERROR,
375    };
376    // `Directive: From<Level>` builds the directive directly, with no
377    // fallible string round-trip to unwrap.
378    EnvFilter::default().add_directive(level.into())
379}