Skip to main content

fresh/services/
warning_log.rs

1//! Warning log layer for tracing
2//!
3//! This module provides a custom tracing layer that captures WARN and ERROR
4//! level logs to a separate file and notifies the editor when warnings occur.
5//! Duplicate messages are suppressed to avoid log spam.
6
7use std::collections::HashMap;
8use std::fs::File;
9use std::io::Write;
10use std::path::PathBuf;
11use std::sync::mpsc;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14use tracing::Level;
15use tracing_subscriber::layer::Context;
16use tracing_subscriber::registry::LookupSpan;
17use tracing_subscriber::Layer;
18
19/// Deduplication state for warning messages
20struct DeduplicationState {
21    /// Map from message hash to (last_seen_time, count)
22    recent_messages: HashMap<u64, (Instant, usize)>,
23    /// Time window for deduplication (messages within this window are deduplicated)
24    window: Duration,
25    /// Maximum number of unique messages to track
26    max_entries: usize,
27}
28
29impl DeduplicationState {
30    fn new() -> Self {
31        Self {
32            recent_messages: HashMap::new(),
33            window: Duration::from_secs(5), // Deduplicate within 5 seconds
34            max_entries: 100,               // Track up to 100 unique messages
35        }
36    }
37
38    /// Check if a message should be logged or suppressed
39    /// Returns (should_log, suppressed_count) where suppressed_count is > 0 when
40    /// we're logging after having suppressed duplicates
41    fn check_message(&mut self, message: &str) -> (bool, usize) {
42        let hash = self.hash_message(message);
43        let now = Instant::now();
44
45        // Clean up old entries periodically
46        if self.recent_messages.len() > self.max_entries {
47            self.recent_messages
48                .retain(|_, (time, _)| now.duration_since(*time) < self.window * 2);
49        }
50
51        if let Some((last_seen, count)) = self.recent_messages.get_mut(&hash) {
52            if now.duration_since(*last_seen) < self.window {
53                // Duplicate within window - suppress
54                *count += 1;
55                *last_seen = now;
56                (false, 0)
57            } else {
58                // Same message but outside window - log it and report suppressed count
59                let suppressed = *count;
60                *count = 1;
61                *last_seen = now;
62                (true, suppressed.saturating_sub(1))
63            }
64        } else {
65            // New message - log it
66            self.recent_messages.insert(hash, (now, 1));
67            (true, 0)
68        }
69    }
70
71    fn hash_message(&self, message: &str) -> u64 {
72        use std::hash::{Hash, Hasher};
73        let mut hasher = std::collections::hash_map::DefaultHasher::new();
74        message.hash(&mut hasher);
75        hasher.finish()
76    }
77}
78
79/// A tracing layer that writes WARN+ logs to a file and notifies via channel
80pub struct WarningLogLayer {
81    file: Arc<Mutex<File>>,
82    sender: mpsc::Sender<()>,
83    dedup: Arc<Mutex<DeduplicationState>>,
84}
85
86/// Handle returned from setup, containing the receiver and log path
87pub struct WarningLogHandle {
88    /// Receiver that gets notified when warnings are logged
89    pub receiver: mpsc::Receiver<()>,
90    /// Path to the warning log file
91    pub path: PathBuf,
92}
93
94/// Create a warning log layer and handle
95///
96/// Returns the layer (to add to tracing subscriber) and a handle (to pass to editor)
97pub fn create() -> std::io::Result<(WarningLogLayer, WarningLogHandle)> {
98    create_with_path(super::log_dirs::warnings_log_path())
99}
100
101/// Create a warning log layer with a specific path (for testing)
102pub fn create_with_path(path: PathBuf) -> std::io::Result<(WarningLogLayer, WarningLogHandle)> {
103    let file = File::create(&path)?;
104
105    let (sender, receiver) = mpsc::channel();
106
107    let layer = WarningLogLayer {
108        file: Arc::new(Mutex::new(file)),
109        sender,
110        dedup: Arc::new(Mutex::new(DeduplicationState::new())),
111    };
112
113    let handle = WarningLogHandle { receiver, path };
114
115    Ok((layer, handle))
116}
117
118impl<S> Layer<S> for WarningLogLayer
119where
120    S: tracing::Subscriber + for<'a> LookupSpan<'a>,
121{
122    fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
123        // Only capture WARN and ERROR
124        let level = *event.metadata().level();
125        if level > Level::WARN {
126            return;
127        }
128
129        // Format the event
130        let mut visitor = StringVisitor::default();
131        event.record(&mut visitor);
132
133        // Check for duplicates
134        let (should_log, suppressed_count) = if let Ok(mut dedup) = self.dedup.lock() {
135            dedup.check_message(&visitor.0)
136        } else {
137            (true, 0) // If lock fails, log anyway
138        };
139
140        if !should_log {
141            return; // Suppress duplicate
142        }
143
144        // Build the log line
145        let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
146        let target = event.metadata().target();
147
148        let line = if suppressed_count > 0 {
149            format!(
150                "{} {} {}: {} (suppressed {} similar messages)\n",
151                timestamp, level, target, visitor.0, suppressed_count
152            )
153        } else {
154            format!("{} {} {}: {}\n", timestamp, level, target, visitor.0)
155        };
156
157        // Write to file -- best-effort logging, nothing useful to do on failure.
158        #[allow(clippy::let_underscore_must_use)]
159        if let Ok(mut file) = self.file.lock() {
160            let _ = file.write_all(line.as_bytes());
161            let _ = file.flush();
162        }
163
164        // Notify that a warning was logged (receiver may be dropped during shutdown).
165        #[allow(clippy::let_underscore_must_use)]
166        let _ = self.sender.send(());
167    }
168}
169
170/// Simple visitor to extract message from event
171#[derive(Default)]
172struct StringVisitor(String);
173
174impl tracing::field::Visit for StringVisitor {
175    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
176        if field.name() == "message" {
177            self.0 = format!("{:?}", value);
178        } else if !self.0.is_empty() {
179            self.0.push_str(&format!(" {}={:?}", field.name(), value));
180        } else {
181            self.0 = format!("{}={:?}", field.name(), value);
182        }
183    }
184
185    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
186        if field.name() == "message" {
187            self.0 = value.to_string();
188        } else if !self.0.is_empty() {
189            self.0.push_str(&format!(" {}={}", field.name(), value));
190        } else {
191            self.0 = format!("{}={}", field.name(), value);
192        }
193    }
194}