feat(logging): tracing broadcast layer for real-time log streaming
This commit is contained in:
parent
dff155a93c
commit
bee11c7735
@ -23,6 +23,7 @@ prost = "0.14"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
|
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|||||||
@ -1,27 +1,93 @@
|
|||||||
use std::path::PathBuf;
|
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_appender::rolling::{RollingFileAppender, Rotation};
|
||||||
|
use tracing_subscriber::layer::Context;
|
||||||
|
use tracing_subscriber::registry::LookupSpan;
|
||||||
|
use tracing_subscriber::Layer;
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
EnvFilter, fmt, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt,
|
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<broadcast::Sender<LogEvent>> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn log_sender() -> Option<broadcast::Sender<LogEvent>> {
|
||||||
|
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<S> Layer<S> 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 {
|
pub fn get_default_log_dir() -> PathBuf {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
home.join(".picobot").join("logs")
|
home.join(".picobot").join("logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the default config file path: ~/.picobot/config.json
|
|
||||||
pub fn get_default_config_path() -> PathBuf {
|
pub fn get_default_config_path() -> PathBuf {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
home.join(".picobot").join("config.json")
|
home.join(".picobot").join("config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize logging with file appender
|
|
||||||
/// Logs are written to ~/.picobot/logs/ with daily rotation
|
|
||||||
pub fn init_logging() {
|
pub fn init_logging() {
|
||||||
|
let (tx, _) = broadcast::channel::<LogEvent>(1024);
|
||||||
|
let _ = LOG_TX.set(tx);
|
||||||
|
|
||||||
let log_dir = get_default_log_dir();
|
let log_dir = get_default_log_dir();
|
||||||
|
|
||||||
// Create log directory if it doesn't exist
|
|
||||||
if !log_dir.exists()
|
if !log_dir.exists()
|
||||||
&& let Err(e) = std::fs::create_dir_all(&log_dir)
|
&& 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");
|
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 env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
let file_layer = fmt::layer()
|
let file_layer = fmt::layer()
|
||||||
@ -55,6 +119,7 @@ pub fn init_logging() {
|
|||||||
.with(env_filter)
|
.with(env_filter)
|
||||||
.with(console_layer)
|
.with(console_layer)
|
||||||
.with(file_layer)
|
.with(file_layer)
|
||||||
|
.with(BroadcastLayer)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
|
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user