Skip to main content

wide_log/
guard.rs

1use std::ops::{Deref, DerefMut};
2use std::time::Instant;
3
4use chrono_tz::Tz;
5
6use crate::key::Key;
7use crate::value::Value;
8use crate::wide_event::WideEvent;
9
10/// RAII guard that owns a [`WideEvent`] and emits it on drop.
11///
12/// The guard starts a timer on creation. On drop, it sets the duration field
13/// (via [`Key::DURATION_PATH`]) to the elapsed milliseconds, sets the
14/// timestamp field (via [`Key::TIMESTAMP_PATH`]) to the current time as an
15/// RFC 3339 string in the guard's timezone, and calls the emit function
16/// with a reference to the event.
17///
18/// Implements [`Deref`] / [`DerefMut`] to [`WideEvent`] so you can call
19/// `add`, `add_path`, `inc`, etc. directly on the guard.
20///
21/// The `wide_log!` macro generates a `WideLogGuard` that wraps this guard
22/// and manages the thread-local / task-local pointer. Users typically
23/// interact with `WideLogGuard`, not `ScopedGuard` directly.
24///
25/// [`WideEvent`]: crate::WideEvent
26pub struct ScopedGuard<K: Key, F>
27where
28    F: FnOnce(&WideEvent<K>) + Send + 'static,
29{
30    event: WideEvent<K>,
31    start: Instant,
32    tz: Tz,
33    emit_fn: Option<F>,
34}
35
36impl<K: Key, F> ScopedGuard<K, F>
37where
38    F: FnOnce(&WideEvent<K>) + Send + 'static,
39{
40    /// Creates a new guard with the given emit function and timezone.
41    ///
42    /// The timezone is used to format the timestamp set on drop.
43    pub fn new_with_tz(emit_fn: F, tz: Tz) -> Self {
44        Self {
45            event: WideEvent::new(),
46            start: Instant::now(),
47            tz,
48            emit_fn: Some(emit_fn),
49        }
50    }
51}
52
53#[cfg(test)]
54impl<K: Key, F> ScopedGuard<K, F>
55where
56    F: FnOnce(&WideEvent<K>) + Send + 'static,
57{
58    /// Creates a new guard with the given emit function and UTC timezone.
59    ///
60    /// The timer starts immediately. On drop, the guard sets
61    /// `K::DURATION_PATH` to the elapsed milliseconds, sets
62    /// `K::TIMESTAMP_PATH` to the current UTC time as RFC 3339, and
63    /// calls `emit_fn`.
64    pub(crate) fn new(emit_fn: F) -> Self {
65        Self::new_with_tz(emit_fn, Tz::UTC)
66    }
67
68    /// Creates a new guard with the given emit function, timezone, and a
69    /// type-conflict callback.
70    ///
71    /// The callback is fired when `object()` is called on a key that already
72    /// has a non-object value. See [`WideEvent::new_with_warnings`].
73    ///
74    /// [`WideEvent::new_with_warnings`]: crate::WideEvent::new_with_warnings
75    pub(crate) fn new_with_warnings(
76        emit_fn: F,
77        tz: Tz,
78        on_type_conflict: crate::wide_event::ConflictFn<K>,
79    ) -> Self {
80        Self {
81            event: WideEvent::new_with_warnings(on_type_conflict),
82            start: Instant::now(),
83            tz,
84            emit_fn: Some(emit_fn),
85        }
86    }
87}
88
89impl<K: Key, F> Deref for ScopedGuard<K, F>
90where
91    F: FnOnce(&WideEvent<K>) + Send + 'static,
92{
93    type Target = WideEvent<K>;
94
95    #[inline]
96    fn deref(&self) -> &WideEvent<K> {
97        &self.event
98    }
99}
100
101impl<K: Key, F> DerefMut for ScopedGuard<K, F>
102where
103    F: FnOnce(&WideEvent<K>) + Send + 'static,
104{
105    #[inline]
106    fn deref_mut(&mut self) -> &mut WideEvent<K> {
107        &mut self.event
108    }
109}
110
111impl<K: Key, F> Drop for ScopedGuard<K, F>
112where
113    F: FnOnce(&WideEvent<K>) + Send + 'static,
114{
115    fn drop(&mut self) {
116        let duration_ms = self.start.elapsed().as_millis() as u64;
117        // Fast path for common 2-segment DURATION_PATH and TIMESTAMP_PATH.
118        // Uses direct indexed writes instead of object() to avoid the overhead
119        // of creating and returning &mut references through the ManuallyDrop chain.
120        if K::DURATION_PATH.len() == 2 && K::TIMESTAMP_PATH.len() == 2 {
121            let dur_idx = K::DURATION_PATH[0].as_index();
122            let ts_idx = K::TIMESTAMP_PATH[0].as_index();
123            self.event.ensure_capacity(dur_idx.max(ts_idx));
124
125            // Create child objects if needed (before mutable borrow)
126            let need_dur_child = match &self.event.values.get(dur_idx) {
127                Some(Some(v)) => !v.is_object(),
128                _ => true,
129            };
130            let need_ts_child = match &self.event.values.get(ts_idx) {
131                Some(Some(v)) => !v.is_object(),
132                _ => true,
133            };
134            if need_dur_child {
135                let child = self.event.new_child();
136                self.event.values[dur_idx] = Some(crate::value::Value::from_object(child));
137            }
138            if need_ts_child {
139                let child = self.event.new_child();
140                self.event.values[ts_idx] = Some(crate::value::Value::from_object(child));
141            }
142
143            // Now write the leaf values
144            if let Some(Some(Value::Object(obj))) = self.event.values.get_mut(dur_idx) {
145                obj.add(K::DURATION_PATH[1], duration_ms);
146            }
147            let now = chrono::Utc::now().with_timezone(&self.tz);
148            if let Some(Some(Value::Object(obj))) = self.event.values.get_mut(ts_idx) {
149                obj.add(K::TIMESTAMP_PATH[1], now.to_rfc3339());
150            }
151        } else {
152            self.event.add_path(K::DURATION_PATH, duration_ms);
153            let now = chrono::Utc::now().with_timezone(&self.tz);
154            self.event.add_path(K::TIMESTAMP_PATH, now.to_rfc3339());
155        }
156        if let Some(emit) = self.emit_fn.take() {
157            emit(&self.event);
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use std::sync::{Arc, Mutex};
165
166    use super::*;
167    use crate::key::test_support::TestKey;
168
169    type CaptureSlot = Arc<Mutex<Option<String>>>;
170
171    fn capture_json() -> (
172        CaptureSlot,
173        impl FnOnce(&WideEvent<TestKey>) + Send + 'static,
174    ) {
175        let slot: CaptureSlot = Arc::new(Mutex::new(None));
176        let slot_clone = slot.clone();
177        let emit = move |we: &WideEvent<TestKey>| {
178            *slot_clone.lock().unwrap() = Some(we.to_json().unwrap());
179        };
180        (slot, emit)
181    }
182
183    #[test]
184    fn guard_sets_duration_path() {
185        let (slot, emit) = capture_json();
186        drop(ScopedGuard::<TestKey, _>::new(emit));
187        let json = slot.lock().unwrap().clone().unwrap();
188        assert!(json.contains("\"duration\""));
189        assert!(json.contains("\"total_ms\""));
190    }
191
192    #[test]
193    fn guard_sets_timestamp() {
194        let (slot, emit) = capture_json();
195        drop(ScopedGuard::<TestKey, _>::new(emit));
196        let json = slot.lock().unwrap().clone().unwrap();
197        let parsed: sonic_rs::Value = sonic_rs::from_str(&json).unwrap();
198        use sonic_rs::JsonValueTrait;
199        let ts = parsed["event"]["timestamp"].as_str().unwrap();
200        assert!(!ts.is_empty(), "timestamp should not be empty");
201        assert!(
202            ts.contains('T'),
203            "timestamp should be RFC 3339 (contain 'T'): {ts}"
204        );
205    }
206
207    #[test]
208    fn guard_deref_add() {
209        let (slot, emit) = capture_json();
210        let mut g = ScopedGuard::<TestKey, _>::new(emit);
211        g.add(TestKey::Status, "ok");
212        drop(g);
213        let json = slot.lock().unwrap().clone().unwrap();
214        assert!(json.contains(r#""status":"ok""#));
215    }
216
217    #[test]
218    fn guard_emit_called_exactly_once() {
219        let counter = Arc::new(Mutex::new(0u32));
220        let c = counter.clone();
221        let emit = move |_: &WideEvent<TestKey>| {
222            *c.lock().unwrap() += 1;
223        };
224        drop(ScopedGuard::<TestKey, _>::new(emit));
225        assert_eq!(*counter.lock().unwrap(), 1);
226    }
227
228    #[test]
229    fn guard_new_with_warnings_fires_callback() {
230        use std::sync::atomic::{AtomicU32, Ordering};
231        static COUNTER: AtomicU32 = AtomicU32::new(0);
232        COUNTER.store(0, Ordering::SeqCst);
233
234        fn cb(_event: &mut WideEvent<TestKey>, _key: TestKey) {
235            COUNTER.fetch_add(1, Ordering::SeqCst);
236        }
237
238        let (slot, emit) = capture_json();
239        let mut g = ScopedGuard::<TestKey, _>::new_with_warnings(emit, Tz::UTC, cb);
240        g.add(TestKey::Details, true);
241        g.object(TestKey::Details);
242        drop(g);
243        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
244        assert!(slot.lock().unwrap().is_some());
245    }
246
247    #[test]
248    fn guard_new_with_warnings_callback_can_mutate_event() {
249        let (slot, emit) = capture_json();
250        let mut g = ScopedGuard::<TestKey, _>::new_with_warnings(emit, Tz::UTC, |event, _key| {
251            event.add(TestKey::Flag, true);
252        });
253        g.add(TestKey::Details, 42u64);
254        g.object(TestKey::Details);
255        drop(g);
256        let json = slot.lock().unwrap().clone().unwrap();
257        assert!(json.contains("\"flag\":true"));
258    }
259
260    #[test]
261    fn guard_new_with_warnings_callback_appends_warning_string() {
262        use crate::value::Value;
263        use smallvec::smallvec;
264
265        let (slot, emit) = capture_json();
266        let mut g = ScopedGuard::<TestKey, _>::new_with_warnings(emit, Tz::UTC, |event, key| {
267            let warning = format!("{} type conflict", key.as_str());
268            let idx = TestKey::Tag.as_index();
269            // Check if there's already an array at Tag
270            let existing = event.values.get(idx).and_then(|v| v.as_ref());
271            if let Some(v) = existing
272                && let Some(arr) = v.as_array_ref()
273            {
274                let mut new_arr = arr.clone();
275                new_arr.push(Value::from(warning));
276                event.add(TestKey::Tag, Value::from_array(new_arr));
277                return;
278            }
279            event.add(
280                TestKey::Tag,
281                Value::from_array(smallvec![Value::from(warning)]),
282            );
283        });
284        g.add(TestKey::Details, true);
285        g.object(TestKey::Details);
286        drop(g);
287        let json = slot.lock().unwrap().clone().unwrap();
288        assert!(
289            json.contains("\"details type conflict\""),
290            "warning string should appear in JSON"
291        );
292    }
293
294    #[test]
295    fn guard_emit_not_called_on_forget() {
296        let counter = Arc::new(Mutex::new(0u32));
297        let c = counter.clone();
298        let emit = move |_: &WideEvent<TestKey>| {
299            *c.lock().unwrap() += 1;
300        };
301        let g = ScopedGuard::<TestKey, _>::new(emit);
302        std::mem::forget(g);
303        assert_eq!(*counter.lock().unwrap(), 0);
304    }
305
306    #[test]
307    fn guard_duration_is_milliseconds() {
308        let (slot, emit) = capture_json();
309        let g = ScopedGuard::<TestKey, _>::new(emit);
310        std::thread::sleep(std::time::Duration::from_millis(2));
311        drop(g);
312        let json = slot.lock().unwrap().clone().unwrap();
313        let parsed: sonic_rs::Value = sonic_rs::from_str(&json).unwrap();
314        use sonic_rs::JsonValueTrait;
315        let total_ms = parsed["duration"]["total_ms"].as_u64().unwrap();
316        assert!(
317            total_ms >= 1,
318            "duration.total_ms should be >= 1, got {total_ms}"
319        );
320    }
321
322    #[test]
323    fn guard_timestamp_reflects_timezone() {
324        let (slot, emit) = capture_json();
325        let g = ScopedGuard::<TestKey, _>::new_with_tz(emit, Tz::America__New_York);
326        drop(g);
327        let json = slot.lock().unwrap().clone().unwrap();
328        let parsed: sonic_rs::Value = sonic_rs::from_str(&json).unwrap();
329        use sonic_rs::JsonValueTrait;
330        let ts = parsed["event"]["timestamp"].as_str().unwrap();
331        assert!(
332            ts.contains("-04:00") || ts.contains("-05:00"),
333            "timestamp should reflect America/New_York offset: {ts}"
334        );
335    }
336}