# One issue with 40,000 events: grouping, fingerprints and the helper that ruined them

> Sentry groups by stack trace, so a shared fetch wrapper merges every unrelated failure into one useless issue. Fingerprints put the grouping back where the cause is.

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

```ts
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

```ts
// 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:

1. **Constant string, no interpolation.** If it contains `${`, it is wrong.
2. **Dotted segments, subsystem first.** `stripe.webhook.signature-invalid`,
   `pdf.parse-failed`, `search.upstream-timeout`. Reads well in a list, sorts
   sensibly, greps easily.
3. **Name the cause, not the location.** `checkout.tax-service-unavailable` is
   durable; `lib.checkout.ts:142` breaks on the next edit.
4. **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.
5. **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 → `denyUrls` with `chrome-extension://` and
  `moz-extension://` patterns.
- `Loading chunk N failed` after a deploy → a stale tab, already in
  `ignoreErrors`.
- `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:

1. Deploy the change.
2. Trigger the failure (or wait for it).
3. Confirm a **new** issue appears with the expected title.
4. 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
