From f0387cb46e820dcdc76440dd463ced9c281b920f Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Mon, 2 Mar 2026 20:43:17 +0000 Subject: [PATCH] feat: add structured JSON logging and file output support Add CLI flags (--log-format, --log-dir, --log-max-files) for JSON log output and daily-rotated file logging via tracing-appender. Embed git commit hash at build time. Convert key info! statements in node.rs and upgrade/monitor.rs to use structured tracing fields for Elasticsearch queryability. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 1 + build.rs | 19 +++++++++++- src/bin/saorsa-node/cli.rs | 25 +++++++++++++++ src/bin/saorsa-node/main.rs | 62 ++++++++++++++++++++++++++++++++++--- src/node.rs | 16 +++++----- src/upgrade/monitor.rs | 17 +++++----- 6 files changed, 118 insertions(+), 22 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fa32dd08..6cded9e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,7 @@ semver = "1" # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" # Error handling thiserror = "2" diff --git a/build.rs b/build.rs index de7061e1..24eefa0c 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,23 @@ //! Build script for saorsa-node. +use std::process::Command; + fn main() { - // Rerun if feature configuration changes + // Rerun if the git HEAD pointer changes (branch switch, detached HEAD, etc.) + println!("cargo:rerun-if-changed=.git/HEAD"); + // Rerun if the current branch tip moves (new commits) + println!("cargo:rerun-if-changed=.git/refs/heads"); + // Rerun if refs are packed (e.g. after `git gc`) + println!("cargo:rerun-if-changed=.git/packed-refs"); println!("cargo:rerun-if-changed=build.rs"); + + let commit = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()); + + println!("cargo:rustc-env=SAORSA_GIT_COMMIT={commit}"); } diff --git a/src/bin/saorsa-node/cli.rs b/src/bin/saorsa-node/cli.rs index 559f899a..e04f2b5a 100644 --- a/src/bin/saorsa-node/cli.rs +++ b/src/bin/saorsa-node/cli.rs @@ -71,6 +71,21 @@ pub struct Cli { #[arg(long, value_enum, default_value = "info", env = "RUST_LOG")] pub log_level: CliLogLevel, + /// Log output format. + #[arg(long, value_enum, default_value = "text", env = "SAORSA_LOG_FORMAT")] + pub log_format: CliLogFormat, + + /// Directory for log file output. + /// When set, logs are written to files in this directory instead of stdout. + /// Files rotate daily and are named saorsa-node.YYYY-MM-DD.log. + #[arg(long, env = "SAORSA_LOG_DIR")] + pub log_dir: Option, + + /// Maximum number of rotated log files to retain (only used with --log-dir). + /// Oldest files are deleted when this limit is reached. Rotation is daily. + #[arg(long, default_value = "7", env = "SAORSA_LOG_MAX_FILES")] + pub log_max_files: usize, + /// Network mode (production, testnet, or development). /// Testnet mode uses relaxed IP diversity limits suitable for /// single-provider deployments with many nodes per IP. @@ -147,6 +162,16 @@ pub enum CliLogLevel { Trace, } +/// Log format CLI enum. +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +pub enum CliLogFormat { + /// Plain text output (default). + #[default] + Text, + /// Structured JSON output. + Json, +} + /// Network mode CLI enum. #[derive(Debug, Clone, Copy, ValueEnum, Default)] pub enum CliNetworkMode { diff --git a/src/bin/saorsa-node/main.rs b/src/bin/saorsa-node/main.rs index 983d3f19..a13acc3a 100644 --- a/src/bin/saorsa-node/main.rs +++ b/src/bin/saorsa-node/main.rs @@ -3,10 +3,11 @@ mod cli; use clap::Parser; -use cli::Cli; +use cli::{Cli, CliLogFormat}; use saorsa_node::NodeBuilder; use tracing::info; -use tracing_subscriber::{fmt, prelude::*, EnvFilter}; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{fmt, EnvFilter, Layer}; #[tokio::main] async fn main() -> color_eyre::Result<()> { @@ -16,16 +17,69 @@ async fn main() -> color_eyre::Result<()> { // Parse CLI arguments let cli = Cli::parse(); + // Extract logging options before consuming the CLI struct + let log_format = cli.log_format; + let log_dir = cli.log_dir.clone(); + let log_max_files = cli.log_max_files; + // Initialize tracing let log_level: String = cli.log_level.into(); let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&log_level)); + // _guard must live for the duration of main() to ensure log flushing. + // The guard's Drop impl flushes buffered logs — it is intentionally held, not read. + #[allow(clippy::collection_is_never_read)] + let _guard: Option; + + let layer: Box + Send + Sync> = match (log_format, log_dir) { + (CliLogFormat::Text, None) => { + _guard = None; + Box::new(fmt::layer()) + } + (CliLogFormat::Json, None) => { + _guard = None; + Box::new(fmt::layer().json().flatten_event(true)) + } + (CliLogFormat::Text, Some(dir)) => { + let file_appender = tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .max_log_files(log_max_files) + .filename_prefix("saorsa-node") + .filename_suffix("log") + .build(dir)?; + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + _guard = Some(guard); + Box::new(fmt::layer().with_writer(non_blocking).with_ansi(false)) + } + (CliLogFormat::Json, Some(dir)) => { + let file_appender = tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .max_log_files(log_max_files) + .filename_prefix("saorsa-node") + .filename_suffix("log") + .build(dir)?; + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + _guard = Some(guard); + Box::new( + fmt::layer() + .json() + .flatten_event(true) + .with_writer(non_blocking) + .with_ansi(false), + ) + } + }; + tracing_subscriber::registry() - .with(fmt::layer()) + .with(layer) .with(filter) .init(); - info!("saorsa-node v{}", env!("CARGO_PKG_VERSION")); + info!( + version = env!("CARGO_PKG_VERSION"), + commit = env!("SAORSA_GIT_COMMIT"), + "saorsa-node starting" + ); // Build configuration let config = cli.into_config()?; diff --git a/src/node.rs b/src/node.rs index ca9dadb9..0a82d8c3 100644 --- a/src/node.rs +++ b/src/node.rs @@ -425,7 +425,7 @@ impl RunningNode { /// /// Returns an error if the node encounters a fatal error. pub async fn run(&mut self) -> Result<()> { - info!("Starting saorsa-node"); + info!("Node runtime loop starting"); // Start the P2P node self.p2p_node @@ -433,10 +433,8 @@ impl RunningNode { .await .map_err(|e| Error::Startup(format!("Failed to start P2P node: {e}")))?; - info!( - "P2P node started, listening on {:?}", - self.p2p_node.listen_addrs().await - ); + let addrs = self.p2p_node.listen_addrs().await; + info!(listen_addrs = ?addrs, "P2P node started"); // Emit started event if let Err(e) = self.events_tx.send(NodeEvent::Started) { @@ -463,9 +461,9 @@ impl RunningNode { result = monitor.check_for_updates() => { if let Ok(Some(upgrade_info)) = result { info!( - "Upgrade available: {} -> {}", - upgrader.current_version(), - upgrade_info.version + current_version = %upgrader.current_version(), + new_version = %upgrade_info.version, + "Upgrade available" ); // Send notification event @@ -479,7 +477,7 @@ impl RunningNode { info!("Starting auto-apply upgrade..."); match upgrader.apply_upgrade(&upgrade_info).await { Ok(UpgradeResult::Success { version }) => { - info!("Upgrade to {} successful! Process will restart.", version); + info!(version = %version, "Upgrade successful, process will restart"); // If we reach here, exec() failed or not supported } Ok(UpgradeResult::RolledBack { reason }) => { diff --git a/src/upgrade/monitor.rs b/src/upgrade/monitor.rs index 59d23de8..d85f05fb 100644 --- a/src/upgrade/monitor.rs +++ b/src/upgrade/monitor.rs @@ -250,10 +250,10 @@ impl UpgradeMonitor { let delay = rollout.calculate_delay_for_version(&info.version); info!( - "New version {} detected. Staged rollout delay: {}h {}m", - info.version, - delay.as_secs() / 3600, - (delay.as_secs() % 3600) / 60 + new_version = %info.version, + delay_hours = delay.as_secs() / 3600, + delay_minutes = (delay.as_secs() % 3600) / 60, + "New version detected, staged rollout delay calculated" ); } @@ -269,8 +269,8 @@ impl UpgradeMonitor { if elapsed >= delay { info!( - "Staged rollout delay elapsed. Ready to upgrade to version {}", - info.version + version = %info.version, + "Staged rollout delay elapsed, ready to upgrade" ); Ok(Some(info)) } else { @@ -343,8 +343,9 @@ impl UpgradeMonitor { let sig_asset = release.assets.iter().find(|a| a.name == sig_name)?; info!( - "New version available: {} -> {}", - self.current_version, latest_version + current_version = %self.current_version, + new_version = %latest_version, + "New version available" ); Some(UpgradeInfo {