Skip to main content

tork_core/logging/
config.rs

1//! Logging configuration.
2//!
3//! [`LoggerConfig`] describes how the application logs: the level, the output
4//! format (a colored developer console or structured JSON), whether HTTP request
5//! logs are emitted, and optional file and OpenTelemetry sinks. It is a plain
6//! value, so an application can build it directly or from its own settings.
7
8use std::path::PathBuf;
9
10/// Default log level when none is configured.
11pub(crate) const DEFAULT_LEVEL: &str = "info";
12/// Default service name reported in structured logs.
13pub(crate) const DEFAULT_SERVICE_NAME: &str = "app";
14/// Default file name prefix for the rolling file sink.
15pub(crate) const DEFAULT_FILE_PREFIX: &str = "app";
16
17/// How much structured error detail a logged record includes.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ErrorLogDetail {
21    /// Log only the error's concrete type name.
22    #[default]
23    TypeOnly,
24    /// Log the type name and the top-level error message.
25    MessageOnly,
26    /// Log the type name, top-level message, and the bounded `source()` chain.
27    FullChain,
28}
29
30/// The console output format.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum LogFormat {
34    /// Choose automatically: a pretty console when attached to a terminal,
35    /// structured JSON otherwise.
36    #[default]
37    Auto,
38    /// A colored, human-readable console line.
39    Pretty,
40    /// A terse single-line console format.
41    Compact,
42    /// One JSON object per line.
43    Json,
44}
45
46/// How often a file log is rolled over.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Rotation {
50    /// Never roll over; a single file grows.
51    Never,
52    /// Roll over hourly.
53    Hourly,
54    /// Roll over daily.
55    #[default]
56    Daily,
57}
58
59/// Configuration for a rolling file log sink.
60#[derive(Debug, Clone)]
61pub struct FileLogConfig {
62    pub(crate) directory: PathBuf,
63    pub(crate) prefix: String,
64    pub(crate) rotation: Rotation,
65    pub(crate) non_blocking: bool,
66}
67
68impl FileLogConfig {
69    /// Creates a file sink writing into `directory`.
70    pub fn new(directory: impl Into<PathBuf>) -> Self {
71        Self {
72            directory: directory.into(),
73            prefix: DEFAULT_FILE_PREFIX.to_owned(),
74            rotation: Rotation::default(),
75            non_blocking: true,
76        }
77    }
78
79    /// Sets the file name prefix.
80    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
81        self.prefix = prefix.into();
82        self
83    }
84
85    /// Sets the rotation policy.
86    pub fn rotation(mut self, rotation: Rotation) -> Self {
87        self.rotation = rotation;
88        self
89    }
90
91    /// Sets whether file writes go through a non-blocking background worker.
92    pub fn non_blocking(mut self, non_blocking: bool) -> Self {
93        self.non_blocking = non_blocking;
94        self
95    }
96}
97
98/// Configuration for OpenTelemetry trace export (effective with the `otel` feature).
99// Fields are read by the OTel layer, which lands behind the `otel` feature later.
100#[allow(dead_code)]
101#[derive(Debug, Clone)]
102pub struct TelemetryConfig {
103    pub(crate) enabled: bool,
104    pub(crate) otlp_endpoint: String,
105    pub(crate) service_name: String,
106}
107
108impl TelemetryConfig {
109    /// Creates a telemetry configuration exporting to `otlp_endpoint`.
110    pub fn new(otlp_endpoint: impl Into<String>) -> Self {
111        Self {
112            enabled: true,
113            otlp_endpoint: otlp_endpoint.into(),
114            service_name: DEFAULT_SERVICE_NAME.to_owned(),
115        }
116    }
117
118    /// Sets the reported service name.
119    pub fn service_name(mut self, name: impl Into<String>) -> Self {
120        self.service_name = name.into();
121        self
122    }
123
124    /// Enables or disables export.
125    pub fn enabled(mut self, enabled: bool) -> Self {
126        self.enabled = enabled;
127        self
128    }
129}
130
131/// How the application logs.
132#[derive(Debug, Clone)]
133pub struct LoggerConfig {
134    pub(crate) level: String,
135    pub(crate) format: LogFormat,
136    pub(crate) color: bool,
137    pub(crate) service_name: String,
138    pub(crate) error_detail: ErrorLogDetail,
139    pub(crate) request_logs: bool,
140    // include_source/include_thread_ids are reserved for the formatter; telemetry
141    // is consumed by the OTel layer behind the `otel` feature.
142    #[allow(dead_code)]
143    pub(crate) include_source: bool,
144    #[allow(dead_code)]
145    pub(crate) include_thread_ids: bool,
146    pub(crate) non_blocking: bool,
147    pub(crate) file: Option<FileLogConfig>,
148    #[allow(dead_code)]
149    pub(crate) telemetry: Option<TelemetryConfig>,
150}
151
152impl Default for LoggerConfig {
153    fn default() -> Self {
154        Self {
155            level: DEFAULT_LEVEL.to_owned(),
156            format: LogFormat::Auto,
157            color: true,
158            service_name: DEFAULT_SERVICE_NAME.to_owned(),
159            error_detail: ErrorLogDetail::default(),
160            request_logs: true,
161            include_source: false,
162            include_thread_ids: false,
163            non_blocking: false,
164            file: None,
165            telemetry: None,
166        }
167    }
168}
169
170impl LoggerConfig {
171    /// Creates a configuration with the default settings.
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Sets the maximum log level (`trace`/`debug`/`info`/`warn`/`error`, or any
177    /// `RUST_LOG`-style directive).
178    pub fn level(mut self, level: impl Into<String>) -> Self {
179        self.level = level.into();
180        self
181    }
182
183    /// Sets the output format.
184    pub fn format(mut self, format: LogFormat) -> Self {
185        self.format = format;
186        self
187    }
188
189    /// Enables or disables ANSI color in the console format.
190    pub fn color(mut self, color: bool) -> Self {
191        self.color = color;
192        self
193    }
194
195    /// Sets the service name reported in structured logs.
196    pub fn service_name(mut self, name: impl Into<String>) -> Self {
197        self.service_name = name.into();
198        self
199    }
200
201    /// Sets how much error detail structured log records include.
202    pub fn error_detail(mut self, detail: ErrorLogDetail) -> Self {
203        self.error_detail = detail;
204        self
205    }
206
207    /// Enables or disables the automatic HTTP request-completed log.
208    pub fn request_logs(mut self, enabled: bool) -> Self {
209        self.request_logs = enabled;
210        self
211    }
212
213    /// Includes the source file and line in each record.
214    pub fn include_source(mut self, include: bool) -> Self {
215        self.include_source = include;
216        self
217    }
218
219    /// Includes the thread id in each record.
220    pub fn include_thread_ids(mut self, include: bool) -> Self {
221        self.include_thread_ids = include;
222        self
223    }
224
225    /// Writes through a non-blocking background worker.
226    pub fn non_blocking(mut self, non_blocking: bool) -> Self {
227        self.non_blocking = non_blocking;
228        self
229    }
230
231    /// Adds a rolling file sink.
232    pub fn file(mut self, file: FileLogConfig) -> Self {
233        self.file = Some(file);
234        self
235    }
236
237    /// Adds OpenTelemetry trace export (effective with the `otel` feature).
238    pub fn telemetry(mut self, telemetry: TelemetryConfig) -> Self {
239        self.telemetry = Some(telemetry);
240        self
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn defaults_are_sensible() {
250        let config = LoggerConfig::new();
251        assert_eq!(config.level, "info");
252        assert_eq!(config.format, LogFormat::Auto);
253        assert!(config.color);
254        assert_eq!(config.error_detail, ErrorLogDetail::TypeOnly);
255        assert!(config.request_logs);
256        assert!(config.file.is_none());
257        assert!(config.telemetry.is_none());
258    }
259
260    #[test]
261    fn builders_set_fields() {
262        let config = LoggerConfig::new()
263            .level("debug")
264            .format(LogFormat::Json)
265            .service_name("tork-api")
266            .error_detail(ErrorLogDetail::FullChain)
267            .request_logs(false)
268            .include_source(true)
269            .include_thread_ids(true)
270            .non_blocking(true)
271            .file(
272                FileLogConfig::new("./logs")
273                    .prefix("api")
274                    .rotation(Rotation::Hourly),
275            );
276
277        assert_eq!(config.level, "debug");
278        assert_eq!(config.format, LogFormat::Json);
279        assert_eq!(config.service_name, "tork-api");
280        assert_eq!(config.error_detail, ErrorLogDetail::FullChain);
281        assert!(!config.request_logs);
282        assert!(config.include_source);
283        assert!(config.include_thread_ids);
284        assert!(config.non_blocking);
285        let file = config.file.expect("file sink");
286        assert_eq!(file.prefix, "api");
287        assert_eq!(file.rotation, Rotation::Hourly);
288    }
289
290    #[test]
291    fn file_and_telemetry_builders_cover_all_fields() {
292        let file = FileLogConfig::new("./logs")
293            .prefix("svc")
294            .rotation(Rotation::Never)
295            .non_blocking(false);
296        assert_eq!(file.directory, PathBuf::from("./logs"));
297        assert_eq!(file.prefix, "svc");
298        assert_eq!(file.rotation, Rotation::Never);
299        assert!(!file.non_blocking);
300
301        let telemetry = TelemetryConfig::new("http://localhost:4317")
302            .service_name("tork-api")
303            .enabled(false);
304        assert!(!telemetry.enabled);
305        assert_eq!(telemetry.otlp_endpoint, "http://localhost:4317");
306        assert_eq!(telemetry.service_name, "tork-api");
307    }
308
309    #[test]
310    fn log_format_and_rotation_deserialize_from_lowercase() {
311        let format: LogFormat = serde_json::from_str("\"json\"").unwrap();
312        assert_eq!(format, LogFormat::Json);
313        let format: LogFormat = serde_json::from_str("\"auto\"").unwrap();
314        assert_eq!(format, LogFormat::Auto);
315        let format: LogFormat = serde_json::from_str("\"pretty\"").unwrap();
316        assert_eq!(format, LogFormat::Pretty);
317        let format: LogFormat = serde_json::from_str("\"compact\"").unwrap();
318        assert_eq!(format, LogFormat::Compact);
319
320        let rotation: Rotation = serde_json::from_str("\"never\"").unwrap();
321        assert_eq!(rotation, Rotation::Never);
322        let rotation: Rotation = serde_json::from_str("\"hourly\"").unwrap();
323        assert_eq!(rotation, Rotation::Hourly);
324        let rotation: Rotation = serde_json::from_str("\"daily\"").unwrap();
325        assert_eq!(rotation, Rotation::Daily);
326    }
327}