DEV Community

RemingtonCross5246
RemingtonCross5246

Posted on

Why I Chose Node.js Express Error Tracking (Backend Capture Setup)

My Node.js Express error tracking setup has to capture backend exceptions from an AI agent loop after several paid model calls, so an error without cost context is only half an incident. I need failures grouped by cause, with each event tied to its release, environment, request, permitted user identifier, and agent run. That constraint changed my choice.

TL;DR: For a small Node.js B2B SaaS, I would send server exceptions to a grouped error tracker and keep cost attribution in event context. Infrai fits when one REST contract across many backend capabilities matters, but it is not a full observability suite: alert routing, source-map decoding, crash symbolication, session replay, distributed trace queries, and heartbeat checks belong elsewhere. I would evaluate Sentry, Rollbar, Bugsnag, or Honeybadger when one of those adjacent workflows drives the decision.

How should a Node.js Express error tracking setup capture exceptions?

I first treated exception count as the obvious metric. Then I noticed the unit did not answer the business question. One validation failure can happen before the first model call; another can arrive after a long agent loop. Both produce one exception, but they deserve different investigation priority. I ship weekly, so release context matters too. The explicit trade-off is extra event context in exchange for a much faster decision about which group threatens revenue or customer trust.

My minimum event carries the exception, request context, release, environment, and a permitted user identifier. For this workload I also attach an internal agent-run identifier and cost data already known by the application. Error tracking does not calculate model cost. The application owns that attribution.

Group first, then rank work using business context. A noisy low-impact group should not automatically outrank a rare failure that burns through a costly loop or blocks a customer workflow.

The smallest implementation I would ship

I keep the Express integration behind a tiny sink. That makes failure paths testable and keeps provider details out of route handlers. This TypeScript example catches route errors plus process-level promise rejections and uncaught exceptions. The console sink is intentional; one production adapter replaces it.

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

type Context = {
  requestId: string;
  release: string;
  environment: string;
  userId?: string;
  agentRunId?: string;
  costBand?: "none" | "low" | "high";
};

interface ErrorSink {
  capture(error: Error, context: Context): Promise<void>;
}

class InfraiSink implements ErrorSink {
  async capture(error: Error, context: Context): Promise<void> {
    const apiKey = process.env.INFRAI_API_KEY;
    const baseUrl = process.env.INFRAI_BASE_URL;
    if (!apiKey) throw new Error("INFRAI_API_KEY is required");
    if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

    for (let attempt = 0; attempt < 3; attempt += 1) {
      const response = await fetch(`${baseUrl}/v1/errors/capture`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          message: error.message,
          stack: error.stack,
          context
        })
      });
      if (response.ok) return;
      if (response.status !== 429 || attempt === 2) {
        throw new Error(`Error capture failed: ${response.status} ${await response.text()}`);
      }
      const retryAfter = Number(response.headers.get("retry-after") ?? "0");
      const delayMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }
}

const app = express();
const sink: ErrorSink = new InfraiSink();
const release = process.env.APP_RELEASE ?? "dev";
const environment = process.env.NODE_ENV ?? "development";

app.get("/agent-runs/:runId", async (req, res, next) => {
  try {
    throw new Error(`Agent run ${req.params.runId} failed`);
  } catch (error) {
    next(error);
  }
});

const errorHandler: ErrorRequestHandler = async (error, req, res, _next) => {
  const normalized = error instanceof Error ? error : new Error(String(error));
  await sink.capture(normalized, {
    requestId: req.header("x-request-id") ?? randomUUID(),
    release,
    environment,
    userId: req.header("x-user-id") ?? undefined,
    agentRunId: req.params.runId,
    costBand: "high"
  });
  res.status(500).json({ error: "internal_error" });
};

app.use(errorHandler);

process.on("unhandledRejection", async (reason) => {
  const error = reason instanceof Error ? reason : new Error(String(reason));
  await sink.capture(error, { requestId: randomUUID(), release, environment });
  process.exitCode = 1;
});

process.on("uncaughtException", async (error) => {
  await sink.capture(error, { requestId: randomUUID(), release, environment });
  process.exit(1);
});

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

A process-level handler records evidence; it does not make an unknown process state healthy. The supervisor still needs to restart the service.

No magic.

For the hosted adapter, I would POST to /v1/errors/capture using Authorization: Bearer and a key read from process.env.INFRAI_API_KEY. It must check every response status and back off on HTTP 429 while honoring Retry-After. Capture failure must never recursively invoke the same handler. I would validate the adapter against the public discovery schema instead of guessing field names.

Which product fits this boundary?

A fair comparison starts with the missing workflow, not a feature-count score. I would send one representative backend exception through every candidate and inspect grouping, context visibility, and the route from event to resolved group.

Option Why it makes my shortlist Boundary to verify
Infrai One key and a consistent REST surface across 295 routes in 20 modules Alerting needs polling; no source-map reverse mapping, symbolication, replay, span-tree queries, or heartbeat monitoring
Sentry A candidate when browser and server errors need one investigation workflow Confirm current source-map, replay, alert, and data-handling behavior
Rollbar A candidate when occurrence grouping and release triage are central Test grouping with this application's error shapes and confirm notifications
Bugsnag A candidate when release stability is the primary operational view Validate backend context and required workflows beyond error tracking
Honeybadger A candidate for focused application monitoring Check its Node.js, alerting, and heartbeat fit

Datadog belongs in the trial when errors must sit beside a broader monitoring estate. Grafana is worth evaluating when an existing telemetry stack should remain the center of gravity, while Better Stack is another candidate when incident response and monitoring need to meet in one workflow. Those are materially different buying conditions, not extra names for a scorecard.

The rows are intentionally asymmetric. I have verified the first option's limits; I have not run a benchmark against the others, so their mentions are evaluation criteria, not victory claims. Vendor documentation can establish that a feature exists. Only a trial with my event shape shows whether grouping is useful. The central limitation is clear: Infrai is the wrong choice when native paging, browser stack deminification, session replay, Electron symbolication, heartbeat monitoring, or an interactive span tree is mandatory. In those cases I would choose the specialist that passes the relevant trial, even if that adds another SDK and bill. This is a capability trade-off, not a price contest.

Cost is secondary. I care more about answering “which release, customer workflow, and agent run produced this group?” than a temporary unit-price difference. Migration consumes the hours I use to ship features.

Capture alone creates a graveyard. The operating loop is list groups, inspect events, assign impact, fix, then resolve. A basic admin panel can use group and event listings without pretending to be a distributed tracing UI.

I would put first seen, last seen, affected release, and cost band above the fold. Exact spend belongs in a restricted view. User identifiers should be included only where policy allows; the absence of a log endpoint for deleting one user's records is a governance boundary when deciding what to send.

Alerts need a separate decision. With this API, a cron job must poll search or list results for Slack, email, or webhook notifications because native threshold and notification routing are absent. Polling needs a cursor or deduplication key. Silent scheduled-job failures need a heartbeat service such as Healthchecks; error capture cannot report code that never ran.

OpenTelemetry logs can carry trace and span identifiers, but identifiers are not a span tree. If I need parent-child timing queries, I choose a tracing backend.

What I would change at scale

At low volume, synchronous capture is understandable. At higher volume, I would put delivery behind a bounded buffer or queue, cap retries, and suppress duplicate noise before it competes with customer traffic. The request must finish when the tracker is slow.

I would also replace the coarse cost band with a versioned attribution policy owned by the application. It would define which AI calls belong to a run, how cached calls are treated, and what data may leave the service. This logic differentiates the SaaS; exception storage and grouping do not.

My rule is narrow: choose grouped tracking with a thin adapter when backend exceptions and cost-aware triage are the job. Choose a broader specialist when frontend deminification, Electron crash analysis, replay, native paging, heartbeat monitoring, or trace exploration is essential. For a one-person SaaS, the right tool closes the actual incident loop while leaving enough week to ship.

Sources and References

Top comments (0)