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}