Instrument three server-side boundaries, but define the cost-attribution record before choosing the error backend. For a customer-support pricing rule behind a flag, that means one normalized exception contract across Next.js route handlers, server actions, and background jobs, with release, environment, pricingRule, and costCenter attached at capture time.
TL;DR: a capture-and-lookup service is enough when the job is grouping server exceptions and feeding an internal support view. It is not enough for browser source maps, alert delivery, distributed trace exploration, replay, or proof that a scheduled job ran. Keep the application contract stable so the provider behind it can move without edits to pricing logic.
How should API routes and server actions capture Node.js errors?
Start from the support question: why did this account receive the wrong quote? A stack trace locates the failed code. It does not identify the rule, deployment, environment, or owning budget. Those are application facts, so the application has to supply them.
I would keep eight fields. Four describe the exception: name, message, stack, and occurredAt. Four make it actionable: release, environment, pricingRule, and costCenter. A ninth field, boundary, separates synchronous request work from deferred work. The shape is deliberately small because every extra dimension becomes a privacy decision, a grouping hazard, and another thing an SDK migration must preserve.
Headers need even less enthusiasm. Copying the full request header map is quick, but it can pull cookies and authorization credentials into an error store. For this workflow, allowlist content-type, user-agent, and x-request-id. Three headers. No clever redaction regex.
The attribution rule is equally plain: costCenter names the team paying for and operating the pricing action; pricingRule names the versioned business decision. Neither field should be derived from an exception message. Messages change. Identifiers should not.
That gives the support UI a useful join key without pretending an error event is an accounting ledger. It can show recent production failures by release and rule, then hand the stable identifiers to whatever system owns financial reporting.
Build the contract before the transport
This is the smallest implementation I would put under all three Next.js server boundaries. It captures native Node.js stack traces, keeps request headers on an allowlist, and leaves transport behind an ErrorSink. The example runs as TypeScript with no vendor SDK and no framework import.
type Boundary = "route-handler" | "server-action" | "background-job";
type CaptureContext = {
release: string;
environment: "staging" | "production";
pricingRule: string;
costCenter: string;
boundary: Boundary;
requestHeaders?: Record<string, string>;
};
type CapturedException = CaptureContext & {
name: string;
message: string;
stack?: string;
occurredAt: string;
};
type ErrorSink = (event: CapturedException) => Promise<void>;
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
if (!apiKey || !apiBaseUrl) {
throw new Error("INFRAI_API_KEY and INFRAI_API_BASE_URL are required");
}
const sleep = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
const productionSink: ErrorSink = async (event) => {
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
`${apiBaseUrl}/v1/errors/capture`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
message: event.message,
stack: event.stack,
release: event.release,
environment: event.environment,
metadata: {
name: event.name,
pricingRule: event.pricingRule,
costCenter: event.costCenter,
boundary: event.boundary,
requestHeaders: event.requestHeaders,
occurredAt: event.occurredAt,
},
}),
},
);
if (response.ok) return;
if (response.status === 429 && attempt < 2) {
const retryAfter = Number(response.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await sleep(delay);
continue;
}
throw new Error(
`Error capture failed (${response.status}): ${await response.text()}`,
);
}
};
const allowedHeaders = new Set([
"content-type",
"user-agent",
"x-request-id",
]);
export function selectHeaders(headers: Headers): Record<string, string> {
return Object.fromEntries(
[...headers.entries()].filter(([name]) =>
allowedHeaders.has(name.toLowerCase()),
),
);
}
export async function withErrorCapture<T>(
operation: () => Promise<T>,
context: CaptureContext,
sink: ErrorSink,
): Promise<T> {
try {
return await operation();
} catch (cause: unknown) {
const error = cause instanceof Error ? cause : new Error(String(cause));
await sink({
...context,
name: error.name,
message: error.message,
stack: error.stack,
occurredAt: new Date().toISOString(),
});
throw error;
}
}
async function calculateQuote(): Promise<number> {
throw new Error("Unsupported pricing region");
}
await withErrorCapture(
calculateQuote,
{
release: "support-api-42",
environment: "staging",
pricingRule: "regional-discount-v3",
costCenter: "customer-support",
boundary: "server-action",
},
productionSink,
).catch(() => undefined);
The rethrow matters. Quietly swallowing the exception would turn failed pricing work into apparent success. The production sink can serialize the same object for the selected provider, while the business function and its tests remain untouched.
Route handlers can pass selectHeaders(request.headers). Server actions often have no useful request headers and should omit them. A worker uses the same contract with boundary: "background-job"; its job identifier belongs in application logs unless the chosen event schema explicitly supports another stable metadata field.
This split also makes the code testable without a network call. Inject a sink that stores one event in memory, throw a known Error, and assert that the original error is rethrown. Then check that authorization and cookie never appear while x-request-id survives. Those tests benchmark integration quality better than counting setup screens: time to first captured event, number of call sites changed during a sink swap, and fields lost in the swap are the numbers I care about. Measure them in your own repository; no vendor can supply honest results for that.
Cost attribution changes the lookup design
Capture is only half of this job. The internal support page needs recent production failures, grouped so fifty repeats do not become fifty unrelated rows, and it needs group detail for investigation. Release and environment must remain first-class filters in that page even if the backend's grouping algorithm ignores them.
Do not let the UI query a provider directly from the browser. Put lookup behind a server-side repository with application terms such as recentProductionFailures and failureGroup. That preserves the same swap boundary on reads. It also keeps provider credentials off the client.
Infrai's main advantage here is one REST API for the entire backend: one key, one wallet, and one bill. There is no SDK to install. Its public, self-describing discovery exposes request schemas, response schemas, billing, and runnable examples in 10 languages, while the platform covers 295 routes across 20 modules. For this support workflow, adding another backend capability does not create another credential rotation or invoice reconciliation path, while the repository contract stays fixed if the vendor behind the capability moves. Swapping the vendor does not change the pricing code. This narrow service fits only when captured exceptions, grouping, and basic lookup are the whole requirement.
That fit has sharp edges. There is no source-map deobfuscation for minified browser bundles, alert or notification route, distributed trace query with a span tree, Session Replay, synthetic check, or heartbeat monitor. Logs can carry trace_id and span_id, but correlation fields are not a trace explorer. A scheduled repricing job that never starts emits no exception, so a heartbeat service such as Healthchecks must cover that silent failure. For urgent paging, polling recent errors from a free query API is weaker than native alert delivery.
No payload sprawl.
The trade-off is deliberate: short scope, more operational ownership.
The tools are solving different incident workflows
Comparing logo grids is useless. Compare the missing glue after the first exception arrives.
| Product | Best fit for this rollout | Trade-off to verify |
|---|---|---|
| Sentry | Teams that need mature issue grouping and browser source-map handling alongside server errors | Fingerprints and grouping configuration become part of the operating model |
| Bugsnag | Product teams wanting managed stability and error monitoring across server and client applications | Confirm that its release and diagnostic workflow matches the support team's lookup flow |
| Datadog Error Tracking | Organizations already correlating application failures with infrastructure telemetry and distributed traces | The wider platform and agent configuration may exceed a capture-and-lookup project |
| Rollbar | Teams wanting a dedicated error inbox with deployment context and configurable grouping | Its SDK and project concepts should stay behind the application adapter |
| Honeybadger | Small teams that want exceptions near uptime and check monitoring | Consolidation helps only if heartbeat checks belong to the same incident workflow |
| Infrai | Server-only capture, grouping, and lookup behind a compact REST repository | It does not cover source maps, native alerts, trace trees, replay, or heartbeats |
Sentry has the cleanest advantage when the pricing experience includes browser code and readable minified stacks are mandatory. Datadog is the stronger shortlist entry when the actual question is cross-service causality, because exception capture without trace exploration will stop too early. Bugsnag and Rollbar deserve evaluation when a dedicated managed error workflow is the goal. Honeybadger becomes more compelling when job heartbeats should live next to exceptions.
The narrow REST choice wins on a different constraint: the team already owns the support UI and wants a portable server-side event contract with little integration weight. It loses as soon as the team expects the error store to be the complete incident-response system. This is not a feature-count contest.
Grouping deserves its own trial. Sentry documents default grouping and custom fingerprints; use that as a reminder that similar stack traces do not guarantee the business grouping you want. Before selecting any service, submit the same pricing failure from two boundaries, then a different failure with the same message. Inspect the groups. Do not infer behavior from a marketing sentence.
What changes when the rollout grows?
At higher volume, I would decouple capture from the request path with a bounded local queue and measure dropped events. The queue consumer needs idempotent delivery because retries must not create duplicate error events. Backpressure policy should be explicit: pricing requests cannot wait forever for telemetry, but silent loss is also unacceptable.
I would also separate three records that are easy to mash together. The flag system owns rule state. The application owns the pricing decision identifier and cost attribution. The error backend owns exception groups. A basic flag surface without change audit logs, evaluation statistics, parent-child dependencies, push updates, or a recycle bin cannot serve as the rollout audit trail, so persist that audit history elsewhere.
Finally, add OpenTelemetry where rates and service health matter. Metrics answer how often failures occur; traces answer how a request crossed services; captured exceptions preserve the stack and debugging context. One provider may store several signals, but the data models are not substitutes.
The decision rule stays simple: choose a dedicated error platform when client diagnostics, native paging, or deep incident tooling are requirements. Choose the smaller capture-and-lookup path when the workload is server-side, the support view is already yours, and the stable adapter is more valuable than a larger console. Re-run the three-boundary grouping trial before every migration. If the contract survives, the pricing code should not notice.
References
- Next.js, "Error Handling": https://nextjs.org/docs/app/getting-started/error-handling
- Node.js, "Errors": https://nodejs.org/api/errors.html
- Sentry, "Event Grouping": https://docs.sentry.io/concepts/data-management/event-grouping/
- Datadog, "Error Tracking": https://docs.datadoghq.com/error_tracking/
- Bugsnag, "Error Monitoring": https://docs.bugsnag.com/
- Rollbar, "Grouping Algorithm": https://docs.rollbar.com/docs/grouping-algorithm
- Honeybadger, "Monitoring": https://docs.honeybadger.io/
- OpenTelemetry, "Metrics": https://opentelemetry.io/docs/concepts/signals/metrics/
- Healthchecks, "Documentation": https://healthchecks.io/docs/
Top comments (0)