Two symptoms, opposite in shape, same underlying cause.
Symptom A. One issue titled Error: Request failed with 40,000 events and
a stack trace whose top frame is fetchJson. It covers your payment provider
timing out, your search index returning 500, and a typo in a URL. Resolving it
resolves all three. Nobody can tell what is actually broken.
Symptom B. Four hundred separate issues, each with one event, all titled
things like Order 8f21c not found, Order 44b90 not found. The same bug,
fragmented into a list nobody can read.
Both are grouping problems, and both are fixed with a fingerprint.
How Sentry decides
By default Sentry groups on the stack trace: the frames in your own code (after removing library frames) plus the exception type. When there is no usable stack, it falls back to the exception message.
That default is good, and it fails in exactly two situations:
- A shared abstraction sits between the cause and the report. A
fetchJson, a retry wrapper, a database client, a generic error handler. The top frames are identical for every caller, so everything merges. - The message contains a variable. With no stack (a thrown string, a
captureMessage), the message is the grouping key, so a message containing an id creates one issue per id.
Fixing symptom A: fingerprint the cause
import { captureHandled } from "@/lib/observability/sentry";
try {
return await fetchJson(searchUrl);
} catch (error) {
captureHandled(error, {
fingerprint: "search.upstream-failure",
tags: { provider: "algolia", status: String(statusOf(error)) },
});
return fallbackResults();
}
captureHandled sets ["{{ default }}", fingerprint]. Two elements, doing two
different jobs:
"{{ default }}"keeps Sentry's own stack-based grouping active inside the group, so two genuinely different failures under the same fingerprint can still split.- your string forces a separation that the stack trace could not express.
Now the payment timeout, the search 500 and the URL typo are three issues, each with a name that says what is broken.
Fixing symptom B: constant message, variable in the tags
// Wrong: the id is in the grouping key.
Sentry.captureMessage(`Order ${orderId} not found`);
// Right: constant message, variable moved to a tag.
captureProblem("Order not found", {
fingerprint: "orders.not-found",
tags: { source: "webhook" },
});
The message is what groups; anything variable in it fragments the issue. Ids, timestamps, URLs with path parameters, user names, and interpolated counts all belong in tags or context, never in the message.
Writing a fingerprint that stays useful
Rules, in the order they get broken:
- Constant string, no interpolation. If it contains
${, it is wrong. - Dotted segments, subsystem first.
stripe.webhook.signature-invalid,pdf.parse-failed,search.upstream-timeout. Reads well in a list, sorts sensibly, greps easily. - Name the cause, not the location.
checkout.tax-service-unavailableis durable;lib.checkout.ts:142breaks on the next edit. - One per class of failure. If a fingerprint gathers two problems you would fix differently, split it. If two fingerprints always get fixed together, merge them.
- Never include an id, a URL with parameters, or a timestamp. A variable fingerprint means one issue per occurrence, which is the same as having no error tracking at all.
The three places grouping can be changed
There is a fingerprint hierarchy, and the wrong tool creates work:
- In code, per capture (
scope.setFingerprint). Precise, versioned, reviewable. Applies only to new events. Use this by default. - Sentry's Issue Grouping settings: fingerprint rules and stack trace rules, applied at ingest, project-wide. Right for patterns you cannot reach from code, such as a vendor SDK's errors. It is configuration living outside your repository, so leave a comment in code pointing at it.
- Merging issues in the UI. A one-off cleanup for issues that already exist. It does not affect how future events group, if you merge without fixing the cause, the split returns tomorrow.
Cardinality is the thing to watch
A fingerprint scheme fails in two directions. Too few distinct values and you have symptom A back. Too many and you have symptom B.
A useful check: after a week, sort issues by event count. Anything with tens of thousands of events under one fingerprint is under-split. A page of issues with one event each and near-identical titles is over-split. Both are fixable in minutes once you look.
The same discipline applies to tags, for a different reason: tags are indexed, so a high-cardinality tag (an order id, an email) is expensive and turns the tag list into a scroll of noise. Tags should have tens of values, not millions.
Noise that is not a grouping problem
Some issues should not be grouped better; they should not be sent:
- Browser extension errors →
denyUrlswithchrome-extension://andmoz-extension://patterns. Loading chunk N failedafter a deploy → a stale tab, already inignoreErrors.AbortError,ECONNRESET→ the user navigated away.NEXT_REDIRECT,NEXT_NOT_FOUND→ framework control flow, thrown by design.
Filter those at the source. A fingerprint on noise is a tidier way of storing something you did not want.
Verifying a change
Fingerprints apply to new events only, so:
- Deploy the change.
- Trigger the failure (or wait for it).
- Confirm a new issue appears with the expected title.
- Resolve or delete the old catch-all issue once nothing new lands in it.
If new events keep joining the old issue, the fingerprint is not being applied, usually because the capture happens somewhere other than where you set the
scope, or because an outer catch reports the same error again without it.