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 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 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
82const 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 let location_fingerprint =
96 generate_location_fingerprint(&error.exception_class, &error.backtrace);
97
98 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 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 let similar_error =
119 find_similar_error(&conn, project_id, &location_fingerprint, &error.message)?;
120
121 if let Some(id) = similar_error {
122 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 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 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 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
180fn 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 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 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", };
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#[derive(Debug, Clone, Serialize)]
385pub struct ErrorTrendPoint {
386 pub hour: String,
387 pub count: i64,
388}
389
390pub 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
437pub fn error_trend_24h(pool: &DbPool, error_id: i64) -> anyhow::Result<Vec<i64>> {
439 let conn = pool.get()?;
440
441 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 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
470pub 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 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 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
525fn 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
555fn extract_error_location(backtrace: &[String]) -> Option<String> {
557 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 if let Some(colon_pos) = frame.rfind(":in ") {
583 return Some(frame[..colon_pos].to_string());
584 }
585 return Some(frame.to_string());
587 }
588 }
589
590 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
600fn 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 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 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 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 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 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 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}