Skip to main content

headless_lms_utils/
error_identifier.rs

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
18/// Normalizes dynamic values out of an error message so that errors with
19/// different UUIDs, addresses, or IDs still hash to the same identifier.
20pub fn normalize_message(message: &str) -> String {
21    // Order matters: UUIDs before long numbers (UUID contains long numeric runs).
22    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
29/// Normalizes a stack trace: strips dynamic addresses and bundler hashes,
30/// and trims each line.
31pub 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    // Strip webpack/vite/esbuild content hashes from filenames.
36    let s = BUNDLER_HASH_RE.replace_all(&s, ".{hash}.$1");
37    let s = LONG_NUMBER_RE.replace_all(&s, "{N}");
38    // Trim each line.
39    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
50/// Frames the parts of an identifier. The stored `error_variants.exact_error_identifier` values were
51/// computed with it and its meaning is documented in that column's comment, so it cannot change
52/// without orphaning every row.
53const PART_SEPARATOR: u8 = 0;
54
55/// Digests `parts` framed by [`PART_SEPARATOR`], dropping the separator wherever a part contains it
56/// itself.
57///
58/// Without that the framing is forgeable: the service name, message and stack trace of an error
59/// report are whatever the reporter sent, and a separator placed where one part ends lets a crafted
60/// report claim another error's identity and merge into its aggregate. Dropping rather than
61/// escaping keeps every identifier already stored valid, and loses nothing real, since Postgres
62/// cannot hold a null byte in a text column anyway.
63fn 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
76/// Computes a stable BLAKE3 identifier for an exact error variant.
77///
78/// Framed by [`hash_identifier`], so ("foo", "") and ("", "foo") are different variants.
79pub 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
96/// Computes a stable BLAKE3 identifier for broadly grouping related errors.
97pub 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    /// One error's message ending where the next field begins must not make it that other error, or
278    /// the two are aggregated as one and the counts of both are wrong.
279    #[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    /// The reporter chooses the message and the stack trace, so a separator byte inside one of them
288    /// would let a crafted report land on an existing error's identifier and poison its aggregate.
289    #[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    /// `exact_error_identifier` is half of a unique constraint and years of occurrence counts hang
298    /// off it, so an identifier that was computed before must still come out the same: a changed
299    /// digest orphans every stored variant and silently restarts its statistics.
300    #[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        // BLAKE3 produces 32 bytes = 64 hex chars by default
341        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}