use std::env::VarError;
use std::path::{Path, PathBuf};
use std::{env, fs};
use crate::error::{FlowError, Result};
use serde::Deserialize;
const LOG_LEVEL_ENV: &str = "NEMO_RELAY_LOG";
const LOG_STDERR_FORMAT_ENV: &str = "NEMO_RELAY_LOG_STDERR_FORMAT";
const LOG_CONFIG_PATH_ENV: &str = "NEMO_RELAY_LOG_CONFIG_PATH";
pub const DEFAULT_FILE_SINK_QUEUE_ENTRIES: usize = 1024;
pub const DEFAULT_FILE_FLUSH_INTERVAL_MILLIS: u64 = 1000;
pub const MAX_FILE_SINK_QUEUE_ENTRIES: usize = 8_192;
pub const MAX_FILE_SINK_RETAINED_FILES: usize = 9;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoggingConfig {
pub level: LogLevel,
pub stderr_format: LogFormat,
pub sinks: Vec<LogSinkConfig>,
pub flush_interval_millis: u64,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: LogLevel::Error,
stderr_format: LogFormat::Human,
sinks: Vec::new(),
flush_interval_millis: DEFAULT_FILE_FLUSH_INTERVAL_MILLIS,
}
}
}
impl LoggingConfig {
pub fn from_environment() -> Result<Option<Self>> {
let level = environment_value(LOG_LEVEL_ENV)?;
let stderr_format = environment_value(LOG_STDERR_FORMAT_ENV)?;
let config_path = environment_value(LOG_CONFIG_PATH_ENV)?;
if config_path.is_some() && (level.is_some() || stderr_format.is_some()) {
return Err(FlowError::InvalidArgument(format!(
"{LOG_CONFIG_PATH_ENV} cannot be combined with {LOG_LEVEL_ENV} or \
{LOG_STDERR_FORMAT_ENV}"
)));
}
if let Some(path) = config_path {
return Self::from_file_path(path).map(Some);
}
if level.is_none() && stderr_format.is_none() {
return Ok(None);
}
let mut config = Self::default();
if let Some(level) = level {
config.level = LogLevel::parse(&level)?;
}
if let Some(stderr_format) = stderr_format {
config.stderr_format = LogFormat::parse(&stderr_format)?;
}
Ok(Some(config))
}
pub fn from_file_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
if !path.is_absolute() {
return Err(FlowError::InvalidArgument(format!(
"logging configuration path must be absolute: {}",
path.display()
)));
}
if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
return Err(FlowError::InvalidArgument(format!(
"logging configuration path must identify a .toml file: {}",
path.display()
)));
}
let contents = fs::read_to_string(path).map_err(|error| {
FlowError::InvalidArgument(format!(
"failed to read logging configuration {}: {error}",
path.display()
))
})?;
Self::from_toml_document(&contents).map_err(|error| {
FlowError::InvalidArgument(format!(
"invalid logging configuration in {}: {error}",
path.display()
))
})
}
#[doc(hidden)]
pub fn from_toml_document(contents: &str) -> Result<Self> {
let document: LoggingDocument = toml::from_str(contents).map_err(|error| {
FlowError::InvalidArgument(format!("invalid logging TOML: {error}"))
})?;
document
.logging
.ok_or_else(|| {
FlowError::InvalidArgument(
"logging configuration requires a [logging] section".into(),
)
})?
.resolve()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
impl LogLevel {
pub fn parse(raw: &str) -> Result<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"error" => Ok(Self::Error),
"warn" | "warning" => Ok(Self::Warn),
"info" => Ok(Self::Info),
"debug" => Ok(Self::Debug),
"trace" => Ok(Self::Trace),
other => Err(FlowError::InvalidArgument(format!(
"invalid logging level '{other}'; expected error, warn, info, debug, or trace"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogFormat {
Human,
Jsonl,
}
impl LogFormat {
pub fn parse(raw: &str) -> Result<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"human" => Ok(Self::Human),
"jsonl" | "json" => Ok(Self::Jsonl),
other => Err(FlowError::InvalidArgument(format!(
"invalid logging format '{other}'; expected human or jsonl"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogSinkConfig {
File(FileLogSinkConfig),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileLogSinkConfig {
pub path: PathBuf,
pub level: LogLevel,
pub format: LogFormat,
pub queue_capacity: usize,
pub rotation: Option<FileLogRotationConfig>,
}
impl Default for FileLogSinkConfig {
fn default() -> Self {
Self {
path: PathBuf::from(".nemo-relay/logs/relay.log.jsonl"),
level: LogLevel::Info,
format: LogFormat::Jsonl,
queue_capacity: DEFAULT_FILE_SINK_QUEUE_ENTRIES,
rotation: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileLogRotationConfig {
max_file_size_bytes: u64,
retained_files: usize,
}
impl FileLogRotationConfig {
pub fn new(max_file_size_bytes: u64, retained_files: usize) -> Result<Self> {
if max_file_size_bytes == 0 {
return Err(FlowError::InvalidArgument(
"logging sink max_file_size_bytes must be greater than 0".into(),
));
}
if retained_files == 0 {
return Err(FlowError::InvalidArgument(
"logging sink retained_files must be greater than 0".into(),
));
}
if retained_files > MAX_FILE_SINK_RETAINED_FILES {
return Err(FlowError::InvalidArgument(format!(
"logging sink retained_files {retained_files} exceeds maximum \
{MAX_FILE_SINK_RETAINED_FILES} backup files per sink"
)));
}
Ok(Self {
max_file_size_bytes,
retained_files,
})
}
pub fn max_file_size_bytes(self) -> u64 {
self.max_file_size_bytes
}
pub fn retained_files(self) -> usize {
self.retained_files
}
}
#[derive(Debug, Deserialize)]
struct LoggingDocument {
logging: Option<RawLoggingConfig>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLoggingConfig {
level: Option<String>,
stderr_format: Option<String>,
flush_interval_millis: Option<u64>,
#[serde(default)]
sinks: Vec<RawFileLogSinkConfig>,
}
impl RawLoggingConfig {
fn resolve(self) -> Result<LoggingConfig> {
let mut config = LoggingConfig::default();
if let Some(level) = self.level {
config.level = LogLevel::parse(&level)?;
}
if let Some(stderr_format) = self.stderr_format {
config.stderr_format = LogFormat::parse(&stderr_format)?;
}
if let Some(flush_interval_millis) = self.flush_interval_millis {
config.flush_interval_millis = flush_interval_millis;
}
if !self.sinks.is_empty() {
config.sinks = self
.sinks
.into_iter()
.map(|sink| sink.resolve(config.level))
.collect::<Result<Vec<_>>>()?;
}
Ok(config)
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFileLogSinkConfig {
path: Option<PathBuf>,
level: Option<String>,
format: Option<String>,
queue_capacity: Option<usize>,
max_file_size_bytes: Option<u64>,
retained_files: Option<usize>,
}
impl RawFileLogSinkConfig {
fn resolve(self, default_level: LogLevel) -> Result<LogSinkConfig> {
let path = self
.path
.ok_or_else(|| FlowError::InvalidArgument("logging sink requires path".into()))?;
if path.as_os_str().is_empty() {
return Err(FlowError::InvalidArgument(
"logging sink path must not be empty".into(),
));
}
let level = self
.level
.as_deref()
.map(LogLevel::parse)
.transpose()?
.unwrap_or(default_level);
let format = self
.format
.as_deref()
.map(LogFormat::parse)
.transpose()?
.unwrap_or(LogFormat::Jsonl);
let queue_capacity = match self.queue_capacity {
Some(0) => {
return Err(FlowError::InvalidArgument(
"logging sink queue_capacity must be greater than 0".into(),
));
}
Some(capacity) if capacity > MAX_FILE_SINK_QUEUE_ENTRIES => {
return Err(FlowError::InvalidArgument(format!(
"logging sink queue_capacity {capacity} exceeds maximum \
{MAX_FILE_SINK_QUEUE_ENTRIES} entries per file sink"
)));
}
Some(capacity) => capacity,
None => DEFAULT_FILE_SINK_QUEUE_ENTRIES,
};
let rotation = match (self.max_file_size_bytes, self.retained_files) {
(None, None) => None,
(Some(max_file_size_bytes), Some(retained_files)) => Some(FileLogRotationConfig::new(
max_file_size_bytes,
retained_files,
)?),
_ => {
return Err(FlowError::InvalidArgument(
"logging sink max_file_size_bytes and retained_files must be configured \
together"
.into(),
));
}
};
Ok(LogSinkConfig::File(FileLogSinkConfig {
path,
level,
format,
queue_capacity,
rotation,
}))
}
}
fn environment_value(name: &str) -> Result<Option<String>> {
match env::var(name) {
Ok(value) if value.is_empty() => Err(FlowError::InvalidArgument(format!(
"{name} must not be empty when set"
))),
Ok(value) => Ok(Some(value)),
Err(VarError::NotPresent) => Ok(None),
Err(VarError::NotUnicode(_)) => Err(FlowError::InvalidArgument(format!(
"{name} must contain valid Unicode"
))),
}
}