Skip to main content

rskit_logging/
lib.rs

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