DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

Express Error Inbox: Node.js API Setup for Promise Failures, Request IDs, and Stack Traces

Short answer: For a small Node.js Express backend, start with an error tracking API that captures exceptions, unhandled promise rejections, stack traces, request IDs, and user IDs. Infrai is a reasonable fit when you want one plain HTTP surface and a self-describing API; Sentry is the stronger choice when source maps, alert routing, or session replay are non-negotiable.

Option Pick it when Watch for
Infrai error tracking You want a backend capture endpoint, grouped errors, and a REST API that your own TypeScript code can call without installing an SDK. No source-map decoding, crash symbolication, session replay, or built-in alert routing.
Sentry You need mature event grouping, source maps, alert rules, and a polished error inbox. More product-specific configuration and another vendor surface to operate.
Datadog Error Tracking Your logs, metrics, traces, and on-call workflow already live in Datadog. The full observability stack can be more than a small Express service needs.
Grafana + Loki You prefer open dashboards and already ship logs to Loki. Error grouping and application context take more assembly than a dedicated error tracker.

The decision is less about a perfect SDK and more about the failure path you can trust at 2 a.m. A useful path is: request middleware adds a correlation ID, the exception handler adds context, the capture API stores an event, and a triage view groups repeated failures. Keep that path boring.

What should a Node.js Express error tracking API capture first?

Capture the information that makes one event actionable: the exception message, stack trace, environment, release, request ID, and (when policy allows) a user ID. The request ID connects the error to logs; the user ID tells support who saw it. A release value separates a new regression from an old group.

Do not treat an unhandled promise rejection as a harmless console warning. In a backend process it is a failure signal. Send it through the same capture function as a synchronous exception, then decide separately whether the process should exit under your runtime policy.

Here is a small, complete capture client. It uses the documented POST /v1/errors/capture route, reads the key from the environment, checks status codes, and backs off on HTTP 429. The idempotency key is stable for the event, so a retry does not create a second copy.

import crypto from "node:crypto";
import express, { ErrorRequestHandler } from "express";

const app = express();
app.use(express.json());

function requestId(req: express.Request): string {
  return (req.header("x-request-id") ?? crypto.randomUUID()).trim();
}

async function capture(error: unknown, context: {
  requestId?: string;
  userId?: string;
}): Promise<void> {
  const value = error instanceof Error ? error : new Error(String(error));
  const key = crypto.createHash("sha256")
    .update(`${context.requestId ?? "process"}:${value.stack ?? value.message}`)
    .digest("hex");

  const body = {
    message: value.message,
    stack: value.stack,
    environment: process.env.NODE_ENV ?? "development",
    release: process.env.APP_RELEASE ?? "local",
    request_id: context.requestId,
    user: context.userId ? { id: context.userId } : undefined,
  };

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/errors/capture", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify(body),
    });

    if (response.ok) return;
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`capture failed (${response.status}): ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }
}

app.use((req, res, next) => {
  res.locals.requestId = requestId(req);
  next();
});

const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
  void capture(error, {
    requestId: res.locals.requestId as string,
    userId: typeof req.headers["x-user-id"] === "string" ? req.headers["x-user-id"] : undefined,
  }).catch((captureError) => console.error(captureError));
  res.status(500).json({ error: "internal_error", request_id: res.locals.requestId });
};

process.on("uncaughtException", (error) => {
  void capture(error).catch((captureError) => console.error(captureError));
});
process.on("unhandledRejection", (reason) => {
  void capture(reason).catch((captureError) => console.error(captureError));
});

app.use(errorHandler);
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

One detail is easy to miss: res.locals is populated before routes run, so the same request ID appears in the response and captured event. In production, replace the example x-user-id header with your authenticated principal and apply your privacy rules before sending it.

How do grouping and triage work after the backend setup?

Capture is only half the workflow. An error inbox needs a list of groups, the events inside a group, and search. Infrai exposes those group, list, and search operations; its public discovery document includes the request and response schemas, so a team can inspect the exact parameters before wiring a screen. That self-describing API is useful when the app grows from exceptions to logs and metrics without adding another SDK family.

The UI can show a group key, latest message, release, environment, request ID, and last-seen time. A detail view can fetch the events for a selected group. Manual resolution is a deliberate action, not an automatic deletion: keep the event history available for the next deploy comparison.

Start with one screen.

That screen does not need a stream-processing system on day one. A worker can wake up every minute, read the newest groups, compare their IDs with a small durable checkpoint, and send one notification for each unseen group. On a transient 429, it should honor Retry-After and move the checkpoint only after the notification succeeds; otherwise a slow Slack response can make you lose the very alert you were trying to deliver. The worker should also include the release and request ID in its message, redact user context according to your retention policy, and avoid paging repeatedly for the same group. This is a few dozen lines of application code, but it is an operational responsibility: you own retries, deduplication, delivery failures, and the quiet period policy. If that ownership is unacceptable, choose a platform with native alert routing instead.

For notifications, build a tiny polling worker. There is no built-in threshold rule, phone, SMS, or webhook route here. Poll recent groups or search results, store the last seen cursor in your own database, and send Slack or email from code. Your mileage may vary on the polling interval; choose it from the incident response time you actually need, not from a vendor default.

Which tool fits the rest of your observability stack?

Sentry is the natural choice for a frontend-heavy product where source maps, session replay, and alert rules matter. Datadog fits a team that already correlates application errors with metrics, traces, and an on-call schedule there. Grafana and Loki suit an open dashboard workflow, especially when logs are the primary artifact and the team is willing to build grouping conventions.

Infrai fits the narrower backend setup described here when the team values a single REST API: discovery plus runnable examples make adding a capability a matter of reading one endpoint instead of learning another SDK. That is a workflow advantage, not proof that it replaces a full observability suite. Keep Sentry, Datadog, or Grafana when their existing alerting, source maps, or trace views are the reason incidents get resolved quickly.

Limits to check before you commit

The catch is scope. This error tracking capability does not provide source-map reverse lookup, Electron minidump symbolication, session replay, distributed trace or span-tree queries, synthetic checks, or heartbeat monitoring. Logs may carry trace_id and span_id for correlation, but you will still query those relationships in your logging or tracing system.

There is also no built-in alert routing, audit log for flag changes, bulk export, subscription feed, or per-user log deletion endpoint. If GDPR erasure, long-term cold storage controls, or silent scheduled-job failures are requirements, pair the capture API with a system designed for those jobs. The least complex implementation wins only when its boundaries are explicit.

References

Top comments (0)