Skip to main content

hyperi_rustlib/logger/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/logger/mod.rs
3// Purpose:   Structured logging with JSON output and sensitive data masking
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Structured logging with JSON output and sensitive data masking.
10//!
11//! Provides production-ready logging matching hyperi-pylib (Python) and hyperi-golib (Go).
12//! Automatically detects terminal vs container environment for format selection.
13//!
14//! ## Features
15//!
16//! - RFC 3339 timestamps with timezone
17//! - JSON output for containers, coloured text for terminals
18//! - Sensitive data masking (passwords, tokens, API keys)
19//! - Environment variable overrides (LOG_LEVEL, LOG_FORMAT, NO_COLOR)
20//!
21//! ## Example
22//!
23//! ```rust,no_run
24//! use hyperi_rustlib::logger;
25//!
26//! // Initialise with defaults (auto-detects format)
27//! logger::setup_default().unwrap();
28//!
29//! // Use tracing macros
30//! tracing::info!(user_id = 123, "User logged in");
31//! tracing::error!(error = "connection failed", "Database error");
32//! ```
33
34pub mod format;
35pub mod helpers;
36mod masking;
37pub mod security;
38
39use std::io;
40use std::sync::OnceLock;
41
42use thiserror::Error;
43use tracing::Level;
44use tracing_subscriber::EnvFilter;
45use tracing_subscriber::Layer as _;
46use tracing_subscriber::fmt::format::FmtSpan;
47use tracing_subscriber::fmt::time::UtcTime;
48use tracing_subscriber::layer::SubscriberExt;
49use tracing_subscriber::util::SubscriberInitExt;
50
51use tracing_throttle::{Policy, TracingRateLimitLayer};
52
53pub use helpers::{log_debounced, log_sampled, log_state_change};
54pub use masking::{MaskingLayer, MaskingWriter, default_sensitive_fields, mask_sensitive_string};
55pub use security::{SecurityEvent, SecurityOutcome};
56
57/// Global flag to track initialisation.
58static LOGGER_INIT: OnceLock<()> = OnceLock::new();
59
60/// Logger errors.
61#[derive(Debug, Error)]
62pub enum LoggerError {
63    /// Logger already initialised.
64    #[error("logger already initialised")]
65    AlreadyInitialised,
66
67    /// Failed to set global subscriber.
68    #[error("failed to set global subscriber: {0}")]
69    SetGlobalError(String),
70
71    /// Invalid log level.
72    #[error("invalid log level: {0}")]
73    InvalidLevel(String),
74
75    /// Invalid log format (expected json, text, or auto).
76    #[error("invalid log format: {0}")]
77    InvalidFormat(String),
78}
79
80/// Log output format.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum LogFormat {
83    /// JSON output (for containers/log aggregators).
84    Json,
85    /// Human-readable coloured text.
86    Text,
87    /// Auto-detect based on environment (JSON in containers, Text on TTY).
88    #[default]
89    Auto,
90}
91
92impl LogFormat {
93    /// Resolve Auto to a concrete format.
94    #[must_use]
95    pub fn resolve(self) -> Self {
96        match self {
97            Self::Auto => {
98                if is_terminal() && !is_no_color() {
99                    Self::Text
100                } else {
101                    Self::Json
102                }
103            }
104            other => other,
105        }
106    }
107}
108
109impl std::str::FromStr for LogFormat {
110    type Err = LoggerError;
111
112    fn from_str(s: &str) -> Result<Self, Self::Err> {
113        match s.to_lowercase().as_str() {
114            "json" => Ok(Self::Json),
115            "text" | "pretty" | "human" => Ok(Self::Text),
116            "auto" => Ok(Self::Auto),
117            _ => Err(LoggerError::InvalidFormat(s.to_string())),
118        }
119    }
120}
121
122/// Log throttle configuration.
123///
124/// Controls global rate limiting via `tracing-throttle`. Disabled by default.
125/// When enabled, identical log events are deduplicated using a token bucket policy.
126#[derive(Debug, Clone)]
127pub struct ThrottleConfig {
128    /// Enable log throttling.
129    pub enabled: bool,
130    /// Token bucket burst capacity (max events before throttling starts).
131    pub burst: f64,
132    /// Token recovery rate (tokens per second).
133    pub rate: f64,
134    /// Maximum number of distinct event signatures to track.
135    pub max_signatures: usize,
136    /// High-cardinality fields to exclude from signature matching.
137    /// Events differing only in these fields will be treated as identical.
138    pub excluded_fields: Vec<String>,
139}
140
141impl Default for ThrottleConfig {
142    fn default() -> Self {
143        Self {
144            enabled: false,
145            burst: 50.0,
146            rate: 1.0,
147            max_signatures: 10_000,
148            excluded_fields: vec![
149                "request_id".to_string(),
150                "trace_id".to_string(),
151                "span_id".to_string(),
152            ],
153        }
154    }
155}
156
157/// Logger configuration options.
158#[derive(Debug, Clone)]
159pub struct LoggerOptions {
160    /// Log level (DEBUG, INFO, WARN, ERROR).
161    pub level: Level,
162    /// Output format.
163    pub format: LogFormat,
164    /// Include source file and line in output.
165    pub add_source: bool,
166    /// Enable sensitive data masking.
167    pub enable_masking: bool,
168    /// Field names to mask.
169    pub sensitive_fields: Vec<String>,
170    /// Include span events.
171    pub span_events: bool,
172    /// Log throttle configuration (deduplicate identical events).
173    pub throttle: ThrottleConfig,
174    /// Service name injected into JSON log output.
175    /// Auto-populated by DfeApp. Falls back to SERVICE_NAME env var.
176    pub service_name: Option<String>,
177    /// Service version injected into JSON log output.
178    /// Auto-populated by DfeApp. Falls back to SERVICE_VERSION env var.
179    pub service_version: Option<String>,
180}
181
182impl Default for LoggerOptions {
183    fn default() -> Self {
184        Self {
185            level: Level::INFO,
186            format: LogFormat::Auto,
187            add_source: true,
188            enable_masking: true,
189            sensitive_fields: default_sensitive_fields(),
190            span_events: false,
191            throttle: ThrottleConfig::default(),
192            service_name: None,
193            service_version: None,
194        }
195    }
196}
197
198/// Initialise the global logger with custom options.
199///
200/// # Errors
201///
202/// Returns an error if the logger is already initialised.
203pub fn setup(opts: LoggerOptions) -> Result<(), LoggerError> {
204    if LOGGER_INIT.get().is_some() {
205        return Err(LoggerError::AlreadyInitialised);
206    }
207
208    let format = opts.format.resolve();
209
210    // Build the env filter
211    let filter = EnvFilter::try_from_default_env()
212        .unwrap_or_else(|_| EnvFilter::new(opts.level.to_string()));
213
214    // RFC 3339 timestamp format
215    let timer = UtcTime::rfc_3339();
216
217    let span_events = if opts.span_events {
218        FmtSpan::NEW | FmtSpan::CLOSE
219    } else {
220        FmtSpan::NONE
221    };
222
223    // Build sensitive fields set for masking writer
224    let sensitive: std::collections::HashSet<String> = if opts.enable_masking {
225        opts.sensitive_fields
226            .iter()
227            .map(|s| s.to_lowercase())
228            .collect()
229    } else {
230        std::collections::HashSet::new()
231    };
232
233    // Build optional throttle filter
234    let throttle_filter = build_throttle_filter(&opts.throttle);
235
236    match format {
237        LogFormat::Json => {
238            let writer = masking::make_masking_writer(
239                sensitive,
240                true,
241                opts.service_name.clone(),
242                opts.service_version.clone(),
243            );
244            let layer = tracing_subscriber::fmt::layer()
245                .json()
246                .with_timer(timer)
247                .with_file(opts.add_source)
248                .with_line_number(opts.add_source)
249                .with_target(true)
250                .with_span_events(span_events)
251                .with_writer(writer);
252
253            if let Some(throttle) = throttle_filter {
254                tracing_subscriber::registry()
255                    .with(filter)
256                    .with(layer.with_filter(throttle))
257                    .try_init()
258                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
259            } else {
260                tracing_subscriber::registry()
261                    .with(filter)
262                    .with(layer)
263                    .try_init()
264                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
265            }
266        }
267        LogFormat::Text => {
268            let writer = masking::make_masking_writer(sensitive, false, None, None);
269            let ansi = !is_no_color();
270            let formatter = format::ColouredFormatter::new(ansi)
271                .with_file(opts.add_source)
272                .with_line_number(opts.add_source);
273            let layer = tracing_subscriber::fmt::layer()
274                .with_ansi(ansi)
275                .with_span_events(span_events)
276                .event_format(formatter)
277                .with_writer(writer);
278
279            if let Some(throttle) = throttle_filter {
280                tracing_subscriber::registry()
281                    .with(filter)
282                    .with(layer.with_filter(throttle))
283                    .try_init()
284                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
285            } else {
286                tracing_subscriber::registry()
287                    .with(filter)
288                    .with(layer)
289                    .try_init()
290                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
291            }
292        }
293        LogFormat::Auto => unreachable!("Auto should be resolved"),
294    }
295
296    let _ = LOGGER_INIT.set(());
297    Ok(())
298}
299
300/// Initialise the global logger with default settings.
301///
302/// Respects environment variables:
303/// - `LOG_LEVEL` or `RUST_LOG`: Log level
304/// - `LOG_FORMAT`: Output format (json, text, auto)
305/// - `NO_COLOR`: Disable coloured output
306/// - `LOG_THROTTLE_ENABLED`: Enable log deduplication (default: false)
307/// - `LOG_THROTTLE_BURST`: Token bucket burst capacity (default: 50)
308/// - `LOG_THROTTLE_RATE`: Token recovery rate per second (default: 1.0)
309///
310/// # Errors
311///
312/// Returns an error if the logger is already initialised.
313pub fn setup_default() -> Result<(), LoggerError> {
314    let level = std::env::var("LOG_LEVEL")
315        .or_else(|_| std::env::var("RUST_LOG"))
316        .ok()
317        .and_then(|s| s.parse().ok())
318        .unwrap_or(Level::INFO);
319
320    let format = std::env::var("LOG_FORMAT")
321        .ok()
322        .and_then(|s| s.parse().ok())
323        .unwrap_or(LogFormat::Auto);
324
325    let throttle_enabled = std::env::var("LOG_THROTTLE_ENABLED")
326        .ok()
327        .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
328
329    let throttle_burst = std::env::var("LOG_THROTTLE_BURST")
330        .ok()
331        .and_then(|v| v.parse().ok())
332        .unwrap_or(50.0);
333
334    let throttle_rate = std::env::var("LOG_THROTTLE_RATE")
335        .ok()
336        .and_then(|v| v.parse().ok())
337        .unwrap_or(1.0);
338
339    let service_name = std::env::var("SERVICE_NAME").ok();
340    let service_version = std::env::var("SERVICE_VERSION").ok();
341
342    setup(LoggerOptions {
343        level,
344        format,
345        throttle: ThrottleConfig {
346            enabled: throttle_enabled,
347            burst: throttle_burst,
348            rate: throttle_rate,
349            ..Default::default()
350        },
351        service_name,
352        service_version,
353        ..Default::default()
354    })
355}
356
357/// Build an optional throttle filter from configuration.
358fn build_throttle_filter(config: &ThrottleConfig) -> Option<TracingRateLimitLayer> {
359    if !config.enabled {
360        return None;
361    }
362
363    let policy = Policy::token_bucket(config.burst, config.rate)
364        .unwrap_or_else(|_| Policy::token_bucket(50.0, 1.0).expect("default policy is valid"));
365
366    let mut builder = TracingRateLimitLayer::builder()
367        .with_policy(policy)
368        .with_max_signatures(config.max_signatures);
369
370    if !config.excluded_fields.is_empty() {
371        builder = builder.with_excluded_fields(config.excluded_fields.clone());
372    }
373
374    match builder.build() {
375        Ok(layer) => Some(layer),
376        Err(e) => {
377            eprintln!("Failed to build log throttle layer: {e}");
378            None
379        }
380    }
381}
382
383/// Check if stderr is a terminal.
384fn is_terminal() -> bool {
385    use std::io::IsTerminal;
386    io::stderr().is_terminal()
387}
388
389/// Check if NO_COLOR environment variable is set.
390fn is_no_color() -> bool {
391    std::env::var("NO_COLOR").is_ok()
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn test_log_format_from_str() {
400        assert_eq!("json".parse::<LogFormat>().unwrap(), LogFormat::Json);
401        assert_eq!("text".parse::<LogFormat>().unwrap(), LogFormat::Text);
402        assert_eq!("pretty".parse::<LogFormat>().unwrap(), LogFormat::Text);
403        assert_eq!("auto".parse::<LogFormat>().unwrap(), LogFormat::Auto);
404        // A bad format string reports InvalidFormat, not InvalidLevel.
405        assert!(matches!(
406            "yaml".parse::<LogFormat>(),
407            Err(LoggerError::InvalidFormat(_))
408        ));
409    }
410
411    #[test]
412    fn test_log_format_resolve() {
413        // Json and Text should stay as-is
414        assert_eq!(LogFormat::Json.resolve(), LogFormat::Json);
415        assert_eq!(LogFormat::Text.resolve(), LogFormat::Text);
416
417        // Auto resolves based on environment
418        let resolved = LogFormat::Auto.resolve();
419        assert!(matches!(resolved, LogFormat::Json | LogFormat::Text));
420    }
421
422    #[test]
423    fn test_logger_options_default() {
424        let opts = LoggerOptions::default();
425        assert_eq!(opts.level, Level::INFO);
426        assert_eq!(opts.format, LogFormat::Auto);
427        assert!(opts.add_source);
428        assert!(opts.enable_masking);
429        assert!(!opts.sensitive_fields.is_empty());
430    }
431
432    #[test]
433    fn test_is_no_color() {
434        temp_env::with_var("NO_COLOR", None::<&str>, || assert!(!is_no_color()));
435        temp_env::with_var("NO_COLOR", Some("1"), || assert!(is_no_color()));
436    }
437}