One failure, four thousand issues: fixing error grouping granularity
An order id in the message text splits a single timeout into thousands of groups. How fingerprints, message normalisation and a cleanup pass fix it on Infrai.
Nothing told the tracker what “the same failure” means, so it fell back to the only thing it had: your message text. Infrai’s capture route does this explicitly — send an event with no fingerprint and grouping keys on the exact message, which the stored event marks as fingerprint_source: "default". Put an order id in that message and every order gets its own group. Send a fingerprint you chose and the server hashes that string instead, which collapses the thousands back into one.
The one-line fix is the easy part. The awkward part is the four thousand groups already sitting in the store, because this API has no merge operation — so this page covers the fix, a normaliser for the call sites you can’t edit, and how to clean up what’s already there.
Pick the granularity deliberately
There’s a ladder, and both ends of it are bad. Too coarse and every timeout in the service lands in one bucket that nobody can act on; too fine and the group list is a receipt printer.
| Grouping key | Groups you get | Reads well when | Fails when |
|---|---|---|---|
| Raw message with ids in it | One per order, user or request | Never | Always — this is the bug being fixed |
| Exception class alone | TimeoutError across the whole app | You have one service and three endpoints | Two unrelated timeouts merge and stay merged |
| Service + operation + class | payments:charge:TimeoutError | Most of the time | Two distinct causes share one operation |
| Service + operation + class + failing dependency | payments:charge:TimeoutError:gateway-timeout | You want the group to name the fix | You start encoding the whole request |
| Normalised message text | Roughly one per distinct message shape | You can’t edit every call site | Your normaliser misses a format |
Row four is where we’d land for a payment timeout. It survives a reworded error string, it splits gateway timeouts from database timeouts, and it reads like a to-do item.
The payload that causes the splintering
This is the shape to stop sending — no fingerprint, and a unique number embedded in the text:
{
"message": "TimeoutError: payments gateway timed out after 30000ms for order 41192",
"exception": "TimeoutError",
"environment": "production",
"release": "payments-2026.07.9"
}
Three of those with different order numbers produced three separate groups in our testing — errgrp_FjexM8OHnvrU2WvcOxqq21DJ, errgrp_RIgUEnkykspOlziSK5fYffkF, errgrp_NExHqPzBHB73lJ1wXrJo2GGY — which is precisely the reported symptom, working as designed.
The fix at the call site
Keep the order id in the human-readable message if you want it; just stop letting it decide the grouping:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"message": "TimeoutError: payments gateway timed out after 30000ms (order 41233)",
"exception": "TimeoutError",
"fingerprint": "payments:charge:TimeoutError:gateway-timeout",
"environment": "production",
"release": "payments-2026.07.9"
}'
{
"ok": true,
"data": {
"event_id": "evt_err_7V1QK2QyFTloXv4ZlowLwu8Z",
"fingerprint": "ddbf49c80a07c6276e23f5053237915d53447d4b8823c592d020dc7f261ad840",
"error_group_id": "errgrp_p0w8rDNm0ul4Ub0AQ045lxXJ",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_7V1QK2QyFTloXv4ZlowLwu8Z"
}
}
The digest that comes back is SHA-256 of the string you sent, not the string itself, and it’s stable across processes and deploys — two machines sending payments:charge:TimeoutError:gateway-timeout hit the same group without coordinating.
When you can’t edit every call site
Legacy code paths, a vendor SDK, a shell script somebody wrote in 2023: sometimes the message arrives with ids baked in and you need to derive the key from the text. Normalise before you hash.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is not set");
const SCRUBBERS = [
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<uuid>"],
[/\b[0-9a-f]{16,}\b/gi, "<hex>"],
[/\b\d{4,}\b/g, "<n>"],
[/\b\d+(\.\d+)?(ms|s|MB|GB)\b/gi, "<duration>"],
[/"[^"]{0,80}"/g, '"<str>"'],
[/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g, "<email>"],
];
export function normalise(message) {
return SCRUBBERS.reduce((text, [pattern, token]) => text.replace(pattern, token), message)
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
}
export async function captureNormalised(service, operation, error) {
const shape = normalise(`${error.name}: ${error.message}`);
const res = await fetch("https://api.infrai.cc/v1/errors/capture", {
method: "POST",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({
message: `${error.name}: ${error.message}`,
exception: error.name,
fingerprint: `${service}:${operation}:${shape}`,
environment: process.env.APP_ENV ?? "production",
release: process.env.RELEASE ?? "payments-2026.07.9",
}),
});
if (!res.ok) throw new Error(`capture failed: HTTP ${res.status} ${await res.text()}`);
return res.json();
}
// "TimeoutError: payments gateway timed out after 30000ms for order 41192"
// -> fingerprint "payments:charge:TimeoutError: payments gateway timed out after <duration> for order <n>"
console.log(normalise("TimeoutError: payments gateway timed out after 30000ms for order 41192"));
Test that function against a hundred real messages from your store before you trust it. A scrubber that’s slightly too aggressive merges unrelated failures, and merging is the one direction you can’t undo.
Cleaning up the groups you already have
Find the splinters first. Search is free-text over events and requires a non-empty q — an empty one returns 400 INVALID_FILTER_SYNTAX — and it returns events, so collapse them by group id yourself:
curl -sS "https://api.infrai.cc/v1/errors/search?q=payments%20gateway%20timed%20out&limit=100" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; items=json.load(sys.stdin)['data']['items']; print(sorted({i['error_group_id'] for i in items}))"
Then close each old group. There’s no merge route, so “cleanup” means marking the splinters resolved and letting the new fingerprint carry the traffic from here:
curl -sS -X POST "https://api.infrai.cc/v1/errors/resolve/errgrp_FjexM8OHnvrU2WvcOxqq21DJ" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{}'
That returns the updated group rather than a bare acknowledgement, with is_resolved: true on it. Two caveats before you loop it over four thousand ids. Resolve is one call per group and the read routes are rate-limited, so pace it — a small delay between calls is kinder than a burst. And a resolved group auto-unresolves if a matching event arrives again, which is the behaviour you want for regressions and a nuisance during cleanup: resolve the splinters after the fingerprint fix is deployed, not before.
Check the count came down:
curl -sS "https://api.infrai.cc/v1/errors/groups?status=unresolved&limit=1" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['total'], 'unresolved groups')"
What this API doesn’t give you
No server-side grouping rules, no UI merge, no retroactive regrouping. Sentry lets you edit grouping rules and merge issues after the fact, and Datadog’s error tracking applies its own similarity algorithm over stack traces — if you want the platform to make grouping decisions for you and correct them later, those are the products that do it. The trade-off here is the opposite: the key is yours, the server does nothing clever with it, and a mistake is fixed by deploying a different string rather than by learning a rule syntax.
Grouping calls cost nothing extra either way. Search, groups, group detail and resolve are free and rate-limited; only capture is metered, at $0.00005 per event verified 2026-07-26, with rates that trend downward over time:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id=="errors.capture") | .billing'
Fixing the fingerprint doesn’t reduce the bill — you’re capturing the same number of events — but it turns four thousand unactionable rows into one row with a count of four thousand, which is the only version anyone reads.