openzeppelin_monitor/utils/logging/
error.rs

1//! Error handling utilities for the application.
2//!
3//! This module provides a structured approach to error handling with context and metadata.
4//! The primary type is [`ErrorContext`], which wraps errors with additional information
5//! such as timestamps, trace IDs, and custom metadata.
6
7use chrono::Utc;
8use std::{collections::HashMap, fmt};
9use uuid::Uuid;
10
11/// A context wrapper for errors with additional metadata.
12///
13/// `ErrorContext` provides a way to enrich errors with contextual information,
14/// making them more useful for debugging and logging. Each error context includes:
15///
16/// - A descriptive message
17/// - An optional source error
18/// - Optional key-value metadata
19/// - A timestamp (automatically generated)
20/// - A unique trace ID (automatically generated)
21///
22/// This structure implements both `Display` and `std::error::Error` traits,
23/// making it suitable for use in error handling chains.
24#[derive(Debug)]
25pub struct ErrorContext {
26	/// The error message
27	pub message: String,
28	/// The source error that caused this error
29	pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
30	/// Additional metadata about the error
31	pub metadata: Option<HashMap<String, String>>,
32	/// The timestamp of the error in RFC 3339 format
33	pub timestamp: String,
34	/// The unique identifier for the error (UUID v4)
35	pub trace_id: String,
36}
37
38impl ErrorContext {
39	/// Creates a new error context with the given message, source, and metadata.
40	///
41	/// # Arguments
42	///
43	/// * `message` - A descriptive error message
44	/// * `source` - An optional source error that caused this error
45	/// * `metadata` - Optional key-value pairs providing additional context
46	///
47	/// # Returns
48	///
49	/// A new `ErrorContext` instance with automatically generated timestamp and trace ID.
50	pub fn new(
51		message: impl Into<String>,
52		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
53		metadata: Option<HashMap<String, String>>,
54	) -> Self {
55		let trace_id = if let Some(ref src) = source {
56			// Try to extract trace ID using the TraceableError trait
57			TraceableError::trace_id(src.as_ref())
58		} else {
59			Uuid::new_v4().to_string()
60		};
61
62		Self {
63			message: message.into(),
64			source,
65			metadata,
66			timestamp: Utc::now().to_rfc3339(),
67			trace_id,
68		}
69	}
70
71	/// Creates a new error context and logs it with the given message, source, and metadata.
72	///
73	/// # Arguments
74	///
75	/// * `message` - A descriptive error message
76	/// * `source` - An optional source error that caused this error
77	/// * `metadata` - Optional key-value pairs providing additional context
78	///
79	/// # Returns
80	///
81	/// A new `ErrorContext` instance with automatically generated timestamp and trace ID.
82	pub fn new_with_log(
83		message: impl Into<String>,
84		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
85		metadata: Option<HashMap<String, String>>,
86	) -> Self {
87		// Create the error context
88		let error_context = Self::new(message, source, metadata);
89
90		// Log the error
91		log_error(&error_context);
92
93		error_context
94	}
95
96	/// Adds a single key-value metadata pair to the error context.
97	///
98	/// This method creates the metadata HashMap if it doesn't already exist.
99	///
100	/// # Arguments
101	///
102	/// * `key` - The metadata key
103	/// * `value` - The metadata value
104	///
105	/// # Returns
106	///
107	/// The modified `ErrorContext` with the new metadata added.
108	pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
109		let metadata = self.metadata.get_or_insert_with(HashMap::new);
110		metadata.insert(key.into(), value.into());
111		self
112	}
113
114	/// Formats the error message with its metadata appended in a readable format.
115	///
116	/// The format is: `"message [key1=value1, key2=value2, ...]"`.
117	/// Metadata keys are sorted alphabetically for consistent output.
118	///
119	/// # Returns
120	///
121	/// A formatted string containing the error message and its metadata.
122	pub fn format_with_metadata(&self) -> String {
123		let mut result = self.message.clone();
124
125		if let Some(metadata) = &self.metadata {
126			if !metadata.is_empty() {
127				let mut parts = Vec::new();
128				// Sort keys for consistent output
129				let mut keys: Vec<_> = metadata.keys().collect();
130				keys.sort();
131
132				for key in keys {
133					if let Some(value) = metadata.get(key) {
134						parts.push(format!("{}={}", key, value));
135					}
136				}
137
138				if !parts.is_empty() {
139					result.push_str(&format!(" [{}]", parts.join(", ")));
140				}
141			}
142		}
143
144		result
145	}
146}
147
148impl fmt::Display for ErrorContext {
149	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150		write!(f, "{}", self.format_with_metadata())
151	}
152}
153
154// Add this implementation with Send + Sync bounds
155impl std::error::Error for ErrorContext {
156	fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
157		self.source
158			.as_ref()
159			.map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
160	}
161}
162
163// Ensure ErrorContext is Send + Sync
164unsafe impl Send for ErrorContext {}
165unsafe impl Sync for ErrorContext {}
166
167/// A trait for errors that can provide a trace ID
168pub trait TraceableError: std::error::Error + Send + Sync {
169	/// Returns the trace ID for this error
170	fn trace_id(&self) -> String;
171}
172
173impl TraceableError for dyn std::error::Error + Send + Sync + 'static {
174	fn trace_id(&self) -> String {
175		// First check if this error itself has a trace ID
176		if let Some(id) = try_extract_trace_id(self) {
177			return id;
178		}
179
180		// Then check the source chain to retain existing trace IDs
181		let mut source = self.source();
182		const MAX_DEPTH: usize = 3; // Limit the recursion depth
183		let mut depth = 0;
184
185		while let Some(err) = source {
186			depth += 1;
187			if depth > MAX_DEPTH {
188				break;
189			}
190
191			// Try to extract trace ID from this error in the chain
192			if let Some(id) = try_extract_trace_id(err) {
193				return id;
194			}
195
196			// Continue with the next source
197			source = err.source();
198		}
199
200		// If no trace ID found, generate a new one
201		Uuid::new_v4().to_string()
202	}
203}
204
205/// Helper function to try extracting a trace ID from an error
206fn try_extract_trace_id(err: &(dyn std::error::Error + 'static)) -> Option<String> {
207	// First check if this error is an ErrorContext
208	if let Some(ctx) = err.downcast_ref::<ErrorContext>() {
209		return Some(ctx.trace_id.clone());
210	}
211
212	// Define a macro to try downcasting to each error type
213	macro_rules! try_downcast {
214		($($ty:path),*) => {
215			$(
216				if let Some(e) = err.downcast_ref::<$ty>() {
217					return Some(e.trace_id());
218				}
219			)*
220		}
221	}
222
223	// Try all error types
224	try_downcast!(
225		crate::services::notification::NotificationError,
226		crate::services::trigger::TriggerError,
227		crate::services::filter::FilterError,
228		crate::services::blockwatcher::BlockWatcherError,
229		crate::services::blockchain::BlockChainError,
230		crate::repositories::RepositoryError,
231		crate::services::trigger::ScriptError,
232		crate::models::ConfigError
233	);
234
235	// No match found
236	None
237}
238
239/// Sanitize error messages to remove HTML content
240fn sanitize_error_message(message: &str) -> String {
241	if message.contains("<html>") || message.contains("<head>") || message.contains("<body>") {
242		if let Some(pos) = message.find('<') {
243			return message[..pos].trim().to_string();
244		}
245	}
246	message.to_string()
247}
248
249/// Helper function to format the complete error chain
250fn format_error_chain(err: &dyn std::error::Error) -> String {
251	let mut result = sanitize_error_message(&err.to_string());
252	let mut source = err.source();
253
254	while let Some(err) = source {
255		result.push_str("\n\tCaused by: ");
256		result.push_str(&sanitize_error_message(&err.to_string()));
257		source = err.source();
258	}
259
260	result
261}
262
263/// Extract structured fields from metadata for tracing
264pub fn metadata_to_fields(metadata: &Option<HashMap<String, String>>) -> Vec<(&str, &str)> {
265	let mut fields = Vec::new();
266	if let Some(metadata) = metadata {
267		for (key, value) in metadata {
268			fields.push((key.as_str(), value.as_str()));
269		}
270	}
271	fields
272}
273
274/// Log the error with structured fields
275fn log_error(error: &ErrorContext) {
276	if let Some(err) = &error.source {
277		tracing::error!(
278			message = error.format_with_metadata(),
279			trace_id = %error.trace_id,
280			timestamp = %error.timestamp,
281			error.chain = %format_error_chain(&**err),
282			"Error occurred"
283		);
284	} else {
285		tracing::error!(
286			message = error.format_with_metadata(),
287			trace_id = %error.trace_id,
288			timestamp = %error.timestamp,
289			"Error occurred"
290		);
291	}
292}
293
294#[cfg(test)]
295mod tests {
296	use crate::services::notification::NotificationError;
297
298	use super::*;
299	use std::io;
300
301	#[test]
302	fn test_new_error_context() {
303		let error = ErrorContext::new("Test error", None, None);
304
305		assert_eq!(error.message, "Test error");
306		assert!(error.source.is_none());
307		assert!(error.metadata.is_none());
308		assert!(!error.timestamp.is_empty());
309		assert!(!error.trace_id.is_empty());
310	}
311
312	#[test]
313	fn test_with_metadata() {
314		let error = ErrorContext::new("Test error", None, None)
315			.with_metadata("key1", "value1")
316			.with_metadata("key2", "value2");
317
318		let metadata = error.metadata.unwrap();
319		assert_eq!(metadata.get("key1"), Some(&"value1".to_string()));
320		assert_eq!(metadata.get("key2"), Some(&"value2".to_string()));
321	}
322
323	#[test]
324	fn test_format_with_metadata() {
325		let error = ErrorContext::new("Test error", None, None)
326			.with_metadata("a", "1")
327			.with_metadata("b", "2");
328
329		// Keys are sorted alphabetically in the output
330		assert_eq!(error.format_with_metadata(), "Test error [a=1, b=2]");
331	}
332
333	#[test]
334	fn test_display_implementation() {
335		let error = ErrorContext::new("Test error", None, None).with_metadata("key", "value");
336
337		assert_eq!(format!("{}", error), "Test error [key=value]");
338	}
339
340	#[test]
341	fn test_with_source_error() {
342		let source_error = io::Error::new(io::ErrorKind::NotFound, "File not found");
343		let boxed_source = Box::new(source_error) as Box<dyn std::error::Error + Send + Sync>;
344
345		let error = ErrorContext::new("Failed to read config", Some(boxed_source), None);
346
347		assert_eq!(error.message, "Failed to read config");
348		assert!(error.source.is_some());
349	}
350
351	#[test]
352	fn test_metadata_to_fields() {
353		let mut metadata = HashMap::new();
354		metadata.insert("key1".to_string(), "value1".to_string());
355		metadata.insert("key2".to_string(), "value2".to_string());
356
357		let metadata = Some(metadata);
358
359		let fields = metadata_to_fields(&metadata);
360
361		// Since HashMap doesn't guarantee order, we need to check contents without assuming order
362		assert_eq!(fields.len(), 2);
363		assert!(fields.contains(&("key1", "value1")));
364		assert!(fields.contains(&("key2", "value2")));
365	}
366
367	#[test]
368	fn test_format_error_chain() {
369		// Create a chain of errors
370		let inner_error = io::Error::new(io::ErrorKind::PermissionDenied, "Permission denied");
371		let middle_error =
372			ErrorContext::new("Failed to open file", Some(Box::new(inner_error)), None);
373		let outer_error =
374			ErrorContext::new("Config loading failed", Some(Box::new(middle_error)), None);
375
376		let formatted = format_error_chain(&outer_error);
377
378		assert!(formatted.contains("Config loading failed"));
379		assert!(formatted.contains("Caused by: Failed to open file"));
380		assert!(formatted.contains("Caused by: Permission denied"));
381	}
382
383	#[test]
384	#[cfg_attr(not(feature = "test-ci-only"), ignore)]
385	fn test_log_error() {
386		use tracing_test::traced_test;
387
388		#[traced_test]
389		fn inner_test() {
390			let error = ErrorContext::new("Test log error", None, None)
391				.with_metadata("test_key", "test_value");
392
393			log_error(&error);
394
395			// Verify log contains our error information
396			assert!(logs_contain("Test log error"));
397			assert!(logs_contain(&error.trace_id));
398			assert!(logs_contain(&error.timestamp));
399
400			// Test with source error
401			let source_error = std::io::Error::new(std::io::ErrorKind::Other, "Source error");
402			let error_with_source =
403				ErrorContext::new("Parent error", Some(Box::new(source_error)), None);
404
405			log_error(&error_with_source);
406
407			assert!(logs_contain("Parent error"));
408			assert!(logs_contain("Source error"));
409		}
410
411		inner_test();
412	}
413
414	// Custom error type for testing
415	#[derive(Debug)]
416	struct TestError {
417		message: String,
418		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
419	}
420
421	impl fmt::Display for TestError {
422		fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423			write!(f, "{}", self.message)
424		}
425	}
426
427	impl std::error::Error for TestError {
428		fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
429			self.source
430				.as_ref()
431				.map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
432		}
433	}
434
435	#[test]
436	fn test_trace_id_propagation() {
437		// Create an inner error with ErrorContext
438		let inner_error = ErrorContext::new(
439			"Inner error",
440			None,
441			Some(HashMap::from([("key".to_string(), "value".to_string())])),
442		);
443
444		// Get the trace ID from the inner error
445		let inner_trace_id = inner_error.trace_id.clone();
446
447		// Create a middle error that wraps the inner error
448		let middle_error = TestError {
449			message: "Middle error".to_string(),
450			source: Some(Box::new(inner_error)),
451		};
452
453		// Create an outer error with ErrorContext that wraps the middle error
454		let outer_error = ErrorContext::new("Outer error", Some(Box::new(middle_error)), None);
455
456		// Get the trace ID from the outer error
457		let outer_trace_id = outer_error.trace_id.clone();
458
459		// Verify that the trace IDs match
460		assert_eq!(
461			inner_trace_id, outer_trace_id,
462			"Trace IDs should match between inner and outer errors"
463		);
464
465		// Test the TraceableError implementation
466		let dyn_error: &(dyn std::error::Error + Send + Sync) = &outer_error;
467		let trace_id = TraceableError::trace_id(dyn_error);
468
469		assert_eq!(
470			inner_trace_id, trace_id,
471			"Trace ID from TraceableError should match the original trace ID"
472		);
473	}
474
475	#[test]
476	fn test_error_sanitization() {
477		// Test HTML sanitization
478		let html_error = "Error occurred<html><body>Some HTML content</body></html>";
479		let sanitized = sanitize_error_message(html_error);
480		assert_eq!(
481			sanitized, "Error occurred",
482			"HTML content should be removed"
483		);
484
485		// Test normal error message
486		let normal_error = "This is a normal error message";
487		let sanitized = sanitize_error_message(normal_error);
488		assert_eq!(
489			sanitized, normal_error,
490			"Normal error should remain unchanged"
491		);
492	}
493
494	#[test]
495	fn test_try_extract_trace_id() {
496		// Test extracting from ErrorContext
497		let error_ctx = ErrorContext::new("Test error", None, None);
498		let expected_trace_id = error_ctx.trace_id.clone();
499
500		let dyn_error: &(dyn std::error::Error + 'static) = &error_ctx;
501		let extracted = try_extract_trace_id(dyn_error);
502
503		assert_eq!(
504			extracted,
505			Some(expected_trace_id),
506			"Should extract trace ID from ErrorContext"
507		);
508
509		// Test with non-traceable error
510		let std_error = io::Error::new(io::ErrorKind::Other, "Standard error");
511		let dyn_error: &(dyn std::error::Error + 'static) = &std_error;
512		let extracted = try_extract_trace_id(dyn_error);
513
514		assert_eq!(
515			extracted, None,
516			"Should return None for non-traceable errors"
517		);
518	}
519
520	// Mock error types to test the try_downcast macro
521	#[derive(Debug)]
522	struct MockTraceableError {
523		trace_id: String,
524	}
525
526	impl MockTraceableError {
527		fn new() -> Self {
528			Self {
529				trace_id: Uuid::new_v4().to_string(),
530			}
531		}
532	}
533
534	impl fmt::Display for MockTraceableError {
535		fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536			write!(f, "Mock traceable error")
537		}
538	}
539
540	impl std::error::Error for MockTraceableError {}
541
542	impl TraceableError for MockTraceableError {
543		fn trace_id(&self) -> String {
544			self.trace_id.clone()
545		}
546	}
547
548	#[test]
549	fn test_trace_id_extraction_with_custom_implementation() {
550		// Create a mock error that implements TraceableError
551		let mock_error = MockTraceableError::new();
552		let expected_trace_id = mock_error.trace_id.clone();
553
554		// We need to test the actual implementation of TraceableError for dyn Error
555		let dyn_error: &(dyn std::error::Error + Send + Sync) = &mock_error;
556		let extracted = TraceableError::trace_id(dyn_error);
557
558		assert!(
559			extracted != expected_trace_id,
560			"Should not extract trace ID from custom error types since it's not in the \
561			 try_downcast! macro list"
562		);
563	}
564
565	#[test]
566	fn test_trace_id_propagation_through_error_chain() {
567		let mock_error = NotificationError::config_error("Test error", None, None);
568		let expected_trace_id = mock_error.trace_id();
569
570		// First, box our error
571		let boxed_error: Box<dyn std::error::Error + Send + Sync> = Box::new(mock_error);
572
573		// Now create an error context with this as the source
574		let error_ctx = ErrorContext::new("Outer error", Some(boxed_error), None);
575
576		// The trace ID should be extracted from our mock error
577		assert_eq!(
578			error_ctx.trace_id, expected_trace_id,
579			"Trace ID should propagate through the error chain"
580		);
581	}
582}