kamu-logging 2.1.0

Structured tracing setup for native services and Cloudflare Workers.
Documentation
//! Configuration for [`init_with`](crate::init_with).

use std::fmt;
use std::str::FromStr;

/// Output format for the fmt layer.
///
/// `Auto` is replaced at init time by [`Format::Pretty`] when the chosen sink
/// is a TTY and [`Format::Compact`] otherwise. Set the `KAMU_LOG_FORMAT`
/// environment variable (`auto`, `compact`, `pretty`, `json`) to override
/// without code changes. On wasm32, `Auto` resolves to [`Format::Json`] for
/// Cloudflare Workers Logs-friendly console output, and [`Format::Pretty`]
/// falls back to non-ANSI compact output.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Format {
    /// Resolved at init time based on sink + env var.
    #[default]
    Auto,
    /// Single-line, machine-readable text format.
    Compact,
    /// Multi-line, human-readable text format with ANSI colors.
    Pretty,
    /// Line-delimited JSON. Required for log aggregators (Vector, Promtail,
    /// Datadog Agent, Fluent Bit).
    Json,
}

impl Format {
    /// Parse an env-var value.
    ///
    /// # Errors
    ///
    /// Returns [`ParseFormatError`] instead of silently selecting [`Self::Auto`]
    /// when the value is unknown.
    pub fn from_env_value(value: &str) -> Result<Self, ParseFormatError> {
        value.parse()
    }
}

impl FromStr for Format {
    type Err = ParseFormatError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "auto" => Ok(Self::Auto),
            "compact" => Ok(Self::Compact),
            "pretty" => Ok(Self::Pretty),
            "json" => Ok(Self::Json),
            _ => Err(ParseFormatError),
        }
    }
}

/// An unknown logging format.
#[non_exhaustive]
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("expected one of: auto, compact, pretty, json")]
pub struct ParseFormatError;

/// Where to write log events.
///
/// `Auto` (default) emits to stderr on native targets. Set `KAMU_LOG_SINK`
/// (`auto`, `stdout`, `stderr`, `journald`) to override without code changes.
/// `Journald` is rejected on targets without the `systemd` feature. On wasm32,
/// `Auto`, `Stdout`, and `Stderr` all map to the JavaScript console.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Sink {
    /// Portable default: stderr on native targets, JavaScript console on wasm32.
    #[default]
    Auto,
    /// Write to stdout.
    Stdout,
    /// Write to stderr.
    Stderr,
    /// Write to the systemd journal. Requires the `systemd` feature.
    Journald,
}

impl Sink {
    /// Parse an env-var value.
    ///
    /// # Errors
    ///
    /// Returns [`ParseSinkError`] instead of silently selecting [`Self::Auto`]
    /// when the value is unknown.
    pub fn from_env_value(value: &str) -> Result<Self, ParseSinkError> {
        value.parse()
    }
}

impl FromStr for Sink {
    type Err = ParseSinkError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "auto" => Ok(Self::Auto),
            "stdout" => Ok(Self::Stdout),
            "stderr" => Ok(Self::Stderr),
            "journald" => Ok(Self::Journald),
            _ => Err(ParseSinkError),
        }
    }
}

/// An unknown logging sink.
#[non_exhaustive]
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("expected one of: auto, stdout, stderr, journald")]
pub struct ParseSinkError;

/// Configuration for [`init_with`](crate::init_with).
///
/// Constructed via [`InitOptions::default`] and the `with_*` builder methods.
/// Each method consumes `self` and returns `Self` to support chaining:
///
/// ```no_run
/// use kamu_logging::{init_with, Format, InitOptions, Sink};
///
/// init_with(
///     InitOptions::default()
///         .with_service_name("my-service")
///         .with_format(Format::Json)
///         .with_sink(Sink::Stdout),
/// )?;
/// # Ok::<(), kamu_logging::Error>(())
/// ```
#[derive(Clone, Default)]
pub struct InitOptions {
    pub(crate) service_name: Option<String>,
    pub(crate) default_filter: Option<String>,
    pub(crate) env_var: Option<String>,
    pub(crate) format: Format,
    pub(crate) sink: Sink,
    pub(crate) idempotent: bool,
    #[cfg(feature = "with-otlp")]
    pub(crate) otlp: Option<crate::otlp::OtlpConfig>,
}

impl fmt::Debug for InitOptions {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut debug = formatter.debug_struct("InitOptions");
        debug
            .field("service_name", &self.service_name)
            .field("default_filter", &self.default_filter)
            .field("env_var", &self.env_var)
            .field("format", &self.format)
            .field("sink", &self.sink)
            .field("idempotent", &self.idempotent);
        #[cfg(feature = "with-otlp")]
        debug.field("otlp", &self.otlp);
        debug.finish()
    }
}

impl InitOptions {
    /// Attach `service.name` to the startup event and, when configured, use it
    /// as the default OTLP resource service name.
    #[must_use]
    pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
        self.service_name = Some(name.into());
        self
    }

    /// Default filter directive when the env var is unset. Defaults to
    /// `"debug"` in debug builds and `"info"` in release builds.
    #[must_use]
    pub fn with_default_filter(mut self, filter: impl Into<String>) -> Self {
        self.default_filter = Some(filter.into());
        self
    }

    /// Environment variable read for the filter directive. Defaults to
    /// `"RUST_LOG"`. Useful for per-binary triggers like `"KKP_LOG"` to avoid
    /// collisions with other tools' `RUST_LOG` settings.
    #[must_use]
    pub fn with_env_var(mut self, var: impl Into<String>) -> Self {
        self.env_var = Some(var.into());
        self
    }

    /// Output format. See [`Format`].
    #[must_use]
    pub fn with_format(mut self, format: Format) -> Self {
        self.format = format;
        self
    }

    /// Output sink. See [`Sink`].
    #[must_use]
    pub fn with_sink(mut self, sink: Sink) -> Self {
        self.sink = sink;
        self
    }

    /// When `true`, a second [`init_with`](crate::init_with) call returns
    /// `Ok(())` when this crate installed the global subscriber.
    ///
    /// Default is `false` so library double-init surfaces as an error. Set
    /// `true` from test harnesses and embedded CLI runs where re-init is
    /// expected.
    #[must_use]
    pub fn idempotent(mut self, enabled: bool) -> Self {
        self.idempotent = enabled;
        self
    }

    /// Attach an OpenTelemetry OTLP exporter layer. Requires the `with-otlp`
    /// feature.
    #[cfg(feature = "with-otlp")]
    #[must_use]
    pub fn with_otlp(mut self, config: crate::otlp::OtlpConfig) -> Self {
        self.otlp = Some(config);
        self
    }

    #[cfg(feature = "systemd")]
    pub(crate) fn resolved_env_var(&self) -> &str {
        self.env_var.as_deref().unwrap_or("RUST_LOG")
    }

    pub(crate) fn resolved_default_filter(&self) -> &str {
        if let Some(filter) = self.default_filter.as_deref() {
            return filter;
        }
        if cfg!(debug_assertions) { "debug" } else { "info" }
    }

    #[cfg(feature = "wasm32")]
    pub(crate) fn resolved_wasm_format(&self) -> Format {
        match self.format {
            Format::Auto => Format::Json,
            format => format,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builder_sets_every_field() {
        let opts = InitOptions::default()
            .with_service_name("svc")
            .with_default_filter("warn")
            .with_env_var("KKP_LOG")
            .with_format(Format::Json)
            .with_sink(Sink::Stdout)
            .idempotent(true);
        assert_eq!(opts.service_name.as_deref(), Some("svc"));
        assert_eq!(opts.default_filter.as_deref(), Some("warn"));
        assert_eq!(opts.env_var.as_deref(), Some("KKP_LOG"));
        assert_eq!(opts.format, Format::Json);
        assert_eq!(opts.sink, Sink::Stdout);
        assert!(opts.idempotent);
    }

    #[cfg(feature = "systemd")]
    #[test]
    fn resolved_helpers_apply_defaults_then_overrides() {
        let default = InitOptions::default();
        assert_eq!(default.resolved_env_var(), "RUST_LOG");
        let expected = if cfg!(debug_assertions) { "debug" } else { "info" };
        assert_eq!(default.resolved_default_filter(), expected);

        let custom = InitOptions::default().with_env_var("KKP_LOG").with_default_filter("trace");
        assert_eq!(custom.resolved_env_var(), "KKP_LOG");
        assert_eq!(custom.resolved_default_filter(), "trace");
    }
}