Skip to main content

kamu_logging/
options.rs

1//! Configuration for [`init_with`](crate::init_with).
2
3use std::fmt;
4use std::str::FromStr;
5
6/// Output format for the fmt layer.
7///
8/// `Auto` is replaced at init time by [`Format::Pretty`] when the chosen sink
9/// is a TTY and [`Format::Compact`] otherwise. Set the `KAMU_LOG_FORMAT`
10/// environment variable (`auto`, `compact`, `pretty`, `json`) to override
11/// without code changes. On wasm32, `Auto` resolves to [`Format::Json`] for
12/// Cloudflare Workers Logs-friendly console output, and [`Format::Pretty`]
13/// falls back to non-ANSI compact output.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Format {
17    /// Resolved at init time based on sink + env var.
18    #[default]
19    Auto,
20    /// Single-line, machine-readable text format.
21    Compact,
22    /// Multi-line, human-readable text format with ANSI colors.
23    Pretty,
24    /// Line-delimited JSON. Required for log aggregators (Vector, Promtail,
25    /// Datadog Agent, Fluent Bit).
26    Json,
27}
28
29impl Format {
30    /// Parse an env-var value.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`ParseFormatError`] instead of silently selecting [`Self::Auto`]
35    /// when the value is unknown.
36    pub fn from_env_value(value: &str) -> Result<Self, ParseFormatError> {
37        value.parse()
38    }
39}
40
41impl FromStr for Format {
42    type Err = ParseFormatError;
43
44    fn from_str(value: &str) -> Result<Self, Self::Err> {
45        match value.trim().to_ascii_lowercase().as_str() {
46            "auto" => Ok(Self::Auto),
47            "compact" => Ok(Self::Compact),
48            "pretty" => Ok(Self::Pretty),
49            "json" => Ok(Self::Json),
50            _ => Err(ParseFormatError),
51        }
52    }
53}
54
55/// An unknown logging format.
56#[non_exhaustive]
57#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
58#[error("expected one of: auto, compact, pretty, json")]
59pub struct ParseFormatError;
60
61/// Where to write log events.
62///
63/// `Auto` (default) emits to stderr on native targets. Set `KAMU_LOG_SINK`
64/// (`auto`, `stdout`, `stderr`, `journald`) to override without code changes.
65/// `Journald` is rejected on targets without the `systemd` feature. On wasm32,
66/// `Auto`, `Stdout`, and `Stderr` all map to the JavaScript console.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum Sink {
70    /// Portable default: stderr on native targets, JavaScript console on wasm32.
71    #[default]
72    Auto,
73    /// Write to stdout.
74    Stdout,
75    /// Write to stderr.
76    Stderr,
77    /// Write to the systemd journal. Requires the `systemd` feature.
78    Journald,
79}
80
81impl Sink {
82    /// Parse an env-var value.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`ParseSinkError`] instead of silently selecting [`Self::Auto`]
87    /// when the value is unknown.
88    pub fn from_env_value(value: &str) -> Result<Self, ParseSinkError> {
89        value.parse()
90    }
91}
92
93impl FromStr for Sink {
94    type Err = ParseSinkError;
95
96    fn from_str(value: &str) -> Result<Self, Self::Err> {
97        match value.trim().to_ascii_lowercase().as_str() {
98            "auto" => Ok(Self::Auto),
99            "stdout" => Ok(Self::Stdout),
100            "stderr" => Ok(Self::Stderr),
101            "journald" => Ok(Self::Journald),
102            _ => Err(ParseSinkError),
103        }
104    }
105}
106
107/// An unknown logging sink.
108#[non_exhaustive]
109#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
110#[error("expected one of: auto, stdout, stderr, journald")]
111pub struct ParseSinkError;
112
113/// Configuration for [`init_with`](crate::init_with).
114///
115/// Constructed via [`InitOptions::default`] and the `with_*` builder methods.
116/// Each method consumes `self` and returns `Self` to support chaining:
117///
118/// ```no_run
119/// use kamu_logging::{init_with, Format, InitOptions, Sink};
120///
121/// init_with(
122///     InitOptions::default()
123///         .with_service_name("my-service")
124///         .with_format(Format::Json)
125///         .with_sink(Sink::Stdout),
126/// )?;
127/// # Ok::<(), kamu_logging::Error>(())
128/// ```
129#[derive(Clone, Default)]
130pub struct InitOptions {
131    pub(crate) service_name: Option<String>,
132    pub(crate) default_filter: Option<String>,
133    pub(crate) env_var: Option<String>,
134    pub(crate) format: Format,
135    pub(crate) sink: Sink,
136    pub(crate) idempotent: bool,
137    #[cfg(feature = "with-otlp")]
138    pub(crate) otlp: Option<crate::otlp::OtlpConfig>,
139}
140
141impl fmt::Debug for InitOptions {
142    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143        let mut debug = formatter.debug_struct("InitOptions");
144        debug
145            .field("service_name", &self.service_name)
146            .field("default_filter", &self.default_filter)
147            .field("env_var", &self.env_var)
148            .field("format", &self.format)
149            .field("sink", &self.sink)
150            .field("idempotent", &self.idempotent);
151        #[cfg(feature = "with-otlp")]
152        debug.field("otlp", &self.otlp);
153        debug.finish()
154    }
155}
156
157impl InitOptions {
158    /// Attach `service.name` to the startup event and, when configured, use it
159    /// as the default OTLP resource service name.
160    #[must_use]
161    pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
162        self.service_name = Some(name.into());
163        self
164    }
165
166    /// Default filter directive when the env var is unset. Defaults to
167    /// `"debug"` in debug builds and `"info"` in release builds.
168    #[must_use]
169    pub fn with_default_filter(mut self, filter: impl Into<String>) -> Self {
170        self.default_filter = Some(filter.into());
171        self
172    }
173
174    /// Environment variable read for the filter directive. Defaults to
175    /// `"RUST_LOG"`. Useful for per-binary triggers like `"KKP_LOG"` to avoid
176    /// collisions with other tools' `RUST_LOG` settings.
177    #[must_use]
178    pub fn with_env_var(mut self, var: impl Into<String>) -> Self {
179        self.env_var = Some(var.into());
180        self
181    }
182
183    /// Output format. See [`Format`].
184    #[must_use]
185    pub fn with_format(mut self, format: Format) -> Self {
186        self.format = format;
187        self
188    }
189
190    /// Output sink. See [`Sink`].
191    #[must_use]
192    pub fn with_sink(mut self, sink: Sink) -> Self {
193        self.sink = sink;
194        self
195    }
196
197    /// When `true`, a second [`init_with`](crate::init_with) call returns
198    /// `Ok(())` when this crate installed the global subscriber.
199    ///
200    /// Default is `false` so library double-init surfaces as an error. Set
201    /// `true` from test harnesses and embedded CLI runs where re-init is
202    /// expected.
203    #[must_use]
204    pub fn idempotent(mut self, enabled: bool) -> Self {
205        self.idempotent = enabled;
206        self
207    }
208
209    /// Attach an OpenTelemetry OTLP exporter layer. Requires the `with-otlp`
210    /// feature.
211    #[cfg(feature = "with-otlp")]
212    #[must_use]
213    pub fn with_otlp(mut self, config: crate::otlp::OtlpConfig) -> Self {
214        self.otlp = Some(config);
215        self
216    }
217
218    #[cfg(feature = "systemd")]
219    pub(crate) fn resolved_env_var(&self) -> &str {
220        self.env_var.as_deref().unwrap_or("RUST_LOG")
221    }
222
223    pub(crate) fn resolved_default_filter(&self) -> &str {
224        if let Some(filter) = self.default_filter.as_deref() {
225            return filter;
226        }
227        if cfg!(debug_assertions) { "debug" } else { "info" }
228    }
229
230    #[cfg(feature = "wasm32")]
231    pub(crate) fn resolved_wasm_format(&self) -> Format {
232        match self.format {
233            Format::Auto => Format::Json,
234            format => format,
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn builder_sets_every_field() {
245        let opts = InitOptions::default()
246            .with_service_name("svc")
247            .with_default_filter("warn")
248            .with_env_var("KKP_LOG")
249            .with_format(Format::Json)
250            .with_sink(Sink::Stdout)
251            .idempotent(true);
252        assert_eq!(opts.service_name.as_deref(), Some("svc"));
253        assert_eq!(opts.default_filter.as_deref(), Some("warn"));
254        assert_eq!(opts.env_var.as_deref(), Some("KKP_LOG"));
255        assert_eq!(opts.format, Format::Json);
256        assert_eq!(opts.sink, Sink::Stdout);
257        assert!(opts.idempotent);
258    }
259
260    #[cfg(feature = "systemd")]
261    #[test]
262    fn resolved_helpers_apply_defaults_then_overrides() {
263        let default = InitOptions::default();
264        assert_eq!(default.resolved_env_var(), "RUST_LOG");
265        let expected = if cfg!(debug_assertions) { "debug" } else { "info" };
266        assert_eq!(default.resolved_default_filter(), expected);
267
268        let custom = InitOptions::default().with_env_var("KKP_LOG").with_default_filter("trace");
269        assert_eq!(custom.resolved_env_var(), "KKP_LOG");
270        assert_eq!(custom.resolved_default_filter(), "trace");
271    }
272}