2. Stack Trace Deduplication Using Signature Hashing
Why raw stack traces cannot be grouped directly
Two crash reports that describe the same defect almost never look byte-identical. The same fault in librender.so may appear with different absolute load addresses (ASLR), different build paths (/home/ci/build-4821/... vs /opt/app/...), different thread IDs, and different inlined-frame expansions depending on the symbolizer version. If you group on the raw trace string, you get one group per report and no aggregation at all. The deduplication module's job is to reduce each trace to a stable signature: a short, deterministic key that is identical for the same defect and different for different defects.
The design tension is precision versus recall. Too aggressive normalization (dropping all arguments, truncating to the top frame) merges distinct bugs into one bucket and hides regressions. Too conservative normalization keeps volatile data in the key and shatters one bug into hundreds of groups. This case builds a signature function with an explicit, tunable normalization policy and a grouping layer that aggregates counts per signature.
Signature anatomy
A practical signature is composed of three parts, each with a different stability profile:
- Fault anchor — the crashing frame: module basename, function name, and offset within the function (not the absolute address). This is the highest-signal component and should almost never be dropped.
- Call-path digest — a hash over the normalized sequence of the top N frames (default N = 8), excluding volatile frames. This distinguishes two crashes that share a faulting function but arrive via different code paths.
- Exception class — the signal or exception type (e.g.,
SIGSEGV,NullPointerException), which separates, for example, a null dereference from a stack overflow in the same function.
Frame normalization is where most of the engineering effort lives. The rules applied per frame are: strip the absolute address and keep only module!function+offset; reduce module paths to their basename; drop frames whose module matches a volatile allowlist (system allocators, JIT regions, sanitizer runtimes); collapse compiler-generated suffixes such as .isra.0, .constprop.1, and lambda numbering; and remove template argument lists that vary by instantiation only when the policy flag collapse_templates is set.
Implementing the signature function
The module below is self-contained: it defines the normalization policy, computes the signature, and groups a batch of reports. It uses only the Python standard library so it can run as-is. The input is a list of report dictionaries with a frames list (each frame a string like "0x7f3a2c1e librender.so!draw+0x42") and an exception field.
import hashlib
import re
from collections import defaultdict
# Volatile modules whose frames carry no defect signal.
VOLATILE_MODULES = {"libc.so", "libpthread.so", "libasan.so", "libjvm.so"}
# Compiler-generated suffixes that vary without changing the defect.
SUFFIX_RE = re.compile(r"\.(isra|constprop|part|cold)\.\d+$")
LAMBDA_RE = re.compile(r"\{lambda\([^)]*\)#\d+\}")
FRAME_RE = re.compile(r"^(?:0x[0-9a-fA-F]+\s+)?([^!]+)!([^+]+)(?:\+(0x[0-9a-fA-F]+))?$")
def normalize_frame(raw, collapse_templates=False):
"""Return 'module!function+offset' or None if the frame is volatile."""
m = FRAME_RE.match(raw.strip())
if not m:
return None
module, func, offset = m.group(1), m.group(2), m.group(3) or "+0x0"
module = module.split("/")[-1] # basename only
if module in VOLATILE_MODULES:
return None
func = SUFFIX_RE.sub("", func)
func = LAMBDA_RE.sub("{lambda}", func)
if collapse_templates:
func = re.sub(r"<[^>]*>", "", func)
return f"{module}!{func}{offset}"
def compute_signature(report, top_n=8, collapse_templates=False):
frames = []
for raw in report["frames"]:
norm = normalize_frame(raw, collapse_templates)
if norm is not None:
frames.append(norm)
if len(frames) == top_n:
break
if not frames:
return None
anchor = frames[0]
path = "\n".join(frames)
digest = hashlib.sha1(path.encode("utf-8")).hexdigest()[:12]
return f"{anchor}|{digest}|{report['exception']}"
def group_reports(reports, **kwargs):
groups = defaultdict(list)
for r in reports:
sig = compute_signature(r, **kwargs)
if sig is not None:
groups[sig].append(r["report_id"])
return dict(groups)
Two details matter for stability. First, the anchor is taken from the first surviving frame after volatile frames are removed, not from frame #0 blindly — otherwise a crash whose top frame is malloc would anchor on the allocator and merge with unrelated defects. Second, the digest is computed over the newline-joined normalized path, so frame order is preserved and two traces with the same frames in different order produce different signatures.
Verifying stability and separation
Run the module against a small fixture that contains the same defect twice with different addresses and build paths, plus a genuinely different defect. The expected observable behavior is: the two same-defect reports land in one group, and the different defect lands in its own group.
reports = [
{"report_id": "A", "exception": "SIGSEGV", "frames": [
"0x7f3a2c1e librender.so!draw+0x42",
"0x7f3a2c9a librender.so!paint+0x18",
"0x7f3a9b00 libc.so!malloc+0x1c",
"0x7f3a2d10 librender.so!scene+0x7e"]},
{"report_id": "B", "exception": "SIGSEGV", "frames": [
"0x55d1e0f0 /opt/app/librender.so!draw+0x42",
"0x55d1e1a0 /opt/app/librender.so!paint+0x18",
"0x55d1f000 libc.so!malloc+0x1c",
"0x55d1e220 /opt/app/librender.so!scene+0x7e"]},
{"report_id": "C", "exception": "SIGSEGV", "frames": [
"0x7f3a2c1e librender.so!draw+0x42",
"0x7f3a2c9a librender.so!paint+0x18",
"0x7f3a2d10 librender.so!scene+0x7e"]},
]
groups = group_reports(reports)
for sig, ids in groups.items():
print(sig, "->", ids)
Expected output (the digest values are deterministic for this input):
librender.so!draw+0x42|<digest>|SIGSEGV -> ['A', 'B']
librender.so!draw+0x42|<digest2>|SIGSEGV -> ['C']
scene frame is absent), so its path digest differs even though the anchor matches. This is the intended behavior: same fault site, different call path means a different signature.Policy knobs and their trade-offs
| Parameter | Default | Effect when increased | Risk |
|---|---|---|---|
top_n | 8 | More frames in digest → finer separation | Volatile tail frames shatter groups |
collapse_templates | False | Merges template instantiations | May merge distinct defects in generic code |
VOLATILE_MODULES | allocator/runtime set | Drops more noise frames | Over-dropping can erase the true fault site |
Aggregation record
Grouping is only useful if the group carries enough context for triage. For each signature, emit a record with the report count, the distinct builds and devices observed, the first and last seen timestamps, and one representative report ID. This record is the hand-off to the frequency and trend analysis stage; the signature itself stays a pure function of the trace so it remains reproducible across runs and machines.
- Pass condition: re-running the module on the same batch yields identical signatures and identical group membership.
- Separation check: a known-different defect (different anchor or different path) must not share a signature with the target defect.
- Failure symptom: every report forms its own group — inspect whether volatile frames or absolute addresses leaked into the key; the usual cause is a frame string that fails
FRAME_REand is silently dropped, leaving an empty or truncated path.