Error tracking for a small Node SaaS: capture exceptions without an SDK
A one-file Express integration on Node 22 — process handlers, fingerprints that survive restarts, sampling, and honest cost math against Sentry and Rollbar.
For a Node backend with a few thousand daily requests, error tracking is about sixty lines: two process-level handlers, one Express error middleware, and a single POST /v1/errors/capture per failure. Infrai’s errors namespace is plain REST with a bearer token, so there’s no agent to install, no SDK version to keep in step with your runtime, and nothing that monkey-patches http behind your back.
The trade is real and this page states it up front: you don’t get parsed stack frames, source-map symbolication or release health charts. If those are the features you’re shopping for, Sentry is the better buy. What you get instead is a capture route, a search route, aggregated groups, and the same key already covering the queue, cron and email that a small SaaS needs anyway.
The reporter, in one module
import { hostname } from "node:os";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const RELEASE = process.env.APP_RELEASE ?? "dev";
const ENV = process.env.NODE_ENV ?? "development";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
/** Stable across restarts, hosts and pods: the code location, not the incident. */
function fingerprintOf(err, scope) {
const frame = String(err?.stack ?? "").split("\n")[1] ?? "";
const site = frame.match(/at\s+([^\s(]+)/)?.[1] ?? "unknown";
return [scope, err?.name ?? "Error", site].join(":");
}
export async function captureError(err, { scope = "app" } = {}) {
const body = {
message: String(err?.stack ?? err?.message ?? err).slice(0, 8000),
exception: err?.name ?? "Error",
fingerprint: fingerprintOf(err, scope),
environment: ENV,
release: RELEASE,
};
try {
const res = await fetch(`${API}/v1/errors/capture`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(2000),
});
if (!res.ok) {
console.error(`[errors] capture rejected ${res.status} on ${hostname()}`);
return null;
}
const { data } = await res.json();
if (data.is_new_group) console.warn(`[errors] new group ${data.error_group_id} on ${RELEASE}`);
return data;
} catch (sendFailure) {
console.error("[errors] capture unreachable:", sendFailure.message);
return null;
}
}
process.on("unhandledRejection", (reason) => {
captureError(reason instanceof Error ? reason : new Error(String(reason)), { scope: "unhandledRejection" });
});
process.on("uncaughtException", async (err) => {
await captureError(err, { scope: "uncaughtException" });
process.exit(1);
});
Three decisions in there are worth defending. The reporter never throws — an error tracker that takes your process down when it can’t reach the network is a liability, so every failure path returns null and logs. The send has a 2,000 ms timeout, because a hung capture inside uncaughtException means the process never exits. And uncaughtException exits deliberately: Node’s own docs are clear that continuing after one leaves the process in an undefined state.
The fingerprint is built from the second line of the stack — the call site — plus the error class and a scope you pass in. Restarts, autoscaling and new pods all produce the same key, which is the whole point.
Wiring it to Express
import express from "express";
import { captureError } from "./errors.js";
const app = express();
app.use(express.json());
app.get("/api/invoices/:id", async (req, res, next) => {
try {
const invoice = await loadInvoice(req.params.id);
res.json(invoice);
} catch (err) {
next(err);
}
});
async function loadInvoice(id) {
if (!/^[0-9a-f-]{6,}$/.test(id)) throw new TypeError(`bad invoice id: ${id}`);
return { id, status: "paid" };
}
app.use((err, req, res, _next) => {
const status = err.status ?? 500;
if (status >= 500) captureError(err, { scope: `${req.method} ${req.route?.path ?? req.path}` });
res.status(status).json({ error: { message: status >= 500 ? "internal error" : err.message } });
});
app.listen(3000, () => console.log("listening on :3000"));
Note the scope: req.route.path gives /api/invoices/:id, the template — not /api/invoices/8f21. Using the concrete URL would spawn a fresh group per invoice, and by Friday you’d have five thousand groups describing one bug.
Only 5xx gets captured. A 404 or a rejected payload is your API working correctly, and paying to record it is a habit worth breaking early.
What the store keeps
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": "TypeError: bad invoice id: 8f21\n at loadInvoice (/app/src/routes/invoices.js:19:11)\n at async /app/src/routes/invoices.js:9:21",
"exception": "TypeError",
"fingerprint": "GET /api/invoices/:id:TypeError:loadInvoice",
"environment": "production",
"release": "2026.07.6"
}'
Then read the stored event back:
curl -sS "https://api.infrai.cc/v1/errors/get/evt_err_TahwBUWHs4y4wZAsHLHCbaOP" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Here’s the part nobody advertises, and we checked it against the live API rather than the docs: the stored exception object is derived from your message. It comes back as {"type": "Message", "value": "<your message>", "stacktrace": []} — the REST route does not parse frames out of the text you send. The stack trace is preserved verbatim inside message, and it’s searchable, but there is no frame table, no in-app flag and no line-of-code preview. Send the whole err.stack, because that string is the only trace you’ll have.
Finding it later
curl -sS "https://api.infrai.cc/v1/errors/search?q=loadInvoice&limit=5" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Search matches the message text, so a function name from a stack trace is a workable query. Each hit carries error_group_id and a dashboard_url; group-level history comes from GET /v1/errors/group_detail/{error_group_id} and the individual occurrences from GET /v1/errors/events/{error_group_id}.
Sizing the bill before you migrate
Capture is $0.00005 per event and every read is free — verified 2026-07-26. That makes the arithmetic dull, which is the idea:
| Events per month | Monthly capture cost | Notes |
|---|---|---|
| 10,000 | $0.50 | Well inside the $2 of new-account credit |
| 100,000 | $5.00 | A busy small SaaS with a couple of noisy loops |
| 1,000,000 | $50.00 | You have a retry storm; fix that first |
| Reads (any volume) | $0.00 | Rate-limited, not metered |
Pull today’s rate rather than trusting the table:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id == "errors.capture") | .billing'
Rates in this catalogue move down over time and discounts run, so the number you get back may be lower. What won’t change is the shape: per-event writes, free reads, no seat licences, no retention tiers, and no plan that suddenly stops accepting events on the 20th of the month.
Compare that with how the specialists price. Sentry, Rollbar and Bugsnag all sell monthly event quotas per plan, which is friendlier at tiny volumes — their free tiers genuinely cover a side project — and gets expensive precisely when a bad release triples your event count. Metered pricing has the opposite failure mode: a retry storm bills you. Sampling is the answer to both.
Sampling the loop that won’t stop
import { captureError } from "./errors.js";
const seen = new Map();
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 10;
export function captureSampled(err, ctx) {
const key = `${ctx?.scope ?? "app"}:${err?.name ?? "Error"}`;
const now = Date.now();
const state = seen.get(key) ?? { count: 0, windowStart: now, dropped: 0 };
if (now - state.windowStart > WINDOW_MS) {
if (state.dropped > 0) console.warn(`[errors] dropped ${state.dropped} duplicate ${key} events`);
state.count = 0;
state.dropped = 0;
state.windowStart = now;
}
state.count += 1;
seen.set(key, state);
if (state.count > MAX_PER_WINDOW) {
state.dropped += 1;
return Promise.resolve(null);
}
return captureError(err, ctx);
}
Ten events per minute per failure class is plenty to establish that something is broken. The group’s count will under-report, admittedly, which is the price of not paying for the four hundredth copy of the same exception.
The honest boundary
No source maps, no frame-level context, no session replay, no on-call routing, and no per-event deletion API. Teams that need minified browser traces resolved should stick with Sentry; Rollbar’s deploy tracking and Bugsnag’s stability scores are also real features with no counterpart here. This route is a good fit when your errors are server-side, your team is small, your stack traces are already readable, and you’d rather add one HTTP call than one more vendor.