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}