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:   FSL-1.1-ALv2
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
76/// Log output format.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum LogFormat {
79    /// JSON output (for containers/log aggregators).
80    Json,
81    /// Human-readable coloured text.
82    Text,
83    /// Auto-detect based on environment (JSON in containers, Text on TTY).
84    #[default]
85    Auto,
86}
87
88impl LogFormat {
89    /// Resolve Auto to a concrete format.
90    #[must_use]
91    pub fn resolve(self) -> Self {
92        match self {
93            Self::Auto => {
94                if is_terminal() && !is_no_color() {
95                    Self::Text
96                } else {
97                    Self::Json
98                }
99            }
100            other => other,
101        }
102    }
103}
104
105impl std::str::FromStr for LogFormat {
106    type Err = LoggerError;
107
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        match s.to_lowercase().as_str() {
110            "json" => Ok(Self::Json),
111            "text" | "pretty" | "human" => Ok(Self::Text),
112            "auto" => Ok(Self::Auto),
113            _ => Err(LoggerError::InvalidLevel(s.to_string())),
114        }
115    }
116}
117
118/// Log throttle configuration.
119///
120/// Controls global rate limiting via `tracing-throttle`. Disabled by default.
121/// When enabled, identical log events are deduplicated using a token bucket policy.
122#[derive(Debug, Clone)]
123pub struct ThrottleConfig {
124    /// Enable log throttling.
125    pub enabled: bool,
126    /// Token bucket burst capacity (max events before throttling starts).
127    pub burst: f64,
128    /// Token recovery rate (tokens per second).
129    pub rate: f64,
130    /// Maximum number of distinct event signatures to track.
131    pub max_signatures: usize,
132    /// High-cardinality fields to exclude from signature matching.
133    /// Events differing only in these fields will be treated as identical.
134    pub excluded_fields: Vec<String>,
135}
136
137impl Default for ThrottleConfig {
138    fn default() -> Self {
139        Self {
140            enabled: false,
141            burst: 50.0,
142            rate: 1.0,
143            max_signatures: 10_000,
144            excluded_fields: vec![
145                "request_id".to_string(),
146                "trace_id".to_string(),
147                "span_id".to_string(),
148            ],
149        }
150    }
151}
152
153/// Logger configuration options.
154#[derive(Debug, Clone)]
155pub struct LoggerOptions {
156    /// Log level (DEBUG, INFO, WARN, ERROR).
157    pub level: Level,
158    /// Output format.
159    pub format: LogFormat,
160    /// Include source file and line in output.
161    pub add_source: bool,
162    /// Enable sensitive data masking.
163    pub enable_masking: bool,
164    /// Field names to mask.
165    pub sensitive_fields: Vec<String>,
166    /// Include span events.
167    pub span_events: bool,
168    /// Log throttle configuration (deduplicate identical events).
169    pub throttle: ThrottleConfig,
170    /// Service name injected into JSON log output.
171    /// Auto-populated by DfeApp. Falls back to SERVICE_NAME env var.
172    pub service_name: Option<String>,
173    /// Service version injected into JSON log output.
174    /// Auto-populated by DfeApp. Falls back to SERVICE_VERSION env var.
175    pub service_version: Option<String>,
176}
177
178impl Default for LoggerOptions {
179    fn default() -> Self {
180        Self {
181            level: Level::INFO,
182            format: LogFormat::Auto,
183            add_source: true,
184            enable_masking: true,
185            sensitive_fields: default_sensitive_fields(),
186            span_events: false,
187            throttle: ThrottleConfig::default(),
188            service_name: None,
189            service_version: None,
190        }
191    }
192}
193
194/// Initialise the global logger with custom options.
195///
196/// # Errors
197///
198/// Returns an error if the logger is already initialised.
199pub fn setup(opts: LoggerOptions) -> Result<(), LoggerError> {
200    if LOGGER_INIT.get().is_some() {
201        return Err(LoggerError::AlreadyInitialised);
202    }
203
204    let format = opts.format.resolve();
205
206    // Build the env filter
207    let filter = EnvFilter::try_from_default_env()
208        .unwrap_or_else(|_| EnvFilter::new(opts.level.to_string()));
209
210    // RFC 3339 timestamp format
211    let timer = UtcTime::rfc_3339();
212
213    let span_events = if opts.span_events {
214        FmtSpan::NEW | FmtSpan::CLOSE
215    } else {
216        FmtSpan::NONE
217    };
218
219    // Build sensitive fields set for masking writer
220    let sensitive: std::collections::HashSet<String> = if opts.enable_masking {
221        opts.sensitive_fields
222            .iter()
223            .map(|s| s.to_lowercase())
224            .collect()
225    } else {
226        std::collections::HashSet::new()
227    };
228
229    // Build optional throttle filter
230    let throttle_filter = build_throttle_filter(&opts.throttle);
231
232    match format {
233        LogFormat::Json => {
234            let writer = masking::make_masking_writer(
235                sensitive,
236                true,
237                opts.service_name.clone(),
238                opts.service_version.clone(),
239            );
240            let layer = tracing_subscriber::fmt::layer()
241                .json()
242                .with_timer(timer)
243                .with_file(opts.add_source)
244                .with_line_number(opts.add_source)
245                .with_target(true)
246                .with_span_events(span_events)
247                .with_writer(writer);
248
249            if let Some(throttle) = throttle_filter {
250                tracing_subscriber::registry()
251                    .with(filter)
252                    .with(layer.with_filter(throttle))
253                    .try_init()
254                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
255            } else {
256                tracing_subscriber::registry()
257                    .with(filter)
258                    .with(layer)
259                    .try_init()
260                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
261            }
262        }
263        LogFormat::Text => {
264            let writer = masking::make_masking_writer(sensitive, false, None, None);
265            let ansi = !is_no_color();
266            let formatter = format::ColouredFormatter::new(ansi)
267                .with_file(opts.add_source)
268                .with_line_number(opts.add_source);
269            let layer = tracing_subscriber::fmt::layer()
270                .with_ansi(ansi)
271                .with_span_events(span_events)
272                .event_format(formatter)
273                .with_writer(writer);
274
275            if let Some(throttle) = throttle_filter {
276                tracing_subscriber::registry()
277                    .with(filter)
278                    .with(layer.with_filter(throttle))
279                    .try_init()
280                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
281            } else {
282                tracing_subscriber::registry()
283                    .with(filter)
284                    .with(layer)
285                    .try_init()
286                    .map_err(|e| LoggerError::SetGlobalError(e.to_string()))?;
287            }
288        }
289        LogFormat::Auto => unreachable!("Auto should be resolved"),
290    }
291
292    let _ = LOGGER_INIT.set(());
293    Ok(())
294}
295
296/// Initialise the global logger with default settings.
297///
298/// Respects environment variables:
299/// - `LOG_LEVEL` or `RUST_LOG`: Log level
300/// - `LOG_FORMAT`: Output format (json, text, auto)
301/// - `NO_COLOR`: Disable coloured output
302/// - `LOG_THROTTLE_ENABLED`: Enable log deduplication (default: false)
303/// - `LOG_THROTTLE_BURST`: Token bucket burst capacity (default: 50)
304/// - `LOG_THROTTLE_RATE`: Token recovery rate per second (default: 1.0)
305///
306/// # Errors
307///
308/// Returns an error if the logger is already initialised.
309pub fn setup_default() -> Result<(), LoggerError> {
310    let level = std::env::var("LOG_LEVEL")
311        .or_else(|_| std::env::var("RUST_LOG"))
312        .ok()
313        .and_then(|s| s.parse().ok())
314        .unwrap_or(Level::INFO);
315
316    let format = std::env::var("LOG_FORMAT")
317        .ok()
318        .and_then(|s| s.parse().ok())
319        .unwrap_or(LogFormat::Auto);
320
321    let throttle_enabled = std::env::var("LOG_THROTTLE_ENABLED")
322        .ok()
323        .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
324
325    let throttle_burst = std::env::var("LOG_THROTTLE_BURST")
326        .ok()
327        .and_then(|v| v.parse().ok())
328        .unwrap_or(50.0);
329
330    let throttle_rate = std::env::var("LOG_THROTTLE_RATE")
331        .ok()
332        .and_then(|v| v.parse().ok())
333        .unwrap_or(1.0);
334
335    let service_name = std::env::var("SERVICE_NAME").ok();
336    let service_version = std::env::var("SERVICE_VERSION").ok();
337
338    setup(LoggerOptions {
339        level,
340        format,
341        throttle: ThrottleConfig {
342            enabled: throttle_enabled,
343            burst: throttle_burst,
344            rate: throttle_rate,
345            ..Default::default()
346        },
347        service_name,
348        service_version,
349        ..Default::default()
350    })
351}
352
353/// Build an optional throttle filter from configuration.
354fn build_throttle_filter(config: &ThrottleConfig) -> Option<TracingRateLimitLayer> {
355    if !config.enabled {
356        return None;
357    }
358
359    let policy = Policy::token_bucket(config.burst, config.rate)
360        .unwrap_or_else(|_| Policy::token_bucket(50.0, 1.0).expect("default policy is valid"));
361
362    let mut builder = TracingRateLimitLayer::builder()
363        .with_policy(policy)
364        .with_max_signatures(config.max_signatures);
365
366    if !config.excluded_fields.is_empty() {
367        builder = builder.with_excluded_fields(config.excluded_fields.clone());
368    }
369
370    match builder.build() {
371        Ok(layer) => Some(layer),
372        Err(e) => {
373            eprintln!("Failed to build log throttle layer: {e}");
374            None
375        }
376    }
377}
378
379/// Check if stderr is a terminal.
380fn is_terminal() -> bool {
381    use std::io::IsTerminal;
382    io::stderr().is_terminal()
383}
384
385/// Check if NO_COLOR environment variable is set.
386fn is_no_color() -> bool {
387    std::env::var("NO_COLOR").is_ok()
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_log_format_from_str() {
396        assert_eq!("json".parse::<LogFormat>().unwrap(), LogFormat::Json);
397        assert_eq!("text".parse::<LogFormat>().unwrap(), LogFormat::Text);
398        assert_eq!("pretty".parse::<LogFormat>().unwrap(), LogFormat::Text);
399        assert_eq!("auto".parse::<LogFormat>().unwrap(), LogFormat::Auto);
400    }
401
402    #[test]
403    fn test_log_format_resolve() {
404        // Json and Text should stay as-is
405        assert_eq!(LogFormat::Json.resolve(), LogFormat::Json);
406        assert_eq!(LogFormat::Text.resolve(), LogFormat::Text);
407
408        // Auto resolves based on environment
409        let resolved = LogFormat::Auto.resolve();
410        assert!(matches!(resolved, LogFormat::Json | LogFormat::Text));
411    }
412
413    #[test]
414    fn test_logger_options_default() {
415        let opts = LoggerOptions::default();
416        assert_eq!(opts.level, Level::INFO);
417        assert_eq!(opts.format, LogFormat::Auto);
418        assert!(opts.add_source);
419        assert!(opts.enable_masking);
420        assert!(!opts.sensitive_fields.is_empty());
421    }
422
423    #[test]
424    fn test_is_no_color() {
425        temp_env::with_var("NO_COLOR", None::<&str>, || assert!(!is_no_color()));
426        temp_env::with_var("NO_COLOR", Some("1"), || assert!(is_no_color()));
427    }
428}