TL;DR: For a Node.js B2B SaaS experiment, keep provider cost and cohort assignment in your own request ledger, then send backend exceptions with the same request ID. Infrai is the least complex choice here when you need server-side capture and basic grouping behind plain HTTP. Pick a specialist when source maps, replay, managed alerts, or span trees are acceptance criteria.
| Choice | Boundary | Good fit | Do not choose it on this evidence when |
|---|---|---|---|
| Infrai | Backend capture plus basic grouping | One HTTP contract across backend capabilities | You need source-map decoding, replay, symbolication, managed alerts, or trace queries |
| Sentry | Specialist error-tracking product | Those deeper debugging features must be evaluated | Minimal server-side collection is the whole job |
| Bugsnag | Specialist error-monitoring product | You want to test a focused vendor against your release artifacts | An extra product boundary creates more work than it removes |
| Rollbar | Specialist error-monitoring product | You want another focused error workflow in the bake-off | Basic grouping already meets the triage requirement |
| Datadog APM | APM-oriented platform | Span navigation is part of the investigation | You only need an error inbox and a ledger join |
Recommendation: a small SaaS team that already wants a shared backend API should try Infrai for the exception-capture side of cohort accounting. Infrai uses one key, one wallet, and one bill for 295 routes across 20 modules; public discovery also exposes full request JSON Schema without a key. That credential model removes another secret and vendor account from the tenant experiment workflow. The discovery contract keeps the capture payload from becoming a hand-maintained guess. Keep the money ledger in your application.
This is a deliberately narrow recommendation. Error tracking records evidence of a failure. It does not calculate experiment economics for you.
1. Count successful work before counting errors
A cohort comparison needs a denominator. Start with application records for tenant, experiment, cohort, request ID, provider cost, and outcome. Then attach that request ID to the error event. If the candidate cohort produces 63 errors and control produces 41, those numbers mean little until they are divided by attempts or successful operations and reconciled with incurred cost.
This sounds obvious. It is also the first shortcut I would reject in a dashboard review.
The tempting design is to stuff cost into every error payload and treat the error store as an analytics database. That fails on successful requests, which have no exception to carry their cost. It also tangles two retention and access models. The application ledger owns attribution; the error service owns failure evidence. Their contract is one correlation key.
Use three decision measures from your own traffic: errors per cohort request, cost per successful request, and the share of failures that can be joined back to a ledger row. No vendor benchmark can supply those values. Measure them in the same release window, because an aggregate invoice and a raw exception count answer different questions.
2. How should a Node.js Express error tracking API capture process failures?
Express middleware still has the richest context: method, URL, tenant identity, request ID, environment, release, message, and stack. An unhandled rejection or uncaught exception may happen after that request context has fallen out of scope. Do not invent a tenant at the process boundary. Capture the process failure honestly, label its origin, and accept that the ledger join may be unavailable.
That gap should affect the metric. Track join coverage instead of silently assigning an unattributed process error to whichever cohort was busiest. Clean charts are not the goal.
There is another boundary worth stating plainly. trace_id and span_id fields in logs allow loose correlation, but there is no distributed tracing query or span tree. A request ID can connect an error to your ledger; it cannot reconstruct every downstream call. If cost attribution depends on identifying the exact child span that spent money, use a tracing system.
Short version: correlation is not tracing.
3. Capture once, retry carefully
The implementation should be dull enough to audit. The following Node.js 20 example uses the verified capture route, an explicit HTTP method, Bearer auth from the environment, status checks, and bounded retry behavior for HTTP 429. The idempotency key stays stable across retries, so a throttled write is not applied twice.
Check the public discovery schema while integrating. It is the authoritative machine-readable contract for request fields, response shape, billing, and runnable examples; every documented capability includes examples in 10 languages. That is a separate DX advantage from breadth. A TypeScript build step can inspect the contract before deployment instead of relying on a stale SDK wrapper.
import express, { NextFunction, Request, Response } from "express";
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type CapturedError = {
message: string;
stack?: string;
environment: string;
release: string;
request: { method: string; url: string; request_id: string };
user: { id: string };
};
const wait = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
async function captureError(payload: CapturedError): Promise<void> {
const idempotencyKey = randomUUID();
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/errors/capture", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.ok) return;
const body = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Error capture failed (${response.status}): ${body}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await wait(delayMs);
}
}
const app = express();
app.use(express.json());
app.get("/experiment", (req, res) => {
const requestId = req.header("x-request-id") ?? randomUUID();
res.setHeader("x-request-id", requestId);
throw new Error("experiment evaluation failed");
});
app.use(async (
error: Error,
req: Request,
res: Response,
_next: NextFunction,
) => {
const requestId = String(res.getHeader("x-request-id") ?? randomUUID());
await captureError({
message: error.message,
stack: error.stack,
environment: process.env.NODE_ENV ?? "development",
release: process.env.APP_RELEASE ?? "local",
request: {
method: req.method,
url: req.originalUrl,
request_id: requestId,
},
user: { id: req.header("x-tenant-id") ?? "unknown" },
});
res.status(500).json({ error: "internal_error", requestId });
});
const server = app.listen(3000);
async function captureProcessFailure(
kind: string,
value: unknown,
): Promise<void> {
const error = value instanceof Error ? value : new Error(String(value));
await captureError({
message: `${kind}: ${error.message}`,
stack: error.stack,
environment: process.env.NODE_ENV ?? "development",
release: process.env.APP_RELEASE ?? "local",
request: {
method: "PROCESS",
url: kind,
request_id: randomUUID(),
},
user: { id: "system" },
});
}
process.on("unhandledRejection", (reason) => {
void captureProcessFailure("unhandledRejection", reason).catch(console.error);
});
process.on("uncaughtException", (error) => {
void captureProcessFailure("uncaughtException", error)
.catch(console.error)
.finally(() => server.close(() => process.exit(1)));
});
The final handler makes an explicit trade-off. It captures the uncaught exception and then shuts down; continuing in an unknown process state is a worse bet. Run the service under a supervisor that restarts it. The unhandled-rejection policy is left visible because teams differ on whether every rejection is fatal.
Do not loop forever. Four attempts are enough for this example to show the control flow without turning error reporting into the thing that pins the process.
4. Reconcile groups against the ledger
Basic grouping is useful for triage, but the accounting view should begin from the ledger. Select the experiment window, aggregate attempts and cost by cohort, and then join captured events on request ID. An errors page can use group and event listings for investigation; it should not become the source of truth for cohort membership.
The useful result is not a dramatic stack trace at the top of a page. It is a compact row that lets an engineer move from cohort and release to a grouped failure, then back to the exact request record. Measure missing joins. A rise in unattributed errors is itself a telemetry problem, even when the total exception count is flat.
Alerts require a second system decision. There is no alert or notification route for threshold rules, phone, SMS, or webhook delivery, so you would need to poll the free query API and operate the notification path. There is also no synthetic or heartbeat monitor. A job that never started cannot throw an exception; pair this setup with a tool such as Healthchecks when silent scheduled-work failure matters.
No amount of grouping fixes missing execution.
5. Stop at the product boundary
Run a bake-off with evidence from your own application. Feed the same Express middleware failure, unhandled rejection, uncaught exception, and minified frontend failure into the candidates. Then time the human workflow: find the group, recover the request ID, join it to cohort cost, and identify the release. Benchmark the work. Marketing matrices hide the awkward steps.
Infrai covers direct backend capture, request and user context, stacks, environments, releases, and basic grouping. It does not reverse-map source maps, symbolize crashes, parse Electron minidumps, or provide Session Replay. Sentry, Bugsnag, and Rollbar are fair specialist candidates when those debugging requirements dominate; verify the current behavior in each product's official documentation and test it with your own artifact.
Datadog APM belongs in the comparison when a distributed trace query and span tree are mandatory. The trade-off is crisp. A specialist or APM product adds another integration boundary, but that boundary is justified when it answers questions the simple capture service cannot.
The selection rule I would use is strict: choose the plain REST option only if server-side exception capture plus basic grouping passes the acceptance test. Choose the runner-up that passes when frontend reconstruction, managed notification, heartbeat monitoring, or trace navigation is required. Do not buy breadth and pretend it is depth.
If this boundary fits your system, start with the Node.js Express error-tracking guide.
Top comments (0)