Skip to main content

torrust_tracker_deployer_lib/bootstrap/
logging.rs

1//! Simplified Structured Logging Configuration
2//!
3//! Provides basic logging configuration with tracing spans for the three-level architecture:
4//! - Commands (Level 1): Top-level orchestration
5//! - Steps (Level 2): Mid-level execution units  
6//! - Remote Actions (Level 3): Leaf-level operations
7//!
8//! ## Persistent Logging
9//!
10//! All logs are always written to a log file for persistent storage.
11//! This enables post-mortem analysis and troubleshooting of production deployments.
12//!
13//! By default, logs are written to `./data/logs/log.txt` in production environments.
14//! For testing, a different log directory can be specified to avoid polluting production data.
15//!
16//! ## Optional Stderr Output
17//!
18//! Logs can optionally be written to stderr for real-time visibility during development
19//! and testing. This is controlled by the `LogOutput` parameter:
20//!
21//! - `LogOutput::FileOnly` - Production mode: logs to file only
22//! - `LogOutput::FileAndStderr` - Development/testing: logs to both file and stderr
23//!
24//! ## Usage
25//!
26//! ### Builder Pattern (Recommended)
27//!
28//! ```rust,no_run
29//! use std::path::Path;
30//! use torrust_tracker_deployer_lib::bootstrap::logging::{LogOutput, LogFormat, LoggingBuilder};
31//!
32//! // Flexible builder API
33//! LoggingBuilder::new(Path::new("./data/logs"))
34//!     .with_format(LogFormat::Compact)
35//!     .with_output(LogOutput::FileAndStderr)
36//!     .init();
37//! ```
38//!
39//! ### Convenience Functions
40//!
41//! ```rust,no_run
42//! use std::path::Path;
43//! use torrust_tracker_deployer_lib::bootstrap::logging::{LogOutput, init_compact};
44//!
45//! // E2E tests - enable stderr visibility with production log location
46//! init_compact(Path::new("./data/logs"), LogOutput::FileAndStderr);
47//!
48//! // Production - file only
49//! init_compact(Path::new("./data/logs"), LogOutput::FileOnly);
50//!
51//! // Integration tests - isolated temp directory
52//! init_compact(Path::new("/tmp/test-xyz/data/logs"), LogOutput::FileAndStderr);
53//! ```
54
55use std::io;
56use std::path::Path;
57use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
58
59/// Log file name used by the logging system
60pub const LOG_FILE_NAME: &str = "log.txt";
61
62/// Output target for logging
63#[derive(Clone, Copy, Debug, clap::ValueEnum)]
64pub enum LogOutput {
65    /// Write logs to file only (production mode)
66    FileOnly,
67    /// Write logs to both file and stderr (development/testing mode)
68    FileAndStderr,
69}
70
71/// Logging format options for different environments
72#[derive(Clone, Debug, clap::ValueEnum)]
73pub enum LogFormat {
74    /// Pretty-printed console output for development (default)
75    Pretty,
76    /// JSON output for production environments
77    Json,
78    /// Compact console output for minimal verbosity
79    Compact,
80}
81
82// ============================================================================
83// LOGGING CONFIGURATION - Domain Type
84// ============================================================================
85
86/// Configuration for logging system initialization
87///
88/// This struct represents the domain-specific logging configuration that is
89/// independent of CLI parsing concerns. It can be constructed from CLI arguments
90/// or other configuration sources without creating circular dependencies.
91///
92/// # Design Principles
93///
94/// - **Independence**: No dependency on presentation layer types
95/// - **Reusability**: Can be constructed from various sources (CLI, config files, tests)
96/// - **Clarity**: Clear field names and comprehensive documentation
97#[derive(Debug, Clone)]
98pub struct LoggingConfig {
99    /// Directory where log files will be written
100    pub log_dir: std::path::PathBuf,
101
102    /// Format for file logging output
103    pub file_format: LogFormat,
104
105    /// Format for stderr logging output
106    pub stderr_format: LogFormat,
107
108    /// Output target (file-only vs file-and-stderr)
109    pub output: LogOutput,
110}
111
112impl LoggingConfig {
113    /// Create a new logging configuration
114    ///
115    /// # Arguments
116    ///
117    /// * `log_dir` - Directory for log files
118    /// * `file_format` - Format for file output
119    /// * `stderr_format` - Format for stderr output
120    /// * `output` - Output target configuration
121    #[must_use]
122    pub fn new(
123        log_dir: std::path::PathBuf,
124        file_format: LogFormat,
125        stderr_format: LogFormat,
126        output: LogOutput,
127    ) -> Self {
128        Self {
129            log_dir,
130            file_format,
131            stderr_format,
132            output,
133        }
134    }
135}
136
137// ============================================================================
138// BUILDER PATTERN - Core Implementation
139// ============================================================================
140
141/// Builder for constructing a tracing subscriber with flexible configuration
142///
143/// This builder provides a fluent API for configuring logging with different
144/// formats and output targets. It eliminates code duplication by centralizing
145/// layer creation and subscriber initialization.
146///
147/// Supports independent format control for file and stderr outputs, with
148/// automatic ANSI code handling (disabled for files, enabled for stderr).
149///
150/// # Examples
151///
152/// ```rust,no_run
153/// use std::path::Path;
154/// use torrust_tracker_deployer_lib::bootstrap::logging::{LogOutput, LogFormat, LoggingBuilder};
155///
156/// // Basic usage with defaults (Compact file format, Pretty stderr format, FileAndStderr output)
157/// LoggingBuilder::new(Path::new("./data/logs")).init();
158///
159/// // Custom configuration with independent formats
160/// LoggingBuilder::new(Path::new("./data/logs"))
161///     .with_file_format(LogFormat::Json)
162///     .with_stderr_format(LogFormat::Pretty)
163///     .with_output(LogOutput::FileAndStderr)
164///     .init();
165///
166/// // Backward compatible with single format for both outputs
167/// LoggingBuilder::new(Path::new("./data/logs"))
168///     .with_format(LogFormat::Compact)
169///     .with_output(LogOutput::FileOnly)
170///     .init();
171/// ```
172pub struct LoggingBuilder {
173    log_dir: std::path::PathBuf,
174    file_format: LogFormat,
175    stderr_format: LogFormat,
176    output: LogOutput,
177}
178
179impl LoggingBuilder {
180    /// Create a new logging builder with default settings
181    ///
182    /// Default configuration:
183    /// - File Format: `LogFormat::Compact` (no ANSI codes)
184    /// - Stderr Format: `LogFormat::Pretty` (with ANSI codes)
185    /// - Output: `LogOutput::FileAndStderr`
186    ///
187    /// # Arguments
188    ///
189    /// * `log_dir` - Directory where log files should be written (e.g., `./data/logs`)
190    #[must_use]
191    pub fn new(log_dir: &Path) -> Self {
192        Self {
193            log_dir: log_dir.to_path_buf(),
194            file_format: LogFormat::Compact,
195            stderr_format: LogFormat::Pretty,
196            output: LogOutput::FileAndStderr,
197        }
198    }
199
200    /// Set the logging format for both file and stderr outputs
201    ///
202    /// This is a convenience method for backward compatibility.
203    /// For independent format control, use `with_file_format()` and `with_stderr_format()`.
204    ///
205    /// # Arguments
206    ///
207    /// * `format` - The desired logging format (Pretty, Json, or Compact)
208    #[must_use]
209    pub fn with_format(mut self, format: LogFormat) -> Self {
210        self.file_format = format.clone();
211        self.stderr_format = format;
212        self
213    }
214
215    /// Set the logging format for file output
216    ///
217    /// ANSI codes are automatically disabled for file output to ensure
218    /// logs are easily parsed with standard text tools (grep, awk, sed).
219    ///
220    /// # Arguments
221    ///
222    /// * `format` - The desired logging format for files (Pretty, Json, or Compact)
223    #[must_use]
224    pub fn with_file_format(mut self, format: LogFormat) -> Self {
225        self.file_format = format;
226        self
227    }
228
229    /// Set the logging format for stderr output
230    ///
231    /// ANSI codes are automatically enabled for stderr output to provide
232    /// colored terminal output for better readability.
233    ///
234    /// # Arguments
235    ///
236    /// * `format` - The desired logging format for stderr (Pretty, Json, or Compact)
237    #[must_use]
238    pub fn with_stderr_format(mut self, format: LogFormat) -> Self {
239        self.stderr_format = format;
240        self
241    }
242
243    /// Set the output target
244    ///
245    /// # Arguments
246    ///
247    /// * `output` - Where to write logs (`FileOnly` or `FileAndStderr`)
248    #[must_use]
249    pub fn with_output(mut self, output: LogOutput) -> Self {
250        self.output = output;
251        self
252    }
253
254    /// Initialize the global tracing subscriber with the configured settings
255    ///
256    /// This consumes the builder and sets up the global logging infrastructure.
257    /// After calling this, all logging macros (`tracing::info!`, etc.) will use
258    /// this configuration.
259    ///
260    /// # Panics
261    ///
262    /// Panics if:
263    /// - Log directory cannot be created (filesystem permissions issue)
264    /// - Subscriber initialization fails (usually means it was already initialized)
265    ///
266    /// Both panics are intentional as logging is critical for observability.
267    pub fn init(self) {
268        let config = LoggingConfig::new(
269            self.log_dir,
270            self.file_format,
271            self.stderr_format,
272            self.output,
273        );
274        init_subscriber(config);
275    }
276}
277
278// ============================================================================
279// PUBLIC INITIALIZATION FUNCTIONS
280// ============================================================================
281
282/// Initialize logging with the provided configuration
283///
284/// This function takes a `LoggingConfig` and sets up the global logging infrastructure.
285/// After calling this, all logging macros (`tracing::info!`, etc.) will use
286/// this configuration.
287///
288/// This is the single source of truth for subscriber initialization.
289/// All other init functions delegate to this to eliminate duplication.
290///
291/// Automatically configures ANSI codes:
292/// - File output: ANSI codes disabled (clean text for parsing)
293/// - Stderr output: ANSI codes enabled (colored terminal output)
294///
295/// Note: We cannot extract the format-specific layer creation into a separate
296/// function because each format (Pretty, Json, Compact) creates a different
297/// concrete type, and Rust's type system requires all match arms to return
298/// the same type. Type erasure with boxed layers would work but adds runtime
299/// overhead for a one-time initialization cost.
300///
301/// # Arguments
302///
303/// * `config` - The logging configuration containing all settings
304///
305/// # Panics
306///
307/// Panics if:
308/// - Log directory cannot be created (filesystem permissions issue)
309/// - Subscriber initialization fails (usually means it was already initialized)
310///
311/// Both panics are intentional as logging is critical for observability.
312///
313/// # Example
314///
315/// ```rust,no_run
316/// use std::path::PathBuf;
317/// use torrust_tracker_deployer_lib::bootstrap::logging::{LogFormat, LogOutput, LoggingConfig, init_subscriber};
318///
319/// let config = LoggingConfig::new(
320///     PathBuf::from("./data/logs"),
321///     LogFormat::Compact,
322///     LogFormat::Pretty,
323///     LogOutput::FileAndStderr,
324/// );
325///
326/// init_subscriber(config);
327/// ```
328#[allow(clippy::too_many_lines)]
329pub fn init_subscriber(config: LoggingConfig) {
330    let file_appender = create_log_file_appender(&config.log_dir);
331    let env_filter = create_env_filter();
332
333    match config.output {
334        LogOutput::FileOnly => {
335            // File-only mode: single layer with ANSI disabled
336            match config.file_format {
337                LogFormat::Pretty => {
338                    tracing_subscriber::registry()
339                        .with(
340                            fmt::layer()
341                                .pretty()
342                                .with_ansi(false)
343                                .with_writer(file_appender),
344                        )
345                        .with(env_filter)
346                        .init();
347                }
348                LogFormat::Json => {
349                    tracing_subscriber::registry()
350                        .with(
351                            fmt::layer()
352                                .json()
353                                .with_ansi(false)
354                                .with_writer(file_appender),
355                        )
356                        .with(env_filter)
357                        .init();
358                }
359                LogFormat::Compact => {
360                    tracing_subscriber::registry()
361                        .with(
362                            fmt::layer()
363                                .compact()
364                                .with_ansi(false)
365                                .with_writer(file_appender),
366                        )
367                        .with(env_filter)
368                        .init();
369                }
370            }
371        }
372        LogOutput::FileAndStderr => {
373            // Dual output mode: file layer (no ANSI) + stderr layer (with ANSI)
374            match (config.file_format, config.stderr_format) {
375                // Pretty file format combinations
376                (LogFormat::Pretty, LogFormat::Pretty) => {
377                    tracing_subscriber::registry()
378                        .with(
379                            fmt::layer()
380                                .pretty()
381                                .with_ansi(false)
382                                .with_writer(file_appender),
383                        )
384                        .with(
385                            fmt::layer()
386                                .pretty()
387                                .with_ansi(true)
388                                .with_writer(io::stderr),
389                        )
390                        .with(env_filter)
391                        .init();
392                }
393                (LogFormat::Pretty, LogFormat::Json) => {
394                    tracing_subscriber::registry()
395                        .with(
396                            fmt::layer()
397                                .pretty()
398                                .with_ansi(false)
399                                .with_writer(file_appender),
400                        )
401                        .with(fmt::layer().json().with_ansi(true).with_writer(io::stderr))
402                        .with(env_filter)
403                        .init();
404                }
405                (LogFormat::Pretty, LogFormat::Compact) => {
406                    tracing_subscriber::registry()
407                        .with(
408                            fmt::layer()
409                                .pretty()
410                                .with_ansi(false)
411                                .with_writer(file_appender),
412                        )
413                        .with(
414                            fmt::layer()
415                                .compact()
416                                .with_ansi(true)
417                                .with_writer(io::stderr),
418                        )
419                        .with(env_filter)
420                        .init();
421                }
422                // JSON file format combinations
423                (LogFormat::Json, LogFormat::Pretty) => {
424                    tracing_subscriber::registry()
425                        .with(
426                            fmt::layer()
427                                .json()
428                                .with_ansi(false)
429                                .with_writer(file_appender),
430                        )
431                        .with(
432                            fmt::layer()
433                                .pretty()
434                                .with_ansi(true)
435                                .with_writer(io::stderr),
436                        )
437                        .with(env_filter)
438                        .init();
439                }
440                (LogFormat::Json, LogFormat::Json) => {
441                    tracing_subscriber::registry()
442                        .with(
443                            fmt::layer()
444                                .json()
445                                .with_ansi(false)
446                                .with_writer(file_appender),
447                        )
448                        .with(fmt::layer().json().with_ansi(true).with_writer(io::stderr))
449                        .with(env_filter)
450                        .init();
451                }
452                (LogFormat::Json, LogFormat::Compact) => {
453                    tracing_subscriber::registry()
454                        .with(
455                            fmt::layer()
456                                .json()
457                                .with_ansi(false)
458                                .with_writer(file_appender),
459                        )
460                        .with(
461                            fmt::layer()
462                                .compact()
463                                .with_ansi(true)
464                                .with_writer(io::stderr),
465                        )
466                        .with(env_filter)
467                        .init();
468                }
469                // Compact file format combinations
470                (LogFormat::Compact, LogFormat::Pretty) => {
471                    tracing_subscriber::registry()
472                        .with(
473                            fmt::layer()
474                                .compact()
475                                .with_ansi(false)
476                                .with_writer(file_appender),
477                        )
478                        .with(
479                            fmt::layer()
480                                .pretty()
481                                .with_ansi(true)
482                                .with_writer(io::stderr),
483                        )
484                        .with(env_filter)
485                        .init();
486                }
487                (LogFormat::Compact, LogFormat::Json) => {
488                    tracing_subscriber::registry()
489                        .with(
490                            fmt::layer()
491                                .compact()
492                                .with_ansi(false)
493                                .with_writer(file_appender),
494                        )
495                        .with(fmt::layer().json().with_ansi(true).with_writer(io::stderr))
496                        .with(env_filter)
497                        .init();
498                }
499                (LogFormat::Compact, LogFormat::Compact) => {
500                    tracing_subscriber::registry()
501                        .with(
502                            fmt::layer()
503                                .compact()
504                                .with_ansi(false)
505                                .with_writer(file_appender),
506                        )
507                        .with(
508                            fmt::layer()
509                                .compact()
510                                .with_ansi(true)
511                                .with_writer(io::stderr),
512                        )
513                        .with(env_filter)
514                        .init();
515                }
516            }
517        }
518    }
519}
520
521/// Create the environment filter from `RUST_LOG` or default to "info"
522///
523/// This reads the `RUST_LOG` environment variable to determine the log level.
524/// If not set, defaults to "info" level logging.
525fn create_env_filter() -> EnvFilter {
526    EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
527}
528
529/// Create the log file appender that writes to `{log_dir}/log.txt`
530///
531/// This function creates the log directory if it doesn't exist and returns
532/// a non-blocking file appender that will append to the log file.
533///
534/// # Arguments
535///
536/// * `log_dir` - Directory where log files should be written (e.g., `./data/logs` for production)
537///
538/// # Panics
539///
540/// Panics if the log directory cannot be created. This is intentional as
541/// logging is critical for observability.
542fn create_log_file_appender(log_dir: &Path) -> tracing_appender::non_blocking::NonBlocking {
543    // Create directory if it doesn't exist
544    std::fs::create_dir_all(log_dir).unwrap_or_else(|_| {
545        panic!(
546            "Failed to create log directory: {} - check filesystem permissions",
547            log_dir.display()
548        )
549    });
550
551    // Create file appender (appends to existing file)
552    let file_appender = tracing_appender::rolling::never(log_dir, LOG_FILE_NAME);
553
554    // Use non-blocking writer for better performance
555    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
556
557    // Note: We intentionally leak the guard to keep the file open for the application lifetime
558    std::mem::forget(guard);
559
560    non_blocking
561}
562
563// ============================================================================
564// CONVENIENCE FUNCTIONS - Thin Wrappers for Backward Compatibility
565// ============================================================================
566
567/// Initialize the tracing subscriber with default pretty formatting
568///
569/// This is a convenience wrapper around `LoggingBuilder` for backward compatibility.
570/// Consider using `LoggingBuilder` directly for more flexibility.
571///
572/// Sets up structured logging with:
573/// - File output to `{log_dir}/log.txt` (always enabled)
574/// - Optional stderr output based on `output` parameter
575/// - Pretty-printed format for development
576/// - Environment-based filtering via `RUST_LOG`
577/// - Support for hierarchical spans across three levels
578///
579/// # Arguments
580///
581/// * `log_dir` - Directory where log files should be written (e.g., `./data/logs` for production)
582/// * `output` - Where to write logs (file only or file + stderr)
583///
584/// # Panics
585///
586/// Panics if log file cannot be created or log directory cannot be created.
587/// This is intentional as logging is critical for observability.
588///
589/// # Example
590/// ```rust,no_run
591/// use std::path::Path;
592/// use torrust_tracker_deployer_lib::bootstrap::logging::{LogOutput, init};
593///
594/// // E2E tests - enable stderr visibility with production location
595/// init(Path::new("./data/logs"), LogOutput::FileAndStderr);
596///
597/// // Production - file only
598/// init(Path::new("./data/logs"), LogOutput::FileOnly);
599///
600/// // Testing - isolated temp directory
601/// init(Path::new("/tmp/test-xyz/data/logs"), LogOutput::FileAndStderr);
602/// ```
603pub fn init(log_dir: &Path, output: LogOutput) {
604    LoggingBuilder::new(log_dir)
605        .with_format(LogFormat::Pretty)
606        .with_output(output)
607        .init();
608}
609
610/// Initialize the tracing subscriber with JSON formatting
611///
612/// This is a convenience wrapper around `LoggingBuilder` for backward compatibility.
613/// Consider using `LoggingBuilder` directly for more flexibility.
614///
615/// Sets up structured logging with:
616/// - File output to `{log_dir}/log.txt` (always enabled)
617/// - Optional stderr output based on `output` parameter
618/// - JSON output format for production environments
619/// - Environment-based filtering via `RUST_LOG`
620/// - Machine-readable log format for monitoring systems
621///
622/// # Arguments
623///
624/// * `log_dir` - Directory where log files should be written (e.g., `./data/logs` for production)
625/// * `output` - Where to write logs (file only or file + stderr)
626///
627/// # Panics
628///
629/// Panics if log file cannot be created or log directory cannot be created.
630/// This is intentional as logging is critical for observability.
631///
632/// # Example
633/// ```rust,no_run
634/// use std::path::Path;
635/// use torrust_tracker_deployer_lib::bootstrap::logging::{LogOutput, init_json};
636///
637/// // E2E tests - enable stderr visibility with production location
638/// init_json(Path::new("./data/logs"), LogOutput::FileAndStderr);
639///
640/// // Production - file only
641/// init_json(Path::new("./data/logs"), LogOutput::FileOnly);
642///
643/// // Testing - isolated temp directory
644/// init_json(Path::new("/tmp/test-xyz/data/logs"), LogOutput::FileAndStderr);
645/// ```
646pub fn init_json(log_dir: &Path, output: LogOutput) {
647    LoggingBuilder::new(log_dir)
648        .with_format(LogFormat::Json)
649        .with_output(output)
650        .init();
651}
652
653/// Initialize the tracing subscriber with compact formatting
654///
655/// This is a convenience wrapper around `LoggingBuilder` for backward compatibility.
656/// Consider using `LoggingBuilder` directly for more flexibility.
657///
658/// Sets up structured logging with:
659/// - File output to `{log_dir}/log.txt` (always enabled)
660/// - Optional stderr output based on `output` parameter
661/// - Compact console output for minimal verbosity
662/// - Environment-based filtering via `RUST_LOG`
663/// - Space-efficient format for development
664///
665/// # Arguments
666///
667/// * `log_dir` - Directory where log files should be written (e.g., `./data/logs` for production)
668/// * `output` - Where to write logs (file only or file + stderr)
669///
670/// # Panics
671///
672/// Panics if log file cannot be created or log directory cannot be created.
673/// This is intentional as logging is critical for observability.
674///
675/// # Example
676/// ```rust,no_run
677/// use std::path::Path;
678/// use torrust_tracker_deployer_lib::bootstrap::logging::{LogOutput, init_compact};
679///
680/// // E2E tests - enable stderr visibility with production location
681/// init_compact(Path::new("./data/logs"), LogOutput::FileAndStderr);
682///
683/// // Production - file only
684/// init_compact(Path::new("./data/logs"), LogOutput::FileOnly);
685///
686/// // Testing - isolated temp directory
687/// init_compact(Path::new("/tmp/test-xyz/data/logs"), LogOutput::FileAndStderr);
688/// ```
689pub fn init_compact(log_dir: &Path, output: LogOutput) {
690    LoggingBuilder::new(log_dir)
691        .with_format(LogFormat::Compact)
692        .with_output(output)
693        .init();
694}
695
696/// Initialize logging based on the chosen format and output target
697///
698/// This is a convenience wrapper around `LoggingBuilder` for backward compatibility.
699/// Consider using `LoggingBuilder` directly for more flexibility.
700///
701/// This function applies the same format to both file and stderr outputs.
702/// For independent format control, use `LoggingBuilder` with `with_file_format()`
703/// and `with_stderr_format()`.
704///
705/// # Arguments
706///
707/// * `log_dir` - Directory where log files should be written (e.g., `./data/logs` for production)
708/// * `output` - Where to write logs (file only or file + stderr)
709/// * `format` - The logging format to use for both outputs
710///
711/// # Panics
712///
713/// Panics if log file cannot be created or log directory cannot be created.
714/// This is intentional as logging is critical for observability.
715///
716/// # Example
717/// ```rust,no_run
718/// use std::path::Path;
719/// use torrust_tracker_deployer_lib::bootstrap::logging::{LogFormat, LogOutput, init_with_format};
720///
721/// // Initialize with JSON format for E2E tests with production location
722/// init_with_format(Path::new("./data/logs"), LogOutput::FileAndStderr, &LogFormat::Json);
723///
724/// // Initialize with compact format for production
725/// init_with_format(Path::new("./data/logs"), LogOutput::FileOnly, &LogFormat::Compact);
726///
727/// // Initialize for testing with isolated directory
728/// init_with_format(Path::new("/tmp/test-xyz/data/logs"), LogOutput::FileAndStderr, &LogFormat::Pretty);
729/// ```
730pub fn init_with_format(log_dir: &Path, output: LogOutput, format: &LogFormat) {
731    LoggingBuilder::new(log_dir)
732        .with_format(format.clone())
733        .with_output(output)
734        .init();
735}