Skip to main content

autocore_std/
logger.rs

1//! UDP Logger for Control Programs
2//!
3//! This module provides a logger implementation that sends log messages via UDP
4//! to the autocore-server for display in the console. The logger is designed to
5//! be non-blocking and fire-and-forget to avoid impacting the control loop timing.
6//!
7//! # Overview
8//!
9//! When you use [`log::info!`], [`log::warn!`], etc. in your control program,
10//! the messages are sent to the autocore-server where they appear in the web
11//! console's log panel. This allows you to monitor your control program in
12//! real-time without needing direct access to stdout.
13//!
14//! # Architecture
15//!
16//! ```text
17//! ┌─────────────────────────────┐
18//! │  Control Program Process    │
19//! │  ┌───────────────────────┐  │
20//! │  │ UdpLogger             │  │
21//! │  │ (implements log::Log) │  │
22//! │  └──────────┬────────────┘  │
23//! │             │ mpsc channel  │
24//! │  ┌──────────▼────────────┐  │
25//! │  │ Background thread     │  │
26//! │  │ (batches & sends UDP) │  │
27//! │  └──────────┬────────────┘  │
28//! └─────────────┼───────────────┘
29//!               │ UDP (fire-and-forget)
30//!               ▼
31//! ┌─────────────────────────────┐
32//! │  autocore-server            │
33//! │  (UDP listener on 39101)    │
34//! └─────────────────────────────┘
35//! ```
36//!
37//! # Automatic Initialization
38//!
39//! When using [`ControlRunner`](crate::ControlRunner), the logger is initialized
40//! automatically. You can configure the log level via [`RunnerConfig::log_level`](crate::RunnerConfig::log_level).
41//!
42//! # Manual Initialization
43//!
44//! If you need to initialize the logger manually (e.g., for testing):
45//!
46//! ```ignore
47//! use autocore_std::logger;
48//! use log::LevelFilter;
49//!
50//! logger::init_udp_logger("127.0.0.1", 39101, LevelFilter::Debug, "my_app")?;
51//!
52//! log::debug!("Debug messages now visible!");
53//! log::info!("Info messages too!");
54//! ```
55//!
56//! # Performance Considerations
57//!
58//! - Log calls are non-blocking (messages are queued to a background thread)
59//! - If the queue fills up (1000 messages), new messages are dropped silently
60//! - Messages are batched and sent every 20ms or when 50 messages accumulate
61//! - UDP is fire-and-forget: delivery is not guaranteed
62
63use log::{LevelFilter, Log, Metadata, Record, SetLoggerError};
64use serde::{Deserialize, Serialize};
65use std::net::{SocketAddr, UdpSocket};
66use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
67use std::thread;
68use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
69
70/// Default UDP port for log messages
71pub const DEFAULT_LOG_UDP_PORT: u16 = 39101;
72
73/// Maximum batch size before forcing a send
74const MAX_BATCH_SIZE: usize = 50;
75
76/// Batch timeout in milliseconds
77const BATCH_TIMEOUT_MS: u64 = 20;
78
79/// Channel capacity for log entries
80const CHANNEL_CAPACITY: usize = 1000;
81
82/// A log entry that will be serialized and sent over UDP
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct LogEntry {
85    /// Timestamp in milliseconds since UNIX epoch
86    pub timestamp_ms: u64,
87    /// Log level: "TRACE", "DEBUG", "INFO", "WARN", "ERROR"
88    pub level: String,
89    /// Source identifier (e.g., "control")
90    pub source: String,
91    /// Log target (module path)
92    pub target: String,
93    /// The log message
94    pub message: String,
95}
96
97impl LogEntry {
98    /// Create a new log entry from a log::Record
99    pub fn from_record(record: &Record, source: &str) -> Self {
100        let timestamp_ms = SystemTime::now()
101            .duration_since(UNIX_EPOCH)
102            .map(|d| d.as_millis() as u64)
103            .unwrap_or(0);
104
105        Self {
106            timestamp_ms,
107            level: record.level().to_string(),
108            source: source.to_string(),
109            target: record.target().to_string(),
110            message: format!("{}", record.args()),
111        }
112    }
113}
114
115/// UDP Logger implementation
116///
117/// This logger sends log messages to a UDP endpoint (the autocore-server).
118/// Messages are sent asynchronously via a background thread to avoid blocking
119/// the control loop.
120pub struct UdpLogger {
121    tx: SyncSender<LogEntry>,
122    level: LevelFilter,
123    source: String,
124}
125
126impl UdpLogger {
127    /// Create a new UDP logger
128    ///
129    /// # Arguments
130    /// * `server_addr` - The address of the autocore-server UDP listener
131    /// * `level` - Minimum log level to capture
132    /// * `source` - Source identifier for log entries (e.g., "control")
133    fn new(server_addr: SocketAddr, level: LevelFilter, source: String) -> Self {
134        let (tx, rx) = mpsc::sync_channel::<LogEntry>(CHANNEL_CAPACITY);
135
136        // Spawn background thread for batching and sending
137        thread::spawn(move || {
138            log_sender_thread(rx, server_addr);
139        });
140
141        Self { tx, level, source }
142    }
143}
144
145impl Log for UdpLogger {
146    fn enabled(&self, metadata: &Metadata) -> bool {
147        metadata.level() <= self.level
148    }
149
150    fn log(&self, record: &Record) {
151        if !self.enabled(record.metadata()) {
152            return;
153        }
154
155        let entry = LogEntry::from_record(record, &self.source);
156
157        // Non-blocking send - drops if channel is full
158        match self.tx.try_send(entry) {
159            Ok(_) => {}
160            Err(TrySendError::Full(_)) => {
161                // Channel full, drop the message to avoid blocking
162            }
163            Err(TrySendError::Disconnected(_)) => {
164                // Background thread died, nothing we can do
165            }
166        }
167    }
168
169    fn flush(&self) {
170        // UDP is fire-and-forget, no flush needed
171    }
172}
173
174/// Background thread that batches log entries and sends them via UDP
175fn log_sender_thread(rx: Receiver<LogEntry>, server_addr: SocketAddr) {
176    // Bind to any available port
177    let socket = match UdpSocket::bind("0.0.0.0:0") {
178        Ok(s) => s,
179        Err(e) => {
180            eprintln!("Failed to create UDP socket for logging: {}", e);
181            return;
182        }
183    };
184
185    // Set socket to non-blocking for timeout handling
186    if let Err(e) = socket.set_read_timeout(Some(Duration::from_millis(BATCH_TIMEOUT_MS))) {
187        eprintln!("Failed to set socket timeout: {}", e);
188    }
189
190    let mut batch: Vec<LogEntry> = Vec::with_capacity(MAX_BATCH_SIZE);
191    let mut last_send = Instant::now();
192
193    loop {
194        // Try to receive with timeout
195        match rx.recv_timeout(Duration::from_millis(BATCH_TIMEOUT_MS)) {
196            Ok(entry) => {
197                batch.push(entry);
198            }
199            Err(mpsc::RecvTimeoutError::Timeout) => {
200                // Timeout - check if we should send
201            }
202            Err(mpsc::RecvTimeoutError::Disconnected) => {
203                // Channel closed, send remaining entries and exit
204                if !batch.is_empty() {
205                    send_batch(&socket, &server_addr, &batch);
206                }
207                break;
208            }
209        }
210
211        // Send if batch is full or timeout elapsed
212        let timeout_elapsed = last_send.elapsed() >= Duration::from_millis(BATCH_TIMEOUT_MS);
213        if !batch.is_empty() && (batch.len() >= MAX_BATCH_SIZE || timeout_elapsed) {
214            send_batch(&socket, &server_addr, &batch);
215            batch.clear();
216            last_send = Instant::now();
217        }
218    }
219}
220
221/// Send a batch of log entries via UDP
222fn send_batch(socket: &UdpSocket, server_addr: &SocketAddr, batch: &[LogEntry]) {
223    match serde_json::to_vec(batch) {
224        Ok(json) => {
225            // Fire and forget - ignore send errors
226            let _ = socket.send_to(&json, server_addr);
227        }
228        Err(e) => {
229            eprintln!("Failed to serialize log batch: {}", e);
230        }
231    }
232}
233
234/// Initialize the UDP logger as the global logger
235///
236/// # Arguments
237/// * `server_host` - The host address of the autocore-server (e.g., "127.0.0.1")
238/// * `port` - The UDP port to send logs to (default: 39101)
239/// * `level` - Minimum log level to capture
240/// * `source` - Source identifier for log entries (e.g., "control")
241///
242/// # Example
243/// ```ignore
244/// use autocore_std::logger;
245/// use log::LevelFilter;
246///
247/// logger::init_udp_logger("127.0.0.1", 39101, LevelFilter::Info, "control")?;
248/// log::info!("Logger initialized!");
249/// ```
250pub fn init_udp_logger(
251    server_host: &str,
252    port: u16,
253    level: LevelFilter,
254    source: &str,
255) -> Result<(), SetLoggerError> {
256    // Parse the server address
257    let server_addr: SocketAddr = format!("{}:{}", server_host, port)
258        .parse()
259        .unwrap_or_else(|_| {
260            // Fall back to localhost if parsing fails
261            format!("127.0.0.1:{}", port).parse().unwrap()
262        });
263
264    let logger = UdpLogger::new(server_addr, level, source.to_string());
265
266    log::set_boxed_logger(Box::new(logger))?;
267    log::set_max_level(level);
268
269    Ok(())
270}
271
272/// Initialize the UDP logger with default settings
273///
274/// Uses:
275/// - Server: 127.0.0.1:39101
276/// - Level: Info
277/// - Source: "control"
278pub fn init_default_logger() -> Result<(), SetLoggerError> {
279    init_udp_logger("127.0.0.1", DEFAULT_LOG_UDP_PORT, LevelFilter::Info, "control")
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_log_entry_from_record() {
288        // Basic test that LogEntry can be created
289        let entry = LogEntry {
290            timestamp_ms: 1234567890,
291            level: "INFO".to_string(),
292            source: "test".to_string(),
293            target: "test::module".to_string(),
294            message: "Test message".to_string(),
295        };
296
297        assert_eq!(entry.level, "INFO");
298        assert_eq!(entry.source, "test");
299    }
300
301    #[test]
302    fn test_log_entry_serialization() {
303        let entry = LogEntry {
304            timestamp_ms: 1234567890,
305            level: "DEBUG".to_string(),
306            source: "control".to_string(),
307            target: "my_program".to_string(),
308            message: "Hello world".to_string(),
309        };
310
311        let json = serde_json::to_string(&entry).unwrap();
312        assert!(json.contains("DEBUG"));
313        assert!(json.contains("Hello world"));
314
315        let batch = vec![entry.clone(), entry];
316        let batch_json = serde_json::to_string(&batch).unwrap();
317        assert!(batch_json.starts_with('['));
318    }
319}