What is event grouping (fingerprinting) in error tracking?
Event grouping is how an error tracker collapses thousands of nearly identical exceptions into a single issue. The fingerprint is the short identifier that decides what collapses with what.
20 seconds. Event grouping is the rule that turns a flood of incoming exceptions into a list of distinct issues. Each event gets a fingerprint, usually a hash of the exception type plus the top in-app stack frames. Events with the same fingerprint increment one issue’s count; events with different fingerprints open new issues. Without grouping, you would have one row per event and the list would be unreadable within an hour.
60 seconds. The default fingerprint is derived from the stack trace, not from the message. That choice is deliberate: messages often interpolate user input, request IDs, or timestamps, and grouping by message would create one issue per user. By grouping on the trace shape instead, the tracker treats every caller of the same broken function as one bug. The two failure modes are over-grouping (genuinely distinct bugs sharing a wrapper exception or a generic Exception raise site) and under-grouping (the same bug splitting across dozens of issues because a frame contains a hashed identifier or a generated class name). Every SDK exposes a fingerprint hint you can set per-capture; every modern tracker also versions its grouping algorithm so you can re-group historical events when the rule changes.
This guide covers the definition, the default algorithm, why messages are excluded, the two pathological cases, the grouping-version concept, the recent move toward embeddings-based grouping, and how urgentry implements the rules the Sentry SDK already expects.
The definition
Event grouping, in error tracking, is the process by which the backend assigns each incoming event to an issue, where an issue represents a class of bug rather than a single occurrence.
The grouping decision is made at ingest time, before the event is written to long-term storage. The backend computes a fingerprint — a short hash derived from a defined set of event fields — and looks up whether an open issue with that fingerprint already exists. If it does, the event is appended to that issue and the issue’s count and last-seen timestamp update. If it does not, a new issue is created and the event becomes its first occurrence.
The user-facing consequence is that a service throwing the same exception ten thousand times in a deploy gone wrong produces one row in the issue list with a count of ten thousand, not ten thousand rows. That collapse is the entire reason error tracking exists as a category. A log file gives you raw events; an error tracker gives you the structure on top of them.
Why grouping matters more than it looks like it should
A production service emits exceptions in bursts that follow the shape of incidents rather than the shape of bugs. One database outage might raise the same OperationalError ten thousand times across every request that touches the database during the outage. One bad deploy might raise the same TypeError on every request that hits the broken handler. Without grouping, the on-call engineer would scroll through ten thousand identical entries trying to find the one new bug that does not belong.
Grouping turns the same event stream into a list of distinct bugs ranked by impact. The on-call view becomes: five known issues with counts trending normally, one new issue introduced by the latest deploy, two issues that had been quiet for a week now spiking. That list is small enough to read in fifteen seconds, which is the only acceptable budget when something is on fire.
The grouping rule is therefore a load-bearing design decision. If it collapses too aggressively, distinct bugs hide under one issue and the on-call engineer cannot tell that a new bug shipped. If it splits too finely, one bug appears as fifty unrelated issues and the count of any single one is too small to alert on. Every tracker has tuned the rule for years, and every tracker has the same long tail of edge cases that still misfire.
How the default fingerprint is computed
The default fingerprint, on a Sentry-compatible backend, is a hash of two things: the exception type and a normalized representation of the stack trace.
The algorithm walks the stack from the innermost frame outward. For each frame, it asks: is this frame in-app, or is it in a library? The in-app determination uses a heuristic combining module paths, file paths, and a configurable allow list. Library frames are excluded from the fingerprint so that two different callers of the same broken library function group together as one bug rather than one issue per library version.
The remaining in-app frames are normalized. File paths are stripped of their working-directory prefix and version-pinned segments. Function names are kept as-is. Line numbers are intentionally excluded, because line numbers change with every refactor and including them would split one bug into one issue per release.
The exception type is appended to the normalized frame list, and the whole structure is hashed. The hash is the fingerprint. A pseudocode sketch:
def fingerprint(event):
frames = [f for f in event.stacktrace if f.in_app]
normalized = [(strip_prefix(f.module), f.function) for f in frames]
return hash(event.exception_type, tuple(normalized))
Two events with the same exception type, raised from the same in-app function in the same module, share a fingerprint. Everything else — the message string, the user ID, the timestamp, the request body, the environment tag — is ignored by the default grouping algorithm. Those fields are still stored on the event and surfaced in the issue detail view; they just do not influence which issue the event lands in.
Why messages are excluded
The message string is excluded from the default fingerprint because messages tend to interpolate per-request data that would otherwise explode an issue into one row per user.
A common pattern in handler code:
raise ValueError(f"user {user_id} not found in tenant {tenant}")
If the fingerprint included the message, this one bug would create one issue per (user_id, tenant) pair. A bug that fires for ten thousand users would create ten thousand issues, each with a count of one, and the on-call view would be unreadable. The same logic applies to messages that interpolate request IDs, trace IDs, timestamps, or any other high-cardinality identifier.
By grouping on the trace shape alone, the tracker treats the bug as one issue regardless of which user triggered it. The user IDs are still on the individual events; the issue detail view shows them in the breadcrumb trail and the tag aggregates. The grouping decision and the per-event data are kept separate on purpose.
For exceptions raised with the same type from many different call sites, the trace separates them. ValueError raised at billing.charge and ValueError raised at auth.login share a type but have different in-app frames; they get different fingerprints.
The two pathological cases
The default rule misfires in two predictable ways: wrapper exceptions that erase the underlying call site, and generic exception types raised from many places.
Wrapper exceptions that flatten distinct bugs
Many codebases wrap raw exceptions in an internal error type before re-raising. The wrapper exception’s constructor becomes the innermost in-app frame, and every wrapped error groups together as one issue regardless of what the original error was.
A Python example:
class AppError(Exception):
def __init__(self, inner):
super().__init__(str(inner))
self.inner = inner
def handler():
try:
do_work()
except Exception as e:
raise AppError(e)
With this pattern, the top in-app frame in every captured event is AppError.__init__, and the exception type is always AppError. Every bug in the application collapses into one issue. The fix is to capture the inner exception’s chain (the __cause__ attribute in Python, the cause property in JavaScript’s Error, the InnerException in .NET) and to derive the fingerprint from the innermost cause, not the outermost wrapper. Modern SDKs do this automatically when the chain is set with raise X from Y; manual wrapping that drops the chain breaks it.
Generic exception types raised from many places
The mirror image is a generic exception type with a varying message that nonetheless gets raised from the same factory function. raise Exception(reason) inside a single parse_response helper called from twenty different handlers will share one fingerprint, because the helper is the innermost in-app frame in all twenty cases.
The default algorithm groups everything under parse_response into one issue. The bug count looks like a major outage; the actual cause is twenty unrelated parse failures sharing a generic raiser. The fix is to either raise more specific exception subclasses at the call sites, or to tell the SDK to use the caller frame as the fingerprint instead of the helper. Both are valid; the second is faster to ship.
Custom fingerprints: the SDK hint
Every Sentry-compatible SDK exposes a fingerprint field on the capture call that overrides the default. The value is a list of strings; the backend hashes the strings and uses the result instead of computing its own.
The two common patterns:
import sentry_sdk
# Force grouping by a specific identifier
sentry_sdk.capture_exception(
error,
fingerprint=["payment-gateway-timeout", gateway_name],
)
# Use the default but add a discriminator
sentry_sdk.capture_exception(
error,
fingerprint=["", region],
)
The literal string is the placeholder that tells the backend to compute the default fingerprint first and append the discriminator. This is the right form when you want to keep the trace-based grouping but split on an additional axis — for example, when one bug behaves differently in different regions and you want one issue per region rather than one global issue.
The fingerprint hint takes precedence over server-side grouping rules. Use it sparingly. Most teams ship a default-only configuration and only reach for the override when they hit one of the two pathological cases above.
Server-side grouping rules and the algorithm version
Grouping is computed at ingest, and the rule used to compute it is versioned on the backend so that the algorithm can evolve without breaking historical issue links.
Sentry tracks this as the grouping_config field on each project. When the project’s grouping config changes, new events are grouped under the new rule while existing issues remain on the old one. The backend then offers a re-grouping operation that walks the historical events and re-computes their fingerprints under the new rule. Re-grouping is opt-in because it can split or merge years of accumulated issue history, and teams want to control when that happens.
The versioning matters most when you change the in-app heuristic, the frame normalization, or the exception-chain handling. A naive global swap would invalidate every link, alert, and Jira ticket that references the old issue IDs. The version field lets you stage the change.
On the urgentry side, the grouping config is stored per project, and the rule version is recorded on each issue. Re-grouping is a background job you trigger explicitly from the project settings page; existing issue IDs remain stable until you opt in.
The recent move toward embedding-based grouping
The last two years have introduced a second class of grouping technique: instead of hashing a normalized stack trace, the backend embeds the event into a vector and groups by cosine similarity against existing issues.
The motivation is the case the hash-based rule cannot reach. Two stack traces that differ only in a generated class name (Hibernate proxies, Spring AOP, JIT-compiled lambdas, Go’s anonymous function suffixes) will hash differently even though the underlying bug is the same. A trained embedding model can recognize the similarity and route both events to the same issue.
Sentry has been productizing this under the Seer brand, including an “issue summary” feature that uses embeddings to consolidate near-duplicate issues and to draft an explanation of what the bug appears to be. The hash-based rule remains the primary grouping decision; the embedding layer runs as a secondary pass that can merge or annotate.
The trade-off is determinism. The hash-based rule is reproducible: same input, same fingerprint, every time. The embedding layer can drift as the model is retrained, which means two events captured a month apart might land in different issues even if the input is identical. Self-hosted operators care about this more than SaaS users do, because the cost of debugging a moving grouping rule on a backend you run yourself is non-trivial. urgentry sticks to hash-based grouping in the default configuration for that reason; embedding-based consolidation is a planned opt-in feature, not the default.
Three antipatterns to avoid
- Including the message in your custom fingerprint. The whole reason the default excludes messages is to prevent per-user-ID issues. Adding the message back via a custom fingerprint reintroduces the problem you were trying to avoid.
- Setting the fingerprint to a constant. A constant like
["unhandled-error"]in a global error handler will collapse every distinct bug in the service into one issue. The on-call view will show one row with a count in the millions and zero useful information about what is wrong. - Ignoring the exception chain when wrapping. If you must wrap exceptions in an internal error type, preserve the chain (
raise NewError from originalin Python,new Error(msg, { cause })in JavaScript). The SDK uses the chain to derive the fingerprint from the inner cause when the outer wrapper is too generic.
Where this fits in the broader stack
Grouping sits between ingest and storage. The order of operations on a single incoming event:
- The SDK serializes the exception into the Sentry envelope format and sends it to the ingest endpoint derived from the DSN.
- The backend authenticates the request, validates the envelope, and applies any PII scrubbing or sampling rules.
- The backend computes the fingerprint using the project’s grouping config (plus any custom fingerprint hint on the event).
- The backend looks up an existing issue with that fingerprint. If one exists, it increments the count and updates the last-seen timestamp. If not, it creates a new issue.
- The event itself is written to the event store, linked to the issue.
- Alert rules are evaluated against the issue (new, regression, threshold), and notifications fire if any match.
The grouping decision is therefore the gate that determines how the on-call engineer sees the world. Everything downstream — the issue list, the alert rules, the search filters, the count graphs — depends on the fingerprint being right.
Frequently asked questions
What is fingerprinting in error tracking?
A fingerprint is a short identifier the error tracker computes from each incoming event so that events with the same fingerprint collapse into one issue. Most trackers derive it from the exception type and the top frames of the stack trace. If two crashes share the same fingerprint, they show up as the same issue, with the count incrementing instead of the issue list growing.
How does Sentry group errors by default?
Sentry walks the stack trace, picks the frames it considers in-app, normalizes file paths and function names, and hashes the result alongside the exception type. Frames in third-party libraries and standard library files are excluded so that two callers of the same library function group together rather than once per library version.
Can two errors with different messages still group together?
Yes. The default algorithm grouping is based on stack trace shape and exception type, not the message string. A NullPointerException raised from the same call site with two different user IDs in the message will still share one fingerprint. This is intentional, because user-specific data in messages would otherwise create one issue per user.
When should you override the default fingerprint?
Override the fingerprint when grouping is too aggressive (one issue swallowing distinct bugs) or too granular (one bug split across hundreds of issues). The two common cases are wrapper exceptions that erase the underlying call site, and generic error types like Exception or RuntimeError raised from many places. The SDK exposes a fingerprint hint you set inside the capture call.
What does urgentry do for grouping?
urgentry implements the same grouping rules a Sentry SDK expects, including the in-app frame heuristic, the exception-type plus stack-trace hash, and the fingerprint override hint sent from the SDK. The grouping rule version is recorded on each issue so that you can re-group historical events after a rule change without breaking links to old issues.
Sources
- Sentry event grouping documentation — the canonical reference for how the default algorithm walks the stack, applies the in-app heuristic, and produces a fingerprint.
- Sentry SDK fingerprinting reference — documents the
fingerprinthint, theplaceholder, and the rules the backend applies when both an SDK hint and a server-side rule are present. - Sentry grouping strategies source — the open-source implementation of the named grouping configurations, including the per-version normalization rules; useful for confirming exactly what changes between algorithm versions.
- OpenTelemetry exception semantic conventions — the OTLP-side reference for which attributes carry the exception type, message, and stack trace, mirrored by Sentry-compatible backends when accepting OTLP ingest.
- urgentry compatibility matrix — source-scanned audit including the grouping endpoints and the per-project
grouping_confighandling urgentry implements against the Sentry surface.
One DSN swap. Sentry-compatible grouping out of the box.
urgentry implements the in-app heuristic, the stack-trace hash, the fingerprint override hint, and the versioned grouping config the Sentry SDK already expects. Switch the DSN, keep your issues.