DEV Community

Great Work
Great Work

Posted on Originally published at runheed.com

Why does my error tracker show one bug as hundreds of different errors?

Originally published at runheed.com.

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.

fingerprint.ts

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

FAQ

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.

Related

Top comments (0)