1use std::env::VarError;
7use std::path::{Path, PathBuf};
8use std::{env, fs};
9
10use crate::error::{FlowError, Result};
11use serde::Deserialize;
12
13const LOG_LEVEL_ENV: &str = "NEMO_RELAY_LOG";
14const LOG_STDERR_FORMAT_ENV: &str = "NEMO_RELAY_LOG_STDERR_FORMAT";
15const LOG_CONFIG_PATH_ENV: &str = "NEMO_RELAY_LOG_CONFIG_PATH";
16
17pub const DEFAULT_FILE_SINK_QUEUE_ENTRIES: usize = 1024;
20
21pub const DEFAULT_FILE_FLUSH_INTERVAL_MILLIS: u64 = 1000;
23
24pub const MAX_FILE_SINK_QUEUE_ENTRIES: usize = 8_192;
30
31pub const MAX_FILE_SINK_RETAINED_FILES: usize = 9;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LoggingConfig {
44 pub level: LogLevel,
46 pub stderr_format: LogFormat,
48 pub sinks: Vec<LogSinkConfig>,
50 pub flush_interval_millis: u64,
53}
54
55impl Default for LoggingConfig {
56 fn default() -> Self {
57 Self {
58 level: LogLevel::Error,
59 stderr_format: LogFormat::Human,
60 sinks: Vec::new(),
61 flush_interval_millis: DEFAULT_FILE_FLUSH_INTERVAL_MILLIS,
62 }
63 }
64}
65
66impl LoggingConfig {
67 pub fn from_environment() -> Result<Option<Self>> {
73 let level = environment_value(LOG_LEVEL_ENV)?;
74 let stderr_format = environment_value(LOG_STDERR_FORMAT_ENV)?;
75 let config_path = environment_value(LOG_CONFIG_PATH_ENV)?;
76
77 if config_path.is_some() && (level.is_some() || stderr_format.is_some()) {
78 return Err(FlowError::InvalidArgument(format!(
79 "{LOG_CONFIG_PATH_ENV} cannot be combined with {LOG_LEVEL_ENV} or \
80 {LOG_STDERR_FORMAT_ENV}"
81 )));
82 }
83 if let Some(path) = config_path {
84 return Self::from_file_path(path).map(Some);
85 }
86 if level.is_none() && stderr_format.is_none() {
87 return Ok(None);
88 }
89
90 let mut config = Self::default();
91 if let Some(level) = level {
92 config.level = LogLevel::parse(&level)?;
93 }
94 if let Some(stderr_format) = stderr_format {
95 config.stderr_format = LogFormat::parse(&stderr_format)?;
96 }
97 Ok(Some(config))
98 }
99
100 pub fn from_file_path(path: impl AsRef<Path>) -> Result<Self> {
102 let path = path.as_ref();
103 if !path.is_absolute() {
104 return Err(FlowError::InvalidArgument(format!(
105 "logging configuration path must be absolute: {}",
106 path.display()
107 )));
108 }
109 if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
110 return Err(FlowError::InvalidArgument(format!(
111 "logging configuration path must identify a .toml file: {}",
112 path.display()
113 )));
114 }
115
116 let contents = fs::read_to_string(path).map_err(|error| {
117 FlowError::InvalidArgument(format!(
118 "failed to read logging configuration {}: {error}",
119 path.display()
120 ))
121 })?;
122 Self::from_toml_document(&contents).map_err(|error| {
123 FlowError::InvalidArgument(format!(
124 "invalid logging configuration in {}: {error}",
125 path.display()
126 ))
127 })
128 }
129
130 #[doc(hidden)]
135 pub fn from_toml_document(contents: &str) -> Result<Self> {
136 let document: LoggingDocument = toml::from_str(contents).map_err(|error| {
137 FlowError::InvalidArgument(format!("invalid logging TOML: {error}"))
138 })?;
139 document
140 .logging
141 .ok_or_else(|| {
142 FlowError::InvalidArgument(
143 "logging configuration requires a [logging] section".into(),
144 )
145 })?
146 .resolve()
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum LogLevel {
153 Error,
155 Warn,
157 Info,
159 Debug,
161 Trace,
163}
164
165impl LogLevel {
166 pub fn parse(raw: &str) -> Result<Self> {
168 match raw.trim().to_ascii_lowercase().as_str() {
169 "error" => Ok(Self::Error),
170 "warn" | "warning" => Ok(Self::Warn),
171 "info" => Ok(Self::Info),
172 "debug" => Ok(Self::Debug),
173 "trace" => Ok(Self::Trace),
174 other => Err(FlowError::InvalidArgument(format!(
175 "invalid logging level '{other}'; expected error, warn, info, debug, or trace"
176 ))),
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum LogFormat {
184 Human,
186 Jsonl,
188}
189
190impl LogFormat {
191 pub fn parse(raw: &str) -> Result<Self> {
193 match raw.trim().to_ascii_lowercase().as_str() {
194 "human" => Ok(Self::Human),
195 "jsonl" | "json" => Ok(Self::Jsonl),
196 other => Err(FlowError::InvalidArgument(format!(
197 "invalid logging format '{other}'; expected human or jsonl"
198 ))),
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum LogSinkConfig {
206 File(FileLogSinkConfig),
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct FileLogSinkConfig {
220 pub path: PathBuf,
222 pub level: LogLevel,
224 pub format: LogFormat,
226 pub queue_capacity: usize,
229 pub rotation: Option<FileLogRotationConfig>,
231}
232
233impl Default for FileLogSinkConfig {
234 fn default() -> Self {
235 Self {
236 path: PathBuf::from(".nemo-relay/logs/relay.log.jsonl"),
237 level: LogLevel::Info,
238 format: LogFormat::Jsonl,
239 queue_capacity: DEFAULT_FILE_SINK_QUEUE_ENTRIES,
240 rotation: None,
241 }
242 }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub struct FileLogRotationConfig {
250 max_file_size_bytes: u64,
251 retained_files: usize,
252}
253
254impl FileLogRotationConfig {
255 pub fn new(max_file_size_bytes: u64, retained_files: usize) -> Result<Self> {
257 if max_file_size_bytes == 0 {
258 return Err(FlowError::InvalidArgument(
259 "logging sink max_file_size_bytes must be greater than 0".into(),
260 ));
261 }
262 if retained_files == 0 {
263 return Err(FlowError::InvalidArgument(
264 "logging sink retained_files must be greater than 0".into(),
265 ));
266 }
267 if retained_files > MAX_FILE_SINK_RETAINED_FILES {
268 return Err(FlowError::InvalidArgument(format!(
269 "logging sink retained_files {retained_files} exceeds maximum \
270 {MAX_FILE_SINK_RETAINED_FILES} backup files per sink"
271 )));
272 }
273 Ok(Self {
274 max_file_size_bytes,
275 retained_files,
276 })
277 }
278
279 pub fn max_file_size_bytes(self) -> u64 {
281 self.max_file_size_bytes
282 }
283
284 pub fn retained_files(self) -> usize {
286 self.retained_files
287 }
288}
289
290#[derive(Debug, Deserialize)]
291struct LoggingDocument {
292 logging: Option<RawLoggingConfig>,
293}
294
295#[derive(Debug, Default, Deserialize)]
296#[serde(deny_unknown_fields)]
297struct RawLoggingConfig {
298 level: Option<String>,
299 stderr_format: Option<String>,
300 flush_interval_millis: Option<u64>,
301 #[serde(default)]
302 sinks: Vec<RawFileLogSinkConfig>,
303}
304
305impl RawLoggingConfig {
306 fn resolve(self) -> Result<LoggingConfig> {
307 let mut config = LoggingConfig::default();
308 if let Some(level) = self.level {
309 config.level = LogLevel::parse(&level)?;
310 }
311 if let Some(stderr_format) = self.stderr_format {
312 config.stderr_format = LogFormat::parse(&stderr_format)?;
313 }
314 if let Some(flush_interval_millis) = self.flush_interval_millis {
315 config.flush_interval_millis = flush_interval_millis;
316 }
317 if !self.sinks.is_empty() {
318 config.sinks = self
319 .sinks
320 .into_iter()
321 .map(|sink| sink.resolve(config.level))
322 .collect::<Result<Vec<_>>>()?;
323 }
324 Ok(config)
325 }
326}
327
328#[derive(Debug, Deserialize)]
329#[serde(deny_unknown_fields)]
330struct RawFileLogSinkConfig {
331 path: Option<PathBuf>,
332 level: Option<String>,
333 format: Option<String>,
334 queue_capacity: Option<usize>,
335 max_file_size_bytes: Option<u64>,
336 retained_files: Option<usize>,
337}
338
339impl RawFileLogSinkConfig {
340 fn resolve(self, default_level: LogLevel) -> Result<LogSinkConfig> {
341 let path = self
342 .path
343 .ok_or_else(|| FlowError::InvalidArgument("logging sink requires path".into()))?;
344 if path.as_os_str().is_empty() {
345 return Err(FlowError::InvalidArgument(
346 "logging sink path must not be empty".into(),
347 ));
348 }
349
350 let level = self
351 .level
352 .as_deref()
353 .map(LogLevel::parse)
354 .transpose()?
355 .unwrap_or(default_level);
356 let format = self
357 .format
358 .as_deref()
359 .map(LogFormat::parse)
360 .transpose()?
361 .unwrap_or(LogFormat::Jsonl);
362 let queue_capacity = match self.queue_capacity {
363 Some(0) => {
364 return Err(FlowError::InvalidArgument(
365 "logging sink queue_capacity must be greater than 0".into(),
366 ));
367 }
368 Some(capacity) if capacity > MAX_FILE_SINK_QUEUE_ENTRIES => {
369 return Err(FlowError::InvalidArgument(format!(
370 "logging sink queue_capacity {capacity} exceeds maximum \
371 {MAX_FILE_SINK_QUEUE_ENTRIES} entries per file sink"
372 )));
373 }
374 Some(capacity) => capacity,
375 None => DEFAULT_FILE_SINK_QUEUE_ENTRIES,
376 };
377
378 let rotation = match (self.max_file_size_bytes, self.retained_files) {
379 (None, None) => None,
380 (Some(max_file_size_bytes), Some(retained_files)) => Some(FileLogRotationConfig::new(
381 max_file_size_bytes,
382 retained_files,
383 )?),
384 _ => {
385 return Err(FlowError::InvalidArgument(
386 "logging sink max_file_size_bytes and retained_files must be configured \
387 together"
388 .into(),
389 ));
390 }
391 };
392 Ok(LogSinkConfig::File(FileLogSinkConfig {
393 path,
394 level,
395 format,
396 queue_capacity,
397 rotation,
398 }))
399 }
400}
401
402fn environment_value(name: &str) -> Result<Option<String>> {
403 match env::var(name) {
404 Ok(value) if value.is_empty() => Err(FlowError::InvalidArgument(format!(
405 "{name} must not be empty when set"
406 ))),
407 Ok(value) => Ok(Some(value)),
408 Err(VarError::NotPresent) => Ok(None),
409 Err(VarError::NotUnicode(_)) => Err(FlowError::InvalidArgument(format!(
410 "{name} must contain valid Unicode"
411 ))),
412 }
413}