Skip to main content

saluki_app/logging/
config.rs

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