diff --git a/Cargo.toml b/Cargo.toml index dc66c67..ef0fe95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ prost = "0.14" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] } tracing-appender = "0.2" +time = { version = "0.3", features = ["formatting", "local-offset"] } anyhow = "1.0" mime_guess = "2.0" base64 = "0.22" diff --git a/src/logging.rs b/src/logging.rs index 2551d88..daf495d 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,27 +1,93 @@ use std::path::PathBuf; +use std::sync::OnceLock; +use tokio::sync::broadcast; +use tracing::field::{Field, Visit}; +use tracing::{Event, Subscriber}; use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::Layer; use tracing_subscriber::{ EnvFilter, fmt, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt, }; -/// Get the default log directory path: ~/.picobot/logs +#[derive(Clone, Debug)] +pub struct LogEvent { + pub ts: String, + pub level: String, + pub target: String, + pub message: String, +} + +static LOG_TX: OnceLock> = OnceLock::new(); + +pub fn log_sender() -> Option> { + LOG_TX.get().cloned() +} + +struct MessageVisitor(String); + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{value:?}"); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.0 = value.to_string(); + } + } +} + +struct BroadcastLayer; + +impl Layer for BroadcastLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let Some(tx) = LOG_TX.get() else { + return; + }; + if tx.receiver_count() == 0 { + return; + } + + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + + let ts = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(); + + let _ = tx.send(LogEvent { + ts, + level: event.metadata().level().to_string(), + target: event.metadata().target().to_string(), + message: visitor.0, + }); + } +} + pub fn get_default_log_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".picobot").join("logs") } -/// Get the default config file path: ~/.picobot/config.json pub fn get_default_config_path() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".picobot").join("config.json") } -/// Initialize logging with file appender -/// Logs are written to ~/.picobot/logs/ with daily rotation pub fn init_logging() { + let (tx, _) = broadcast::channel::(1024); + let _ = LOG_TX.set(tx); + let log_dir = get_default_log_dir(); - // Create log directory if it doesn't exist if !log_dir.exists() && let Err(e) = std::fs::create_dir_all(&log_dir) { @@ -32,10 +98,8 @@ pub fn init_logging() { ); } - // Create file appender with daily rotation let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log"); - // Build subscriber with both console and file output let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let file_layer = fmt::layer() @@ -55,6 +119,7 @@ pub fn init_logging() { .with(env_filter) .with(console_layer) .with(file_layer) + .with(BroadcastLayer) .init(); tracing::info!("Logging initialized. Log directory: {}", log_dir.display());