Skip to main content

oxirs_gql/observability/
error_aggregation.rs

1//! Error Aggregation and Grouping
2//!
3//! Provides intelligent error aggregation, grouping, and analysis
4//! for GraphQL operations.
5//!
6//! # Features
7//!
8//! - Automatic error grouping by similarity
9//! - Error frequency tracking
10//! - Root cause analysis
11//! - Error pattern detection
12//! - Stack trace fingerprinting
13//! - Error trend analysis
14
15use std::cmp::Reverse;
16use std::collections::HashMap;
17use std::time::{Duration, SystemTime};
18
19/// Error severity level
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum ErrorSeverity {
22    Low,
23    Medium,
24    High,
25    Critical,
26}
27
28/// Error category
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub enum ErrorCategory {
31    Validation,
32    Authorization,
33    Network,
34    Database,
35    Internal,
36    Timeout,
37    RateLimit,
38    Unknown,
39}
40
41/// Individual error occurrence
42#[derive(Debug, Clone)]
43pub struct ErrorOccurrence {
44    pub timestamp: SystemTime,
45    pub error_message: String,
46    pub error_type: String,
47    pub stack_trace: Option<String>,
48    pub query_id: Option<String>,
49    pub user_id: Option<String>,
50    pub field_path: Option<Vec<String>>,
51    pub context: HashMap<String, String>,
52}
53
54/// Grouped errors with statistics
55#[derive(Debug, Clone)]
56pub struct ErrorGroup {
57    pub id: String,
58    pub fingerprint: String,
59    pub error_type: String,
60    pub category: ErrorCategory,
61    pub severity: ErrorSeverity,
62    pub first_seen: SystemTime,
63    pub last_seen: SystemTime,
64    pub occurrences: Vec<ErrorOccurrence>,
65    pub count: usize,
66    pub affected_users: usize,
67    pub affected_queries: usize,
68    pub sample_message: String,
69}
70
71impl ErrorGroup {
72    /// Calculate error rate (errors per minute)
73    pub fn error_rate(&self) -> f64 {
74        if self.occurrences.len() < 2 {
75            return 0.0;
76        }
77
78        let duration = self
79            .last_seen
80            .duration_since(self.first_seen)
81            .unwrap_or(Duration::ZERO);
82
83        let minutes = duration.as_secs_f64() / 60.0;
84        if minutes > 0.0 {
85            self.count as f64 / minutes
86        } else {
87            0.0
88        }
89    }
90
91    /// Check if error is trending up
92    pub fn is_trending_up(&self, window: Duration) -> bool {
93        let now = SystemTime::now();
94        let cutoff = now - window;
95
96        let recent_count = self
97            .occurrences
98            .iter()
99            .filter(|e| e.timestamp >= cutoff)
100            .count();
101
102        let total_duration = now
103            .duration_since(self.first_seen)
104            .unwrap_or(Duration::ZERO);
105        let window_fraction = window.as_secs_f64() / total_duration.as_secs_f64();
106
107        recent_count as f64 > self.count as f64 * window_fraction * 1.5
108    }
109}
110
111/// Error aggregation manager
112pub struct ErrorAggregator {
113    groups: HashMap<String, ErrorGroup>,
114    max_occurrences_per_group: usize,
115}
116
117impl ErrorAggregator {
118    /// Create a new error aggregator
119    pub fn new(max_occurrences_per_group: usize) -> Self {
120        Self {
121            groups: HashMap::new(),
122            max_occurrences_per_group,
123        }
124    }
125
126    /// Record an error
127    pub fn record_error(&mut self, occurrence: ErrorOccurrence) {
128        let fingerprint = Self::generate_fingerprint(&occurrence);
129        let category = Self::categorize_error(&occurrence);
130        let severity = Self::assess_severity(&occurrence, &category);
131
132        if let Some(group) = self.groups.get_mut(&fingerprint) {
133            // Update existing group
134            group.last_seen = occurrence.timestamp;
135            group.count += 1;
136
137            // Track unique users and queries
138            if let Some(ref user_id) = occurrence.user_id {
139                if !group
140                    .occurrences
141                    .iter()
142                    .any(|e| e.user_id.as_ref() == Some(user_id))
143                {
144                    group.affected_users += 1;
145                }
146            }
147
148            if let Some(ref query_id) = occurrence.query_id {
149                if !group
150                    .occurrences
151                    .iter()
152                    .any(|e| e.query_id.as_ref() == Some(query_id))
153                {
154                    group.affected_queries += 1;
155                }
156            }
157
158            // Add occurrence
159            group.occurrences.push(occurrence);
160
161            // Trim if needed
162            if group.occurrences.len() > self.max_occurrences_per_group {
163                group.occurrences.drain(0..1);
164            }
165        } else {
166            // Create new group
167            let id = format!("group-{}", self.groups.len());
168            let sample_message = occurrence.error_message.clone();
169            let first_seen = occurrence.timestamp;
170
171            let occurrences = vec![occurrence];
172            let affected_users = occurrences[0].user_id.as_ref().map(|_| 1).unwrap_or(0);
173            let affected_queries = occurrences[0].query_id.as_ref().map(|_| 1).unwrap_or(0);
174
175            self.groups.insert(
176                fingerprint.clone(),
177                ErrorGroup {
178                    id,
179                    fingerprint,
180                    error_type: occurrences[0].error_type.clone(),
181                    category,
182                    severity,
183                    first_seen,
184                    last_seen: first_seen,
185                    occurrences,
186                    count: 1,
187                    affected_users,
188                    affected_queries,
189                    sample_message,
190                },
191            );
192        }
193    }
194
195    /// Get all error groups
196    pub fn get_all_groups(&self) -> Vec<&ErrorGroup> {
197        self.groups.values().collect()
198    }
199
200    /// Get error groups by category
201    pub fn get_groups_by_category(&self, category: ErrorCategory) -> Vec<&ErrorGroup> {
202        self.groups
203            .values()
204            .filter(|g| g.category == category)
205            .collect()
206    }
207
208    /// Get error groups by severity
209    pub fn get_groups_by_severity(&self, severity: ErrorSeverity) -> Vec<&ErrorGroup> {
210        self.groups
211            .values()
212            .filter(|g| g.severity == severity)
213            .collect()
214    }
215
216    /// Get top error groups by count
217    pub fn get_top_errors(&self, limit: usize) -> Vec<&ErrorGroup> {
218        let mut groups: Vec<_> = self.groups.values().collect();
219        groups.sort_by_key(|g| Reverse(g.count));
220        groups.into_iter().take(limit).collect()
221    }
222
223    /// Get trending errors
224    pub fn get_trending_errors(&self, window: Duration) -> Vec<&ErrorGroup> {
225        self.groups
226            .values()
227            .filter(|g| g.is_trending_up(window))
228            .collect()
229    }
230
231    /// Get error statistics
232    pub fn get_statistics(&self) -> ErrorStatistics {
233        let total_errors: usize = self.groups.values().map(|g| g.count).sum();
234        let total_groups = self.groups.len();
235
236        let mut by_category: HashMap<ErrorCategory, usize> = HashMap::new();
237        let mut by_severity: HashMap<ErrorSeverity, usize> = HashMap::new();
238
239        for group in self.groups.values() {
240            *by_category.entry(group.category.clone()).or_insert(0) += group.count;
241            *by_severity.entry(group.severity).or_insert(0) += group.count;
242        }
243
244        ErrorStatistics {
245            total_errors,
246            total_groups,
247            by_category,
248            by_severity,
249        }
250    }
251
252    /// Clear all error data
253    pub fn clear(&mut self) {
254        self.groups.clear();
255    }
256
257    /// Generate error fingerprint for grouping
258    fn generate_fingerprint(occurrence: &ErrorOccurrence) -> String {
259        // Use error type and a normalized message
260        let normalized_message = Self::normalize_message(&occurrence.error_message);
261
262        // Hash for consistent fingerprint
263        let mut hash: u64 = 5381;
264        for byte in occurrence.error_type.bytes() {
265            hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
266        }
267        for byte in normalized_message.bytes() {
268            hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
269        }
270
271        format!("{:x}", hash)
272    }
273
274    /// Normalize error message for grouping
275    fn normalize_message(message: &str) -> String {
276        // Remove numbers, IDs, and variable parts
277        let mut normalized = message.to_string();
278
279        // Replace numbers with placeholder
280        normalized = normalized
281            .split_whitespace()
282            .map(|word| {
283                if word.chars().all(|c| c.is_numeric()) {
284                    "<num>"
285                } else {
286                    word
287                }
288            })
289            .collect::<Vec<_>>()
290            .join(" ");
291
292        // Normalize case
293        normalized.to_lowercase()
294    }
295
296    /// Categorize error based on content
297    fn categorize_error(occurrence: &ErrorOccurrence) -> ErrorCategory {
298        let message_lower = occurrence.error_message.to_lowercase();
299        let type_lower = occurrence.error_type.to_lowercase();
300
301        if type_lower.contains("validation") || message_lower.contains("invalid") {
302            ErrorCategory::Validation
303        } else if type_lower.contains("auth") || message_lower.contains("unauthorized") {
304            ErrorCategory::Authorization
305        } else if type_lower.contains("database")
306            || type_lower.contains("db")
307            || message_lower.contains("database")
308            || message_lower.contains("query")
309        {
310            ErrorCategory::Database
311        } else if message_lower.contains("network") || message_lower.contains("connection") {
312            ErrorCategory::Network
313        } else if message_lower.contains("timeout") {
314            ErrorCategory::Timeout
315        } else if message_lower.contains("rate limit") {
316            ErrorCategory::RateLimit
317        } else if type_lower.contains("internal") {
318            ErrorCategory::Internal
319        } else {
320            ErrorCategory::Unknown
321        }
322    }
323
324    /// Assess error severity
325    fn assess_severity(occurrence: &ErrorOccurrence, category: &ErrorCategory) -> ErrorSeverity {
326        match category {
327            ErrorCategory::Authorization => ErrorSeverity::High,
328            ErrorCategory::Internal => ErrorSeverity::Critical,
329            ErrorCategory::Database => ErrorSeverity::High,
330            ErrorCategory::Timeout => ErrorSeverity::Medium,
331            ErrorCategory::RateLimit => ErrorSeverity::Low,
332            ErrorCategory::Validation => ErrorSeverity::Low,
333            ErrorCategory::Network => ErrorSeverity::Medium,
334            ErrorCategory::Unknown => {
335                // Check message for severity hints
336                let message_lower = occurrence.error_message.to_lowercase();
337                if message_lower.contains("critical") || message_lower.contains("fatal") {
338                    ErrorSeverity::Critical
339                } else if message_lower.contains("error") {
340                    ErrorSeverity::High
341                } else {
342                    ErrorSeverity::Medium
343                }
344            }
345        }
346    }
347
348    /// Generate error report
349    pub fn generate_report(&self) -> String {
350        let mut report = String::from("=== Error Aggregation Report ===\n\n");
351
352        let stats = self.get_statistics();
353        report.push_str(&format!("Total Errors: {}\n", stats.total_errors));
354        report.push_str(&format!("Error Groups: {}\n\n", stats.total_groups));
355
356        // By category
357        report.push_str("Errors by Category:\n");
358        for (category, count) in &stats.by_category {
359            report.push_str(&format!("  {:?}: {}\n", category, count));
360        }
361        report.push('\n');
362
363        // By severity
364        report.push_str("Errors by Severity:\n");
365        for (severity, count) in &stats.by_severity {
366            report.push_str(&format!("  {:?}: {}\n", severity, count));
367        }
368        report.push('\n');
369
370        // Top errors
371        report.push_str("Top 5 Errors:\n");
372        for (i, group) in self.get_top_errors(5).iter().enumerate() {
373            report.push_str(&format!(
374                "{}. {} (count: {}, users: {})\n",
375                i + 1,
376                group.sample_message,
377                group.count,
378                group.affected_users
379            ));
380        }
381
382        report
383    }
384}
385
386/// Error statistics
387#[derive(Debug)]
388pub struct ErrorStatistics {
389    pub total_errors: usize,
390    pub total_groups: usize,
391    pub by_category: HashMap<ErrorCategory, usize>,
392    pub by_severity: HashMap<ErrorSeverity, usize>,
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_record_error() {
401        let mut aggregator = ErrorAggregator::new(100);
402
403        let occurrence = ErrorOccurrence {
404            timestamp: SystemTime::now(),
405            error_message: "Invalid input".to_string(),
406            error_type: "ValidationError".to_string(),
407            stack_trace: None,
408            query_id: Some("query-1".to_string()),
409            user_id: Some("user-1".to_string()),
410            field_path: None,
411            context: HashMap::new(),
412        };
413
414        aggregator.record_error(occurrence);
415
416        assert_eq!(aggregator.get_all_groups().len(), 1);
417    }
418
419    #[test]
420    fn test_error_grouping() {
421        let mut aggregator = ErrorAggregator::new(100);
422
423        // Same error type, should group together
424        for i in 0..3 {
425            aggregator.record_error(ErrorOccurrence {
426                timestamp: SystemTime::now(),
427                error_message: format!("Invalid input {}", i),
428                error_type: "ValidationError".to_string(),
429                stack_trace: None,
430                query_id: Some(format!("query-{}", i)),
431                user_id: None,
432                field_path: None,
433                context: HashMap::new(),
434            });
435        }
436
437        // Should be grouped into one
438        let groups = aggregator.get_all_groups();
439        assert_eq!(groups.len(), 1);
440        assert_eq!(groups[0].count, 3);
441    }
442
443    #[test]
444    fn test_error_categorization() {
445        let mut aggregator = ErrorAggregator::new(100);
446
447        aggregator.record_error(ErrorOccurrence {
448            timestamp: SystemTime::now(),
449            error_message: "Unauthorized access".to_string(),
450            error_type: "AuthError".to_string(),
451            stack_trace: None,
452            query_id: None,
453            user_id: None,
454            field_path: None,
455            context: HashMap::new(),
456        });
457
458        let groups = aggregator.get_groups_by_category(ErrorCategory::Authorization);
459        assert_eq!(groups.len(), 1);
460    }
461
462    #[test]
463    fn test_error_severity() {
464        let mut aggregator = ErrorAggregator::new(100);
465
466        aggregator.record_error(ErrorOccurrence {
467            timestamp: SystemTime::now(),
468            error_message: "Database connection failed".to_string(),
469            error_type: "DatabaseError".to_string(),
470            stack_trace: None,
471            query_id: None,
472            user_id: None,
473            field_path: None,
474            context: HashMap::new(),
475        });
476
477        let groups = aggregator.get_groups_by_severity(ErrorSeverity::High);
478        assert_eq!(groups.len(), 1);
479    }
480
481    #[test]
482    fn test_top_errors() {
483        let mut aggregator = ErrorAggregator::new(100);
484
485        // Error with 5 occurrences
486        for _ in 0..5 {
487            aggregator.record_error(ErrorOccurrence {
488                timestamp: SystemTime::now(),
489                error_message: "Error A".to_string(),
490                error_type: "ErrorA".to_string(),
491                stack_trace: None,
492                query_id: None,
493                user_id: None,
494                field_path: None,
495                context: HashMap::new(),
496            });
497        }
498
499        // Error with 2 occurrences
500        for _ in 0..2 {
501            aggregator.record_error(ErrorOccurrence {
502                timestamp: SystemTime::now(),
503                error_message: "Error B".to_string(),
504                error_type: "ErrorB".to_string(),
505                stack_trace: None,
506                query_id: None,
507                user_id: None,
508                field_path: None,
509                context: HashMap::new(),
510            });
511        }
512
513        let top = aggregator.get_top_errors(1);
514        assert_eq!(top.len(), 1);
515        assert_eq!(top[0].count, 5);
516    }
517
518    #[test]
519    fn test_affected_users_tracking() {
520        let mut aggregator = ErrorAggregator::new(100);
521
522        aggregator.record_error(ErrorOccurrence {
523            timestamp: SystemTime::now(),
524            error_message: "Error".to_string(),
525            error_type: "Error".to_string(),
526            stack_trace: None,
527            query_id: None,
528            user_id: Some("user-1".to_string()),
529            field_path: None,
530            context: HashMap::new(),
531        });
532
533        aggregator.record_error(ErrorOccurrence {
534            timestamp: SystemTime::now(),
535            error_message: "Error".to_string(),
536            error_type: "Error".to_string(),
537            stack_trace: None,
538            query_id: None,
539            user_id: Some("user-2".to_string()),
540            field_path: None,
541            context: HashMap::new(),
542        });
543
544        let groups = aggregator.get_all_groups();
545        assert_eq!(groups[0].affected_users, 2);
546    }
547
548    #[test]
549    fn test_statistics() {
550        let mut aggregator = ErrorAggregator::new(100);
551
552        aggregator.record_error(ErrorOccurrence {
553            timestamp: SystemTime::now(),
554            error_message: "Invalid input".to_string(),
555            error_type: "ValidationError".to_string(),
556            stack_trace: None,
557            query_id: None,
558            user_id: None,
559            field_path: None,
560            context: HashMap::new(),
561        });
562
563        aggregator.record_error(ErrorOccurrence {
564            timestamp: SystemTime::now(),
565            error_message: "Unauthorized".to_string(),
566            error_type: "AuthError".to_string(),
567            stack_trace: None,
568            query_id: None,
569            user_id: None,
570            field_path: None,
571            context: HashMap::new(),
572        });
573
574        let stats = aggregator.get_statistics();
575        assert_eq!(stats.total_errors, 2);
576        assert_eq!(stats.total_groups, 2);
577    }
578
579    #[test]
580    fn test_clear() {
581        let mut aggregator = ErrorAggregator::new(100);
582
583        aggregator.record_error(ErrorOccurrence {
584            timestamp: SystemTime::now(),
585            error_message: "Error".to_string(),
586            error_type: "Error".to_string(),
587            stack_trace: None,
588            query_id: None,
589            user_id: None,
590            field_path: None,
591            context: HashMap::new(),
592        });
593
594        assert_eq!(aggregator.get_all_groups().len(), 1);
595
596        aggregator.clear();
597        assert_eq!(aggregator.get_all_groups().len(), 0);
598    }
599
600    #[test]
601    fn test_generate_report() {
602        let mut aggregator = ErrorAggregator::new(100);
603
604        aggregator.record_error(ErrorOccurrence {
605            timestamp: SystemTime::now(),
606            error_message: "Test error".to_string(),
607            error_type: "TestError".to_string(),
608            stack_trace: None,
609            query_id: None,
610            user_id: None,
611            field_path: None,
612            context: HashMap::new(),
613        });
614
615        let report = aggregator.generate_report();
616        assert!(report.contains("Error Aggregation Report"));
617        assert!(report.contains("Total Errors: 1"));
618    }
619
620    #[test]
621    fn test_error_rate_calculation() {
622        let mut aggregator = ErrorAggregator::new(100);
623
624        let first_time = SystemTime::now();
625
626        aggregator.record_error(ErrorOccurrence {
627            timestamp: first_time,
628            error_message: "Error".to_string(),
629            error_type: "Error".to_string(),
630            stack_trace: None,
631            query_id: None,
632            user_id: None,
633            field_path: None,
634            context: HashMap::new(),
635        });
636
637        aggregator.record_error(ErrorOccurrence {
638            timestamp: first_time + Duration::from_secs(60),
639            error_message: "Error".to_string(),
640            error_type: "Error".to_string(),
641            stack_trace: None,
642            query_id: None,
643            user_id: None,
644            field_path: None,
645            context: HashMap::new(),
646        });
647
648        let groups = aggregator.get_all_groups();
649        let rate = groups[0].error_rate();
650        assert!(rate > 0.0);
651    }
652
653    #[test]
654    fn test_normalize_message() {
655        let msg1 = "Error with ID 12345";
656        let msg2 = "Error with ID 67890";
657
658        let norm1 = ErrorAggregator::normalize_message(msg1);
659        let norm2 = ErrorAggregator::normalize_message(msg2);
660
661        // Should normalize to same pattern
662        assert_eq!(norm1, norm2);
663    }
664
665    #[test]
666    fn test_max_occurrences_per_group() {
667        let mut aggregator = ErrorAggregator::new(2);
668
669        for _ in 0..5 {
670            aggregator.record_error(ErrorOccurrence {
671                timestamp: SystemTime::now(),
672                error_message: "Error".to_string(),
673                error_type: "Error".to_string(),
674                stack_trace: None,
675                query_id: None,
676                user_id: None,
677                field_path: None,
678                context: HashMap::new(),
679            });
680        }
681
682        let groups = aggregator.get_all_groups();
683        assert_eq!(groups[0].occurrences.len(), 2); // Limited to 2
684        assert_eq!(groups[0].count, 5); // But count is still 5
685    }
686}