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