Skip to main content

rskit_logging/
setup.rs

1//! Subscriber setup — building and installing `tracing` subscribers.
2//!
3//! This module owns everything that depends on the `tracing` ecosystem:
4//! [`init_logging`] and friends, the [`LoggingGuard`], and the
5//! `EnvFilter`/output plumbing. It is gated behind the default-on `setup`
6//! feature so that consumers wanting only the configuration vocabulary
7//! (see [`crate::config`]) do not link `tracing-subscriber`.
8
9use std::collections::HashMap;
10use std::fs::OpenOptions;
11use std::sync::Arc;
12
13use tracing::dispatcher::DefaultGuard;
14use tracing_subscriber::{EnvFilter, fmt, fmt::writer::BoxMakeWriter, layer::SubscriberExt};
15
16use crate::config::{LogFormat, LogOutput, LoggingConfig};
17use crate::error::{self, LoggingResult};
18use crate::masking::{self, MaskingConfig};
19use crate::module_levels;
20use crate::sampling::{self, SamplingConfig};
21
22/// Options for [`init_logging_full`].
23#[cfg(feature = "otlp")]
24pub struct LoggingSetup<'a> {
25    /// Base logging configuration.
26    pub config: &'a LoggingConfig,
27    /// Optional rate-based sampling configuration.
28    pub sampling: Option<&'a SamplingConfig>,
29    /// Optional per-module level overrides.
30    pub module_levels: Option<&'a HashMap<String, String>>,
31    /// Optional sensitive data masking configuration.
32    pub masking: Option<&'a MaskingConfig>,
33    /// Optional OTLP exporter configuration.
34    pub otlp: Option<&'a crate::otlp::OtlpConfig>,
35    /// Service name reported to OpenTelemetry.
36    pub service_name: &'a str,
37    /// Deployment environment reported to OpenTelemetry.
38    pub environment: &'a str,
39    /// Service version reported to OpenTelemetry.
40    pub version: &'a str,
41}
42
43#[cfg(feature = "otlp")]
44impl<'a> LoggingSetup<'a> {
45    /// Create full logging setup options with no optional layers enabled.
46    #[must_use]
47    pub const fn new(
48        config: &'a LoggingConfig,
49        service_name: &'a str,
50        environment: &'a str,
51        version: &'a str,
52    ) -> Self {
53        Self {
54            config,
55            sampling: None,
56            module_levels: None,
57            masking: None,
58            otlp: None,
59            service_name,
60            environment,
61            version,
62        }
63    }
64
65    /// Add rate-based sampling.
66    #[must_use]
67    pub const fn with_sampling(mut self, sampling: &'a SamplingConfig) -> Self {
68        self.sampling = Some(sampling);
69        self
70    }
71
72    /// Add per-module log level overrides.
73    #[must_use]
74    pub const fn with_module_levels(mut self, module_levels: &'a HashMap<String, String>) -> Self {
75        self.module_levels = Some(module_levels);
76        self
77    }
78
79    /// Add sensitive data masking.
80    #[must_use]
81    pub const fn with_masking(mut self, masking: &'a MaskingConfig) -> Self {
82        self.masking = Some(masking);
83        self
84    }
85
86    /// Add OTLP export.
87    #[must_use]
88    pub const fn with_otlp(mut self, otlp: &'a crate::otlp::OtlpConfig) -> Self {
89        self.otlp = Some(otlp);
90        self
91    }
92}
93
94/// Opaque guard — drop to restore the previous tracing subscriber.
95///
96/// Keep this alive for the lifetime of your service (e.g. bind it to a
97/// variable in `main`). When OTLP export is enabled through `init_logging_full`,
98/// the guard also owns the OTLP provider and shuts it
99/// down on drop to flush pending records.
100pub struct LoggingGuard {
101    #[allow(dead_code)]
102    guard: DefaultGuard,
103    #[cfg(feature = "otlp")]
104    otlp_provider: Option<crate::otlp::OtlpProvider>,
105}
106
107impl LoggingGuard {
108    fn new(guard: DefaultGuard) -> Self {
109        Self {
110            guard,
111            #[cfg(feature = "otlp")]
112            otlp_provider: None,
113        }
114    }
115
116    #[cfg(feature = "otlp")]
117    fn with_otlp_provider(
118        guard: DefaultGuard,
119        otlp_provider: Option<crate::otlp::OtlpProvider>,
120    ) -> Self {
121        Self {
122            guard,
123            otlp_provider,
124        }
125    }
126}
127
128#[cfg(feature = "otlp")]
129impl Drop for LoggingGuard {
130    fn drop(&mut self) {
131        if let Some(provider) = self.otlp_provider.take()
132            && let Err(error) = provider.shutdown()
133        {
134            tracing::warn!(%error, "failed to shut down OTLP logging provider");
135        }
136    }
137}
138
139/// Initialize structured logging from a [`LoggingConfig`] with default masking.
140///
141/// - `LogFormat::Json` → newline-delimited JSON (production)
142/// - `LogFormat::Console` → human-readable with colour (development)
143///
144/// Masking is **enabled by default** — sensitive fields are redacted before
145/// reaching the output sink.  Use [`init_logging_with_options`] for full
146/// control over masking, sampling, and per-module levels.
147///
148/// The `RUST_LOG` env var takes precedence over `cfg.level` when set.
149///
150/// # Errors
151///
152/// Returns an error when the configured file output cannot be opened.
153pub fn init_logging(cfg: &LoggingConfig) -> LoggingResult<LoggingGuard> {
154    init_logging_with_default_masking(cfg)
155}
156
157/// Initialize logging from `RUST_LOG` only (no config struct needed).
158pub fn init_logging_env() -> LoggingGuard {
159    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
160    let layer = fmt::layer().pretty();
161    let dispatcher = tracing_subscriber::registry()
162        .with(filter)
163        .with(layer)
164        .into();
165    LoggingGuard::new(tracing::dispatcher::set_default(&dispatcher))
166}
167
168fn init_logging_with_default_masking(cfg: &LoggingConfig) -> LoggingResult<LoggingGuard> {
169    let filter = build_filter(&cfg.level, None);
170    let masker: Arc<dyn masking::Masker> = Arc::new(masking::DefaultMasker::default());
171    let writer = masking::MaskingMakeWriter::new(build_output_writer(&cfg.output)?, masker);
172
173    let guard = match cfg.format {
174        LogFormat::Json => {
175            let layer = fmt::layer()
176                .json()
177                .with_current_span(true)
178                .with_span_list(true)
179                .with_writer(writer);
180            let dispatcher = tracing_subscriber::registry()
181                .with(filter)
182                .with(layer)
183                .into();
184            tracing::dispatcher::set_default(&dispatcher)
185        }
186        _ => {
187            let layer = fmt::layer().pretty().with_writer(writer);
188            let dispatcher = tracing_subscriber::registry()
189                .with(filter)
190                .with(layer)
191                .into();
192            tracing::dispatcher::set_default(&dispatcher)
193        }
194    };
195
196    Ok(LoggingGuard::new(guard))
197}
198
199/// Initialize logging with explicit masking configuration.
200///
201/// When `masking_cfg.enabled` is `true`, all log output passes through a
202/// [`MaskingMakeWriter`](crate::masking::MaskingMakeWriter) that redacts
203/// secrets and PII before they reach the output sink.  When masking is
204/// disabled, logging goes directly to the configured output.
205pub fn init_logging_with_masking(
206    cfg: &LoggingConfig,
207    masking_cfg: &masking::MaskingConfig,
208) -> LoggingResult<LoggingGuard> {
209    let m = if masking_cfg.enabled {
210        Some(masking_cfg)
211    } else {
212        None
213    };
214    init_logging_with_options(cfg, None, None, m)
215}
216
217/// Enhanced logging init with optional sampling, per-module levels, and masking.
218///
219/// This is the primary initialisation entry point.  All other `init_logging*`
220/// functions delegate here.
221///
222/// - **Sampling** — when `sampling` is `Some` and enabled, a
223///   [`SamplingLayer`](crate::sampling::SamplingLayer) is added to drop events
224///   exceeding per-level rate limits.
225/// - **Module levels** — when `module_levels` is `Some`, per-module filter directives
226///   are merged into the [`EnvFilter`].
227/// - **Masking** — when `masking_cfg` is `Some` and enabled, a
228///   [`MaskingMakeWriter`](crate::masking::MaskingMakeWriter) wraps the
229///   configured output to redact secrets.  Pass `None` to disable masking
230///   entirely.
231///
232/// # Errors
233///
234/// Returns an error when a custom masking regex pattern is invalid or when the
235/// configured file output cannot be opened.
236pub fn init_logging_with_options(
237    cfg: &LoggingConfig,
238    sampling_cfg: Option<&SamplingConfig>,
239    module_levels: Option<&HashMap<String, String>>,
240    masking_cfg: Option<&MaskingConfig>,
241) -> LoggingResult<LoggingGuard> {
242    let filter = build_filter(&cfg.level, module_levels);
243
244    let sampling_layer = sampling_cfg
245        .filter(|s| s.enabled)
246        .map(sampling::SamplingLayer::new);
247    let writer = build_output_writer(&cfg.output)?;
248
249    if let Some(m) = masking_cfg.filter(|m| m.enabled) {
250        let masker: Arc<dyn masking::Masker> = Arc::new(masking::DefaultMasker::new(m)?);
251        let writer = masking::MaskingMakeWriter::new(writer, masker);
252
253        let guard = match cfg.format {
254            LogFormat::Json => {
255                let layer = fmt::layer()
256                    .json()
257                    .with_current_span(true)
258                    .with_span_list(true)
259                    .with_writer(writer);
260                let dispatcher = tracing_subscriber::registry()
261                    .with(filter)
262                    .with(sampling_layer)
263                    .with(layer)
264                    .into();
265                tracing::dispatcher::set_default(&dispatcher)
266            }
267            _ => {
268                let layer = fmt::layer().pretty().with_writer(writer);
269                let dispatcher = tracing_subscriber::registry()
270                    .with(filter)
271                    .with(sampling_layer)
272                    .with(layer)
273                    .into();
274                tracing::dispatcher::set_default(&dispatcher)
275            }
276        };
277        return Ok(LoggingGuard::new(guard));
278    }
279
280    let guard = match cfg.format {
281        LogFormat::Json => {
282            let layer = fmt::layer()
283                .json()
284                .with_current_span(true)
285                .with_span_list(true)
286                .with_writer(writer);
287            let dispatcher = tracing_subscriber::registry()
288                .with(filter)
289                .with(sampling_layer)
290                .with(layer)
291                .into();
292            tracing::dispatcher::set_default(&dispatcher)
293        }
294        _ => {
295            let layer = fmt::layer().pretty().with_writer(writer);
296            let dispatcher = tracing_subscriber::registry()
297                .with(filter)
298                .with(sampling_layer)
299                .with(layer)
300                .into();
301            tracing::dispatcher::set_default(&dispatcher)
302        }
303    };
304
305    Ok(LoggingGuard::new(guard))
306}
307
308/// Build an [`EnvFilter`] from the configured level and optional module overrides.
309fn build_filter(level: &str, module_levels: Option<&HashMap<String, String>>) -> EnvFilter {
310    match module_levels {
311        Some(levels) if !levels.is_empty() => module_levels::build_env_filter(level, levels),
312        _ => EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)),
313    }
314}
315
316fn build_output_writer(output: &LogOutput) -> LoggingResult<BoxMakeWriter> {
317    let writer = match output {
318        LogOutput::Stdout => BoxMakeWriter::new(std::io::stdout),
319        LogOutput::Stderr => BoxMakeWriter::new(std::io::stderr),
320        LogOutput::File { path } => {
321            let file = OpenOptions::new()
322                .create(true)
323                .append(true)
324                .open(path)
325                .map_err(|err| error::log_file_open(path.clone(), err))?;
326            BoxMakeWriter::new(Arc::new(file))
327        }
328    };
329
330    Ok(writer)
331}
332
333/// Enhanced logging init with all options including OTLP export.
334///
335/// Layers the subscriber stack as follows:
336///
337/// 1. [`EnvFilter`] — base level + optional per-module overrides
338/// 2. Optional [`SamplingLayer`](crate::sampling::SamplingLayer) — rate-based log sampling
339/// 3. Format layer (JSON or console)
340/// 4. Optional [`OtlpProvider`](crate::otlp::OtlpProvider) layer — bridges events to OTel Logs SDK
341///
342/// The returned [`LoggingGuard`] **must** be held for the lifetime of the
343/// service.  When dropped it restores the previous subscriber and (when OTLP
344/// is enabled) shuts down the provider, flushing pending logs.
345///
346/// # Errors
347///
348/// Returns an error if the OTLP provider cannot be created (e.g. invalid
349/// endpoint or transport failure), or if a custom masking regex pattern is
350/// invalid.
351#[cfg(feature = "otlp")]
352pub fn init_logging_full(setup: LoggingSetup<'_>) -> LoggingResult<LoggingGuard> {
353    use crate::otlp;
354
355    let filter = build_filter(&setup.config.level, setup.module_levels);
356
357    let sampling_layer = setup
358        .sampling
359        .filter(|s| s.enabled)
360        .map(sampling::SamplingLayer::new);
361    let writer = build_output_writer(&setup.config.output)?;
362
363    let otlp_provider = match setup.otlp {
364        Some(oc) => {
365            otlp::OtlpProvider::new(oc, setup.service_name, setup.environment, setup.version)?
366        }
367        None => None,
368    };
369    let otlp_layer = otlp_provider
370        .as_ref()
371        .map(|p| p.layer::<tracing_subscriber::Registry>());
372
373    if let Some(m) = setup.masking.filter(|m| m.enabled) {
374        let masker: Arc<dyn masking::Masker> = Arc::new(masking::DefaultMasker::new(m)?);
375        let writer = masking::MaskingMakeWriter::new(writer, masker);
376
377        let guard = match setup.config.format {
378            LogFormat::Json => {
379                let layer = fmt::layer()
380                    .json()
381                    .with_current_span(true)
382                    .with_span_list(true)
383                    .with_writer(writer);
384                let dispatcher = tracing_subscriber::registry()
385                    .with(filter)
386                    .with(sampling_layer)
387                    .with(layer)
388                    .with(otlp_layer)
389                    .into();
390                tracing::dispatcher::set_default(&dispatcher)
391            }
392            _ => {
393                let layer = fmt::layer().pretty().with_writer(writer);
394                let dispatcher = tracing_subscriber::registry()
395                    .with(filter)
396                    .with(sampling_layer)
397                    .with(layer)
398                    .with(otlp_layer)
399                    .into();
400                tracing::dispatcher::set_default(&dispatcher)
401            }
402        };
403        return Ok(LoggingGuard::with_otlp_provider(guard, otlp_provider));
404    }
405
406    let guard = match setup.config.format {
407        LogFormat::Json => {
408            let layer = fmt::layer()
409                .json()
410                .with_current_span(true)
411                .with_span_list(true)
412                .with_writer(writer);
413            let dispatcher = tracing_subscriber::registry()
414                .with(filter)
415                .with(sampling_layer)
416                .with(layer)
417                .with(otlp_layer)
418                .into();
419            tracing::dispatcher::set_default(&dispatcher)
420        }
421        _ => {
422            let layer = fmt::layer().pretty().with_writer(writer);
423            let dispatcher = tracing_subscriber::registry()
424                .with(filter)
425                .with(sampling_layer)
426                .with(layer)
427                .with(otlp_layer)
428                .into();
429            tracing::dispatcher::set_default(&dispatcher)
430        }
431    };
432
433    Ok(LoggingGuard::with_otlp_provider(guard, otlp_provider))
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn init_console_does_not_panic() {
442        let cfg = LoggingConfig::default();
443        let _guard = init_logging(&cfg).unwrap();
444        tracing::info!("test log");
445    }
446
447    #[test]
448    fn init_json_does_not_panic() {
449        let cfg = LoggingConfig {
450            format: LogFormat::Json,
451            ..Default::default()
452        };
453        let _guard = init_logging(&cfg).unwrap();
454        tracing::info!("test json log");
455    }
456}