Skip to main content

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}