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.

Raw stack trace (report A) #0 0x7f3a2c1e librender.so!draw+0x42 #1 0x7f3a2c9a librender.so!paint+0x18 #2 0x7f3a9b00 libc.so!malloc+0x1c #3 0x7f3a2d10 librender.so!scene+0x7e #4 0x7f3a9b40 libc.so!free+0x08 #5 0x7f3a2e00 librender.so!frame+0x2a #6 0x7f3a2f10 app!main+0x11 build: /home/ci/build-4821/... tid: 0x3f2a signal: SIGSEGV red = volatile frames (dropped) addresses & build paths vary per run Normalized frames librender.so!draw+0x42 librender.so!paint+0x18 (malloc dropped) librender.so!scene+0x7e (free dropped) librender.so!frame+0x2a app!main+0x11 rules: strip addr, basename module, drop volatile, collapse .isra/.constprop keep top N=8 frames Signature key anchor: librender.so!draw+0x42 path digest (SHA-1, 12 hex): a3f9c1e07b2d exception: SIGSEGV sig = draw+0x42|a3f9c1e07b2d|SIGSEGV stable across ASLR & build paths Grouping layer — aggregate by signature key draw+0x42|a3f9c1e07b2d|SIGSEGV count=37 builds=4 devices=12 paint+0x18|5e2b8d1c9a04|SIGSEGV count=9 builds=2 devices=5 scene+0x7e|b71f0a2e6c88|SIGABRT count=3 builds=1 devices=2

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']
Reports A and B differ in absolute addresses and module paths but normalize to the same frame sequence, so they share a signature. Report C has a shorter call path (the 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

ParameterDefaultEffect when increasedRisk
top_n8More frames in digest → finer separationVolatile tail frames shatter groups
collapse_templatesFalseMerges template instantiationsMay merge distinct defects in generic code
VOLATILE_MODULESallocator/runtime setDrops more noise framesOver-dropping can erase the true fault site
Do not include thread IDs, timestamps, or build numbers in the signature. They are per-report metadata and belong in the aggregation record, not the key. Including them guarantees one group per report and defeats deduplication entirely.

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_RE and is silently dropped, leaving an empty or truncated path.