Skip to main content

mini_apm/models/
error.rs

1use crate::DbPool;
2use chrono::Utc;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct AppError {
8    pub id: i64,
9    pub fingerprint: String,
10    pub exception_class: String,
11    pub message: String,
12    pub first_seen_at: String,
13    pub last_seen_at: String,
14    pub occurrence_count: i64,
15    pub status: String,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ErrorOccurrence {
20    pub id: i64,
21    pub error_id: i64,
22    pub request_id: Option<String>,
23    pub user_id: Option<String>,
24    pub backtrace: Vec<String>,
25    pub params: Option<serde_json::Value>,
26    pub happened_at: String,
27    pub source_context: Option<SourceContext>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SourceContext {
32    pub file: String,
33    pub lineno: i64,
34    pub pre_context: Vec<String>,
35    pub context_line: String,
36    pub post_context: Vec<String>,
37}
38
39impl SourceContext {
40    /// Returns pre_context lines with their line numbers
41    pub fn pre_context_with_lines(&self) -> Vec<(i64, &str)> {
42        let start = self.lineno - self.pre_context.len() as i64;
43        self.pre_context
44            .iter()
45            .enumerate()
46            .map(|(idx, line)| (start + idx as i64, line.as_str()))
47            .collect()
48    }
49
50    /// Returns post_context lines with their line numbers
51    pub fn post_context_with_lines(&self) -> Vec<(i64, &str)> {
52        self.post_context
53            .iter()
54            .enumerate()
55            .map(|(idx, line)| (self.lineno + 1 + idx as i64, line.as_str()))
56            .collect()
57    }
58}
59
60#[derive(Debug, Deserialize)]
61pub struct IncomingError {
62    pub exception_class: String,
63    pub message: String,
64    pub backtrace: Vec<String>,
65    pub fingerprint: String,
66    pub request_id: Option<String>,
67    pub user_id: Option<String>,
68    pub params: Option<serde_json::Value>,
69    pub timestamp: Option<String>,
70    pub source_context: Option<IncomingSourceContext>,
71}
72
73#[derive(Debug, Deserialize)]
74pub struct IncomingSourceContext {
75    pub file: String,
76    pub lineno: i64,
77    pub pre_context: Option<Vec<String>>,
78    pub context_line: String,
79    pub post_context: Option<Vec<String>>,
80}
81
82/// Minimum similarity threshold for grouping errors (50%)
83const SIMILARITY_THRESHOLD: f64 = 0.5;
84
85pub fn insert(
86    pool: &DbPool,
87    error: &IncomingError,
88    project_id: Option<i64>,
89) -> anyhow::Result<i64> {
90    let conn = pool.get()?;
91    let now = Utc::now().to_rfc3339();
92    let timestamp = error.timestamp.as_ref().unwrap_or(&now);
93
94    // Generate location-based fingerprint for smart grouping
95    let location_fingerprint =
96        generate_location_fingerprint(&error.exception_class, &error.backtrace);
97
98    // Try to find existing error by:
99    // 1. First check exact fingerprint match (backward compatibility)
100    // 2. Then check location fingerprint + message similarity >= 50%
101    let existing: Option<i64> = conn
102        .query_row(
103            "SELECT id FROM errors WHERE fingerprint = ?1 AND ((?2 IS NULL AND project_id IS NULL) OR project_id = ?2)",
104            rusqlite::params![&error.fingerprint, project_id],
105            |row| row.get(0),
106        )
107        .ok();
108
109    let error_id = if let Some(id) = existing {
110        // Exact fingerprint match - update existing error
111        conn.execute(
112            "UPDATE errors SET last_seen_at = ?1, occurrence_count = occurrence_count + 1 WHERE id = ?2",
113            (timestamp, id),
114        )?;
115        id
116    } else {
117        // Try to find similar error by location + message similarity
118        let similar_error =
119            find_similar_error(&conn, project_id, &location_fingerprint, &error.message)?;
120
121        if let Some(id) = similar_error {
122            // Found similar error - group with it
123            conn.execute(
124                "UPDATE errors SET last_seen_at = ?1, occurrence_count = occurrence_count + 1 WHERE id = ?2",
125                (timestamp, id),
126            )?;
127            id
128        } else {
129            // No similar error found - create new one with location fingerprint
130            conn.execute(
131                r#"
132                INSERT INTO errors (project_id, fingerprint, exception_class, message, first_seen_at, last_seen_at, occurrence_count, status)
133                VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 'open')
134                "#,
135                (
136                    project_id,
137                    &location_fingerprint,
138                    &error.exception_class,
139                    &error.message,
140                    timestamp,
141                    timestamp,
142                ),
143            )?;
144            conn.last_insert_rowid()
145        }
146    };
147
148    // Convert IncomingSourceContext to SourceContext for storage
149    let source_context_json = error.source_context.as_ref().and_then(|sc| {
150        let ctx = SourceContext {
151            file: sc.file.clone(),
152            lineno: sc.lineno,
153            pre_context: sc.pre_context.clone().unwrap_or_default(),
154            context_line: sc.context_line.clone(),
155            post_context: sc.post_context.clone().unwrap_or_default(),
156        };
157        serde_json::to_string(&ctx).ok()
158    });
159
160    // Insert occurrence
161    conn.execute(
162        r#"
163        INSERT INTO error_occurrences (error_id, request_id, user_id, backtrace, params, happened_at, source_context)
164        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
165        "#,
166        (
167            error_id,
168            &error.request_id,
169            &error.user_id,
170            serde_json::to_string(&error.backtrace)?,
171            error.params.as_ref().and_then(|p| serde_json::to_string(p).ok()),
172            timestamp,
173            source_context_json,
174        ),
175    )?;
176
177    Ok(error_id)
178}
179
180/// Find an existing error with the same location fingerprint and similar message
181fn find_similar_error(
182    conn: &r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>,
183    project_id: Option<i64>,
184    location_fingerprint: &str,
185    message: &str,
186) -> anyhow::Result<Option<i64>> {
187    // Find errors with the same location fingerprint
188    let mut stmt = conn.prepare(
189        "SELECT id, message FROM errors WHERE fingerprint = ?1 AND ((?2 IS NULL AND project_id IS NULL) OR project_id = ?2)"
190    )?;
191
192    let candidates: Vec<(i64, String)> = stmt
193        .query_map(rusqlite::params![location_fingerprint, project_id], |row| {
194            Ok((row.get(0)?, row.get(1)?))
195        })?
196        .collect::<Result<Vec<_>, _>>()?;
197
198    // Check message similarity for each candidate
199    for (id, existing_message) in candidates {
200        let similarity = text_similarity(message, &existing_message);
201        if similarity >= SIMILARITY_THRESHOLD {
202            return Ok(Some(id));
203        }
204    }
205
206    Ok(None)
207}
208
209pub fn list(
210    pool: &DbPool,
211    project_id: Option<i64>,
212    status: Option<&str>,
213    limit: i64,
214) -> anyhow::Result<Vec<AppError>> {
215    list_filtered(pool, project_id, status, None, None, "last_seen", limit)
216}
217
218pub struct ErrorListResult {
219    pub errors: Vec<AppError>,
220    pub total_count: i64,
221}
222
223pub fn list_filtered(
224    pool: &DbPool,
225    project_id: Option<i64>,
226    status: Option<&str>,
227    search: Option<&str>,
228    since: Option<&str>,
229    sort_by: &str,
230    limit: i64,
231) -> anyhow::Result<Vec<AppError>> {
232    list_paginated(pool, project_id, status, search, since, sort_by, limit, 0)
233}
234
235#[allow(clippy::too_many_arguments)]
236pub fn list_paginated(
237    pool: &DbPool,
238    project_id: Option<i64>,
239    status: Option<&str>,
240    search: Option<&str>,
241    since: Option<&str>,
242    sort_by: &str,
243    limit: i64,
244    offset: i64,
245) -> anyhow::Result<Vec<AppError>> {
246    let conn = pool.get()?;
247
248    let order_clause = match sort_by {
249        "first_seen" => "first_seen_at DESC",
250        "count" => "occurrence_count DESC",
251        _ => "last_seen_at DESC", // default: last_seen
252    };
253
254    let sql = format!(
255        r#"
256        SELECT id, fingerprint, exception_class, message,
257               strftime('%Y-%m-%d %H:%M', first_seen_at),
258               strftime('%Y-%m-%d %H:%M', last_seen_at),
259               occurrence_count, status
260        FROM errors
261        WHERE (?1 IS NULL OR project_id = ?1)
262          AND (?2 IS NULL OR status = ?2)
263          AND (?3 IS NULL OR exception_class LIKE '%' || ?3 || '%' OR message LIKE '%' || ?3 || '%')
264          AND (?4 IS NULL OR last_seen_at >= ?4)
265        ORDER BY {}
266        LIMIT ?5 OFFSET ?6
267        "#,
268        order_clause
269    );
270
271    let mut stmt = conn.prepare(&sql)?;
272    let errors = stmt
273        .query_map(
274            rusqlite::params![project_id, status, search, since, limit, offset],
275            map_error,
276        )?
277        .collect::<Result<Vec<_>, _>>()?;
278
279    Ok(errors)
280}
281
282pub fn count_filtered(
283    pool: &DbPool,
284    project_id: Option<i64>,
285    status: Option<&str>,
286    search: Option<&str>,
287    since: Option<&str>,
288) -> anyhow::Result<i64> {
289    let conn = pool.get()?;
290
291    let count: i64 = conn.query_row(
292        r#"
293        SELECT COUNT(*)
294        FROM errors
295        WHERE (?1 IS NULL OR project_id = ?1)
296          AND (?2 IS NULL OR status = ?2)
297          AND (?3 IS NULL OR exception_class LIKE '%' || ?3 || '%' OR message LIKE '%' || ?3 || '%')
298          AND (?4 IS NULL OR last_seen_at >= ?4)
299        "#,
300        rusqlite::params![project_id, status, search, since],
301        |row| row.get(0),
302    )?;
303
304    Ok(count)
305}
306
307pub fn find(pool: &DbPool, id: i64) -> anyhow::Result<Option<AppError>> {
308    let conn = pool.get()?;
309    let error = conn
310        .query_row(
311            "SELECT id, fingerprint, exception_class, message,
312                    strftime('%Y-%m-%d %H:%M', first_seen_at),
313                    strftime('%Y-%m-%d %H:%M', last_seen_at),
314                    occurrence_count, status
315             FROM errors WHERE id = ?1",
316            [id],
317            map_error,
318        )
319        .ok();
320    Ok(error)
321}
322
323pub fn occurrences(
324    pool: &DbPool,
325    error_id: i64,
326    limit: i64,
327) -> anyhow::Result<Vec<ErrorOccurrence>> {
328    let conn = pool.get()?;
329    let mut stmt = conn.prepare(
330        "SELECT id, error_id, request_id, user_id, backtrace, params,
331                strftime('%Y-%m-%d %H:%M', happened_at), source_context
332         FROM error_occurrences WHERE error_id = ?1 ORDER BY happened_at DESC LIMIT ?2",
333    )?;
334
335    let occs = stmt
336        .query_map([error_id, limit], |row| {
337            let backtrace_str: String = row.get(4)?;
338            let params_str: Option<String> = row.get(5)?;
339            let source_context_str: Option<String> = row.get(7)?;
340            Ok(ErrorOccurrence {
341                id: row.get(0)?,
342                error_id: row.get(1)?,
343                request_id: row.get(2)?,
344                user_id: row.get(3)?,
345                backtrace: serde_json::from_str(&backtrace_str).unwrap_or_default(),
346                params: params_str.and_then(|s| serde_json::from_str(&s).ok()),
347                happened_at: row.get(6)?,
348                source_context: source_context_str.and_then(|s| serde_json::from_str(&s).ok()),
349            })
350        })?
351        .collect::<Result<Vec<_>, _>>()?;
352
353    Ok(occs)
354}
355
356pub fn count_since(pool: &DbPool, project_id: Option<i64>, since: &str) -> anyhow::Result<i64> {
357    let conn = pool.get()?;
358    let count: i64 = conn.query_row(
359        "SELECT COUNT(*) FROM error_occurrences eo
360         JOIN errors e ON e.id = eo.error_id
361         WHERE eo.happened_at >= ?1 AND (?2 IS NULL OR e.project_id = ?2)",
362        rusqlite::params![since, project_id],
363        |row| row.get(0),
364    )?;
365    Ok(count)
366}
367
368pub fn update_status(pool: &DbPool, id: i64, status: &str) -> anyhow::Result<()> {
369    let conn = pool.get()?;
370    conn.execute("UPDATE errors SET status = ?1 WHERE id = ?2", (status, id))?;
371    Ok(())
372}
373
374pub fn delete_occurrences_before(pool: &DbPool, before: &str) -> anyhow::Result<usize> {
375    let conn = pool.get()?;
376    let deleted = conn.execute(
377        "DELETE FROM error_occurrences WHERE happened_at < ?1",
378        [before],
379    )?;
380    Ok(deleted)
381}
382
383/// Error trend point for charting
384#[derive(Debug, Clone, Serialize)]
385pub struct ErrorTrendPoint {
386    pub hour: String,
387    pub count: i64,
388}
389
390/// Get hourly error occurrence counts for a specific error (for trend sparklines)
391pub fn error_trend(
392    pool: &DbPool,
393    error_id: i64,
394    hours: i64,
395) -> anyhow::Result<Vec<ErrorTrendPoint>> {
396    let conn = pool.get()?;
397    let mut stmt = conn.prepare(
398        r#"
399        WITH hours AS (
400            SELECT datetime('now', '-' || (value - 1) || ' hours') as hour
401            FROM generate_series(1, ?2)
402        )
403        SELECT strftime('%Y-%m-%d %H:00', h.hour) as hour,
404               COALESCE(SUM(CASE WHEN eo.happened_at IS NOT NULL THEN 1 ELSE 0 END), 0) as cnt
405        FROM (
406            SELECT datetime('now', '-' || (value - 1) || ' hours') as hour
407            FROM (
408                SELECT 1 as value UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
409                UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8
410                UNION SELECT 9 UNION SELECT 10 UNION SELECT 11 UNION SELECT 12
411                UNION SELECT 13 UNION SELECT 14 UNION SELECT 15 UNION SELECT 16
412                UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION SELECT 20
413                UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24
414            )
415            WHERE value <= ?2
416        ) h
417        LEFT JOIN error_occurrences eo
418            ON strftime('%Y-%m-%d %H', eo.happened_at) = strftime('%Y-%m-%d %H', h.hour)
419            AND eo.error_id = ?1
420        GROUP BY strftime('%Y-%m-%d %H:00', h.hour)
421        ORDER BY hour ASC
422        "#,
423    )?;
424
425    let points = stmt
426        .query_map(rusqlite::params![error_id, hours], |row| {
427            Ok(ErrorTrendPoint {
428                hour: row.get(0)?,
429                count: row.get(1)?,
430            })
431        })?
432        .collect::<Result<Vec<_>, _>>()?;
433
434    Ok(points)
435}
436
437/// Get simplified 24h trend for an error (returns just the hourly counts as a string for sparkline)
438pub fn error_trend_24h(pool: &DbPool, error_id: i64) -> anyhow::Result<Vec<i64>> {
439    let conn = pool.get()?;
440
441    // Get occurrence counts per hour for the last 24 hours
442    let mut stmt = conn.prepare(
443        r#"
444        SELECT strftime('%Y-%m-%d %H', happened_at) as hour, COUNT(*) as cnt
445        FROM error_occurrences
446        WHERE error_id = ?1 AND happened_at >= datetime('now', '-24 hours')
447        GROUP BY hour
448        ORDER BY hour ASC
449        "#,
450    )?;
451
452    let hour_counts: std::collections::HashMap<String, i64> = stmt
453        .query_map([error_id], |row| {
454            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
455        })?
456        .filter_map(|r| r.ok())
457        .collect();
458
459    // Generate 24 hours of data, filling in zeros where no occurrences
460    let mut counts = Vec::with_capacity(24);
461    for i in (0..24).rev() {
462        let hour = chrono::Utc::now() - chrono::Duration::hours(i);
463        let hour_key = hour.format("%Y-%m-%d %H").to_string();
464        counts.push(*hour_counts.get(&hour_key).unwrap_or(&0));
465    }
466
467    Ok(counts)
468}
469
470/// Get overall hourly error counts (for error index chart)
471pub fn hourly_error_stats(
472    pool: &DbPool,
473    project_id: Option<i64>,
474    hours: i64,
475) -> anyhow::Result<Vec<ErrorTrendPoint>> {
476    let conn = pool.get()?;
477
478    let mut stmt = conn.prepare(
479        r#"
480        SELECT strftime('%Y-%m-%d %H:00', eo.happened_at) as hour_label, COUNT(*) as cnt
481        FROM error_occurrences eo
482        JOIN errors e ON e.id = eo.error_id
483        WHERE eo.happened_at >= datetime('now', '-' || ?2 || ' hours')
484          AND (?1 IS NULL OR e.project_id = ?1)
485        GROUP BY strftime('%Y-%m-%d %H', eo.happened_at)
486        ORDER BY eo.happened_at ASC
487        "#,
488    )?;
489
490    // Collect data into a HashMap for lookup
491    let data_points: std::collections::HashMap<String, i64> = stmt
492        .query_map(rusqlite::params![project_id, hours], |row| {
493            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
494        })?
495        .filter_map(|r| r.ok())
496        .collect();
497
498    // Fill in all hours with zeros for missing data
499    let mut points = Vec::with_capacity(hours as usize);
500    for i in (0..hours).rev() {
501        let hour = chrono::Utc::now() - chrono::Duration::hours(i);
502        let hour_key = hour.format("%Y-%m-%d %H:00").to_string();
503        points.push(ErrorTrendPoint {
504            hour: hour_key.clone(),
505            count: *data_points.get(&hour_key).unwrap_or(&0),
506        });
507    }
508
509    Ok(points)
510}
511
512fn map_error(row: &rusqlite::Row) -> rusqlite::Result<AppError> {
513    Ok(AppError {
514        id: row.get(0)?,
515        fingerprint: row.get(1)?,
516        exception_class: row.get(2)?,
517        message: row.get(3)?,
518        first_seen_at: row.get(4)?,
519        last_seen_at: row.get(5)?,
520        occurrence_count: row.get(6)?,
521        status: row.get(7)?,
522    })
523}
524
525/// Calculate text similarity using word-based Jaccard similarity (0.0 to 1.0)
526fn text_similarity(a: &str, b: &str) -> f64 {
527    let normalize = |s: &str| -> HashSet<String> {
528        s.to_lowercase()
529            .split(|c: char| !c.is_alphanumeric())
530            .filter(|w| !w.is_empty())
531            .map(|w| w.to_string())
532            .collect()
533    };
534
535    let words_a = normalize(a);
536    let words_b = normalize(b);
537
538    if words_a.is_empty() && words_b.is_empty() {
539        return 1.0;
540    }
541    if words_a.is_empty() || words_b.is_empty() {
542        return 0.0;
543    }
544
545    let intersection = words_a.intersection(&words_b).count();
546    let union = words_a.union(&words_b).count();
547
548    if union == 0 {
549        0.0
550    } else {
551        intersection as f64 / union as f64
552    }
553}
554
555/// Extract first app frame from backtrace (skip library/framework frames)
556fn extract_error_location(backtrace: &[String]) -> Option<String> {
557    // Common patterns for library/framework code to skip
558    let skip_patterns = [
559        "/gems/",
560        "/vendor/",
561        "/ruby/",
562        "/lib/ruby/",
563        "node_modules/",
564        "/usr/lib/",
565        "/usr/local/lib/",
566        "<internal:",
567        "(eval)",
568        "(irb)",
569        "/activerecord-",
570        "/activesupport-",
571        "/actionpack-",
572        "/rack-",
573        "/railties-",
574        "/bundler/",
575    ];
576
577    for frame in backtrace {
578        let is_library = skip_patterns.iter().any(|p| frame.contains(p));
579        if !is_library && !frame.trim().is_empty() {
580            // Extract file:line portion (strip method name if present)
581            // Format is usually "path/to/file.rb:123:in `method_name'"
582            if let Some(colon_pos) = frame.rfind(":in ") {
583                return Some(frame[..colon_pos].to_string());
584            }
585            // Or just "path/to/file.rb:123"
586            return Some(frame.to_string());
587        }
588    }
589
590    // Fallback to first frame if all look like library code
591    backtrace.first().map(|s| {
592        if let Some(colon_pos) = s.rfind(":in ") {
593            s[..colon_pos].to_string()
594        } else {
595            s.to_string()
596        }
597    })
598}
599
600/// Generate a location-based fingerprint from exception class and backtrace
601fn generate_location_fingerprint(exception_class: &str, backtrace: &[String]) -> String {
602    let location = extract_error_location(backtrace).unwrap_or_default();
603    format!("{}:{}", exception_class, location)
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn test_text_similarity_identical() {
612        assert_eq!(text_similarity("hello world", "hello world"), 1.0);
613    }
614
615    #[test]
616    fn test_text_similarity_completely_different() {
617        assert_eq!(text_similarity("hello world", "foo bar baz"), 0.0);
618    }
619
620    #[test]
621    fn test_text_similarity_partial_overlap() {
622        // "undefined method foo for nil" vs "undefined method bar for nil"
623        // Words: {undefined, method, foo, for, nil} vs {undefined, method, bar, for, nil}
624        // Intersection: {undefined, method, for, nil} = 4
625        // Union: {undefined, method, foo, bar, for, nil} = 6
626        // Similarity: 4/6 = 0.666...
627        let sim = text_similarity(
628            "undefined method foo for nil",
629            "undefined method bar for nil",
630        );
631        assert!(sim > 0.6 && sim < 0.7);
632    }
633
634    #[test]
635    fn test_text_similarity_case_insensitive() {
636        assert_eq!(text_similarity("Hello World", "hello world"), 1.0);
637    }
638
639    #[test]
640    fn test_text_similarity_splits_on_punctuation() {
641        // "can't find user_id!" -> words: {can, t, find, user, id}
642        // "can t find user id" -> words: {can, t, find, user, id}
643        // These should be identical
644        assert_eq!(
645            text_similarity("can't find user_id!", "can t find user id"),
646            1.0
647        );
648    }
649
650    #[test]
651    fn test_text_similarity_empty_strings() {
652        assert_eq!(text_similarity("", ""), 1.0);
653        assert_eq!(text_similarity("hello", ""), 0.0);
654        assert_eq!(text_similarity("", "hello"), 0.0);
655    }
656
657    #[test]
658    fn test_text_similarity_above_threshold() {
659        // Similar error messages should be >= 50%
660        let sim = text_similarity(
661            "PG::ConnectionBad: connection to server at \"localhost\" failed",
662            "PG::ConnectionBad: connection to server at \"192.168.1.1\" failed",
663        );
664        assert!(sim >= SIMILARITY_THRESHOLD);
665    }
666
667    #[test]
668    fn test_extract_error_location_app_frame() {
669        let backtrace = vec![
670            "/usr/local/lib/ruby/gems/3.0.0/gems/activerecord-7.0.0/lib/active_record/base.rb:123:in `find'".to_string(),
671            "/app/models/user.rb:42:in `authenticate'".to_string(),
672            "/app/controllers/sessions_controller.rb:15:in `create'".to_string(),
673        ];
674        let location = extract_error_location(&backtrace);
675        assert_eq!(location, Some("/app/models/user.rb:42".to_string()));
676    }
677
678    #[test]
679    fn test_extract_error_location_skips_gems() {
680        let backtrace = vec![
681            "/gems/rack-2.0.0/lib/rack/handler.rb:10:in `call'".to_string(),
682            "/vendor/bundle/gems/rails-7.0.0/lib/rails.rb:5:in `run'".to_string(),
683            "app/services/payment.rb:88:in `process'".to_string(),
684        ];
685        let location = extract_error_location(&backtrace);
686        assert_eq!(location, Some("app/services/payment.rb:88".to_string()));
687    }
688
689    #[test]
690    fn test_extract_error_location_no_app_frame() {
691        let backtrace = vec![
692            "/gems/activerecord-7.0.0/lib/active_record/base.rb:123:in `find'".to_string(),
693            "/vendor/bundle/gems/rails-7.0.0/lib/rails.rb:5:in `run'".to_string(),
694        ];
695        let location = extract_error_location(&backtrace);
696        // Falls back to first frame with method stripped
697        assert_eq!(
698            location,
699            Some("/gems/activerecord-7.0.0/lib/active_record/base.rb:123".to_string())
700        );
701    }
702
703    #[test]
704    fn test_extract_error_location_empty_backtrace() {
705        let backtrace: Vec<String> = vec![];
706        let location = extract_error_location(&backtrace);
707        assert_eq!(location, None);
708    }
709
710    #[test]
711    fn test_generate_location_fingerprint() {
712        let backtrace = vec!["app/models/user.rb:42:in `save'".to_string()];
713        let fingerprint = generate_location_fingerprint("ActiveRecord::RecordInvalid", &backtrace);
714        assert_eq!(
715            fingerprint,
716            "ActiveRecord::RecordInvalid:app/models/user.rb:42"
717        );
718    }
719
720    #[test]
721    fn test_generate_location_fingerprint_empty_backtrace() {
722        let backtrace: Vec<String> = vec![];
723        let fingerprint = generate_location_fingerprint("RuntimeError", &backtrace);
724        assert_eq!(fingerprint, "RuntimeError:");
725    }
726
727    #[test]
728    fn test_similar_errors_should_group() {
729        // These errors occur on the same line with similar messages
730        let msg1 = "Couldn't find User with 'id'=123";
731        let msg2 = "Couldn't find User with 'id'=456";
732        let sim = text_similarity(msg1, msg2);
733        assert!(
734            sim >= SIMILARITY_THRESHOLD,
735            "Similar errors should group: similarity = {}",
736            sim
737        );
738    }
739
740    #[test]
741    fn test_different_errors_should_not_group() {
742        // These errors have completely different messages
743        let msg1 = "undefined method 'foo' for nil:NilClass";
744        let msg2 = "PG::ConnectionBad: connection refused";
745        let sim = text_similarity(msg1, msg2);
746        assert!(
747            sim < SIMILARITY_THRESHOLD,
748            "Different errors should not group: similarity = {}",
749            sim
750        );
751    }
752}