diff --git a/CHANGELOG.md b/CHANGELOG.md index be628f8c..e2da55f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,8 @@ Bob Management GUI changelog ## [Unreleased] #### Added - - Initial project structure, backend only (#9) - Initial project stricture, frontend (#10) - Dockerfile and Docker-Compose to simplify deployment (#5) - CI/CD configuration (#11) +- Logger Initialization (#14) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8c4d8dc4..e4f2d2ec 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -22,6 +22,8 @@ tower-http = { version = "0.4", features = ["cors", "fs"] } ## Logging tracing = "0.1" +file-rotate = "0.7" +tracing-appender = "0.2" tracing-subscriber = "0.3" ## Error Handling diff --git a/backend/src/config.rs b/backend/src/config.rs index 9cce6bd5..8beec0f7 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -1,12 +1,59 @@ -use cli::Config; +use crate::prelude::*; +use cli::{Config, LoggerConfig}; +use file_rotate::{suffix::AppendTimestamp, ContentLimit, FileRotate}; +use thiserror::Error; use tower_http::cors::CorsLayer; +use tracing_appender::non_blocking::{NonBlocking, WorkerGuard}; +use tracing_subscriber::{filter::LevelFilter, prelude::*, util::SubscriberInitExt}; +#[allow(clippy::module_name_repetitions)] pub trait ConfigExt { /// Return either very permissive [`CORS`](`CorsLayer`) configuration /// or empty one based on `cors_allow_all` field fn get_cors_configuration(&self) -> CorsLayer; } +pub trait LoggerExt { + /// Initialize logger. + /// + /// Returns [`WorkerGuard`]s for off-thread writers. + /// Should not be dropped. + /// + /// # Errors + /// + /// Function returns error if `init_file_rotate` fails + fn init_logger(&self) -> Result, LoggerError>; + + /// Returns [`std:io::Write`] object that rotates files on write + /// + /// # Errors + /// + /// Function returns error if `log_file` is not specified + fn init_file_rotate(&self) -> Result, LoggerError>; + + /// Returns non-blocking file writer + /// + /// Also returns [`WorkerGuard`] for off-thread writing. + /// Should not be dropped. + /// + /// # Errors + /// + /// This function will return an error if the file logger configuration is empty, file logging + /// is disabled or logs filename is not specified + fn non_blocking_file_writer(&self) -> Result<(NonBlocking, WorkerGuard), LoggerError>; + + /// Returns non-blocking stdout writer + /// + /// Also returns [`WorkerGuard`] for off-thread writing. + /// Should not be dropped. + /// + /// # Errors + /// + /// This function will return an error if the stdout logger configuration is empty or stdout logging + /// is disabled + fn non_blocking_stdout_writer(&self) -> Result<(NonBlocking, WorkerGuard), LoggerError>; +} + impl ConfigExt for Config { fn get_cors_configuration(&self) -> CorsLayer { self.cors_allow_all @@ -14,3 +61,98 @@ impl ConfigExt for Config { .unwrap_or_default() } } + +impl LoggerExt for LoggerConfig { + fn init_logger(&self) -> Result, LoggerError> { + let mut guards = Vec::with_capacity(2); + + let file_writer = disable_on_error(self.non_blocking_file_writer())?; + let stdout_writer = disable_on_error(self.non_blocking_stdout_writer())?; + + let mut layers_iter = + [file_writer, stdout_writer] + .into_iter() + .flatten() + .map(|(writer, guard)| { + guards.push(guard); + tracing_subscriber::fmt::layer() + .with_writer(writer) + .with_filter(LevelFilter::from_level(self.trace_level)) + }); + + if let Some(first_layer) = layers_iter.next() { + tracing_subscriber::registry() + .with(layers_iter.fold(first_layer.boxed(), |layer, next_layer| { + layer.and_then(next_layer).boxed() + })) + .init(); + }; + + Ok(guards) + } + + fn init_file_rotate(&self) -> Result, LoggerError> { + let config = self.file.as_ref().ok_or(LoggerError::EmptyConfig)?; + let log_file = config.log_file.as_ref().ok_or(LoggerError::NoFileName)?; + if log_file.as_os_str().is_empty() { + return Err(LoggerError::NoFileName.into()); + } + + Ok(FileRotate::new( + log_file, + AppendTimestamp::default(file_rotate::suffix::FileLimit::MaxFiles(config.log_amount)), + ContentLimit::BytesSurpassed(config.log_size), + file_rotate::compression::Compression::OnRotate(1), + None, + )) + } + + fn non_blocking_file_writer(&self) -> Result<(NonBlocking, WorkerGuard), LoggerError> { + self.file.as_ref().map_or_else( + || Err(LoggerError::EmptyConfig.into()), + |config| { + if config.enabled { + Ok(tracing_appender::non_blocking(self.init_file_rotate()?)) + } else { + Err(LoggerError::NotEnabled.into()) + } + }, + ) + } + + fn non_blocking_stdout_writer(&self) -> Result<(NonBlocking, WorkerGuard), LoggerError> { + self.stdout.as_ref().map_or_else( + || Err(LoggerError::EmptyConfig.into()), + |config| { + if config.enabled { + Ok(tracing_appender::non_blocking(std::io::stdout())) + } else { + Err(LoggerError::NotEnabled.into()) + } + }, + ) + } +} + +#[derive(Debug, Error)] +pub enum LoggerError { + #[error("Empty logger configuration")] + EmptyConfig, + #[error("No filename specified")] + NoFileName, + #[error("This logger is not enabled")] + NotEnabled, +} + +/// Consume some errors to produce empty logger +fn disable_on_error( + logger: Result<(NonBlocking, WorkerGuard), LoggerError>, +) -> Result, LoggerError> { + Ok(match logger { + Ok(writer) => Some(writer), + Err(e) => match e.current_context() { + LoggerError::NotEnabled | LoggerError::EmptyConfig => None, + _ => return Err(e), + }, + }) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 78dd9901..3006b219 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -4,7 +4,6 @@ use axum::{routing::get, Router}; use utoipa::OpenApi; - pub mod config; pub mod connector; pub mod error; diff --git a/backend/src/main.rs b/backend/src/main.rs index 554417bc..37b3376e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,8 +1,12 @@ -#![allow(clippy::multiple_crate_versions)] +#![allow( + clippy::multiple_crate_versions, + clippy::unwrap_used, + clippy::expect_used +)] use axum::Router; use bob_management::{ - config::ConfigExt, + config::{ConfigExt, LoggerExt}, prelude::*, root, router::{ApiV1, ApiVersion, NoApi, RouterApiExt}, @@ -12,15 +16,13 @@ use bob_management::{ use cli::Parser; use error_stack::{Result, ResultExt}; use hyper::Method; -use std::{env, path::PathBuf}; +use std::env; use tower::ServiceBuilder; use tower_http::{cors::CorsLayer, services::ServeDir}; -use tracing::Level; const FRONTEND_FOLDER: &str = "frontend"; #[tokio::main] -#[allow(clippy::unwrap_used, clippy::expect_used)] async fn main() -> Result<(), AppError> { let config = cli::Config::try_from(cli::Args::parse()) .change_context(AppError::InitializationError) @@ -28,7 +30,7 @@ async fn main() -> Result<(), AppError> { let logger = &config.logger; - init_tracer(&logger.log_file, logger.trace_level); + let _guard = logger.init_logger().unwrap(); tracing::info!("Logger: {logger:?}"); let cors: CorsLayer = config.get_cors_configuration(); @@ -50,11 +52,6 @@ async fn main() -> Result<(), AppError> { Ok(()) } -fn init_tracer(_log_file: &Option, trace_level: Level) { - let subscriber = tracing_subscriber::fmt().with_max_level(trace_level); - subscriber.init(); -} - #[allow(clippy::unwrap_used, clippy::expect_used)] fn router(cors: CorsLayer) -> Router { let mut frontend = env::current_exe().expect("Couldn't get current executable path."); diff --git a/cli/src/config.rs b/cli/src/config.rs index 10568eaf..78678ef2 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -26,28 +26,53 @@ pub struct Config { } /// Logger Configuration passed on initialization -#[allow(clippy::module_name_repetitions)] #[serde_as] #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct LoggerConfig { - /// [Stub] File to save logs - pub log_file: Option, - - /// [Stub] Number of log files - #[serde(default = "LoggerConfig::default_log_amount")] - pub log_amount: usize, + /// Rolling file logger config + #[serde(default)] + pub file: Option, - /// [Stub] Max size of a single log file, in bytes - #[serde(default = "LoggerConfig::default_log_size")] - pub log_size: u64, + /// Stdout logger config + #[serde(default)] + pub stdout: Option, /// Tracing Level - #[serde(default = "LoggerConfig::tracing_default")] + #[serde(default = "LoggerConfig::level_default")] #[serde_as(as = "DisplayFromStr")] pub trace_level: tracing::Level, } +/// File Logger Configuration for writing logs to files +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct FileLogger { + /// Enable log output to file + pub enabled: bool, + + /// File to save logs + pub log_file: Option, + + /// Number of log files + #[serde(default = "FileLogger::default_log_amount")] + pub log_amount: usize, + + /// Max size of a single log file, in bytes + #[serde(default = "FileLogger::default_log_size")] + pub log_size: usize, +} + +/// Stdout Logger Configuration for printing logs into stdout +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct StdoutLogger { + /// Enable log output to stdout + pub enabled: bool, +} + impl Default for Config { fn default() -> Self { Self { @@ -59,41 +84,77 @@ impl Default for Config { } } +impl Default for LoggerConfig { + fn default() -> Self { + Self { + file: None, + stdout: None, + trace_level: Self::level_default(), + } + } +} + impl Config { + #[must_use] pub const fn default_cors() -> bool { false } + #[must_use] pub const fn default_timeout() -> Duration { Duration::from_millis(5000) } } impl LoggerConfig { - pub const fn tracing_default() -> tracing::Level { - tracing::Level::INFO + #[must_use] + pub const fn level_default() -> tracing::Level { + tracing::Level::TRACE + } +} + +impl FileLogger { + #[must_use] + pub const fn default_enabled() -> bool { + false } + #[must_use] pub const fn default_log_amount() -> usize { 5 } - pub const fn default_log_size() -> u64 { - 10u64.pow(6) + #[must_use] + pub const fn default_log_size() -> usize { + 10usize.pow(6) } } -impl Default for LoggerConfig { +impl StdoutLogger { + #[must_use] + pub const fn default_enabled() -> bool { + false + } +} + +impl Default for FileLogger { fn default() -> Self { Self { log_file: None, log_amount: Self::default_log_amount(), log_size: Self::default_log_size(), - trace_level: Self::tracing_default(), + enabled: Self::default_enabled(), } } } +impl Default for StdoutLogger { + fn default() -> Self { + Self { + enabled: Self::default_enabled(), + } + } +} pub trait FromFile { /// Parses the file spcified in `path` /// diff --git a/cli/src/lib.rs b/cli/src/lib.rs index efdf62bc..1c055388 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -3,4 +3,4 @@ mod config; pub use clap::Parser; pub use cli::Args; -pub use config::{Config, FromFile, LoggerConfig}; +pub use config::{Config, FileLogger, FromFile, LoggerConfig, StdoutLogger}; diff --git a/config.yaml b/config.yaml index db7be989..6768d5d1 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,8 @@ address: 0.0.0.0:9000 logger: trace-level: INFO + file: + enabled: true + log-file: /tmp/bob.log + stdout: + enabled: true diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 71019618..20b05c78 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -19,4 +19,3 @@ tsync = "2" [dependencies] tsync = "2" -