Fingerprinting
How events are grouped into issues — and how to override grouping when the default isn't right for your codebase.
Every incoming event is hashed into a 16-character fingerprint. Events with the same fingerprint roll up into a single issue — same fingerprint, same row in the dashboard. Get the fingerprinting right and your dashboard reflects real bugs; get it wrong and one bug looks like ten, or ten bugs look like one.
How the default fingerprint is computed
For events with an exception (the common case), the algorithm walks these inputs in order until it has enough signal:
- User-supplied fingerprint always wins. If your SDK sets
event.fingerprint = ["…"], that becomes the hash input directly and steps 2–4 are skipped. - Exception type —
TypeError,NetworkError, etc. Plus the exception message with variable content (UUIDs, hex, quoted strings, numbers) replaced by placeholders, so "Cannot read properties of undefined (readingorder_abc123)" and "…(readingorder_xyz789)" collapse to the same fingerprint. - Top in-app stack frames — the first 3 frames where
in_app === true, byfunction+filename basename. Vendor frames are ignored so a small bundle change in a library doesn't split your existing issue. - Fallback — if there's no usable stack (a bare message, a CSP violation, a custom
captureMessage), we use the message text after the same variable-content normalization.
The whole input is prefixed with an algorithm version (v1 today). If we ever change how grouping works, the prefix bumps to v2, which gives every event a new namespace — old issues keep their existing IDs and stay grouped exactly as they were.
When the default groups errors that should be separate
The classic case: a single helper throws the same TypeError from a dozen call sites in the codebase. The top in-app frame is the helper, so all twelve roll into one issue with no way to tell which caller is the noisy one.
The fix is to add the caller's identity to the fingerprint:
Sentry.withScope((scope) => {
scope.setFingerprint(["{{ default }}", "checkout-form"]);
throw new TypeError("validateAddress: city is empty");
});The string {{ default }} is a Sentry-compat sentinel that GlitchReplay also honors — it tells the resolver "take the default fingerprint, then append my extra parts." The result is per-call-site issues without losing the "same exception type" signal.
When the default splits errors that should be one
The other classic: every release ships with a different bundle hash, so the basename in the stack frame changes from chunk-abc123.js to chunk-def456.js. The function name is the same, the line is the same, but the basename flips and you get a new issue per release.
Two ways to handle this:
- Set release tags consistently and upload source maps. The fingerprint algorithm prefers the resolved (deminified) function name and original filename when source maps are uploaded, which collapses across releases automatically. See the source-maps guide.
- Force a custom fingerprint. If the resolved frame is still varying (e.g., your code-splitting strategy genuinely produces different paths), pin it manually:
A literal string with noSentry.withScope((scope) => { scope.setFingerprint(["checkout-validate-address-v1"]); throw new TypeError("validateAddress: city is empty"); });{{ default }}overrides the full algorithm — every event with this fingerprint, regardless of stack or message, becomes one issue.
When to use a tag instead
Tags are for splitting views of issues; fingerprints are for splitting the issues themselves. If you want all checkout errors in one issue but want to filter by region, that's tags:
Sentry.setTag("region", "us-east-1");
Sentry.setTag("checkout_step", "address");Use setFingerprint when two events should be different rows in the dashboard. Use setTag when they should be the same row but you want a faceted view.
Per-platform examples
Node.js / serverless
Same Sentry-compat API, no scope wrapper needed if you only want one fingerprint per call:
try {
await processOrder(orderId);
} catch (err) {
Sentry.captureException(err, {
fingerprint: ["{{ default }}", "process-order", String(orderId).slice(0, 4)],
});
throw err;
}Python (sentry-sdk)
import sentry_sdk
with sentry_sdk.push_scope() as scope:
scope.fingerprint = ["{{ default }}", "checkout-validate"]
raise ValueError("city is empty")Go (sentry-go)
hub := sentry.CurrentHub().Clone()
hub.Scope().SetFingerprint([]string{"{{ default }}", "checkout-validate"})
hub.CaptureException(err)Verifying the fingerprint
Every event payload includes the final fingerprint after normalization. After a deploy, trigger your test error and inspect the captured event — either via Copy for LLM on the issue or via the API:
curl https://glitchreplay.com/api/v1/issues/<issue_id> \
-H "Authorization: Bearer grt_…" \
| jq '.fingerprint, .event_count'Then trigger the same error from a different call site (or with a different fingerprint string) and confirm a new issue appears. If two events that should be separate keep merging, your fingerprint string isn't making it onto the event — usually a scope leak (you set the fingerprint on the wrong scope, or didn't pop it).
Re-grouping existing events
Fingerprints are computed at ingest time, so changing your setFingerprint call affects future events only — existing issues keep their old IDs. To force a re-group of already-captured events, you'll need to mark the old issue as resolved (it stops attracting new events with the same hash) and let the next-arriving event create a fresh issue with the updated fingerprint.
Related: SDK setup · Source maps · Blog: how to think about fingerprinting at scale