Error grouping, search and resolve: a lighter Sentry or Rollbar alternative
How fingerprint grouping, event search, group detail and one-call resolve work over plain REST on Infrai, and where Sentry, Rollbar or Bugsnag still earn their price.
If what you want from an error tracker is four things — group identical failures, search the raw events, open one group in detail, mark it resolved — Infrai’s errors namespace does exactly that over nine REST routes and no SDK. You send a fingerprint you chose, the server hashes it and aggregates by it, and the whole triage loop is POST /v1/errors/capture, GET /v1/errors/search, GET /v1/errors/group_detail/{error_group_id}, POST /v1/errors/resolve/{error_group_id}.
That’s a smaller product than Sentry, Rollbar or Bugsnag, and for a two-engineer B2B SaaS shipping server errors from a handful of Node services, smaller is usually the point. What follows is the loop end to end, with the responses this API really returns, plus the cases where we’d tell you to buy the specialist instead.
Grouping is a decision about who owns the key
Every error tracker has to answer one question: when are two stack traces the same problem? Sentry answers it with grouping rules over the stack trace and lets you override them; Rollbar runs a fingerprinting algorithm on the exception class, message and top frames. Both are good at it, and both mean the vendor owns your grouping until you learn their override syntax.
Infrai flips the default. You send the grouping key.
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": "PostgresError: deadlock detected\n at renderInvoice (/app/src/billing/invoice.js:118:11)",
"exception": "PostgresError",
"fingerprint": "billing:renderInvoice:deadlock",
"environment": "production",
"release": "2026.07.6"
}'
{
"ok": true,
"data": {
"event_id": "evt_err_Vd9aQ6KfgXpuIOmMM9FMBToL",
"fingerprint": "d6f52ac95d4419d70b8271fecc649da25e59f618ecb97047e12462ac476e8558",
"error_group_id": "errgrp_wTyAm2QYumxweOXGhyNlvgiL",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_Vd9aQ6KfgXpuIOmMM9FMBToL"
}
}
The digest coming back is SHA-256 of the string you sent, so billing:renderInvoice:deadlock stays a stable key across restarts, hosts and releases without your raw key being readable in the store. is_new_group is the field to alert on: true means this shape of failure has never been seen on this account, which on a release you shipped twenty minutes ago is the definition of a regression.
A fingerprint with a customer id or an order number in it is a mistake you make once. It splits one problem into four hundred groups, and no aggregation view can put them back together.
| Grouping behaviour | Sentry | Rollbar | Bugsnag | Infrai errors |
|---|---|---|---|---|
| Default key | Server-side rules over stack frames | Algorithmic, class + message + frames | Algorithmic, error class + location | The fingerprint string you send |
| Override mechanism | Fingerprint rules, SDK hook | Custom fingerprint field | Custom grouping hash | There’s nothing to override |
| Frame-level source maps | Yes | Yes | Yes | Not supported |
| Regression signal | Regression detection on resolved issues | Reactivation | Reopened on new occurrence | is_new_group, plus auto-unresolve |
| Integration cost | SDK per runtime | SDK per runtime | SDK per runtime | One HTTPS POST |
Finding the event you can only half describe
Search is free-text over the stored events, and it takes one required parameter:
curl -sS "https://api.infrai.cc/v1/errors/search?q=deadlock&limit=3" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"event_id": "evt_err_Vd9aQ6KfgXpuIOmMM9FMBToL",
"error_group_id": "errgrp_wTyAm2QYumxweOXGhyNlvgiL",
"timestamp": "2026-07-26T00:17:10.650852Z",
"level": "error",
"title": "PostgresError: deadlock detected",
"environment": "production",
"release": "2026.07.6",
"tags": {}
}
],
"next_cursor": "2",
"total": 2
}
}
Here’s a caveat we found in our own testing that the reference doesn’t spell out: GET /v1/errors/search honours q, limit and cursor, but adding environment or level to a search URL doesn’t narrow the result set. The structured filters live on the listing route instead — GET /v1/errors/list?environment=staging&level=warning&release=2026.07.5 filters properly, and GET /v1/errors/groups?status=unresolved filters groups. So the working pattern is: search when you remember a word from the message, list when you know the facet.
Paginate on the opaque next_cursor, never on an offset you compute.
Group detail, then resolve, then watch it come back
curl -sS "https://api.infrai.cc/v1/errors/group_detail/errgrp_wTyAm2QYumxweOXGhyNlvgiL" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The group carries count, user_count, first_seen_at, last_seen_at, is_resolved, the environments and releases arrays, and a representative_event with the full stored payload — enough to size the blast radius before anyone opens a terminal. Closing it out is one call, and the group id belongs in the path:
curl -sS -X POST "https://api.infrai.cc/v1/errors/resolve/errgrp_wTyAm2QYumxweOXGhyNlvgiL" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"error_group_id": "errgrp_wTyAm2QYumxweOXGhyNlvgiL"}'
Resolve hands back the whole group with is_resolved flipped to true. The behaviour worth knowing about is what happens next: capture another event with the same fingerprint and the group auto-unresolves, its count increments, and is_new_group comes back false. A fix that didn’t hold reopens itself without a webhook, a cron job or a human.
The triage loop as a script
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function call(path, init = {}) {
const res = await fetch(`${API}${path}`, { headers, ...init });
const payload = await res.json();
if (!res.ok || payload.ok === false) {
throw new Error(`${path} -> ${res.status} ${payload?.error?.code ?? "unknown"}`);
}
return payload.data;
}
export async function noisiestUnresolved(limit = 5) {
const { groups } = await call(`/v1/errors/groups?status=unresolved&limit=${limit}`);
return groups
.sort((a, b) => b.count - a.count)
.map((g) => ({
id: g.error_group_id,
title: g.title.split("\n")[0],
count: g.count,
users: g.user_count,
releases: g.releases,
ageHours: Math.round((Date.now() - Date.parse(g.first_seen_at)) / 36e5),
}));
}
export async function closeOut(groupId) {
const group = await call(`/v1/errors/resolve/${groupId}`, {
method: "POST",
body: JSON.stringify({ error_group_id: groupId }),
});
return group.is_resolved;
}
const top = await noisiestUnresolved();
for (const g of top) console.log(`${g.count.toString().padStart(5)} ${g.releases.join(",") || "-"} ${g.title}`);
Node 22 runs that as-is — global fetch, top-level await, no dependencies. Twenty-eight lines is roughly the whole client library you need.
What it costs, and what it doesn’t cover
Capture is the only billable route in the namespace, at $0.00005 per event — verified 2026-07-26 — and every read (list, search, get, groups, group_detail, events) is free but rate-limited. New accounts start with $2 of credit, which is about 39,999 captures before you’ve spent anything. Read today’s figure rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id | startswith("errors.")) | {id, billable: .billing.is_billable, unit: .billing.unit}'
Rates here move down over time and discount campaigns run, so what you read may well be lower than what’s printed above. The structural facts outlive the number: writes are metered per event, reads aren’t, and the same key that captures an error also reaches the queue, the cron scheduler, object storage and transactional email — so the alert you want to send about that group doesn’t need a second vendor.
Now the honest boundary. Infrai’s errors routes don’t do source-map symbolication, release health, session replay, per-event deletion, or alert routing with on-call schedules. If minified frontend stack traces are the thing you’re buying, Sentry is a better product and you should pay for it. If you already run Datadog for infrastructure, folding errors into it beats adding a second pane. Bugsnag’s stability scores are genuinely useful for mobile release decisions, and nothing here replaces them. PostHog’s comparison of error tracking tools is a fair read on that whole market.
Where this API wins is the small B2B SaaS with server-side errors in the US and EU, a team that would rather write eight lines of fetch than adopt an SDK per runtime, and a bill that already covers the other five things the app needs.