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}