This page tunes the grouping and volume controls introduced in Error Telemetry & Crash Reporting, where the payload and delivery layers are defined.
The exact problem #
Two failure shapes make a crash dashboard useless, and they are opposites.
Over-splitting. One bug produces four hundred issues because the message contains an order number — Cannot read properties of undefined (reading 'total') for order 88142 — so every affected order gets its own row. Nobody notices the pattern, and the issue with the highest count looks like a rare edge case.
Over-merging. Every TypeError in the application lands in one issue because the grouping key is just the error name. It has thousands of events, four different root causes, and no owner. It gets muted.
Between them sits a third problem: a single user in a render loop can emit thousands of events for a bug that affects one person, distorting every impact metric on the dashboard. Fingerprinting fixes the first two; sampling with counting fixes the third.
Zero-to-working fingerprint #
// fingerprint.ts
const FRAMEWORK_FRAME = /node_modules|react-dom|scheduler|webpack|vite\/deps|\/@fs\//;
interface FingerprintInput {
name: string;
message: string;
stack?: string;
scope: string; // the catching boundary — 'checkout-summary'
}
export function fingerprintOf({ name, message, stack, scope }: FingerprintInput): string {
return hash32([name, normaliseMessage(message), scope, ...topFrames(stack)].join('|'));
}
/** Values change per occurrence; shape does not. Keep the shape only. */
function normaliseMessage(message: string): string {
return message
.replace(/\b\d+(\.\d+)?\b/g, 'N') // 88142 → N
.replace(/'[^']*'/g, "'S'") // 'total' → 'S'
.replace(/"[^"]*"/g, '"S"')
.replace(/\bhttps?:\/\/\S+/g, 'URL')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
}
/** Two application frames, positions removed so a diff does not fork the issue. */
function topFrames(stack: string | undefined): string[] {
if (!stack) return [];
return stack
.split('\n')
.slice(1)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !FRAMEWORK_FRAME.test(line))
.slice(0, 2)
.map((line) => line.replace(/:\d+:\d+\)?/g, ')').replace(/\?[a-z0-9]+/gi, ''));
}
function hash32(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, '0');
}
Step-by-step #
- Normalise numbers and quoted strings first. These are the two inputs that most often carry per-occurrence values.
reading 'total' for order 88142becomesreading 'S' for order N. - Drop framework frames.
react-dom,schedulerand bundler-injected frames are identical for every crash in the app and contribute nothing. Removing them promotes your own code into the top two frames. - Strip line and column positions. Without this, a formatting change resets every issue in the dashboard on the next deploy, and your regression history evaporates.
- Add the boundary scope. This is what separates the same helper failing under checkout from the same helper failing under an admin export. Different owners, different urgency.
- Do not include the release. Tempting, but it forks every issue on every deploy. Send
releaseas a payload field and let the backend show “first seen in 4.19.0” instead. - Hash last. Everything above is about choosing inputs; the hash function itself only needs to be deterministic and cheap.
| Fingerprint input | Include? | Why |
|---|---|---|
error.name |
Yes | Cheap separator between TypeError and NetworkError |
| Normalised message | Yes | Carries the failure shape without the values |
| Top two application frames | Yes | Locates the code without over-fitting |
| Boundary scope | Yes | Assigns ownership; splits shared utilities by feature |
| Route pattern | Sometimes | Useful when one component renders on many routes; noisy otherwise |
| Release / build id | No | Forks every issue on every deploy |
| Raw message | No | One issue per input value |
| Line and column numbers | No | Resets history on cosmetic diffs |
Sampling that keeps impact honest #
Grouping fixes the dashboard; sampling keeps the pipeline affordable. The rule below sends the first three occurrences of a fingerprint in full, then only on powers of two — so a loop is visible as 4, 8, 16, 32 rather than as four thousand identical rows.
// sampler.ts
const FULL_PER_FINGERPRINT = 3;
const SESSION_CAP = 25;
const counts = new Map<string, number>();
let sentThisSession = 0;
export function shouldSend(fingerprint: string): boolean {
const n = (counts.get(fingerprint) ?? 0) + 1;
counts.set(fingerprint, n);
if (sentThisSession >= SESSION_CAP) return false;
if (n > FULL_PER_FINGERPRINT && (n & (n - 1)) !== 0) return false; // not a power of two
sentThisSession += 1;
return true;
}
/** Loop heuristic: many occurrences of one fingerprint in a short window. */
export function looksLikeLoop(fingerprint: string, withinMs = 5000): boolean {
const n = counts.get(fingerprint) ?? 0;
return n >= 10 && Date.now() - sessionStart < withinMs * 12;
}
/** Send totals once, when the page is hidden — accurate impact, tiny cost. */
export function installAggregateFlush(endpoint: string, sessionId: string): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden' || counts.size === 0) return;
const body = JSON.stringify({ sessionId, counts: Object.fromEntries(counts) });
navigator.sendBeacon?.(endpoint, new Blob([body], { type: 'text/plain' }));
});
}
Edge cases #
| Situation | Effect on grouping | Handling |
|---|---|---|
| Error rethrown by a wrapper with a new message | Two fingerprints for one root cause | Preserve cause and fingerprint on the innermost error |
Third-party script errors (Script error.) |
One giant opaque issue | Group separately by origin; do not mix with first-party |
| Same component rendered on ten routes | One issue, ambiguous location | Add route pattern to the inputs for that scope only |
| Error inside a shared design-system component | One issue owned by nobody | Scope is the consuming boundary, so ownership follows the feature |
| Localised error messages | One issue per language | Fingerprint on an error code, not the display message |
Minified name collapsed to Error |
Weak separator | Set explicit err.name on domain errors you throw yourself |
The localisation row catches teams out late: as soon as a translated string reaches an error message, grouping splits by locale. Throwing typed domain errors with a stable code — and letting the UI localise separately — is the durable fix, and it composes with the propagation rules in Error Propagation Strategies.
Verification #
- Replay a day of raw events through the new fingerprint offline. Count distinct fingerprints before and after; a healthy tuning collapses hundreds of rows into tens without any row exceeding a few unrelated root causes.
- Deploy a whitespace-only change. Issue counts must not reset. If they do, positions are still leaking into the hash.
- Force the same error from two boundaries. Confirm two fingerprints with the correct scopes — this is the ownership property.
- Simulate a render loop. Arm a repeating fault with the harness from Testing & Fault Injection for Error Boundaries and confirm the network shows roughly seven reports for thirty-two occurrences, plus one aggregate on hide.
- Check the aggregate arrives. Hide the tab (switch tabs, do not close it) and confirm the
visibilitychangebeacon lands with the full count.
Frequently asked questions #
Why did one bug split into dozens of separate issues?
Because a variable value is inside the fingerprint — an order id in the message, or a line and column that shifted with a deploy. Normalise digits and quoted strings, and strip positions from frames before hashing.
Should the client or the server compute the fingerprint?
The client: only it knows the boundary scope, route pattern and recovery outcome. Send the fingerprint as a field. A backend that re-derives grouping from a minified stack gives different answers on every deploy.
Does sampling hide how many users are affected?
Only if you sample without counting. Keep per-fingerprint counters locally and flush totals as one aggregate when the page is hidden, so impact stays accurate while you store a fraction of the reports.
Related #
- Error Telemetry & Crash Reporting — the surrounding pipeline and delivery guarantees
- Building a Structured Error Payload for Crash Reports — the fields the fingerprint is computed from
- Managing State Reset After Uncaught Promise Rejections — fixing the loops that sampling makes visible