santh-tracing 0.2.0

Consistent tracing setup for CLI tools - stderr/JSON/file sinks, a secret-redacting writer, and operation spans
Documentation
#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::pedantic)]
#![cfg_attr(
    not(test),
    deny(
        clippy::unwrap_used,
        clippy::expect_used,
        clippy::todo,
        clippy::unimplemented,
        clippy::panic
    )
)]
#![allow(
    clippy::module_name_repetitions,
    clippy::must_use_candidate,
    clippy::missing_errors_doc
)]
//! Consistent tracing shape for Santh CLI tools.
//!
//! # Quick start
//!
//! ```
//! use santh_tracing::{init, LogLevel};
//!
//! let _guard = init("mytool", LogLevel::Info);
//! santh_tracing::tracing::info!("ready");
//! ```
//!
//! # Safe-defaults answers
//!
//! - Input size: log-line size tracks what the caller emits; the optional
//!   [`RedactingWriter`] buffers one line at a time, never the whole stream.
//! - Recursion depth: operation spans nest only as deep as the caller's
//!   [`with_op`] calls; the library itself adds no recursion.
//! - Outbound network: none. The crate performs no network access.
//! - Process spawning: none. The crate spawns no child processes.
//! - Filesystem writes: none by default (logs go to stderr). A file is opened
//!   for append only when the caller explicitly sets
//!   [`InitConfig::file_sink`], which validates the path up front.
//! - Credential exposure: the default sink does not redact. Wrap a sink in
//!   [`RedactingWriter`] to mask secrets line-by-line through `santh-error`'s
//!   redactor before they reach the destination.

mod error;
pub mod metrics;
mod redacting_writer;

use std::cell::RefCell;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};

use tracing_subscriber::fmt::writer::BoxMakeWriter;
use tracing_subscriber::{
    fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, Registry,
};

pub use error::Error;
pub use metrics::*;
pub use redacting_writer::RedactingWriter;

/// Log level configured at init time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
    /// Trace-level diagnostics.
    Trace,
    /// Debug-level diagnostics.
    Debug,
    /// Info-level diagnostics.
    Info,
    /// Warning-level diagnostics.
    Warn,
    /// Error-level diagnostics.
    Error,
}

/// Guard returned by [`init`]; dropping does not uninstall the global subscriber.
pub struct InitGuard;

static INIT: OnceLock<()> = OnceLock::new();
thread_local! {
    static TOOL: RefCell<String> = const { RefCell::new(String::new()) };
    static OP_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}

/// Install the global Santh tracing subscriber for a tool process.
///
/// Equivalent to `InitConfig::new(tool, level).init()`: the filter comes from
/// `RUST_LOG` when set, otherwise `level`. Logs are written to **stderr** so
/// machine-readable findings on stdout are never corrupted (the Santh logging
/// contract). For JSON output, a log file, or a custom filter, use
/// [`init_with`] with an [`InitConfig`].
pub fn init(tool: &str, level: LogLevel) -> InitGuard {
    init_with(InitConfig::new(tool, level))
}

/// Declarative configuration for [`init_with`].
///
/// One entry point, many shapes: every tool configures tracing by chaining
/// builder methods rather than reaching for `tracing_subscriber` directly.
/// The default is the Santh house style - stderr, full human format, target
/// suppressed, `RUST_LOG`-or-`level` filtering.
#[derive(Debug, Clone)]
pub struct InitConfig {
    tool: String,
    level: LogLevel,
    deny_by_default: bool,
    format: OutputFormat,
    without_time: bool,
    file_path: Option<PathBuf>,
    default_filter: Option<String>,
    env_var: Option<String>,
}

/// Human/JSON/compact event format. The three are mutually exclusive, so a
/// single field captures the choice (the last builder call wins) instead of a
/// pair of `bool`s that could both be set.
#[derive(Debug, Clone, Copy)]
enum OutputFormat {
    Human,
    Json,
    Compact,
}

impl InitConfig {
    /// Start a configuration for `tool`, defaulting to `level` when the
    /// environment does not specify a filter.
    #[must_use]
    pub fn new(tool: impl Into<String>, level: LogLevel) -> Self {
        Self {
            tool: tool.into(),
            level,
            deny_by_default: false,
            format: OutputFormat::Human,
            without_time: false,
            file_path: None,
            default_filter: None,
            env_var: None,
        }
    }

    /// Emit nothing when the filter environment variable is unset, instead of
    /// falling back to `level`. For tools that must stay silent unless the
    /// operator opts in (e.g. via `RUST_LOG`).
    #[must_use]
    pub fn deny_by_default(mut self) -> Self {
        self.deny_by_default = true;
        self
    }

    /// Emit newline-delimited JSON (one object per event) instead of the
    /// human format. Mutually exclusive with [`compact`](Self::compact); the
    /// last one set wins.
    #[must_use]
    pub fn json_output(mut self) -> Self {
        self.format = OutputFormat::Json;
        self
    }

    /// Use the compact single-line human format. Mutually exclusive with
    /// [`json_output`](Self::json_output); the last one set wins.
    #[must_use]
    pub fn compact(mut self) -> Self {
        self.format = OutputFormat::Compact;
        self
    }

    /// Suppress the timestamp field. Useful for deterministic test output and
    /// for tools whose host (journald, the TUI) already timestamps lines.
    #[must_use]
    pub fn without_time(mut self) -> Self {
        self.without_time = true;
        self
    }

    /// Set a default filter directive string (e.g. `"info,chromiumoxide=error"`)
    /// used when the filter environment variable is unset. Takes precedence
    /// over the bare `level` fallback.
    #[must_use]
    pub fn default_filter(mut self, directives: impl Into<String>) -> Self {
        self.default_filter = Some(directives.into());
        self
    }

    /// Read the filter from a tool-specific environment variable name (e.g.
    /// `"WARPSCAN_LOG"`) instead of the default `RUST_LOG`.
    #[must_use]
    pub fn env_var(mut self, name: impl Into<String>) -> Self {
        self.env_var = Some(name.into());
        self
    }

    /// Write logs to `path` (opened for append, ANSI colour off) instead of
    /// stderr. Intended for TUI tools whose terminal is owned by the UI.
    ///
    /// # Errors
    ///
    /// Returns [`std::io::Error`] if `path` cannot be opened for append, so the
    /// caller can fall back loudly rather than silently losing logs.
    pub fn file_sink(mut self, path: impl Into<PathBuf>) -> std::io::Result<Self> {
        let path = path.into();
        // Validate up front so the caller learns of the failure here, not at
        // the first dropped log line.
        std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&path)?;
        self.file_path = Some(path);
        Ok(self)
    }

    /// Install this configuration as the global subscriber, returning the
    /// process-lifetime [`InitGuard`]. Idempotent: only the first call in a
    /// process installs a subscriber.
    pub fn init(self) -> InitGuard {
        init_with(self)
    }

    /// Resolve the [`EnvFilter`]: the configured environment variable (or
    /// `RUST_LOG`) wins; otherwise `default_filter`, else silence when
    /// `deny_by_default`, else the bare `level`.
    fn build_filter(&self) -> EnvFilter {
        let from_env = match &self.env_var {
            Some(name) => EnvFilter::try_from_env(name),
            None => EnvFilter::try_from_default_env(),
        };
        from_env.unwrap_or_else(|_| {
            if let Some(directives) = &self.default_filter {
                EnvFilter::new(directives.clone())
            } else if self.deny_by_default {
                // "off" disables every level; an empty filter would still
                // admit ERROR (EnvFilter's default directive).
                EnvFilter::new("off")
            } else {
                level_filter(self.level)
            }
        })
    }

    /// Build the boxed fmt layer for the selected format, time, and writer.
    fn fmt_layer(
        &self,
        writer: BoxMakeWriter,
        ansi: bool,
    ) -> Box<dyn Layer<Registry> + Send + Sync> {
        let base = fmt::layer()
            .with_target(false)
            .with_ansi(ansi)
            .with_writer(writer);
        match self.format {
            OutputFormat::Json => {
                let layer = base.json();
                if self.without_time {
                    layer.without_time().boxed()
                } else {
                    layer.boxed()
                }
            }
            OutputFormat::Compact => {
                let layer = base.compact();
                if self.without_time {
                    layer.without_time().boxed()
                } else {
                    layer.boxed()
                }
            }
            OutputFormat::Human => {
                if self.without_time {
                    base.without_time().boxed()
                } else {
                    base.boxed()
                }
            }
        }
    }

    /// Build and globally install the subscriber for this configuration.
    fn install(self) {
        let filter = self.build_filter();
        let (writer, ansi): (BoxMakeWriter, bool) = match &self.file_path {
            Some(path) => match std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(path)
            {
                Ok(file) => (BoxMakeWriter::new(Mutex::new(file)), false),
                // file_sink() validated openability; if it has since become
                // unopenable, fall back loudly to stderr rather than panic.
                Err(_) => (BoxMakeWriter::new(std::io::stderr), true),
            },
            None => (BoxMakeWriter::new(std::io::stderr), true),
        };
        let layer = self.fmt_layer(writer, ansi);
        // fmt layer at the base (it is typed `Layer<Registry>`); the global
        // EnvFilter layers on top and filters events for the whole stack.
        Registry::default().with(layer).with(filter).init();
    }
}

/// Install the global Santh tracing subscriber from an [`InitConfig`].
///
/// Idempotent via a process-global [`OnceLock`]: the first call installs the
/// subscriber and wins; later calls are no-ops except that they update the
/// current thread's tool name (so [`with_op`] spans are labelled correctly).
pub fn init_with(config: InitConfig) -> InitGuard {
    let tool = config.tool.clone();
    // First call installs the subscriber and wins; the returned `&()` is not
    // needed (`get_or_init` is not `#[must_use]`).
    INIT.get_or_init(move || config.install());
    TOOL.with(|name| *name.borrow_mut() = tool);
    InitGuard
}

/// The tool name recorded by the most recent [`init`]/[`init_with`] on this
/// thread. Used by the Prometheus metrics facade to namespace metric names
/// under `santh_<tool>_`; the no-op facade never reads it, so the function
/// only exists in builds that can call it.
#[cfg(feature = "prometheus")]
pub(crate) fn get_tool_name() -> String {
    TOOL.with(|name| name.borrow().clone())
}

/// Re-export for tool code and tests.
pub mod tracing {
    pub use tracing::*;
}

/// Run `body` inside a nested operation span.
pub fn with_op<F, R>(op: &str, body: F) -> R
where
    F: FnOnce() -> R,
{
    OP_STACK.with(|stack| stack.borrow_mut().push(op.to_string()));
    let tool = TOOL.with(|t| t.borrow().clone());
    let ops_chain = OP_STACK.with(|stack| {
        stack
            .borrow()
            .iter()
            .map(|name| format!("op=\"{name}\""))
            .collect::<Vec<_>>()
            .join(" ")
    });
    let span = tracing::info_span!(
        parent: tracing::Span::current(),
        "santh_op",
        tool = %tool,
        op = op,
        ops = %ops_chain
    );
    let result = span.in_scope(body);
    OP_STACK.with(|stack| {
        stack.borrow_mut().pop();
    });
    result
}

/// Run `body` inside a span with explicit tool, operation, and target fields.
#[macro_export]
macro_rules! santh_span {
    ($tool:expr, $op:expr, $target:expr, $body:block) => {{
        let span = $crate::tracing::info_span!("santh", tool = $tool, op = $op, target = $target);
        let _enter = span.enter();
        $body
    }};
}

fn level_filter(level: LogLevel) -> EnvFilter {
    use tracing::Level;
    let level = match level {
        LogLevel::Trace => Level::TRACE,
        LogLevel::Debug => Level::DEBUG,
        LogLevel::Info => Level::INFO,
        LogLevel::Warn => Level::WARN,
        LogLevel::Error => Level::ERROR,
    };
    // `Directive: From<Level>` builds the directive directly, with no
    // fallible string round-trip to unwrap.
    EnvFilter::default().add_directive(level.into())
}