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}