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