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');
}
How normalisation collapses events into fingerprints On the left, four raw events differ only by an order number and a line and column position, and a fifth event has the same message but a different boundary scope. Arrows lead through a normalise box. On the right, the first four merge into one fingerprint while the fifth becomes a second, separate fingerprint owned by another feature. raw events …order 88142 · :1:882 …order 90311 · :1:882 …order 88142 · :1:904 …order 12007 · :1:904 same message, scope: admin-export normalise digits · quotes · positions normalise scope differs fingerprint 4a91c2f0 4 events · 1 issue · checkout fingerprint b7203e14 1 event · 1 issue · admin

Step-by-step #

  1. Normalise numbers and quoted strings first. These are the two inputs that most often carry per-occurrence values. reading 'total' for order 88142 becomes reading 'S' for order N.
  2. Drop framework frames. react-dom, scheduler and 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.
  3. 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.
  4. 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.
  5. Do not include the release. Tempting, but it forks every issue on every deploy. Send release as a payload field and let the backend show “first seen in 4.19.0” instead.
  6. 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' }));
  });
}
Which occurrences are sent under power-of-two sampling A row of thirty-two tick marks representing occurrences of one fingerprint. Ticks one, two, three, four, eight, sixteen and thirty-two are tall and solid, marked sent in full. All other ticks are short, marked counted only. A box on the right shows one aggregate event carrying the total of thirty-two. occurrences of one fingerprint in a session 1 4 8 16 32 tall = sent in full · short = counted locally only aggregate on hide { 4a91c2f0: 32 } one small beacon 7 reports stored, 32 counted

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.

Dashboard before and after fingerprint tuning Left panel labelled before shows eight thin rows each marked one event, implying a long list continues. Right panel labelled after shows four taller rows, each carrying an occurrence count and an affected-session count. before — grouped on the raw message after — normalised inputs plus scope 412 issues, each "1 event", no owner checkout-summary · 318 events · 74 sessions admin-export · 61 events · 3 sessions dashboard-panel · 22 events · 19 sessions search-remote · 11 events · 11 sessions

Verification #

  1. 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.
  2. Deploy a whitespace-only change. Issue counts must not reset. If they do, positions are still leaking into the hash.
  3. Force the same error from two boundaries. Confirm two fingerprints with the correct scopes — this is the ownership property.
  4. 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.
  5. Check the aggregate arrives. Hide the tab (switch tabs, do not close it) and confirm the visibilitychange beacon 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.