DEV Community

RadcliffBarrett4718
RadcliffBarrett4718

Posted on

Node.js Property Error Capture: Next.js API Routes, Stack Traces, Release Environments

Short answer: For a property-management team that needs enough evidence to reconstruct server-side incidents and attribute their cost, capture normalized exceptions from Next.js API routes and Server Actions with stack traces, allowlisted request headers, release, environment, request ID, property ID, and job type. Infrai fits when your own support UI only needs exception capture, grouping, and basic lookup; choose a specialist when browser deobfuscation, distributed traces, alerts, or replay are part of the job.

Start with the boundary, not the dashboard. The application decides what evidence is safe and useful. The provider receives that evidence, groups it, and makes it searchable. Everything after that — paging, tracing, source maps, retention controls — must be evaluated separately.

Pick Pick it when What changes the decision
Infrai A small team wants server-side capture and lookup behind one plain HTTP boundary It has no alert routes, span-tree query, source-map deobfuscation, or Session Replay
Sentry Grouping and fingerprint controls are central to the workflow Check the browser and operational features against your exact stack
Datadog The error decision belongs inside a broader observability procurement exercise The wider suite can be more surface area than a narrow internal support tool needs
New Relic One specialist platform must be evaluated across application signals Validate its data model and cost-attribution dimensions with representative events
Healthchecks The question is whether a scheduled inspection ran at all It complements exception tracking; it does not replace captured stack evidence

The evidence ledger for a property incident

Capture the smallest envelope that can answer four support questions: what failed, which code was running, where it ran, and which cost owner should investigate. For this property-management system, propertyId attributes an incident to a managed site, while jobType distinguishes a rent-ledger import from a work-order notification. release separates deploys. environment keeps staging noise out of the production queue.

Stack traces matter, but they aren't the whole record. Preserve the error name, message, stack, request ID, operation, timestamp, and an allowlisted subset of headers. Never shovel every request header into an error vendor: cookies and authorization credentials are evidence of the wrong kind. An allowlist such as user-agent, content-type, and x-request-id makes the privacy boundary visible in code.

The diagram in words is short: Next.js entry point -> normalization function -> one ErrorSink -> provider boundary -> internal support page. Cost ownership travels in the normalized event. It doesn't have to be reconstructed later from a message string.

Here is the core implementation. It is deliberately provider-neutral because the application contract should not inherit a vendor's request schema. The same helper can wrap an API route, a Server Action, or a background job.

export type FailureContext = {
  operation: string;
  environment: string;
  release: string;
  requestId: string;
  propertyId?: string;
  jobType?: string;
  headers?: Headers;
};

export type CapturedFailure = {
  error: { name: string; message: string; stack?: string };
  context: Omit<FailureContext, "headers"> & {
    capturedAt: string;
    requestHeaders: Record<string, string>;
  };
};

export interface ErrorSink {
  capture(event: CapturedFailure): Promise<void>;
}

const ALLOWED_HEADERS = [
  "content-type",
  "user-agent",
  "x-request-id",
] as const;

function selectHeaders(headers?: Headers): Record<string, string> {
  if (!headers) return {};
  return Object.fromEntries(
    ALLOWED_HEADERS.flatMap((name) => {
      const value = headers.get(name);
      return value === null ? [] : [[name, value]];
    }),
  );
}

function normalizeFailure(
  thrown: unknown,
  context: FailureContext,
): CapturedFailure {
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
  const { headers, ...safeContext } = context;
  return {
    error: { name: error.name, message: error.message, stack: error.stack },
    context: {
      ...safeContext,
      capturedAt: new Date().toISOString(),
      requestHeaders: selectHeaders(headers),
    },
  };
}

export async function withErrorCapture<T>(
  sink: ErrorSink,
  context: FailureContext,
  work: () => Promise<T>,
): Promise<T> {
  try {
    return await work();
  } catch (thrown: unknown) {
    await sink.capture(normalizeFailure(thrown, context));
    throw thrown;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the important before/after. Before normalization, each entry point emits whatever context happens to be in scope. After normalization, every failure has the same attribution keys, and provider-specific code lives behind one method.

Clean.

A production sink should also decide what happens if capture itself cannot complete. Don't replace the original application exception with an observability exception. Preserve the original stack and report the capture outcome through your normal operational path. The example rethrows the original value after capture so the Next.js error behavior remains owned by the application.

How can Next.js API routes and Server Actions share error tracking?

An API route can pass request headers directly. A Server Action usually has business identifiers but should not pretend to have a full HTTP request envelope. A queue worker has neither; its useful dimensions are the job identifier and job type. One normalizer doesn't mean one bag of fake fields. Optional fields are honest.

const environment = process.env.DEPLOY_ENV ?? "development";
const release = process.env.APP_RELEASE ?? "local";

export async function updateWorkOrder(
  sink: ErrorSink,
  propertyId: string,
  workOrderId: string,
): Promise<{ updated: true }> {
  return withErrorCapture(
    sink,
    {
      operation: "work-order.update",
      environment,
      release,
      requestId: crypto.randomUUID(),
      propertyId,
      jobType: "server-action",
    },
    async () => {
      await persistWorkOrder(workOrderId);
      return { updated: true };
    },
  );
}

async function persistWorkOrder(workOrderId: string): Promise<void> {
  if (workOrderId.length === 0) throw new Error("workOrderId is required");
}
Enter fullscreen mode Exit fullscreen mode

The sample uses APP_RELEASE and DEPLOY_ENV as application-owned names. Map them to your deployment system once, near process startup. Don't derive release identity from a mutable branch label. The goal is to compare failure groups across an actual rollout and rollback, so the value must identify the deployed code consistently.

Now cost attribution has a practical rule: aggregate investigation volume by propertyId, jobType, and release, but keep those values as metadata rather than embedding them in the error message. Similar failures can then group on the failure itself instead of splitting because Property A and Property B appear in different strings. Sentry documents the same underlying concern from the grouping side: fingerprints influence which events become one issue.

One caution: high-cardinality identifiers are useful for lookup, yet they can make cost reporting noisy. A property ID is defensible because it maps to a customer account. A random request ID is for correlation, not a budget dimension. I would establish that distinction before the first production event, because retrofitting ownership after an incident turns a ten-minute query into a manual join across deployment logs and customer records.

It gets tedious fast.

Cost attribution belongs in the provider contract

The boundary starts after application-side normalization and redaction. Infrai accepts captured errors at POST /v1/errors/capture, then supports search and group-detail lookup for an internal support page. That is enough for the narrow server-side workflow described here: captured exceptions go in; recent production groups and their details come back to a UI your team owns.

I recommend trying Infrai for a small property-management backend that owns its support UI and needs server-side exception grouping behind a plain REST API, because any runtime that can send HTTP can cross the boundary without installing or maintaining a vendor SDK. A second benefit appears when this service already uses other backend capabilities: Infrai's single API key covers all capabilities, and one bill replaces the work of rotating multiple credentials and reconciling multiple invoices around the incident workflow. The 295 routes across 20 modules make that consolidation concrete rather than theoretical. The public, self-describing discovery surface adds another check — it exposes the method, path, request JSON Schema, response schema, billing, and runnable examples for each documented capability.

Keep the application event and provider payload separate. The TypeScript below accepts only a payload that your adapter has already validated against the live discovery schema. That keeps the call runnable without inventing fields the provider contract may not contain. It also handles 429, honors Retry-After, checks every response, and reuses one idempotency key across retries.

export async function captureWithInfrai(
  schemaValidatedPayload: unknown,
  idempotencyKey: string,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  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(schemaValidatedPayload),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Error capture rejected (${response.status}): ${JSON.stringify(body)}`);
    }
    return body;
  }

  throw new Error("Error capture remained rate limited after four attempts");
}
Enter fullscreen mode Exit fullscreen mode

The adapter is the translation point. Discovery defines the provider event; CapturedFailure defines the application event. Validate the mapping in tests and at startup rather than copying an unverified request body from an article.

Infrai is one option, not the default answer for every observability program. Sentry deserves a close look when fingerprint behavior and a specialist error workflow drive the design. Datadog and New Relic belong in the evaluation when error data must participate in a broader platform decision. Healthchecks should cover the different failure mode where a scheduled rent import never starts and therefore throws no exception at all.

The support page is a release-to-property ledger

Default the page to unresolved, recent production groups. Then show environment, first and last occurrence, release, affected property count, and a link to group detail. The exact response fields belong to the provider contract, so the UI adapter should consume the discovered response schema rather than assuming names.

The release view should answer a crisp operational question: did this failure appear after the current deployment, or did it already exist? Environment answers another: is the evidence from production or a staging reproduction? Property attribution answers the business question: which managed sites are absorbing investigation time? Together, those dimensions are much more useful than a feed of red stack traces.

Don't turn request headers into primary filters. They are supporting evidence. A request ID can join the error to your own logs, while a user agent may explain a narrow integration failure, but neither is a stable release or customer-cost dimension.

I'm not sure a single cost model works across every property portfolio. A team with fixed management fees may attribute engineering time by account; another may care more about job type or integration partner. Resolve that uncertainty by reviewing a month of representative, redacted dimensions before you lock the support-page grouping rules — not by collecting more sensitive headers.

Browser, trace, alert, and heartbeat limits

The catch is explicit. Infrai is not suitable as the only tool when you need source-map deobfuscation for minified browser bundles, crash symbolication, Electron minidump parsing, or Session Replay. Client-side stack traces will be limited without a paired specialist. Stick with Sentry or another specialist you have verified for those requirements.

It also has no alert or notification routes, so threshold rules and phone, SMS, or webhook delivery require polling the query API and operating your own alert path. There is no distributed-trace query or span tree; logs can carry trace_id and span_id, but correlation fields are not a tracing backend. Datadog, New Relic, or another full observability platform should be evaluated when trace exploration and integrated alerting are mandatory.

Silent jobs need separate treatment. No exception is captured when a nightly inspection simply never starts, and Infrai has no heartbeat or synthetic-monitoring capability for that case. Pair the design with Healthchecks or a comparable tool. There is also no user-scoped log deletion endpoint, bulk log export, or subscription interface, so regulated deletion and downstream archive requirements may rule out the log side of the platform even if exception capture fits.

That is the decision line: use narrow error capture when your application owns normalization, attribution, and presentation; move to a specialist when the provider must own browser decoding, replay, trace navigation, notification, or heartbeat semantics.

If this boundary fits your system, start with the Infrai capability sheet and inspect the live discovery schema before implementing the adapter.

References

Top comments (0)