Skip to main content

saluki_app/logging/
config.rs

1use std::fmt;
2
3use bytesize::ByteSize;
4use saluki_error::{generic_error, ErrorContext as _, GenericError};
5use serde::Deserialize;
6use tracing_subscriber::{filter::LevelFilter, EnvFilter};
7
8const DEFAULT_LOG_FILE_MAX_SIZE: ByteSize = ByteSize::mib(10);
9const DEFAULT_LOG_FILE_MAX_ROLLS: usize = 1;
10
11/// Logging configuration.
12///
13/// This is a plain value type. Callers construct instances directly, either via [`simple`] for a basic
14/// console-only configuration or via a translator that maps from the application's wider configuration into this
15/// type, applying any application-specific rules.
16///
17/// [`simple`]: LoggingConfiguration::simple
18pub struct LoggingConfiguration {
19    /// Verbosity directives (for example, `info`, `debug`, `saluki=trace`).
20    pub log_level: LogLevel,
21
22    /// Whether to emit log records as JSON instead of the default human-readable format.
23    pub log_format_json: bool,
24
25    /// Whether to use RFC 3339 timestamps (`2024-12-31T23:59:59Z`) in log output.
26    ///
27    /// When `false` (the default), timestamps use the legacy format (`2024-12-31 23:59:59 UTC`).
28    ///
29    /// Defaults to `false`.
30    pub log_format_rfc3339: bool,
31
32    /// Whether to write log records to standard output.
33    pub log_to_console: bool,
34
35    /// Whether to write log records to syslog.
36    ///
37    /// Defaults to `false`. When this is `true` and [`syslog_uri`] is empty, callers should resolve the destination to
38    /// the platform's default local syslog URI before constructing the logging output stack.
39    ///
40    /// [`syslog_uri`]: LoggingConfiguration::syslog_uri
41    pub log_to_syslog: bool,
42
43    /// URI of the syslog destination.
44    ///
45    /// Defaults to an empty string. An empty value means "use the platform default" only when [`log_to_syslog`] is
46    /// enabled; otherwise it has no effect. Supported URI schemes are handled by the syslog output implementation.
47    ///
48    /// [`log_to_syslog`]: LoggingConfiguration::log_to_syslog
49    pub syslog_uri: String,
50
51    /// Whether to use the Agent's RFC-style syslog header.
52    ///
53    /// Defaults to `false`, which preserves the Agent's legacy syslog header format. Set this to `true` when the
54    /// receiving syslog daemon expects the Agent's RFC-style header.
55    pub syslog_rfc: bool,
56
57    /// Path to the log file to write to, or empty to disable file logging.
58    pub log_file: String,
59
60    /// Maximum size of a log file before it's rolled over.
61    pub log_file_max_size: ByteSize,
62
63    /// Maximum number of rolled-over log files to retain.
64    pub log_file_max_rolls: usize,
65}
66
67impl LoggingConfiguration {
68    /// Returns a configuration that writes only to the console in human-readable format at INFO level.
69    ///
70    /// Used as a safe default when an application hasn't yet supplied an explicit configuration.
71    pub fn simple() -> Self {
72        Self {
73            log_level: LevelFilter::INFO.into(),
74            log_format_json: false,
75            log_format_rfc3339: false,
76            log_to_console: true,
77            log_to_syslog: false,
78            syslog_uri: String::new(),
79            syslog_rfc: false,
80            log_file: String::new(),
81            log_file_max_size: DEFAULT_LOG_FILE_MAX_SIZE,
82            log_file_max_rolls: DEFAULT_LOG_FILE_MAX_ROLLS,
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn simple_defaults_to_console_only_logging_with_syslog_disabled() {
93        let config = LoggingConfiguration::simple();
94
95        assert_eq!(config.log_level.as_env_filter().to_string(), "info");
96        assert!(!config.log_format_json);
97        assert!(!config.log_format_rfc3339);
98        assert!(config.log_to_console);
99        assert!(!config.log_to_syslog);
100        assert!(config.syslog_uri.is_empty());
101        assert!(!config.syslog_rfc);
102        assert!(config.log_file.is_empty());
103        assert_eq!(config.log_file_max_size, DEFAULT_LOG_FILE_MAX_SIZE);
104        assert_eq!(config.log_file_max_rolls, DEFAULT_LOG_FILE_MAX_ROLLS);
105    }
106}
107
108/// A parsed `tracing` log level filter.
109///
110/// Wraps [`EnvFilter`] so it can be deserialized from a string (for example, `"info"`, `"saluki=trace,info"`).
111#[derive(Deserialize)]
112#[serde(try_from = "String")]
113pub struct LogLevel(EnvFilter);
114
115impl LogLevel {
116    /// Returns the underlying `EnvFilter`.
117    pub fn as_env_filter(&self) -> EnvFilter {
118        self.0.clone()
119    }
120}
121
122impl From<LevelFilter> for LogLevel {
123    fn from(level: LevelFilter) -> Self {
124        Self(EnvFilter::default().add_directive(level.into()))
125    }
126}
127
128impl TryFrom<String> for LogLevel {
129    type Error = GenericError;
130
131    fn try_from(value: String) -> Result<Self, Self::Error> {
132        if value.is_empty() {
133            return Err(generic_error!("Log level cannot be empty."));
134        }
135
136        EnvFilter::builder()
137            .parse(value)
138            .map(Self)
139            .error_context("Failed to parse valid log level.")
140    }
141}
142
143impl fmt::Display for LogLevel {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        self.0.fmt(f)
146    }
147}