Skip to main content

astrid_telemetry/
logging.rs

1//! Logging configuration and setup.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use tracing_appender::rolling::{RollingFileAppender, Rotation};
6use tracing_subscriber::{
7    EnvFilter,
8    fmt::{self, format::FmtSpan},
9    layer::SubscriberExt,
10    util::SubscriberInitExt,
11};
12
13use crate::error::{TelemetryError, TelemetryResult};
14
15/// Helper to convert init errors to our error type.
16fn init_err<E: std::fmt::Display>(e: E) -> TelemetryError {
17    TelemetryError::InitError(e.to_string())
18}
19
20/// File rotation strategy.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum FileRotation {
24    /// Rotate daily.
25    #[default]
26    Daily,
27    /// Rotate hourly.
28    Hourly,
29    /// Rotate every minute (for testing).
30    Minutely,
31    /// Never rotate.
32    Never,
33}
34
35/// Log format options.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum LogFormat {
39    /// Human-readable format with colors (default).
40    #[default]
41    Pretty,
42    /// Compact single-line format.
43    Compact,
44    /// JSON format for structured logging.
45    Json,
46    /// Full format with all fields.
47    Full,
48}
49
50/// Log output target.
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum LogTarget {
54    /// Log to stdout.
55    Stdout,
56    /// Log to stderr.
57    #[default]
58    Stderr,
59    /// Log to a file (path to directory, filename prefix).
60    File(PathBuf),
61}
62
63/// File logging configuration.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct FileLogConfig {
66    /// Directory to write log files to.
67    pub directory: PathBuf,
68    /// File name prefix (e.g., "astrid" produces "astrid.2024-01-15.log").
69    #[serde(default = "default_file_prefix")]
70    pub prefix: String,
71    /// Rotation strategy.
72    #[serde(default)]
73    pub rotation: FileRotation,
74    /// Maximum number of log files to keep (0 = unlimited).
75    #[serde(default)]
76    pub max_files: usize,
77}
78
79fn default_file_prefix() -> String {
80    "astrid".to_string()
81}
82
83impl Default for FileLogConfig {
84    fn default() -> Self {
85        Self {
86            directory: PathBuf::from("logs"),
87            prefix: default_file_prefix(),
88            rotation: FileRotation::default(),
89            max_files: 0,
90        }
91    }
92}
93
94/// Logging configuration.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[expect(clippy::struct_excessive_bools)]
97pub struct LogConfig {
98    /// Log level filter (e.g., "info", "debug", "trace").
99    #[serde(default = "default_level")]
100    pub level: String,
101    /// Log format.
102    #[serde(default)]
103    pub format: LogFormat,
104    /// Log target.
105    #[serde(default)]
106    pub target: LogTarget,
107    /// File logging configuration (used when target is File).
108    #[serde(default)]
109    pub file: FileLogConfig,
110    /// Whether to include timestamps.
111    #[serde(default = "default_true")]
112    pub timestamps: bool,
113    /// Whether to include file/line info.
114    #[serde(default)]
115    pub file_info: bool,
116    /// Whether to include thread IDs.
117    #[serde(default)]
118    pub thread_ids: bool,
119    /// Whether to include thread names.
120    #[serde(default)]
121    pub thread_names: bool,
122    /// Whether to include span events.
123    #[serde(default)]
124    pub span_events: bool,
125    /// Whether to use ANSI colors.
126    #[serde(default = "default_true")]
127    pub ansi: bool,
128    /// Directive overrides (e.g., `astrid_mcp=debug`).
129    #[serde(default)]
130    pub directives: Vec<String>,
131}
132
133fn default_level() -> String {
134    "info".to_string()
135}
136
137fn default_true() -> bool {
138    true
139}
140
141impl Default for LogConfig {
142    fn default() -> Self {
143        Self {
144            level: default_level(),
145            format: LogFormat::default(),
146            target: LogTarget::default(),
147            file: FileLogConfig::default(),
148            timestamps: true,
149            file_info: false,
150            thread_ids: false,
151            thread_names: false,
152            span_events: false,
153            ansi: true,
154            directives: Vec::new(),
155        }
156    }
157}
158
159impl LogConfig {
160    /// Create a new log config with the specified level.
161    #[must_use]
162    pub fn new(level: impl Into<String>) -> Self {
163        Self {
164            level: level.into(),
165            ..Default::default()
166        }
167    }
168
169    /// Set the log format.
170    #[must_use]
171    pub fn with_format(mut self, format: LogFormat) -> Self {
172        self.format = format;
173        self
174    }
175
176    /// Set the log target.
177    #[must_use]
178    pub fn with_target(mut self, target: LogTarget) -> Self {
179        self.target = target;
180        self
181    }
182
183    /// Configure file logging with daily rotation.
184    #[must_use]
185    pub fn with_file_logging(
186        mut self,
187        directory: impl Into<PathBuf>,
188        prefix: impl Into<String>,
189    ) -> Self {
190        self.target = LogTarget::File(directory.into());
191        self.file.prefix = prefix.into();
192        self.file.rotation = FileRotation::Daily;
193        // Disable ANSI colors for file output
194        self.ansi = false;
195        self
196    }
197
198    /// Configure file logging with custom rotation.
199    #[must_use]
200    pub fn with_file_logging_rotation(
201        mut self,
202        directory: impl Into<PathBuf>,
203        prefix: impl Into<String>,
204        rotation: FileRotation,
205    ) -> Self {
206        self.target = LogTarget::File(directory.into());
207        self.file.prefix = prefix.into();
208        self.file.rotation = rotation;
209        // Disable ANSI colors for file output
210        self.ansi = false;
211        self
212    }
213
214    /// Add a directive override.
215    #[must_use]
216    pub fn with_directive(mut self, directive: impl Into<String>) -> Self {
217        self.directives.push(directive.into());
218        self
219    }
220
221    /// Disable timestamps.
222    #[must_use]
223    pub fn without_timestamps(mut self) -> Self {
224        self.timestamps = false;
225        self
226    }
227
228    /// Enable file/line info.
229    #[must_use]
230    pub fn with_file_info(mut self) -> Self {
231        self.file_info = true;
232        self
233    }
234
235    /// Enable span events.
236    #[must_use]
237    pub fn with_span_events(mut self) -> Self {
238        self.span_events = true;
239        self
240    }
241
242    /// Disable ANSI colors.
243    #[must_use]
244    pub fn without_ansi(mut self) -> Self {
245        self.ansi = false;
246        self
247    }
248
249    /// Build the env filter from config.
250    fn build_filter(&self) -> TelemetryResult<EnvFilter> {
251        let mut filter = EnvFilter::try_new(&self.level)
252            .map_err(|e| TelemetryError::ConfigError(e.to_string()))?;
253
254        for directive in &self.directives {
255            filter = filter.add_directive(directive.parse().map_err(
256                |e: tracing_subscriber::filter::ParseError| {
257                    TelemetryError::ConfigError(e.to_string())
258                },
259            )?);
260        }
261
262        Ok(filter)
263    }
264
265    /// Get span events configuration.
266    fn span_events(&self) -> FmtSpan {
267        if self.span_events {
268            FmtSpan::NEW | FmtSpan::CLOSE
269        } else {
270            FmtSpan::NONE
271        }
272    }
273}
274
275/// Set up logging with the given configuration.
276///
277/// # Errors
278///
279/// Returns an error if the configuration is invalid or logging cannot be initialized.
280pub fn setup_logging(config: &LogConfig) -> TelemetryResult<()> {
281    let filter = config.build_filter()?;
282
283    match (&config.target, config.format) {
284        (LogTarget::Stdout, LogFormat::Json) => {
285            setup_json_logging(filter, config, std::io::stdout)?;
286        },
287        (LogTarget::Stdout, LogFormat::Pretty) => {
288            setup_pretty_logging(filter, config, std::io::stdout)?;
289        },
290        (LogTarget::Stdout, LogFormat::Compact) => {
291            setup_compact_logging(filter, config, std::io::stdout)?;
292        },
293        (LogTarget::Stdout, LogFormat::Full) => {
294            setup_full_logging(filter, config, std::io::stdout)?;
295        },
296        (LogTarget::Stderr, LogFormat::Json) => {
297            setup_json_logging(filter, config, std::io::stderr)?;
298        },
299        (LogTarget::Stderr, LogFormat::Pretty) => {
300            setup_pretty_logging(filter, config, std::io::stderr)?;
301        },
302        (LogTarget::Stderr, LogFormat::Compact) => {
303            setup_compact_logging(filter, config, std::io::stderr)?;
304        },
305        (LogTarget::Stderr, LogFormat::Full) => {
306            setup_full_logging(filter, config, std::io::stderr)?;
307        },
308        (LogTarget::File(dir), format) => {
309            // Create the directory if it doesn't exist
310            std::fs::create_dir_all(dir).map_err(|e| {
311                TelemetryError::ConfigError(format!("failed to create log directory: {e}"))
312            })?;
313
314            let rotation = match config.file.rotation {
315                FileRotation::Daily => Rotation::DAILY,
316                FileRotation::Hourly => Rotation::HOURLY,
317                FileRotation::Minutely => Rotation::MINUTELY,
318                FileRotation::Never => Rotation::NEVER,
319            };
320
321            let appender = RollingFileAppender::new(rotation, dir, &config.file.prefix);
322
323            match format {
324                LogFormat::Json => setup_json_logging(filter, config, appender)?,
325                LogFormat::Pretty => setup_pretty_logging(filter, config, appender)?,
326                LogFormat::Compact => setup_compact_logging(filter, config, appender)?,
327                LogFormat::Full => setup_full_logging(filter, config, appender)?,
328            }
329        },
330    }
331
332    Ok(())
333}
334
335fn setup_json_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
336where
337    W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
338{
339    let layer = fmt::layer()
340        .json()
341        .with_writer(writer)
342        .with_file(config.file_info)
343        .with_line_number(config.file_info)
344        .with_thread_ids(config.thread_ids)
345        .with_thread_names(config.thread_names)
346        .with_span_events(config.span_events());
347
348    if config.timestamps {
349        tracing_subscriber::registry()
350            .with(filter)
351            .with(layer)
352            .try_init()
353            .map_err(init_err)
354    } else {
355        tracing_subscriber::registry()
356            .with(filter)
357            .with(layer.without_time())
358            .try_init()
359            .map_err(init_err)
360    }
361}
362
363fn setup_pretty_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
364where
365    W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
366{
367    let layer = fmt::layer()
368        .pretty()
369        .with_writer(writer)
370        .with_ansi(config.ansi)
371        .with_file(config.file_info)
372        .with_line_number(config.file_info)
373        .with_thread_ids(config.thread_ids)
374        .with_thread_names(config.thread_names)
375        .with_span_events(config.span_events());
376
377    if config.timestamps {
378        tracing_subscriber::registry()
379            .with(filter)
380            .with(layer)
381            .try_init()
382            .map_err(init_err)
383    } else {
384        tracing_subscriber::registry()
385            .with(filter)
386            .with(layer.without_time())
387            .try_init()
388            .map_err(init_err)
389    }
390}
391
392fn setup_compact_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
393where
394    W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
395{
396    let layer = fmt::layer()
397        .compact()
398        .with_writer(writer)
399        .with_ansi(config.ansi)
400        .with_file(config.file_info)
401        .with_line_number(config.file_info)
402        .with_thread_ids(config.thread_ids)
403        .with_thread_names(config.thread_names)
404        .with_span_events(config.span_events());
405
406    if config.timestamps {
407        tracing_subscriber::registry()
408            .with(filter)
409            .with(layer)
410            .try_init()
411            .map_err(init_err)
412    } else {
413        tracing_subscriber::registry()
414            .with(filter)
415            .with(layer.without_time())
416            .try_init()
417            .map_err(init_err)
418    }
419}
420
421fn setup_full_logging<W>(filter: EnvFilter, config: &LogConfig, writer: W) -> TelemetryResult<()>
422where
423    W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
424{
425    let layer = fmt::layer()
426        .with_writer(writer)
427        .with_ansi(config.ansi)
428        .with_file(config.file_info)
429        .with_line_number(config.file_info)
430        .with_thread_ids(config.thread_ids)
431        .with_thread_names(config.thread_names)
432        .with_span_events(config.span_events());
433
434    if config.timestamps {
435        tracing_subscriber::registry()
436            .with(filter)
437            .with(layer)
438            .try_init()
439            .map_err(init_err)
440    } else {
441        tracing_subscriber::registry()
442            .with(filter)
443            .with(layer.without_time())
444            .try_init()
445            .map_err(init_err)
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_log_config_default() {
455        let config = LogConfig::default();
456        assert_eq!(config.level, "info");
457        assert_eq!(config.format, LogFormat::Pretty);
458        assert!(config.timestamps);
459        assert!(config.ansi);
460    }
461
462    #[test]
463    fn test_log_config_builder() {
464        let config = LogConfig::new("debug")
465            .with_format(LogFormat::Json)
466            .without_timestamps()
467            .with_file_info()
468            .with_directive("astrid_mcp=trace");
469
470        assert_eq!(config.level, "debug");
471        assert_eq!(config.format, LogFormat::Json);
472        assert!(!config.timestamps);
473        assert!(config.file_info);
474        assert_eq!(config.directives, vec!["astrid_mcp=trace"]);
475    }
476
477    #[test]
478    fn test_log_config_serialization() {
479        let config = LogConfig::new("warn").with_format(LogFormat::Compact);
480
481        let json = serde_json::to_string(&config).unwrap();
482        assert!(json.contains("\"level\":\"warn\""));
483        assert!(json.contains("\"format\":\"compact\""));
484
485        let parsed: LogConfig = serde_json::from_str(&json).unwrap();
486        assert_eq!(parsed.level, "warn");
487        assert_eq!(parsed.format, LogFormat::Compact);
488    }
489
490    #[test]
491    fn test_build_filter() {
492        let config = LogConfig::new("debug").with_directive("astrid=trace");
493
494        let filter = config.build_filter();
495        assert!(filter.is_ok());
496    }
497
498    #[test]
499    fn test_build_filter_invalid() {
500        // EnvFilter is permissive with unknown targets, so we test invalid syntax
501        let config = LogConfig::new("debug").with_directive("[invalid=syntax");
502
503        let filter = config.build_filter();
504        assert!(filter.is_err());
505    }
506}