Skip to main content

lgp/utils/
tracing.rs

1//! Tracing configuration and initialization for the LGP framework.
2//!
3//! This module provides utilities for setting up structured logging and tracing
4//! throughout the Linear GP system.
5//!
6//! # Usage
7//!
8//! ```rust,no_run
9//! use lgp::utils::tracing::{TracingConfig, init_tracing};
10//!
11//! // Initialize with defaults (reads RUST_LOG and LGP_LOG_FORMAT env vars)
12//! init_tracing(TracingConfig::default());
13//!
14//! // Or with custom configuration
15//! use lgp::utils::tracing::TracingFormat;
16//! let config = TracingConfig::new()
17//!     .with_format(TracingFormat::Json)
18//!     .with_span_events(true);
19//! init_tracing(config);
20//! ```
21//!
22//! # Environment Variables
23//!
24//! - `RUST_LOG`: Controls log level filtering (e.g., `lgp=debug`, `lgp=trace`)
25//! - `LGP_LOG_FORMAT`: Override output format (`pretty`, `compact`, `json`)
26
27use std::env;
28use std::path::PathBuf;
29use tracing_appender::non_blocking::WorkerGuard;
30use tracing_subscriber::{
31    fmt::{self, format::FmtSpan},
32    prelude::*,
33    EnvFilter,
34};
35
36/// Output format for tracing logs.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum TracingFormat {
39    /// Human-readable, colorized output with full span information.
40    /// Best for development and debugging.
41    #[default]
42    Pretty,
43    /// Condensed single-line output.
44    /// Good for production with moderate verbosity.
45    Compact,
46    /// JSON-structured output.
47    /// Best for log aggregation systems (ELK, Datadog, etc.).
48    Json,
49}
50
51impl std::str::FromStr for TracingFormat {
52    type Err = String;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        match s.to_lowercase().as_str() {
56            "pretty" => Ok(TracingFormat::Pretty),
57            "compact" => Ok(TracingFormat::Compact),
58            "json" => Ok(TracingFormat::Json),
59            _ => Err(format!(
60                "Unknown format: {}. Expected: pretty, compact, or json",
61                s
62            )),
63        }
64    }
65}
66
67impl TracingFormat {
68    /// Parse format from string (case-insensitive), returning None if invalid.
69    pub fn parse(s: &str) -> Option<Self> {
70        s.parse().ok()
71    }
72}
73
74/// Configuration for tracing initialization.
75#[derive(Debug, Clone)]
76pub struct TracingConfig {
77    /// Output format for logs.
78    pub format: TracingFormat,
79    /// Whether to log span enter/exit events.
80    pub span_events: bool,
81    /// Whether to include file name and line numbers in output.
82    pub file_info: bool,
83    /// Whether to include thread IDs in output.
84    pub thread_ids: bool,
85    /// Whether to include thread names in output.
86    pub thread_names: bool,
87    /// Whether to include target (module path) in output.
88    pub target: bool,
89    /// Default filter directive if RUST_LOG is not set.
90    pub default_filter: String,
91    /// Optional log file path. If set, logs are written to this file.
92    pub log_file: Option<PathBuf>,
93    /// Whether to also log to stdout when file logging is enabled.
94    pub log_to_stdout: bool,
95}
96
97impl Default for TracingConfig {
98    fn default() -> Self {
99        Self {
100            format: TracingFormat::Pretty,
101            span_events: false,
102            file_info: false,
103            thread_ids: false,
104            thread_names: false,
105            target: true,
106            default_filter: "lgp=info".to_string(),
107            log_file: None,
108            log_to_stdout: true,
109        }
110    }
111}
112
113impl TracingConfig {
114    /// Create a new tracing configuration with defaults.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Set the output format.
120    pub fn with_format(mut self, format: TracingFormat) -> Self {
121        self.format = format;
122        self
123    }
124
125    /// Enable or disable span enter/exit events.
126    pub fn with_span_events(mut self, enabled: bool) -> Self {
127        self.span_events = enabled;
128        self
129    }
130
131    /// Enable or disable file name and line number output.
132    pub fn with_file_info(mut self, enabled: bool) -> Self {
133        self.file_info = enabled;
134        self
135    }
136
137    /// Enable or disable thread ID output.
138    pub fn with_thread_ids(mut self, enabled: bool) -> Self {
139        self.thread_ids = enabled;
140        self
141    }
142
143    /// Enable or disable thread name output.
144    pub fn with_thread_names(mut self, enabled: bool) -> Self {
145        self.thread_names = enabled;
146        self
147    }
148
149    /// Enable or disable target (module path) output.
150    pub fn with_target(mut self, enabled: bool) -> Self {
151        self.target = enabled;
152        self
153    }
154
155    /// Set the default filter directive (used if RUST_LOG is not set).
156    pub fn with_default_filter(mut self, filter: impl Into<String>) -> Self {
157        self.default_filter = filter.into();
158        self
159    }
160
161    /// Set log file path (enables file logging).
162    pub fn with_log_file(mut self, path: impl Into<PathBuf>) -> Self {
163        self.log_file = Some(path.into());
164        self
165    }
166
167    /// Control whether to also log to stdout when file logging is enabled.
168    pub fn with_stdout(mut self, enabled: bool) -> Self {
169        self.log_to_stdout = enabled;
170        self
171    }
172
173    /// Create a configuration optimized for verbose debugging.
174    pub fn verbose() -> Self {
175        Self {
176            format: TracingFormat::Pretty,
177            span_events: true,
178            file_info: true,
179            thread_ids: true,
180            thread_names: false,
181            target: true,
182            default_filter: "lgp=debug".to_string(),
183            log_file: None,
184            log_to_stdout: true,
185        }
186    }
187
188    /// Create a configuration optimized for production/JSON logging.
189    pub fn production() -> Self {
190        Self {
191            format: TracingFormat::Json,
192            span_events: false,
193            file_info: false,
194            thread_ids: false,
195            thread_names: false,
196            target: true,
197            default_filter: "lgp=info".to_string(),
198            log_file: None,
199            log_to_stdout: true,
200        }
201    }
202}
203
204/// Guards returned by tracing initialization. Must be held for the program
205/// lifetime to ensure all non-blocking log writes are flushed.
206pub struct TracingGuard {
207    _guards: Vec<WorkerGuard>,
208}
209
210/// Initialize the tracing subscriber with the given configuration.
211///
212/// Returns a [`TracingGuard`] that must be held for the duration of the program
213/// to ensure all logs are flushed. All writers (stdout and file) use non-blocking
214/// I/O so that high-volume debug/trace logging does not block computation.
215///
216/// This function should be called once at application startup, before any
217/// tracing macros are used.
218///
219/// # Environment Variables
220///
221/// - `RUST_LOG`: Controls log level filtering. Examples:
222///   - `lgp=debug` - Debug level for lgp crate
223///   - `lgp=trace` - Trace level for lgp crate (very verbose)
224///   - `lgp::core=trace,lgp=info` - Different levels for different modules
225///
226/// - `LGP_LOG_FORMAT`: Override the output format regardless of config.
227///   Values: `pretty`, `compact`, `json`
228///
229/// # Panics
230///
231/// This function will panic if called more than once, as the global subscriber
232/// can only be set once.
233pub fn init_tracing(config: TracingConfig) -> TracingGuard {
234    // Check for format override via environment variable
235    let format = env::var("LGP_LOG_FORMAT")
236        .ok()
237        .and_then(|s| TracingFormat::parse(&s))
238        .unwrap_or(config.format);
239
240    // Build the environment filter
241    let filter = EnvFilter::try_from_default_env()
242        .unwrap_or_else(|_| EnvFilter::new(&config.default_filter));
243
244    // Determine span events
245    let span_events = if config.span_events {
246        FmtSpan::NEW | FmtSpan::CLOSE
247    } else {
248        FmtSpan::NONE
249    };
250
251    // If file logging is configured, use non-blocking file writer
252    if let Some(log_path) = &config.log_file {
253        // Create parent directories if needed
254        if let Some(parent) = log_path.parent() {
255            if !parent.as_os_str().is_empty() {
256                std::fs::create_dir_all(parent).ok();
257            }
258        }
259
260        // Create file appender with non-blocking writer
261        let file = std::fs::OpenOptions::new()
262            .create(true)
263            .append(true)
264            .open(log_path)
265            .expect("Failed to open log file");
266
267        let (non_blocking, file_guard) = tracing_appender::non_blocking(file);
268
269        // Build subscriber with file layer (and optionally stdout)
270        let mut guards = vec![file_guard];
271        if config.log_to_stdout {
272            let stdout_guard =
273                init_with_file_and_stdout(format, filter, span_events, &config, non_blocking);
274            guards.push(stdout_guard);
275        } else {
276            init_with_file_only(format, filter, span_events, &config, non_blocking);
277        }
278
279        return TracingGuard { _guards: guards };
280    }
281
282    // Standard stdout-only setup (also non-blocking)
283    let stdout_guard = init_stdout_only(format, filter, span_events, &config);
284    TracingGuard {
285        _guards: vec![stdout_guard],
286    }
287}
288
289/// Initialize tracing with file output only.
290fn init_with_file_only(
291    format: TracingFormat,
292    filter: EnvFilter,
293    span_events: FmtSpan,
294    config: &TracingConfig,
295    writer: tracing_appender::non_blocking::NonBlocking,
296) {
297    match format {
298        TracingFormat::Pretty => {
299            let subscriber = tracing_subscriber::registry().with(filter).with(
300                fmt::layer()
301                    .with_writer(writer)
302                    .with_ansi(false)
303                    .pretty()
304                    .with_span_events(span_events)
305                    .with_file(config.file_info)
306                    .with_line_number(config.file_info)
307                    .with_thread_ids(config.thread_ids)
308                    .with_thread_names(config.thread_names)
309                    .with_target(config.target),
310            );
311            tracing::subscriber::set_global_default(subscriber)
312                .expect("Failed to set tracing subscriber");
313        }
314        TracingFormat::Compact => {
315            let subscriber = tracing_subscriber::registry().with(filter).with(
316                fmt::layer()
317                    .with_writer(writer)
318                    .with_ansi(false)
319                    .compact()
320                    .with_span_events(span_events)
321                    .with_file(config.file_info)
322                    .with_line_number(config.file_info)
323                    .with_thread_ids(config.thread_ids)
324                    .with_thread_names(config.thread_names)
325                    .with_target(config.target),
326            );
327            tracing::subscriber::set_global_default(subscriber)
328                .expect("Failed to set tracing subscriber");
329        }
330        TracingFormat::Json => {
331            let subscriber = tracing_subscriber::registry().with(filter).with(
332                fmt::layer()
333                    .with_writer(writer)
334                    .json()
335                    .with_span_events(span_events)
336                    .with_file(config.file_info)
337                    .with_line_number(config.file_info)
338                    .with_thread_ids(config.thread_ids)
339                    .with_thread_names(config.thread_names)
340                    .with_target(config.target),
341            );
342            tracing::subscriber::set_global_default(subscriber)
343                .expect("Failed to set tracing subscriber");
344        }
345    }
346}
347
348/// Initialize tracing with both file and stdout output.
349///
350/// Returns a `WorkerGuard` for the non-blocking stdout writer that must be held
351/// alongside the file guard for proper cleanup.
352fn init_with_file_and_stdout(
353    format: TracingFormat,
354    filter: EnvFilter,
355    span_events: FmtSpan,
356    config: &TracingConfig,
357    file_writer: tracing_appender::non_blocking::NonBlocking,
358) -> WorkerGuard {
359    let (nb_stdout, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
360
361    match format {
362        TracingFormat::Pretty => {
363            let file_layer = fmt::layer()
364                .with_writer(file_writer)
365                .with_ansi(false)
366                .pretty()
367                .with_span_events(span_events.clone())
368                .with_file(config.file_info)
369                .with_line_number(config.file_info)
370                .with_thread_ids(config.thread_ids)
371                .with_thread_names(config.thread_names)
372                .with_target(config.target);
373            let stdout_layer = fmt::layer()
374                .with_writer(nb_stdout)
375                .pretty()
376                .with_span_events(span_events)
377                .with_file(config.file_info)
378                .with_line_number(config.file_info)
379                .with_thread_ids(config.thread_ids)
380                .with_thread_names(config.thread_names)
381                .with_target(config.target);
382            let subscriber = tracing_subscriber::registry()
383                .with(filter)
384                .with(file_layer)
385                .with(stdout_layer);
386            tracing::subscriber::set_global_default(subscriber)
387                .expect("Failed to set tracing subscriber");
388        }
389        TracingFormat::Compact => {
390            let file_layer = fmt::layer()
391                .with_writer(file_writer)
392                .with_ansi(false)
393                .compact()
394                .with_span_events(span_events.clone())
395                .with_file(config.file_info)
396                .with_line_number(config.file_info)
397                .with_thread_ids(config.thread_ids)
398                .with_thread_names(config.thread_names)
399                .with_target(config.target);
400            let stdout_layer = fmt::layer()
401                .with_writer(nb_stdout)
402                .compact()
403                .with_span_events(span_events)
404                .with_file(config.file_info)
405                .with_line_number(config.file_info)
406                .with_thread_ids(config.thread_ids)
407                .with_thread_names(config.thread_names)
408                .with_target(config.target);
409            let subscriber = tracing_subscriber::registry()
410                .with(filter)
411                .with(file_layer)
412                .with(stdout_layer);
413            tracing::subscriber::set_global_default(subscriber)
414                .expect("Failed to set tracing subscriber");
415        }
416        TracingFormat::Json => {
417            let file_layer = fmt::layer()
418                .with_writer(file_writer)
419                .json()
420                .with_span_events(span_events.clone())
421                .with_file(config.file_info)
422                .with_line_number(config.file_info)
423                .with_thread_ids(config.thread_ids)
424                .with_thread_names(config.thread_names)
425                .with_target(config.target);
426            let stdout_layer = fmt::layer()
427                .with_writer(nb_stdout)
428                .json()
429                .with_span_events(span_events)
430                .with_file(config.file_info)
431                .with_line_number(config.file_info)
432                .with_thread_ids(config.thread_ids)
433                .with_thread_names(config.thread_names)
434                .with_target(config.target);
435            let subscriber = tracing_subscriber::registry()
436                .with(filter)
437                .with(file_layer)
438                .with(stdout_layer);
439            tracing::subscriber::set_global_default(subscriber)
440                .expect("Failed to set tracing subscriber");
441        }
442    }
443
444    stdout_guard
445}
446
447/// Initialize tracing with stdout only (non-blocking).
448///
449/// Returns a `WorkerGuard` for the non-blocking stdout writer that must be held
450/// for the program lifetime to ensure all logs are flushed.
451fn init_stdout_only(
452    format: TracingFormat,
453    filter: EnvFilter,
454    span_events: FmtSpan,
455    config: &TracingConfig,
456) -> WorkerGuard {
457    let (nb_stdout, guard) = tracing_appender::non_blocking(std::io::stdout());
458
459    match format {
460        TracingFormat::Pretty => {
461            let subscriber = tracing_subscriber::registry().with(filter).with(
462                fmt::layer()
463                    .with_writer(nb_stdout)
464                    .pretty()
465                    .with_span_events(span_events)
466                    .with_file(config.file_info)
467                    .with_line_number(config.file_info)
468                    .with_thread_ids(config.thread_ids)
469                    .with_thread_names(config.thread_names)
470                    .with_target(config.target),
471            );
472            tracing::subscriber::set_global_default(subscriber)
473                .expect("Failed to set tracing subscriber");
474        }
475        TracingFormat::Compact => {
476            let subscriber = tracing_subscriber::registry().with(filter).with(
477                fmt::layer()
478                    .with_writer(nb_stdout)
479                    .compact()
480                    .with_span_events(span_events)
481                    .with_file(config.file_info)
482                    .with_line_number(config.file_info)
483                    .with_thread_ids(config.thread_ids)
484                    .with_thread_names(config.thread_names)
485                    .with_target(config.target),
486            );
487            tracing::subscriber::set_global_default(subscriber)
488                .expect("Failed to set tracing subscriber");
489        }
490        TracingFormat::Json => {
491            let subscriber = tracing_subscriber::registry().with(filter).with(
492                fmt::layer()
493                    .with_writer(nb_stdout)
494                    .json()
495                    .with_span_events(span_events)
496                    .with_file(config.file_info)
497                    .with_line_number(config.file_info)
498                    .with_thread_ids(config.thread_ids)
499                    .with_thread_names(config.thread_names)
500                    .with_target(config.target),
501            );
502            tracing::subscriber::set_global_default(subscriber)
503                .expect("Failed to set tracing subscriber");
504        }
505    }
506
507    guard
508}
509
510/// Try to initialize tracing, returning Ok if successful or if already initialized.
511///
512/// This is useful in tests or when multiple initialization paths exist.
513pub fn try_init_tracing(config: TracingConfig) -> Result<(), Box<dyn std::error::Error>> {
514    // Check for format override via environment variable
515    let format = env::var("LGP_LOG_FORMAT")
516        .ok()
517        .and_then(|s| TracingFormat::parse(&s))
518        .unwrap_or(config.format);
519
520    // Build the environment filter
521    let filter = EnvFilter::try_from_default_env()
522        .unwrap_or_else(|_| EnvFilter::new(&config.default_filter));
523
524    // Determine span events
525    let span_events = if config.span_events {
526        FmtSpan::NEW | FmtSpan::CLOSE
527    } else {
528        FmtSpan::NONE
529    };
530
531    // Build and set the subscriber based on format
532    let result = match format {
533        TracingFormat::Pretty => {
534            let subscriber = tracing_subscriber::registry().with(filter).with(
535                fmt::layer()
536                    .pretty()
537                    .with_span_events(span_events)
538                    .with_file(config.file_info)
539                    .with_line_number(config.file_info)
540                    .with_thread_ids(config.thread_ids)
541                    .with_thread_names(config.thread_names)
542                    .with_target(config.target),
543            );
544            tracing::subscriber::set_global_default(subscriber)
545        }
546        TracingFormat::Compact => {
547            let subscriber = tracing_subscriber::registry().with(filter).with(
548                fmt::layer()
549                    .compact()
550                    .with_span_events(span_events)
551                    .with_file(config.file_info)
552                    .with_line_number(config.file_info)
553                    .with_thread_ids(config.thread_ids)
554                    .with_thread_names(config.thread_names)
555                    .with_target(config.target),
556            );
557            tracing::subscriber::set_global_default(subscriber)
558        }
559        TracingFormat::Json => {
560            let subscriber = tracing_subscriber::registry().with(filter).with(
561                fmt::layer()
562                    .json()
563                    .with_span_events(span_events)
564                    .with_file(config.file_info)
565                    .with_line_number(config.file_info)
566                    .with_thread_ids(config.thread_ids)
567                    .with_thread_names(config.thread_names)
568                    .with_target(config.target),
569            );
570            tracing::subscriber::set_global_default(subscriber)
571        }
572    };
573
574    result.map_err(|e| e.into())
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn test_format_from_str() {
583        assert_eq!(TracingFormat::parse("pretty"), Some(TracingFormat::Pretty));
584        assert_eq!(TracingFormat::parse("PRETTY"), Some(TracingFormat::Pretty));
585        assert_eq!(
586            TracingFormat::parse("compact"),
587            Some(TracingFormat::Compact)
588        );
589        assert_eq!(TracingFormat::parse("json"), Some(TracingFormat::Json));
590        assert_eq!(TracingFormat::parse("invalid"), None);
591    }
592
593    #[test]
594    fn test_config_builder() {
595        let config = TracingConfig::new()
596            .with_format(TracingFormat::Json)
597            .with_span_events(true)
598            .with_file_info(true)
599            .with_thread_ids(true)
600            .with_default_filter("lgp=trace");
601
602        assert_eq!(config.format, TracingFormat::Json);
603        assert!(config.span_events);
604        assert!(config.file_info);
605        assert!(config.thread_ids);
606        assert_eq!(config.default_filter, "lgp=trace");
607    }
608
609    #[test]
610    fn test_verbose_config() {
611        let config = TracingConfig::verbose();
612        assert_eq!(config.format, TracingFormat::Pretty);
613        assert!(config.span_events);
614        assert!(config.file_info);
615        assert_eq!(config.default_filter, "lgp=debug");
616    }
617
618    #[test]
619    fn test_production_config() {
620        let config = TracingConfig::production();
621        assert_eq!(config.format, TracingFormat::Json);
622        assert!(!config.span_events);
623        assert!(!config.file_info);
624        assert_eq!(config.default_filter, "lgp=info");
625    }
626
627    #[test]
628    fn test_file_logging_config() {
629        let config = TracingConfig::new()
630            .with_log_file("/tmp/test.log")
631            .with_stdout(false);
632
633        assert_eq!(config.log_file, Some(PathBuf::from("/tmp/test.log")));
634        assert!(!config.log_to_stdout);
635
636        // Default should have no log file and stdout enabled
637        let default = TracingConfig::default();
638        assert!(default.log_file.is_none());
639        assert!(default.log_to_stdout);
640    }
641}