Skip to main content

rustrails_support/
error_reporter.rs

1use once_cell::sync::Lazy;
2use parking_lot::RwLock;
3use serde_json::Value;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7/// Severity attached to a reported error.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Severity {
10    /// A handled condition that should still be surfaced.
11    Warning,
12    /// An unhandled error.
13    Error,
14    /// A terminal condition requiring urgent attention.
15    Fatal,
16}
17
18/// A delivered error report.
19#[derive(Debug, Clone, PartialEq)]
20pub struct ErrorReport {
21    /// The rendered error message.
22    pub error: String,
23    /// Whether the error was handled by the caller.
24    pub handled: bool,
25    /// The resolved severity.
26    pub severity: Severity,
27    /// Structured context attached to the report.
28    pub context: HashMap<String, Value>,
29}
30
31/// A subscriber notified when an error is reported.
32pub trait ErrorSubscriber: Send + Sync {
33    /// Handles an emitted error report.
34    fn report(&self, report: &ErrorReport);
35}
36
37/// A synchronous fan-out error reporter.
38#[derive(Default)]
39pub struct ErrorReporter {
40    subscribers: RwLock<Vec<Arc<dyn ErrorSubscriber>>>,
41}
42
43impl ErrorReporter {
44    /// Creates an empty error reporter.
45    #[must_use]
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Registers a subscriber to receive future error reports.
51    pub fn subscribe<S>(&self, subscriber: S)
52    where
53        S: ErrorSubscriber + 'static,
54    {
55        self.subscribers.write().push(Arc::new(subscriber));
56    }
57
58    /// Reports an error, resolving severity from `handled`.
59    pub fn report(&self, error: impl ToString, handled: bool, context: HashMap<String, Value>) {
60        let severity = if handled {
61            Severity::Warning
62        } else {
63            Severity::Error
64        };
65        self.report_with_severity(error, handled, severity, context);
66    }
67
68    /// Reports an error with an explicit severity.
69    pub fn report_with_severity(
70        &self,
71        error: impl ToString,
72        handled: bool,
73        severity: Severity,
74        context: HashMap<String, Value>,
75    ) {
76        let report = ErrorReport {
77            error: error.to_string(),
78            handled,
79            severity,
80            context,
81        };
82        let subscribers = self.subscribers.read().clone();
83        for subscriber in subscribers {
84            subscriber.report(&report);
85        }
86    }
87}
88
89static GLOBAL_ERROR_REPORTER: Lazy<ErrorReporter> = Lazy::new(ErrorReporter::new);
90
91/// Returns the global error reporter.
92#[must_use]
93pub fn global_reporter() -> &'static ErrorReporter {
94    &GLOBAL_ERROR_REPORTER
95}
96
97/// Registers a subscriber on the global error reporter.
98pub fn subscribe<S>(subscriber: S)
99where
100    S: ErrorSubscriber + 'static,
101{
102    global_reporter().subscribe(subscriber);
103}
104
105pub(crate) fn reset_global_reporter() {
106    GLOBAL_ERROR_REPORTER.subscribers.write().clear();
107}
108
109#[cfg(test)]
110mod tests {
111    use super::{
112        ErrorReport, ErrorReporter, ErrorSubscriber, Severity, global_reporter,
113        reset_global_reporter, subscribe,
114    };
115    use parking_lot::Mutex;
116    use serde_json::json;
117    use std::collections::HashMap;
118    use std::sync::Arc;
119
120    #[derive(Default)]
121    struct RecordingSubscriber {
122        reports: Arc<Mutex<Vec<ErrorReport>>>,
123    }
124
125    impl RecordingSubscriber {
126        fn new() -> Self {
127            Self::default()
128        }
129
130        fn handle(&self) -> Arc<Mutex<Vec<ErrorReport>>> {
131            Arc::clone(&self.reports)
132        }
133    }
134
135    impl ErrorSubscriber for RecordingSubscriber {
136        fn report(&self, report: &ErrorReport) {
137            self.reports.lock().push(report.clone());
138        }
139    }
140
141    #[test]
142    fn report_delivers_to_registered_subscriber() {
143        let reporter = ErrorReporter::new();
144        let subscriber = RecordingSubscriber::new();
145        let reports = subscriber.handle();
146        reporter.subscribe(subscriber);
147
148        reporter.report("boom", false, HashMap::new());
149
150        let reports = reports.lock();
151        assert_eq!(reports.len(), 1);
152        assert_eq!(reports[0].error, "boom");
153    }
154
155    #[test]
156    fn handled_reports_are_warnings() {
157        let reporter = ErrorReporter::new();
158        let subscriber = RecordingSubscriber::new();
159        let reports = subscriber.handle();
160        reporter.subscribe(subscriber);
161
162        reporter.report("handled", true, HashMap::new());
163
164        assert_eq!(reports.lock()[0].severity, Severity::Warning);
165    }
166
167    #[test]
168    fn unhandled_reports_are_errors() {
169        let reporter = ErrorReporter::new();
170        let subscriber = RecordingSubscriber::new();
171        let reports = subscriber.handle();
172        reporter.subscribe(subscriber);
173
174        reporter.report("unhandled", false, HashMap::new());
175
176        assert_eq!(reports.lock()[0].severity, Severity::Error);
177    }
178
179    #[test]
180    fn explicit_fatal_severity_is_preserved() {
181        let reporter = ErrorReporter::new();
182        let subscriber = RecordingSubscriber::new();
183        let reports = subscriber.handle();
184        reporter.subscribe(subscriber);
185
186        reporter.report_with_severity("fatal", false, Severity::Fatal, HashMap::new());
187
188        assert_eq!(reports.lock()[0].severity, Severity::Fatal);
189    }
190
191    #[test]
192    fn report_preserves_handled_flag() {
193        let reporter = ErrorReporter::new();
194        let subscriber = RecordingSubscriber::new();
195        let reports = subscriber.handle();
196        reporter.subscribe(subscriber);
197
198        reporter.report("handled", true, HashMap::new());
199
200        assert!(reports.lock()[0].handled);
201    }
202
203    #[test]
204    fn report_clones_context_for_subscribers() {
205        let reporter = ErrorReporter::new();
206        let subscriber = RecordingSubscriber::new();
207        let reports = subscriber.handle();
208        reporter.subscribe(subscriber);
209
210        let context = HashMap::from([(String::from("request_id"), json!("abc123"))]);
211        reporter.report("boom", false, context);
212
213        assert_eq!(
214            reports.lock()[0].context.get("request_id"),
215            Some(&json!("abc123"))
216        );
217    }
218
219    #[test]
220    fn multiple_subscribers_receive_the_same_report() {
221        let reporter = ErrorReporter::new();
222        let first = RecordingSubscriber::new();
223        let second = RecordingSubscriber::new();
224        let first_reports = first.handle();
225        let second_reports = second.handle();
226        reporter.subscribe(first);
227        reporter.subscribe(second);
228
229        reporter.report("boom", false, HashMap::new());
230
231        assert_eq!(first_reports.lock().len(), 1);
232        assert_eq!(second_reports.lock().len(), 1);
233    }
234
235    #[test]
236    fn subscribers_receive_reports_in_subscription_order() {
237        struct OrderedSubscriber {
238            name: &'static str,
239            calls: Arc<Mutex<Vec<&'static str>>>,
240        }
241
242        impl ErrorSubscriber for OrderedSubscriber {
243            fn report(&self, _: &ErrorReport) {
244                self.calls.lock().push(self.name);
245            }
246        }
247
248        let reporter = ErrorReporter::new();
249        let calls = Arc::new(Mutex::new(Vec::new()));
250        reporter.subscribe(OrderedSubscriber {
251            name: "first",
252            calls: Arc::clone(&calls),
253        });
254        reporter.subscribe(OrderedSubscriber {
255            name: "second",
256            calls: Arc::clone(&calls),
257        });
258
259        reporter.report("boom", false, HashMap::new());
260
261        assert_eq!(&*calls.lock(), &["first", "second"]);
262    }
263
264    #[test]
265    fn report_accepts_non_string_errors() {
266        let reporter = ErrorReporter::new();
267        let subscriber = RecordingSubscriber::new();
268        let reports = subscriber.handle();
269        reporter.subscribe(subscriber);
270
271        reporter.report(404, false, HashMap::new());
272
273        assert_eq!(reports.lock()[0].error, "404");
274    }
275
276    #[test]
277    fn global_reporter_returns_the_same_instance() {
278        let first = global_reporter() as *const ErrorReporter;
279        let second = global_reporter() as *const ErrorReporter;
280
281        assert_eq!(first, second);
282    }
283
284    #[test]
285    fn global_subscribe_registers_subscribers() {
286        reset_global_reporter();
287        let subscriber = RecordingSubscriber::new();
288        let reports = subscriber.handle();
289        subscribe(subscriber);
290
291        global_reporter().report("boom", false, HashMap::new());
292
293        assert_eq!(reports.lock().len(), 1);
294    }
295
296    #[test]
297    fn global_reporter_can_emit_fatal_reports() {
298        reset_global_reporter();
299        let subscriber = RecordingSubscriber::new();
300        let reports = subscriber.handle();
301        subscribe(subscriber);
302
303        global_reporter().report_with_severity("fatal", false, Severity::Fatal, HashMap::new());
304
305        assert_eq!(reports.lock()[0].severity, Severity::Fatal);
306    }
307}