Stable crash grouping: keep the device out of the fingerprint

One bug becoming 300 issues is a key made of environment facts. How to normalise a mobile stack trace into a fingerprint, and a metric that catches it breaking.

Your grouping key contains facts about the phone. That’s the entire diagnosis: if a Pixel 8 on Android 15 at line 412 produces a different key than a Galaxy S23 on Android 14 at line 409, you’ll get one issue per device-build combination and the count is combinatorial. A stable fingerprint is a pure function of the bug — the exception type and the app code path that raised it — and everything else is an attribute you filter by afterwards. Infrai’s error surface takes that literally: grouping is whatever you put in the fingerprint field, so the normalisation below is yours to own, and its metrics routes give you a cheap way to prove it’s still working.

That trade is worth naming up front. Crashlytics and Sentry derive the key for you from parsed frames, which is less work and less control; here you decide, which is more work and considerably more predictable across releases.

What’s in the key that shouldn’t be

Environment factWhere it belongsWhy it can’t be in the key
Device model, OS versionTagMultiplies groups by your device matrix
Line and column numbersDroppedEvery refactor forks the group
Memory addresses, thread idsDroppedUnique per crash — one group per user
App release / build numberTagYou want to see the bug span releases
Framework and runtime framesDropped from selectionThe bug is in your code, not in libdispatch
Interpolated ids in the messageScrubbedorder 4471 not found is one bug, not 4,471

Line numbers are the subtle one. They feel like part of the identity of a crash, and they’re the single biggest source of fragmentation after device model, because obfuscated builds renumber frames on every compile.

Normalising a trace into a fingerprint

Pick the topmost frame that belongs to your code, strip everything positional, and hash a small tuple:

import { createHash } from "node:crypto";

const APP_PREFIXES = ["com.acme.", "AcmeApp/", "app://"];
const NOISE = [
  /0x[0-9a-f]{6,}/gi,          // memory addresses
  /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, // uuids
  /\b\d{3,}\b/g,               // ids, counts, timestamps
];

const isAppFrame = (f) => APP_PREFIXES.some((p) => (f.module ?? f.file ?? "").startsWith(p));

function scrub(text) {
  return NOISE.reduce((s, re) => s.replace(re, "?"), String(text ?? ""))
    .replace(/<[^>]{1,40}>/g, "<T>")   // generic type parameters
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, 200);
}

export function fingerprint(crash) {
  const frames = crash.stacktrace?.frames ?? [];
  const culprit = [...frames].reverse().find(isAppFrame) ?? frames.at(-1) ?? {};
  const parts = [
    crash.type ?? "UnknownError",                       // NullPointerException
    (culprit.module ?? culprit.file ?? "unknown").replace(/\.(kt|java|swift|m)$/, ""),
    culprit.function ?? "unknown",                       // NOT culprit.lineno
    scrub(crash.value),
  ];
  return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
}

console.log(fingerprint({
  type: "NullPointerException",
  value: "Attempt to read field 'id' on null object reference for order 4471",
  stacktrace: { frames: [
    { module: "android.os.Handler", function: "dispatchMessage", lineno: 106 },
    { module: "com.acme.checkout.CartFragment", function: "onBind", lineno: 412 },
  ] },
}));

Frames arrive innermost-last in most formats, which is why the search runs in reverse. Note what isn’t in parts: no line number, no device, no build. Two users on different phones with the same null field land on the same sixteen hex characters, and a refactor that moves onBind down thirty lines doesn’t fork the issue.

For obfuscated Android builds, feed the deobfuscated symbol into culprit.function or accept that grouping resets whenever R8 remaps names. Swift’s mangled generics need the same treatment — that’s what the <T> collapse in scrub is for.

Don’t over-merge on the way back

The opposite failure is easier to cause than people expect. Fingerprint on the exception type alone and every NullPointerException in the app becomes one 40,000-event issue that nobody can act on. Include the module and function; that’s what keeps distinct bugs distinct.

A quick sanity rule: if a group’s events span more than two or three call paths, your key is too coarse. If a single call path has produced more than a handful of groups since the last release, it’s too fine.

Where the key goes

The routes that accept it are in the errors namespace, and the honest way to look them up is to ask the API rather than trust a blog post:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.namespace == "errors") | {id, method, path, billable: .billing.is_billable}]'

The capture route takes a body of this shape, and the field that decides everything is fingerprint:

{
  "title": "NullPointerException in CartFragment.onBind",
  "message": "Attempt to read field 'id' on null object reference",
  "fingerprint": "a3f19c2b71d84e05",
  "level": "error",
  "release": "7.4.2",
  "environment": "production",
  "tags": { "device": "Pixel 8", "os": "android-15", "locale": "en-GB" },
  "user_id": "u_88213"
}

One limitation to plan around: the server won’t derive a key from your frames. Send no fingerprint and grouping falls back to the title and message, so unscrubbed interpolated ids will fragment exactly as they did before. Stack frames aren’t retained as a grouping input either — if you want frame-based clustering computed for you, that’s a job for a specialist crash reporter.

A metric that catches the fingerprint breaking

Here’s the part most teams skip. Grouping quality degrades silently: it breaks in the release where you changed the obfuscation config, and nobody notices until triage feels heavy. So publish two numbers per release and watch the ratio.

curl -sS -X POST "https://api.infrai.cc/v1/metrics/batch" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
        "points": [
          { "name": "app.crash.events", "value": 471, "type": "counter",
            "tags": { "release": "7.4.2", "platform": "android" } },
          { "name": "app.crash.groups", "value": 355, "type": "gauge",
            "tags": { "release": "7.4.2", "platform": "android" } }
        ]
      }'
{ "ok": true, "data": { "accepted": 2 } }

Then read them back — free, and one call each:

curl -sS "https://api.infrai.cc/v1/metrics/query?name=app.crash.groups&agg=sum&tag.release=7.4.2&tag.platform=android" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "name": "app.crash.groups",
    "agg": "sum",
    "points": [{ "ts": "2026-07-26T01:31:13.451506Z", "value": 355.0 }]
  }
}

Events per group is the health signal. Release 7.4.1 sat at 430 events across 12 groups — about 36 events each. Release 7.4.2 has a similar 471 events spread over 355 groups, roughly 1.3 each, and a ratio that collapses toward 1 while the event count stays flat means the fingerprint fragmented, not that the app got buggier. Alert on that ratio and you’ll catch a broken key in hours instead of at the next triage meeting.

Read the agg field back before you trust the value, incidentally — the supported set is avg, sum, count, p50 and p99, and an unsupported one like max is quietly answered with the mean.

What this costs, and who does it better

Metric points are $0.001 each and reads are free; two points per release is a rounding error, and new accounts start with $2 of credit. Error capture is metered separately and far cheaper per event, which matters when a bad release produces tens of thousands. Verified 2026-07-26 — get today’s numbers with:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Rates here move downward over time and campaigns run, so what you read may be lower than what’s printed.

If you want frame parsing, symbolication of iOS dSYMs, ProGuard mapping upload and grouping computed server-side, buy it — Crashlytics is free and does this well for mobile, and Sentry’s grouping engine is the most configurable of the lot. Datadog Error Tracking and New Relic Mobile both fold crash grouping into an existing observability contract, which is the right call if you’re already paying for one.

The case for owning the key yourself is narrower and honest: you get grouping that doesn’t change when a vendor tunes its algorithm, one credential across crashes, metrics, logs and the email that pages someone, and no mapping-file upload step in your release pipeline. You give up symbolication. For a team that already deobfuscates in CI, that’s usually a fair trade.

References

Browse more metrics developer guides