Why does my error tracker show one bug as hundreds of different errors?
Error trackers group repeats of a bug by a fingerprint, usually built from the error type, the message and where it was thrown. If the message holds a value that changes each time, like a user ID, an email, a timestamp or a duration, every occurrence gets a new fingerprint. You get a new-error alert per user and a list nobody can read. If the fingerprint includes the line number, an edit higher up the file makes every error below it look new. Replace the changing values before hashing, and leave the line number out.
What splits one bug into many
| Message | What changes each time |
|---|---|
| User 8124 not found | The ID |
| Request to payments.example timed out after 10032ms | The duration |
| connect ECONNREFUSED 10.0.0.4:5432 | The address and port |
| No account for ann@example.com | The email |
| Order 6f1c2a9e-4b7d-4e2a-9c51-3d8e0b7a1f42 failed | The UUID |
| Expired at 2026-11-07T04:00:12.123Z | The timestamp |
Build output does it too. Bundlers put content hashes in file names, such as chunk-3f9a1c2b.js or .next/server/chunks/8123.js, so the same frame gets a new file name on every deploy.
Normalize before hashing
Replace the values that change, then hash. Order matters: replace UUIDs, timestamps and emails before plain numbers, or the number rule eats them first.
import crypto from 'node:crypto';
const RULES: Array<[RegExp, string]> = [
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>'],
[/\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?/g, '<time>'],
[/[\w.+-]+@[\w-]+\.[\w.-]+/g, '<email>'],
[/\b0x[0-9a-f]+\b/gi, '<hex>'],
// Numbers on their own or stuck to a unit: 8124, user_4821, 10032ms. E11000 and utf8 stay.
[/(^|[^a-z0-9])\d+(?:\.\d+)*(ms|s|m|h|kb|mb|gb|b)?(?![a-z0-9])/gi, '$1<n>'],
];
export function normalize(message: string): string {
let out = message;
for (const [re, to] of RULES) out = out.replace(re, to);
return out;
}
export function fingerprint(type: string, message: string, file?: string, fn?: string): string {
const parts = [type, normalize(message), file ?? '', fn ?? ''];
return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 32);
}Keep the words that tell bugs apart. ECONNREFUSED and ETIMEDOUT are different problems, and so are E11000 and E11001, and reading 'total' and reading 'items'. A rule that strips every digit or every quoted string merges them.
Leave the line number out
File and function are enough to place an error. With the line number in the fingerprint, adding an import at the top of a file changes the fingerprint of every error below it. They all look new, and a bug you marked fixed doesn't reopen when it comes back, because it now has a different fingerprint.
Use the first frame from your own code, not from a library, so the same bug reached through two call paths into a shared helper still groups by where your code went wrong.
Test both directions
- Pairs that must group: the same message with different IDs, durations, emails and timestamps, and the same frame in two different bundle chunks.
- Pairs that must stay apart: different error codes, different property names, different functions.
- Run the old and new fingerprint over a day of real events and count the groups before you switch. Existing groups will split from new ones once, so tell people.
Questions
- Should I add a custom fingerprint for some errors?
- Yes, when you know better than the rules: one group per third-party API, or splitting a generic error by its cause. Runheed's ingest takes a fingerprint array that replaces the computed one.
- Does this hide real differences?
- It can. Two bugs that differ only in a number, such as two different HTTP status codes in the message, merge. Put the code in the error type or a custom fingerprint when it matters.