1use std::sync::LazyLock;
2
3use regex::Regex;
4
5static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
6 Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
7 .expect("valid regex")
8});
9static HEX_ADDR_RE: LazyLock<Regex> =
10 LazyLock::new(|| Regex::new(r"0x[0-9a-fA-F]{6,}").expect("valid regex"));
11static TIMESTAMP_RE: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}").expect("valid regex"));
13static LONG_NUMBER_RE: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"\b\d{5,}\b").expect("valid regex"));
15static BUNDLER_HASH_RE: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"\.[0-9a-f]{8,}\.(js|css|wasm|map)").expect("valid regex"));
17
18pub fn normalize_message(message: &str) -> String {
21 let s = UUID_RE.replace_all(message, "{uuid}");
23 let s = HEX_ADDR_RE.replace_all(&s, "{addr}");
24 let s = TIMESTAMP_RE.replace_all(&s, "{timestamp}");
25 let s = LONG_NUMBER_RE.replace_all(&s, "{N}");
26 s.into_owned()
27}
28
29pub fn normalize_stack_trace(stack_trace: &str) -> String {
32 let s = UUID_RE.replace_all(stack_trace, "{uuid}");
33 let s = HEX_ADDR_RE.replace_all(&s, "{addr}");
34 let s = TIMESTAMP_RE.replace_all(&s, "{timestamp}");
35 let s = BUNDLER_HASH_RE.replace_all(&s, ".{hash}.$1");
37 let s = LONG_NUMBER_RE.replace_all(&s, "{N}");
38 s.lines().map(str::trim).collect::<Vec<_>>().join("\n")
40}
41
42pub fn canonicalize_grouping_message(normalized_message: &str) -> String {
43 normalized_message
44 .split_whitespace()
45 .collect::<Vec<_>>()
46 .join(" ")
47 .to_lowercase()
48}
49
50const PART_SEPARATOR: u8 = 0;
54
55fn hash_identifier(parts: &[&str]) -> String {
64 let mut hasher = blake3::Hasher::new();
65 for (idx, part) in parts.iter().enumerate() {
66 if idx > 0 {
67 hasher.update(&[PART_SEPARATOR]);
68 }
69 for run in part.as_bytes().split(|byte| *byte == PART_SEPARATOR) {
70 hasher.update(run);
71 }
72 }
73 hasher.finalize().to_hex().to_string()
74}
75
76pub fn calculate_exact_error_identifier(
80 service: &str,
81 error_source: &str,
82 message: &str,
83 stack_trace: Option<&str>,
84) -> String {
85 let normalized_message = normalize_message(message);
86 let normalized_stack = stack_trace.map(normalize_stack_trace);
87
88 hash_identifier(&[
89 service,
90 error_source,
91 normalized_message.as_str(),
92 normalized_stack.as_deref().unwrap_or(""),
93 ])
94}
95
96pub fn calculate_error_grouping_identifier(
98 service: &str,
99 error_source: &str,
100 message: &str,
101) -> String {
102 let normalized_message = normalize_message(message);
103 let grouping_message = canonicalize_grouping_message(&normalized_message);
104
105 hash_identifier(&[service, error_source, grouping_message.as_str()])
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn test_normalize_message_uuid() {
114 let msg = "User 550e8400-e29b-41d4-a716-446655440000 not found";
115 assert_eq!(normalize_message(msg), "User {uuid} not found");
116 }
117
118 #[test]
119 fn test_normalize_message_hex_addr() {
120 let msg = "Segfault at 0x7f2a3b4c5d6e in thread";
121 assert_eq!(normalize_message(msg), "Segfault at {addr} in thread");
122 }
123
124 #[test]
125 fn test_normalize_message_timestamp() {
126 let msg = "Request failed at 2024-01-15T10:30:00 with status 503";
127 assert_eq!(
128 normalize_message(msg),
129 "Request failed at {timestamp} with status 503"
130 );
131 }
132
133 #[test]
134 fn test_normalize_message_long_number() {
135 let msg = "Record 123456 not found";
136 assert_eq!(normalize_message(msg), "Record {N} not found");
137 }
138
139 #[test]
140 fn test_normalize_message_short_number_unchanged() {
141 let msg = "HTTP 500 error on route /api";
142 assert_eq!(normalize_message(msg), "HTTP 500 error on route /api");
143 }
144
145 #[test]
146 fn test_normalize_message_multiple_patterns() {
147 let msg = "User 550e8400-e29b-41d4-a716-446655440000 (id=987654) at 0x7f2a3b4c5d6e";
148 assert_eq!(normalize_message(msg), "User {uuid} (id={N}) at {addr}");
149 }
150
151 #[test]
152 fn test_normalize_stack_trace_hex_addr() {
153 let trace = "at process (0x00007f0a1234abcd)";
154 assert_eq!(normalize_stack_trace(trace), "at process ({addr})");
155 }
156
157 #[test]
158 fn test_normalize_stack_trace_bundler_hash_js() {
159 let trace = "at fn (app.abc12345def0.js:10:5)";
160 assert_eq!(normalize_stack_trace(trace), "at fn (app.{hash}.js:10:5)");
161 }
162
163 #[test]
164 fn test_normalize_stack_trace_bundler_hash_css() {
165 let trace = "loaded styles.abc98765def0.css";
166 assert_eq!(normalize_stack_trace(trace), "loaded styles.{hash}.css");
167 }
168
169 #[test]
170 fn test_normalize_stack_trace_bundler_hash_digits_only() {
171 let trace = "at fn (app.12345678.js:10:5)";
172 assert_eq!(normalize_stack_trace(trace), "at fn (app.{hash}.js:10:5)");
173 }
174
175 #[test]
176 fn test_normalize_stack_trace_line_trimming() {
177 let trace = " at foo (bar.js:1:1) \n at baz (qux.js:2:2) ";
178 assert_eq!(
179 normalize_stack_trace(trace),
180 "at foo (bar.js:1:1)\nat baz (qux.js:2:2)"
181 );
182 }
183
184 #[test]
185 fn test_same_error_different_uuids_same_exact_identifier() {
186 let fp1 = calculate_exact_error_identifier(
187 "main-frontend",
188 "frontend",
189 "User 550e8400-e29b-41d4-a716-446655440000 not found",
190 None,
191 );
192 let fp2 = calculate_exact_error_identifier(
193 "main-frontend",
194 "frontend",
195 "User 660f9511-f3ac-52e5-b827-557766551111 not found",
196 None,
197 );
198 assert_eq!(fp1, fp2);
199 }
200
201 #[test]
202 fn test_same_error_different_hex_addr_in_stack_same_exact_identifier() {
203 let fp1 = calculate_exact_error_identifier(
204 "headless-lms",
205 "backend",
206 "null pointer dereference",
207 Some("at 0x7f0a1234abcd"),
208 );
209 let fp2 = calculate_exact_error_identifier(
210 "headless-lms",
211 "backend",
212 "null pointer dereference",
213 Some("at 0x7f9b5678efab"),
214 );
215 assert_eq!(fp1, fp2);
216 }
217
218 #[test]
219 fn test_same_stack_different_bundler_hash_same_exact_identifier() {
220 let fp1 = calculate_exact_error_identifier(
221 "main-frontend",
222 "frontend",
223 "Cannot read property",
224 Some("at fn (app.abc12345def0.js:10:5)"),
225 );
226 let fp2 = calculate_exact_error_identifier(
227 "main-frontend",
228 "frontend",
229 "Cannot read property",
230 Some("at fn (app.fed09876543.js:10:5)"),
231 );
232 assert_eq!(fp1, fp2);
233 }
234
235 #[test]
236 fn test_different_errors_different_exact_identifiers() {
237 let fp1 = calculate_exact_error_identifier(
238 "main-frontend",
239 "frontend",
240 "Cannot read property 'foo' of undefined",
241 None,
242 );
243 let fp2 = calculate_exact_error_identifier(
244 "main-frontend",
245 "frontend",
246 "Cannot read property 'bar' of undefined",
247 None,
248 );
249 assert_ne!(fp1, fp2);
250 }
251
252 #[test]
253 fn test_source_affects_exact_identifier() {
254 let fp1 = calculate_exact_error_identifier(
255 "main-frontend",
256 "frontend",
257 "an error occurred",
258 None,
259 );
260 let fp2 =
261 calculate_exact_error_identifier("main-frontend", "backend", "an error occurred", None);
262 assert_ne!(fp1, fp2);
263 }
264
265 #[test]
266 fn test_stack_presence_affects_exact_identifier() {
267 let fp1 = calculate_exact_error_identifier(
268 "main-frontend",
269 "frontend",
270 "an error",
271 Some("at foo (a.js:1:1)"),
272 );
273 let fp2 = calculate_exact_error_identifier("main-frontend", "frontend", "an error", None);
274 assert_ne!(fp1, fp2);
275 }
276
277 #[test]
280 fn fields_that_only_differ_in_where_they_are_split_get_different_identifiers() {
281 assert_ne!(
282 calculate_exact_error_identifier("main-frontend", "frontend", "foobar", None),
283 calculate_exact_error_identifier("main-frontend", "frontend", "foo", Some("bar")),
284 );
285 }
286
287 #[test]
290 fn a_separator_byte_inside_a_field_cannot_forge_another_errors_identifier() {
291 assert_ne!(
292 calculate_exact_error_identifier("main-frontend", "frontend", "x\0y", None),
293 calculate_exact_error_identifier("main-frontend", "frontend", "x", Some("y\0")),
294 );
295 }
296
297 #[test]
301 fn an_identifier_computed_by_an_earlier_release_still_matches() {
302 assert_eq!(
303 calculate_exact_error_identifier(
304 "main-frontend",
305 "frontend",
306 "Record 123456 not found",
307 Some("at fn (app.abc12345def0.js:10:5)"),
308 ),
309 "52bb48c4ba91e36118ee65ac89da79c2ff6b6a16ccf0b405548628f495c8a80d",
310 );
311 assert_eq!(
312 calculate_error_grouping_identifier(
313 "main-frontend",
314 "frontend",
315 "Record 123456 not found",
316 ),
317 "17cb38f2ae609f927849a7591c4926b4c165e2cc7858810abb04a0607c38e52a",
318 );
319 }
320
321 #[test]
322 fn test_exact_identifier_is_deterministic() {
323 let fp1 = calculate_exact_error_identifier(
324 "headless-lms",
325 "backend",
326 "test error",
327 Some("stack trace"),
328 );
329 let fp2 = calculate_exact_error_identifier(
330 "headless-lms",
331 "backend",
332 "test error",
333 Some("stack trace"),
334 );
335 assert_eq!(fp1, fp2);
336 }
337
338 #[test]
339 fn test_exact_identifier_length() {
340 let fp = calculate_exact_error_identifier("main-frontend", "frontend", "error", None);
342 assert_eq!(fp.len(), 64);
343 }
344
345 #[test]
346 fn test_grouping_message_collapses_whitespace_and_case() {
347 let msg = " Cannot READ property {uuid} ";
348 assert_eq!(
349 canonicalize_grouping_message(msg),
350 "cannot read property {uuid}"
351 );
352 }
353
354 #[test]
355 fn test_grouping_identifier_is_case_and_whitespace_insensitive() {
356 let fp1 = calculate_error_grouping_identifier(
357 "main-frontend",
358 "frontend",
359 "Cannot read properties of undefined (reading 'foo')",
360 );
361 let fp2 = calculate_error_grouping_identifier(
362 "main-frontend",
363 "frontend",
364 " cannot read properties of undefined (reading 'foo') ",
365 );
366 assert_eq!(fp1, fp2);
367 }
368
369 #[test]
370 fn test_grouping_identifier_normalizes_dynamic_message_values() {
371 let fp1 = calculate_error_grouping_identifier(
372 "main-frontend",
373 "frontend",
374 "Request 123456 failed for user 550e8400-e29b-41d4-a716-446655440000",
375 );
376 let fp2 = calculate_error_grouping_identifier(
377 "main-frontend",
378 "frontend",
379 "Request 987654 failed for user 660f9511-f3ac-52e5-b827-557766551111",
380 );
381 assert_eq!(fp1, fp2);
382 }
383
384 #[test]
385 fn test_grouping_identifier_differs_for_different_messages() {
386 let fp1 = calculate_error_grouping_identifier(
387 "main-frontend",
388 "frontend",
389 "Cannot read properties of undefined",
390 );
391 let fp2 = calculate_error_grouping_identifier(
392 "main-frontend",
393 "frontend",
394 "Network request failed",
395 );
396 assert_ne!(fp1, fp2);
397 }
398}