Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 78 additions & 5 deletions crates/trapfall-core/src/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ use trapfall_proto::{Event, Exception, Fingerprint, StackFrame};
/// 3. Fallback: hash (event_id — should never happen)
pub fn derive_fingerprint(event: &Event) -> Fingerprint {
if let Some(exceptions) = &event.exception {
if let Some(exc) = exceptions.values.first() {
if let Some(exc) = exceptions.values.last() {
return fingerprint_exception(exc);
}
}

if let Some(msg) = &event.message {
let mut components = vec![msg.clone()];
let mut components = vec![normalize_message(msg)];
if let Some(env) = &event.environment {
components.push(env.clone());
}
Expand All @@ -32,12 +32,14 @@ fn fingerprint_exception(exc: &Exception) -> Fingerprint {
let mut components = vec![exc.exc_type.clone()];

if let Some(st) = &exc.stacktrace {
if let Some(frame) = top_in_app_frame(&st.frames) {
if let Some(frame) = top_in_app_frame(&st.frames).or_else(|| st.frames.last()) {
// Tagged so (function=None, file=Some) and (function=Some, file=None)
// cannot hash identically.
if let Some(func) = &frame.function {
components.push(func.clone());
components.push(format!("function:{func}"));
}
if let Some(file) = &frame.filename {
components.push(file.clone());
components.push(format!("file:{file}"));
}
}
}
Expand All @@ -52,6 +54,36 @@ fn top_in_app_frame(frames: &[StackFrame]) -> Option<&StackFrame> {
frames.iter().rev().find(|f| f.in_app)
}

/// Replace volatile tokens (UUIDs, hex ids, numbers) so messages that differ
/// only by identifiers group together, e.g. `user 42 not found`.
fn normalize_message(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
let mut token = String::new();
let flush = |token: &mut String, out: &mut String| {
if token.is_empty() {
return;
}
let has_digit = token.chars().any(|c| c.is_ascii_digit());
let hexish = token.len() >= 8 && token.chars().all(|c| c.is_ascii_hexdigit() || c == '-');
if has_digit && (hexish || token.chars().all(|c| c.is_ascii_digit() || c == '.')) {
out.push_str("<n>");
} else {
out.push_str(token);
}
token.clear();
};
for c in msg.chars() {
if c.is_alphanumeric() || c == '-' || c == '.' {
token.push(c);
} else {
flush(&mut token, &mut out);
out.push(c);
}
}
flush(&mut token, &mut out);
out
}

fn hash_components(components: &[String]) -> Fingerprint {
let refs: Vec<&str> = components.iter().map(|s| s.as_str()).collect();
let hash = blake3_hash(&refs);
Expand Down Expand Up @@ -202,4 +234,45 @@ mod tests {
let fp = derive_fingerprint(&event);
assert!(fp.hash.len() == 64);
}

#[test]
fn fingerprint_uses_last_exception_in_chain() {
let mut e = make_exception_event("Inner", Some("f"), Some("a.rs"));
let outer = make_exception_event("Outer", Some("f"), Some("a.rs"));
e.exception.as_mut().unwrap().values.extend(outer.exception.unwrap().values);
assert_eq!(derive_fingerprint(&e).components[0], "Outer");
}

#[test]
fn fingerprint_falls_back_to_last_frame_when_none_in_app() {
let mut a = make_exception_event("E", Some("f1"), Some("a.rs"));
let mut b = make_exception_event("E", Some("f2"), Some("b.rs"));
for ev in [&mut a, &mut b] {
ev.exception.as_mut().unwrap().values[0].stacktrace.as_mut().unwrap().frames[0].in_app = false;
}
assert_ne!(derive_fingerprint(&a).hash, derive_fingerprint(&b).hash);
}

#[test]
fn fingerprint_function_vs_file_not_ambiguous() {
let a = make_exception_event("E", Some("x"), None);
let b = make_exception_event("E", None, Some("x"));
assert_ne!(derive_fingerprint(&a).hash, derive_fingerprint(&b).hash);
}

#[test]
fn fingerprint_message_normalizes_ids_and_numbers() {
let mk = |m: &str| {
let mut e = make_exception_event("x", None, None);
e.exception = None;
e.message = Some(m.into());
derive_fingerprint(&e)
};
assert_eq!(mk("user 42 not found").hash, mk("user 9001 not found").hash);
assert_eq!(
mk("job 3f2b8c1e-1111-2222-3333-444455556666 failed").hash,
mk("job aaaaaaaa-1111-2222-3333-444455556666 failed").hash
);
assert_ne!(mk("user 42 not found").hash, mk("user 42 not allowed").hash);
}
}
Loading
Loading