Skip to main content

doido_core/
logger.rs

1//! Centralized logging setup for the whole framework.
2//!
3//! [`init`] installs a global `tracing_subscriber` once at application boot.
4//! Everything that emits `tracing` events then flows through it:
5//!
6//! - **HTTP requests/responses** — `doido-controller`'s `logging::log_requests`
7//!   middleware logs a `request` and a `response` line at `INFO`, correlated by
8//!   a shared `request_id`.
9//! - **ORM queries** — `doido-model`'s connection enables sea-orm's SQL logging,
10//!   which emits `tracing` events under target `sqlx::query`.
11//! - **Jobs, mail, custom events** — the helpers in [`crate::trace`].
12//!
13//! Applications configure the logger from the `logger` section of
14//! `config/<env>.yml`, deserialized into [`LoggerConfig`] and applied via
15//! [`init_with_config`]. That controls the verbosity (built from a log `level`
16//! or an explicit `directives` filter), whether output is redirected to a
17//! `file`, and whether sea-orm emits `sql` statement logs. Because sea-orm logs
18//! through this same subscriber, redirecting to a file captures SQL too.
19//!
20//! The `RUST_LOG` environment variable, when set, overrides the configured
21//! verbosity (env vars win over config, matching the rest of the framework).
22
23use serde::Deserialize;
24use std::fs::{File, OpenOptions};
25use std::path::Path;
26use std::sync::{Mutex, Once};
27use tracing_subscriber::fmt::writer::BoxMakeWriter;
28use tracing_subscriber::{fmt, EnvFilter};
29
30/// `tracing` target for the "request received" event emitted by
31/// `doido-controller`'s logging middleware.
32pub const REQUEST_TARGET: &str = "doido::request";
33
34/// `tracing` target for the "response sent" event emitted by `doido-controller`'s
35/// logging middleware. [`LogFormat::JsonResponse`] isolates this target.
36pub const RESPONSE_TARGET: &str = "doido::response";
37
38/// Framework targets quieted below the application log level.
39///
40/// `sqlx::query=info` surfaces sea-orm's SQL statements (logged by sqlx under
41/// that target) while `sqlx=warn` quiets the rest of the connection-pool
42/// chatter; `hyper`/`tower` internals are quieted too. Appended after the app
43/// level by [`directives_for_level`].
44pub const NOISE_DIRECTIVES: &str = "sqlx=warn,sqlx::query=info,hyper=warn,tower=warn";
45
46/// Default `EnvFilter` directives when `RUST_LOG` is unset.
47///
48/// `info` shows app logs and the HTTP request/response events (emitted by
49/// `tower_http` at INFO), followed by the [`NOISE_DIRECTIVES`] noise reduction.
50pub const DEFAULT_DIRECTIVES: &str = "info,sqlx=warn,sqlx::query=info,hyper=warn,tower=warn";
51
52/// Builds `EnvFilter` directives for an application log `level` (e.g. `info`,
53/// `debug`, `warn`), appending the framework [`NOISE_DIRECTIVES`] so SQL/HTTP
54/// internals stay quiet regardless of the chosen level.
55///
56/// `directives_for_level("info")` is equivalent to [`DEFAULT_DIRECTIVES`].
57pub fn directives_for_level(level: &str) -> String {
58    format!("{level},{NOISE_DIRECTIVES}")
59}
60
61/// How log events are rendered.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum LogFormat {
65    /// Single-line, human-readable events (the default).
66    #[default]
67    Compact,
68    /// Pretty, multi-line output with every field plus thread and source
69    /// location — for inspecting all the structured data during development.
70    Verbose,
71    /// One JSON object per HTTP **response** event (status, latency and the
72    /// correlation ids), suppressing everything else. Suited to access logs and
73    /// latency metrics. Honours an explicit `directives`/`RUST_LOG` override if
74    /// you need to widen it.
75    JsonResponse,
76}
77
78/// Logging settings, deserialized from the `logger` section of
79/// `config/<env>.yml`.
80///
81/// The application log `level` is combined with the framework's
82/// [`NOISE_DIRECTIVES`] to build the `tracing` `EnvFilter`; set `directives` to
83/// take full control of the filter string instead. When `file` is set, log
84/// output is appended to that path (relative to the project root) instead of
85/// stdout. `sql` toggles sea-orm's SQL statement logging. `format` selects the
86/// output renderer ([`LogFormat`]).
87#[derive(Debug, Clone, Deserialize)]
88pub struct LoggerConfig {
89    /// Base application log level: `trace`, `debug`, `info`, `warn`, or `error`.
90    #[serde(default = "default_level")]
91    pub level: String,
92    /// Optional full `EnvFilter` directive string. When set it fully replaces
93    /// the directives derived from `level` (and the built-in noise reduction),
94    /// e.g. `info,my_app=debug,sqlx=warn`.
95    #[serde(default)]
96    pub directives: Option<String>,
97    /// Optional path to redirect log output to (appended, ANSI colours off).
98    /// Parent directories are created as needed. When unset, logs go to stdout.
99    #[serde(default)]
100    pub file: Option<String>,
101    /// Whether sea-orm logs each SQL statement (target `sqlx::query`). Defaults
102    /// to `true`; set `false` to silence query logging at the source.
103    #[serde(default = "default_sql")]
104    pub sql: bool,
105    /// Output renderer: `compact` (default), `verbose`, or `json_response`.
106    #[serde(default)]
107    pub format: LogFormat,
108}
109
110fn default_level() -> String {
111    "info".to_string()
112}
113
114fn default_sql() -> bool {
115    true
116}
117
118impl Default for LoggerConfig {
119    fn default() -> Self {
120        Self {
121            level: default_level(),
122            directives: None,
123            file: None,
124            sql: default_sql(),
125            format: LogFormat::default(),
126        }
127    }
128}
129
130impl LoggerConfig {
131    /// Resolves the `EnvFilter` directive string used as the fallback when
132    /// `RUST_LOG` is unset: the explicit `directives` override if present;
133    /// otherwise, for [`LogFormat::JsonResponse`], a filter isolating the
134    /// [`RESPONSE_TARGET`]; otherwise the directives built from `level`.
135    pub fn directives(&self) -> String {
136        match (&self.directives, self.format) {
137            (Some(directives), _) => directives.clone(),
138            (None, LogFormat::JsonResponse) => format!("off,{RESPONSE_TARGET}=info"),
139            (None, _) => directives_for_level(&self.level),
140        }
141    }
142}
143
144static INIT: Once = Once::new();
145
146/// Installs the global tracing subscriber using `RUST_LOG` (or
147/// [`DEFAULT_DIRECTIVES`]) and stdout output. Idempotent and safe to call more
148/// than once.
149pub fn init() {
150    init_with_config(&LoggerConfig::default());
151}
152
153/// Like [`init`] but uses `default_directives` when `RUST_LOG` is unset.
154pub fn init_with(default_directives: &str) {
155    init_with_config(&LoggerConfig {
156        directives: Some(default_directives.to_string()),
157        ..LoggerConfig::default()
158    });
159}
160
161/// Installs the global tracing subscriber from a [`LoggerConfig`]: verbosity
162/// from `RUST_LOG` or the config's [`directives`](LoggerConfig::directives),
163/// output to the config's `file` when set (otherwise stdout), and rendering per
164/// the config's [`format`](LoggerConfig::format). Idempotent and safe to call
165/// more than once; only the first call takes effect.
166pub fn init_with_config(config: &LoggerConfig) {
167    INIT.call_once(|| {
168        let filter = EnvFilter::try_from_default_env()
169            .unwrap_or_else(|_| EnvFilter::new(config.directives()));
170
171        // Box the writer so the builder is one type whether output is a file or
172        // stdout; only the formatter then varies per `format` below. A log file
173        // gets no ANSI colour escapes; stdout keeps tty auto-detection.
174        let (writer, to_file) = match open_log_file(config.file.as_deref()) {
175            Some(file) => (BoxMakeWriter::new(Mutex::new(file)), true),
176            None => (BoxMakeWriter::new(std::io::stdout), false),
177        };
178        let builder = fmt().with_env_filter(filter).with_writer(writer);
179        let builder = if to_file {
180            builder.with_ansi(false)
181        } else {
182            builder
183        };
184
185        // `try_init` returns Err if a subscriber is already set (e.g. in tests);
186        // ignore it — the `Once` already guards against repeated setup here.
187        match config.format {
188            LogFormat::Compact => {
189                let _ = builder.with_target(true).try_init();
190            }
191            LogFormat::Verbose => {
192                let _ = builder
193                    .pretty()
194                    .with_target(true)
195                    .with_thread_names(true)
196                    .with_file(true)
197                    .with_line_number(true)
198                    .try_init();
199            }
200            LogFormat::JsonResponse => {
201                // Flatten the event fields (status, latency_ms, request_id, …)
202                // to the top level of each JSON line.
203                let _ = builder.json().flatten_event(true).try_init();
204            }
205        }
206    });
207}
208
209/// Opens the configured log `file` for appending, creating parent directories
210/// as needed. Returns `None` (logging stays on stdout) when no file is
211/// configured or the file can't be opened.
212fn open_log_file(path: Option<&str>) -> Option<File> {
213    let path = path?;
214    if let Some(parent) = Path::new(path).parent() {
215        if !parent.as_os_str().is_empty() {
216            let _ = std::fs::create_dir_all(parent);
217        }
218    }
219    match OpenOptions::new().create(true).append(true).open(path) {
220        Ok(file) => Some(file),
221        Err(e) => {
222            // The subscriber isn't installed yet, so warn via stderr directly.
223            eprintln!("doido: could not open log file '{path}': {e}; logging to stdout");
224            None
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn default_directives_are_valid() {
235        // `try_new` errors on a malformed filter; the defaults must always parse.
236        assert!(EnvFilter::try_new(DEFAULT_DIRECTIVES).is_ok());
237    }
238
239    #[test]
240    fn info_level_matches_default_directives() {
241        assert_eq!(directives_for_level("info"), DEFAULT_DIRECTIVES);
242    }
243
244    #[test]
245    fn level_is_prepended_to_noise_directives() {
246        let directives = directives_for_level("debug");
247        assert!(directives.starts_with("debug,"));
248        assert!(directives.ends_with(NOISE_DIRECTIVES));
249        // Whatever the level, the result must remain a valid filter.
250        assert!(EnvFilter::try_new(&directives).is_ok());
251    }
252
253    #[test]
254    fn config_defaults_to_info_and_sql_on() {
255        let config = LoggerConfig::default();
256        assert_eq!(config.level, "info");
257        assert!(config.sql);
258        assert!(config.file.is_none());
259        assert_eq!(config.directives(), DEFAULT_DIRECTIVES);
260    }
261
262    #[test]
263    fn explicit_directives_override_level() {
264        let config = LoggerConfig {
265            level: "info".to_string(),
266            directives: Some("warn,my_app=debug".to_string()),
267            ..LoggerConfig::default()
268        };
269        assert_eq!(config.directives(), "warn,my_app=debug");
270    }
271
272    #[test]
273    fn default_format_is_compact() {
274        assert_eq!(LoggerConfig::default().format, LogFormat::Compact);
275    }
276
277    #[test]
278    fn json_response_format_isolates_response_target() {
279        let config = LoggerConfig {
280            format: LogFormat::JsonResponse,
281            ..LoggerConfig::default()
282        };
283        let directives = config.directives();
284        assert_eq!(directives, format!("off,{RESPONSE_TARGET}=info"));
285        // The isolating filter must still be a valid `EnvFilter`.
286        assert!(EnvFilter::try_new(&directives).is_ok());
287    }
288
289    #[test]
290    fn explicit_directives_win_over_json_response_default() {
291        let config = LoggerConfig {
292            format: LogFormat::JsonResponse,
293            directives: Some("info".to_string()),
294            ..LoggerConfig::default()
295        };
296        assert_eq!(config.directives(), "info");
297    }
298
299    #[test]
300    fn format_deserializes_from_snake_case() {
301        #[derive(serde::Deserialize)]
302        struct Wrapper {
303            format: LogFormat,
304        }
305        let parsed: Wrapper = serde_norway::from_str("format: json_response\n").unwrap();
306        assert_eq!(parsed.format, LogFormat::JsonResponse);
307        let parsed: Wrapper = serde_norway::from_str("format: verbose\n").unwrap();
308        assert_eq!(parsed.format, LogFormat::Verbose);
309    }
310}