Build the small internal dashboard first: an unresolved production inbox, a group drill-down, and an acknowledge/resolve action are enough for a solo SaaS to start operating an AI agent reliably. The deciding constraint is cost attribution. An edtech agent can fail after several model calls, so the useful screen is the one that keeps the error, stack trace, request metadata, latency, and call cost close enough to investigate together.
TL;DR: keep the first version boring. List unresolved groups, search their returned data by message or environment, inspect one group, and resolve it after verification. Choose a managed error product when source maps, replay, alert routing, or richer triage will save more founder hours than the custom screen costs.
How should a Node.js error tracking admin page list production errors?
An ordinary exception list answers “what broke?” My agent loop also needs “which run broke, after how much work, and where did the time go?” Infrai specifies per-call cost, vendor, and latency metadata across its native and OpenAI-compatible AI surfaces. Its error endpoints provide the separate inbox and drill-down needed for the operational side. Keeping a stable contract while the provider behind a capability changes is useful here: model routing can move without forcing the admin tool to learn another client library.
That still does not make error tracking the system of record for revenue or billing. I would attach a stable run identifier to application request metadata, retain the AI call metadata in the application's own run record, and use that identifier while investigating the corresponding error. This is correlation, not accounting magic.
I first expected four workflows to belong in version one: list, search, inspect, and resolve. Then I separated the write path. A read-only page can be useful before its access controls are perfect; a resolve button cannot. For a one-person product that ships weekly, the revenue-per-hour test is harsher: can this page shorten the next production investigation enough to justify another afternoon of maintenance? The three read workflows pass that test immediately. Resolution follows after authentication and an audit decision. Custom assignment rules, elaborate charts, and a home-built notification designer do not. That is an explicit trade: less UI now, fewer ways to hide an incident later.
There is another boundary. The lightweight API does not provide built-in threshold, phone, SMS, or webhook notifications. Polling for new critical groups belongs in a small admin worker if alerts are required. It also does not supply a distributed-trace query or span tree, source-map decoding, crash symbolication, Session Replay, or heartbeat monitoring. Logs may carry trace and span identifiers for correlation, but that is not a trace explorer.
Short scope. Clear exit.
The smallest working Node.js implementation
This example deliberately uses two read routes. It renders the returned JSON rather than pretending undocumented response fields exist, and its search walks the returned group objects so message and environment values can be found without coupling the page to a guessed schema. Set INFRAI_API_KEY, then run the file with a TypeScript runner such as tsx.
import { createServer } from "node:http";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = ["https://api", "infrai", "cc"].join(".");
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function api(url: URL): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await wait(delayMs);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Error API returned ${response.status}: ${body}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Error API remained rate limited after retries");
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[character] ?? character);
}
createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://localhost:3000");
const groupId = url.searchParams.get("group");
const query = (url.searchParams.get("q") ?? "").toLowerCase();
const dataUrl = groupId
? new URL(`/v1/errors/group_detail/${encodeURIComponent(groupId)}`, baseUrl)
: new URL("/v1/errors/groups", baseUrl);
const data = await api(dataUrl);
const serialized = JSON.stringify(data, null, 2);
const visible = !query || serialized.toLowerCase().includes(query);
const title = groupId ? `Error group ${groupId}` : "Production errors";
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<style>body{font:16px system-ui;max-width:960px;margin:40px auto;padding:0 20px}input{padding:8px;width:24rem}pre{white-space:pre-wrap;background:#f4f4f4;padding:16px}</style></head>
<body><h1>${escapeHtml(title)}</h1>
<form><input name="q" value="${escapeHtml(query)}" placeholder="Search message or environment"><button>Search</button></form>
${visible ? `<pre>${escapeHtml(serialized)}</pre>` : "<p>No matching returned data.</p>"}
</body></html>`);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
response.end(message);
}
}).listen(3000, () => {
process.stdout.write("Dashboard listening on http://localhost:3000\n");
});
The ugly JSON is intentional. It proves authentication, rate-limit handling, error surfacing, list retrieval, filtering, and drill-down before any React table starts consuming the week. To inspect a group, open the page with its identifier in the group query parameter. The retry loop makes at most four attempts and starts its exponential fallback at 500 milliseconds, while honoring Retry-After when the service supplies it. Once the returned schema is familiar, replace the pre element with explicit columns and links.
The third workflow, resolution, should be a POST action with a confirmation step. A write retry needs an idempotency key so a repeated request cannot double-apply; the platform's convention defines that header and a 24-hour default deduplication window. I would add this only after the read path is useful, because a hand-built button with weak access control is worse than resolving an issue in a trusted console.
A fair build-or-buy decision
The choice is less about logo preference than missing workflow cost.
| Option | Best fit | Boundary that changes my choice |
|---|---|---|
| Small internal Node.js page | A founder needs a production inbox, raw group detail, local search, and basic resolution | The team owns access control, polling, UI maintenance, and alert delivery |
| Sentry | Browser or full-stack teams need documented source maps, tracing, or Session Replay alongside issue tracking | More product surface and configuration than a tiny admin inbox |
| Bugsnag | Teams want a dedicated error-monitoring workflow instead of maintaining their own triage UI | Cost attribution for an AI loop still belongs in application run data |
| Rollbar | Teams want a managed error product with source-map handling and established notification integrations | It introduces another vendor contract and operational console |
| Datadog | Teams want error tracking inside a broader managed monitoring platform | A broad platform is a larger operational commitment than one internal inbox |
| Grafana | Teams already operate a telemetry stack and want errors near dashboards and traces | Integration and operations remain team responsibilities |
| Better Stack | Small teams want managed logs, errors, and incident response in one external service | AI-call cost attribution still needs a stable run record in the application |
| Healthchecks | Scheduled agent jobs need a dead-man's-switch signal when expected work never starts | It complements error tracking; it does not replace exception grouping or event detail |
This is why “build versus buy” is the wrong binary. I can keep a thin operational view close to the edtech admin area and still use Healthchecks for silent scheduled-job failures. If frontend debugging becomes important, Sentry's documented replay and source-map capabilities can justify adoption. Bugsnag and Rollbar deserve the same evaluation against the team's actual language stack, notification needs, retention requirements, and privacy review, using their current documentation rather than a feature checklist frozen in an article.
Infrai is a strong fit when the stable REST contract matters more than a polished error-triage suite, especially if the application already uses the same key and metadata surface for AI calls. It is a poor fit as the sole observability tool when the deciding requirement is paging, trace-tree analysis, browser replay, symbolication, or missed-job detection.
What I would change at scale
First, I would stop searching serialized objects and map the verified response schema into a small view model. Then I would persist a cursor or last-seen marker in the polling worker, define severity rules in application code, and send alerts through an independently managed channel. The worker must be observable itself. A heartbeat service is the clean answer for “the poller never ran,” because an error inbox cannot report a process that emitted no error.
At two engineers, an ownership field and audit trail start to matter. At five, permission boundaries, retention policy, export, and incident handoff can dominate the original UI work. The lightweight surface has no distributed trace explorer, no notification routes, and no source-map or replay layer; those are product boundaries, not weekend backlog items I would casually promise to implement.
Privacy can force the decision sooner. Error payloads and request metadata may contain student or account data, so collection should be minimized before ingestion. A system without a per-user log deletion interface or a bulk export/subscription interface requires a deliberate data-governance review. No dashboard convenience cancels that obligation.
My decision rule is simple: keep the internal page while it answers the next support question in minutes and consumes little maintenance time. Move to a managed specialist when one missing capability repeatedly delays releases or incident response. Shipping weekly matters. So does knowing when infrastructure has quietly become the product.
References
- The Twelve-Factor App, “Logs”: https://12factor.net/logs
- Sentry documentation, “Source Maps”: https://docs.sentry.io/platforms/javascript/sourcemaps/
- Sentry documentation, “Session Replay”: https://docs.sentry.io/product/explore/session-replay/
- Bugsnag documentation: https://docs.bugsnag.com/
- Rollbar documentation: https://docs.rollbar.com/
- Datadog Error Tracking documentation: https://docs.datadoghq.com/error_tracking/
- Grafana documentation, “Error tracking”: https://grafana.com/docs/grafana-cloud/monitor-applications/application-observability/error-tracking/
- Better Stack documentation: https://betterstack.com/docs/
- Healthchecks documentation: https://healthchecks.io/docs/
Top comments (0)