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}