Skip to main content

nemo_relay/logging/
config.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Resolved operational logging configuration types and source parsing.
5
6use 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
17/// Default number of pending asynchronous queue entries per file sink when `queue_capacity` is
18/// omitted.
19pub const DEFAULT_FILE_SINK_QUEUE_ENTRIES: usize = 1024;
20
21/// Default periodic flush interval when [`LoggingConfig::flush_interval_millis`] is omitted.
22pub const DEFAULT_FILE_FLUSH_INTERVAL_MILLIS: u64 = 1000;
23
24/// Fixed hard maximum number of pending asynchronous queue entries per file sink.
25///
26/// This is a non-configurable safety limit, not the queue size itself. The async queue
27/// preallocates every slot, so an oversized `queue_capacity` can panic the process at startup;
28/// configuration above this bound is rejected with a config error. It cannot be raised.
29pub const MAX_FILE_SINK_QUEUE_ENTRIES: usize = 8_192;
30
31/// Fixed hard maximum number of retained backup files per rotating file sink.
32///
33/// Size-based rotation renames existing backup files on each rotation, so an unbounded value can
34/// make one log write perform excessive filesystem work. This limit counts backup files and does
35/// not include the active log file.
36pub const MAX_FILE_SINK_RETAINED_FILES: usize = 9;
37
38/// Operational logging configuration for [`LoggingRuntime::configure`](super::LoggingRuntime::configure).
39///
40/// `level` is the process-wide **minimum severity**: call sites may emit any level, but records
41/// less severe than this threshold are discarded. Per-file sinks may raise their own minimum.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LoggingConfig {
44    /// Minimum severity for operational logs.
45    pub level: LogLevel,
46    /// Encoding for the always-on stderr sink.
47    pub stderr_format: LogFormat,
48    /// Additional file sinks beyond stderr.
49    pub sinks: Vec<LogSinkConfig>,
50    /// Periodic flush cadence in milliseconds applied to all file sinks. `0` disables periodic
51    /// flush (shutdown flush only). Defaults to [`DEFAULT_FILE_FLUSH_INTERVAL_MILLIS`].
52    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    /// Resolves logging configuration from the supported process environment.
68    ///
69    /// Returns `None` when no logging environment variables are present. Direct level and stderr
70    /// format settings may be combined. `NEMO_RELAY_LOG_CONFIG_PATH` selects an absolute TOML file
71    /// instead and is mutually exclusive with both direct settings.
72    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    /// Loads logging configuration from an absolute TOML file containing `[logging]`.
101    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    /// Parses a TOML document containing Relay's existing `[logging]` schema.
131    ///
132    /// This is exposed for Relay frontends that already own TOML discovery and merging. Most
133    /// callers should use [`Self::from_file_path`] or construct [`LoggingConfig`] directly.
134    #[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/// Global / per-sink minimum severity for operational logs.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum LogLevel {
153    /// Error and above.
154    Error,
155    /// Warning and above.
156    Warn,
157    /// Informational and above.
158    Info,
159    /// Debug and above.
160    Debug,
161    /// Trace and above (most verbose).
162    Trace,
163}
164
165impl LogLevel {
166    /// Parses a config string into a [`LogLevel`].
167    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/// Output encoding for an operational log sink.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum LogFormat {
184    /// Single-line human-readable text.
185    Human,
186    /// One JSON object per line.
187    Jsonl,
188}
189
190impl LogFormat {
191    /// Parses a config string into a [`LogFormat`].
192    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/// Additional operational log sink beyond always-on stderr.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum LogSinkConfig {
206    /// Append-only file sink with an async delivery queue.
207    File(FileLogSinkConfig),
208}
209
210/// File sink settings for non-blocking operational logging.
211///
212/// Relative `path` values are resolved against the process current working directory at sink open
213/// time. Absolute paths are used as-is. `~` and env expansion are not applied.
214///
215/// File sinks write through an async queue so logging cannot stall the process on disk I/O.
216/// `queue_capacity` is an optional advanced override; an omitted value uses
217/// [`DEFAULT_FILE_SINK_QUEUE_ENTRIES`].
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct FileLogSinkConfig {
220    /// Destination file path.
221    pub path: PathBuf,
222    /// Minimum severity for this file sink.
223    pub level: LogLevel,
224    /// Output encoding for this file sink.
225    pub format: LogFormat,
226    /// Maximum pending asynchronous queue entries for this file sink. Must be greater than 0 and
227    /// at most [`MAX_FILE_SINK_QUEUE_ENTRIES`].
228    pub queue_capacity: usize,
229    /// Optional size-based rotation and retention settings.
230    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/// Size-based rotation settings for a file log sink.
246///
247/// `retained_files` counts previous log files and excludes the active file.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub struct FileLogRotationConfig {
250    max_file_size_bytes: u64,
251    retained_files: usize,
252}
253
254impl FileLogRotationConfig {
255    /// Creates validated size-based rotation settings.
256    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    /// Maximum active file size before the next record triggers rotation.
280    pub fn max_file_size_bytes(self) -> u64 {
281        self.max_file_size_bytes
282    }
283
284    /// Number of previous log files retained in addition to the active file.
285    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}