rex_logger/memory_logger/handler.rs
1//! Script log handler implementation for capturing and storing Rhai script logs.
2//!
3//! This module provides the core functionality for capturing log events from Rhai scripts
4//! and storing them in memory for later retrieval. It implements a tracing layer that
5//! filters for script-specific log events and converts them into structured log entries.
6use crate::memory_logger::config::{
7 DEFAULT_LOG_ENTRIES_LIMIT, DEFAULT_MESSAGE_LENGTH_LIMIT, LogEntry, RhaiContext,
8};
9use crate::tracing_logger::level::log_level;
10use crate::{RUNNER_AND_SYSLOG_TARGET, RUNNER_TARGET, RUNNER_TARGET_FOR_SCRIPT_LOGS};
11use bounded_vec_deque::BoundedVecDeque;
12use chrono::Utc;
13use std::fmt::Debug;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex, OnceLock};
16use tracing::field::{Field, Visit};
17use tracing::{Event, Subscriber};
18use tracing_subscriber::Layer;
19use tracing_subscriber::layer::Context;
20use tracing_subscriber::registry::LookupSpan;
21
22/// Handle for accessing and managing script logs captured during execution.
23///
24/// This structure provides a thread-safe interface for storing and retrieving
25/// log entries generated by Rhai scripts. It implements the tracing `Layer` trait
26/// to automatically capture relevant log events.
27#[derive(Debug, Clone)]
28pub struct ScriptLogHandle {
29 logs: Arc<Mutex<BoundedVecDeque<LogEntry>>>,
30 rhai_context_stack: Arc<Mutex<Vec<RhaiContext>>>,
31 max_script_log_message_length: usize,
32 max_limit_applied: Arc<AtomicBool>,
33}
34
35/// Resource Acquisition Is Initialization (RAII) guard for automatic Rhai context cleanup.
36///
37/// This guard ensures that Rhai execution contexts are automatically removed
38/// from the stack when they go out of scope, providing exception-safe context
39/// management even if panics occur during script execution.
40#[derive(Debug)]
41pub struct RhaiContextGuard;
42
43impl Drop for RhaiContextGuard {
44 /// Automatically pops the Rhai context from the stack when the guard is dropped.
45 fn drop(&mut self) {
46 pop_current_rhai_context();
47 }
48}
49
50/// Global singleton instance of the script log handle.
51pub static SCRIPT_HANDLE: OnceLock<ScriptLogHandle> = OnceLock::new();
52
53/// Retrieves the global script log handle instance.
54///
55/// This function provides access to the singleton `ScriptLogHandle` instance.
56/// Returns `None` if the handle has not been initialized yet.
57///
58/// # Returns
59///
60/// * `Some(&ScriptLogHandle)` - Reference to the global handle if initialized
61/// * `None` - If the handle has not been initialized
62pub fn get_script_handle() -> Option<&'static ScriptLogHandle> {
63 SCRIPT_HANDLE.get()
64}
65
66/// Pushes a new Rhai execution context onto the stack and returns a guard for automatic cleanup.
67///
68/// This function creates a new `RhaiContext` with the provided function name and line number,
69/// then pushes it onto the global context stack.
70///
71/// # Examples
72///
73/// ```
74/// use rex_logger::memory_logger::handler::push_rhai_context_with_guard;
75/// let guard = push_rhai_context_with_guard(Some("my_function"), 42);
76/// ```
77pub fn push_rhai_context_with_guard(
78 rhai_api_name: Option<&str>,
79 line_number: u32,
80) -> RhaiContextGuard {
81 if let Some(handle) = SCRIPT_HANDLE.get() {
82 handle.push_rhai_context(RhaiContext {
83 rhai_api_name: rhai_api_name.map(str::to_string),
84 line_number,
85 });
86 }
87
88 RhaiContextGuard
89}
90
91/// Retrieves the current Rhai execution context from the top of the stack.
92///
93/// This function returns the most recently pushed context without removing it from
94/// the stack, following Last-In-First-Out (LIFO) semantics.
95///
96/// # Examples
97///
98/// ```
99/// use rex_logger::memory_logger::handler::{push_rhai_context_with_guard, get_current_rhai_context};
100/// let guard = push_rhai_context_with_guard(Some("test_function"), 10);
101///
102/// if let Some(context) = get_current_rhai_context() {
103/// println!("Currently executing: {:?} at line {}",
104/// context.rhai_api_name, context.line_number);
105/// }
106/// ```
107pub fn get_current_rhai_context() -> Option<RhaiContext> {
108 SCRIPT_HANDLE
109 .get()
110 .and_then(ScriptLogHandle::get_current_rhai_context)
111}
112
113/// Pops and returns the current Rhai execution context from the stack.
114///
115/// This function removes the most recently pushed context from the stack and returns it,
116/// following Last-In-First-Out (LIFO) semantics.
117///
118/// # Examples
119///
120/// ```
121/// use rex_logger::memory_logger::handler::{push_rhai_context_with_guard, get_current_rhai_context, pop_current_rhai_context};
122/// let guard = push_rhai_context_with_guard(Some("test_function"), 10);
123///
124///
125/// if let Some(context) = pop_current_rhai_context() {
126/// println!("Popped context: {:?} at line {}",
127/// context.rhai_api_name, context.line_number);
128/// }
129///
130/// // Stack is now empty
131/// assert!(get_current_rhai_context().is_none());
132/// ```
133pub fn pop_current_rhai_context() -> Option<RhaiContext> {
134 SCRIPT_HANDLE
135 .get()
136 .and_then(ScriptLogHandle::pop_rhai_context)
137}
138
139impl Default for ScriptLogHandle {
140 fn default() -> Self {
141 Self::new(DEFAULT_MESSAGE_LENGTH_LIMIT, DEFAULT_LOG_ENTRIES_LIMIT)
142 }
143}
144
145impl ScriptLogHandle {
146 /// Creates a new `ScriptLogHandle` with specified message length and max log entries limits
147 pub fn new(max_script_log_message_length: usize, max_log_entries: usize) -> Self {
148 Self {
149 logs: Arc::new(Mutex::new(BoundedVecDeque::new(max_log_entries))),
150 rhai_context_stack: Arc::new(Mutex::new(Vec::new())),
151 max_script_log_message_length,
152 max_limit_applied: Arc::new(AtomicBool::new(false)),
153 }
154 }
155
156 /// Truncates the message if it exceeds the configured length limit.
157 fn truncate_message(&self, message: String) -> String {
158 if message.len() > self.max_script_log_message_length {
159 let truncate_suffix = "...[truncate]";
160 let available_chars = self
161 .max_script_log_message_length
162 .saturating_sub(truncate_suffix.len());
163 format!("{}{}", &message[..available_chars], truncate_suffix)
164 } else {
165 message
166 }
167 }
168
169 /// Converts a tracing event into a structured log entry.
170 #[allow(clippy::unused_self)]
171 fn convert_event_to_log_entry(&self, event: &Event<'_>) -> LogEntry {
172 let mut message = String::new();
173 let mut visitor = MessageVisitor(&mut message);
174 event.record(&mut visitor);
175
176 LogEntry {
177 timestamp: Utc::now(),
178 line_number: get_current_rhai_context().map_or_else(|| 0, |ctx| ctx.line_number),
179 level: event.metadata().level().to_string(),
180 message: self.truncate_message(message),
181 rhai_api_name: get_current_rhai_context().and_then(|ctx| ctx.rhai_api_name),
182 }
183 }
184
185 /// Retrieves all captured script log entries.
186 ///
187 /// This method returns a clone of all log entries that have been captured
188 /// since the handle was created or last cleared. The returned vector
189 /// contains entries in the order they were captured.
190 ///
191 /// # Returns
192 ///
193 /// A `Vec<LogEntry>` containing all captured log entries. Returns an empty
194 /// vector if no logs have been captured or if there was an error accessing
195 /// the internal log storage.
196 pub fn get_logs(&self) -> Vec<LogEntry> {
197 self.logs
198 .lock()
199 .map(|guard| guard.iter().cloned().collect())
200 .unwrap_or_default()
201 }
202
203 /// Returns whether the maximum limit was applied and elements were removed.
204 ///
205 /// This method checks if the bounded deque ever reached capacity and had to
206 /// evict older entries to make room for new ones.
207 ///
208 /// # Returns
209 ///
210 /// `true` if the limit was applied at least once, `false` otherwise.
211 pub fn was_max_limit_applied(&self) -> bool {
212 self.max_limit_applied.load(Ordering::Relaxed)
213 }
214
215 fn add_log_entry(&self, log_entry: LogEntry) {
216 if let Ok(mut logs) = self.logs.lock() {
217 let displaced_entry = logs.push_back(log_entry);
218 if displaced_entry.is_some() {
219 self.max_limit_applied.store(true, Ordering::Relaxed);
220 }
221 }
222 }
223
224 // Push the current Rhai execution context
225 fn push_rhai_context(&self, context: RhaiContext) {
226 if let Ok(mut stack) = self.rhai_context_stack.lock() {
227 stack.push(context);
228 }
229 }
230
231 // Clear the current Rhai execution context
232 fn pop_rhai_context(&self) -> Option<RhaiContext> {
233 self.rhai_context_stack
234 .lock()
235 .ok()
236 .and_then(|mut stack| stack.pop())
237 }
238
239 // Get the current Rhai execution context
240 fn get_current_rhai_context(&self) -> Option<RhaiContext> {
241 self.rhai_context_stack
242 .lock()
243 .ok()
244 .and_then(|stack| stack.last().cloned())
245 }
246}
247
248/// Implementation of the tracing `Layer` trait for `ScriptLogHandle`.
249///
250/// This implementation allows the script log handle to be used as a tracing layer,
251/// automatically capturing events that match the script log target and converting
252/// them into structured log entries.
253impl<S> Layer<S> for ScriptLogHandle
254where
255 S: Subscriber + for<'lookup> LookupSpan<'lookup>,
256{
257 /// Processes tracing events and captures those matching the script log target.
258 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
259 if event.metadata().target() == RUNNER_AND_SYSLOG_TARGET
260 || event.metadata().target() == RUNNER_TARGET
261 || event.metadata().target() == RUNNER_TARGET_FOR_SCRIPT_LOGS
262 {
263 if event.metadata().target() == RUNNER_TARGET && get_current_rhai_context().is_none() {
264 return;
265 }
266
267 let current_level_filter = log_level();
268 if current_level_filter >= *event.metadata().level() {
269 let log_entry = self.convert_event_to_log_entry(event);
270 self.add_log_entry(log_entry);
271 }
272 }
273 }
274}
275
276/// Visitor implementation for extracting message content from tracing events.
277///
278/// This internal structure implements the `Visit` trait to extract the "message"
279/// field from tracing events. It's used by the `convert_event_to_log_entry` method
280/// to build the log message content.
281struct MessageVisitor<'a>(&'a mut String);
282
283impl Visit for MessageVisitor<'_> {
284 /// Records debug-formatted field values.
285 ///
286 /// This method is called for fields that implement `Debug`. If the field
287 /// name is "message", it formats the value using debug formatting and
288 /// stores it in the internal string buffer.
289 ///
290 /// # Arguments
291 ///
292 /// * `field` - The field being visited
293 /// * `value` - The debug-formattable value
294 fn record_debug(&mut self, field: &Field, value: &dyn Debug) {
295 if field.name() == "message" {
296 *self.0 = format!("{value:?}");
297 }
298 }
299
300 /// Records string field values.
301 ///
302 /// This method is called for string fields. If the field name is "message",
303 /// it directly stores the string value in the internal buffer. This method
304 /// takes precedence over `record_debug` for string values.
305 ///
306 /// # Arguments
307 ///
308 /// * `field` - The field being visited
309 /// * `value` - The string value
310 fn record_str(&mut self, field: &Field, value: &str) {
311 if field.name() == "message" {
312 *self.0 = value.to_string();
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use rstest::rstest;
321
322 /// Given: ScriptLogHandle implements Default
323 /// When: Default::default() is called
324 /// Then: It should create a handle with no logs
325 #[test]
326 fn test_default_implementation() {
327 let handle = ScriptLogHandle::default();
328 let logs = handle.get_logs();
329 assert!(logs.is_empty());
330 assert_eq!(logs.len(), 0);
331 }
332
333 // Given: Various message lengths and limits with expected truncation behavior
334 /// When: truncate_message is called
335 /// Then: Messages should be handled correctly based on the should_truncate flag
336 #[rstest]
337 #[case(
338 50,
339 "This is a very long message that definitely exceeds the limit",
340 true
341 )]
342 #[case(30, "Short message that fits", false)]
343 #[case(100, "Medium length message for testing purposes", false)]
344 #[case(20, "This message is way too long for the limit", true)]
345 fn test_truncate_message_dynamic(
346 #[case] max_length: usize,
347 #[case] input: &str,
348 #[case] should_truncate: bool,
349 ) {
350 let handler = ScriptLogHandle::new(max_length, DEFAULT_LOG_ENTRIES_LIMIT);
351 let result = handler.truncate_message(input.to_string());
352
353 if should_truncate {
354 assert_eq!(result.len(), max_length);
355 assert!(result.ends_with("...[truncate]"));
356 let expected_prefix_len = max_length - "...[truncate]".len();
357 assert_eq!(
358 &result[..expected_prefix_len],
359 &input[..expected_prefix_len]
360 );
361 } else {
362 assert_eq!(result, input);
363 }
364 }
365
366 /// Given: ScriptLogHandle with a limit of 10 log entries
367 /// When: 10 log entries are added, then an 11th entry is added
368 /// Then: The first 10 entries should be retained, flag should be false initially, then true after exceeding limit, and oldest entry should be evicted
369 #[test]
370 fn test_bounded_log_entries() {
371 let handle = ScriptLogHandle::new(1000, 10);
372 let mut all_messages = Vec::new();
373 assert!(!handle.was_max_limit_applied());
374
375 for i in 0..10 {
376 let log_entry = LogEntry {
377 timestamp: Utc::now(),
378 line_number: i + 1,
379 level: "INFO".to_string(),
380 message: format!("Test message {}", i + 1),
381 rhai_api_name: None,
382 };
383 all_messages.push(log_entry.clone());
384 handle.add_log_entry(log_entry);
385 }
386
387 assert!(!handle.was_max_limit_applied());
388 let logs = handle.get_logs();
389 assert_eq!(logs.len(), 10);
390
391 for (i, log) in logs.iter().enumerate() {
392 assert_eq!(log.message, all_messages[i].message);
393 }
394
395 let log_entry_11 = LogEntry {
396 timestamp: Utc::now(),
397 line_number: 11,
398 level: "INFO".to_string(),
399 message: "Test message 11".to_string(),
400 rhai_api_name: None,
401 };
402 all_messages.push(log_entry_11.clone());
403 handle.add_log_entry(log_entry_11);
404
405 assert!(handle.was_max_limit_applied());
406 let logs = handle.get_logs();
407 assert_eq!(logs.len(), 10);
408
409 for (i, log) in logs.iter().enumerate() {
410 let expected_index = i + 1; // Messages 2-11 correspond to indices 1-10 in all_messages
411 assert_eq!(log.message, all_messages[expected_index].message);
412 assert_eq!(log.line_number, all_messages[expected_index].line_number);
413 }
414 }
415}