DEV Community

Keria
Keria

Posted on

Capturing Production Errors Across Next.js Routes and Server Actions (Cohort Rollbacks)

TL;DR: Capture an exception once at the server boundary, attach a small set of stable experiment and tenant-cohort fields, return a safe result to the caller, and let the original error remain the cause. For a game feature rollout, the useful production question is not merely whether requests failed. It is whether the treatment increased failures for a specific cohort enough to justify a rollback. Build the event shape around that decision.

A plain try/catch around every API route and server action looks sensible at first. It produces duplicate events when a lower layer reports an error and the boundary reports it again, while inconsistent tags make treatment and control hard to compare. The better constraint is one reporting owner per invocation and one shared envelope for both entry points.

How should Next.js API routes and server actions capture errors?

Suppose a multiplayer game is testing a new inventory reconciliation path. Tenants are grouped into small_studio, mid_market, and publisher; each invocation is assigned control or treatment. Rollback safety depends on comparing like with like. An aggregate error count can hide a treatment regression in one cohort behind much larger traffic elsewhere.

That is the trap.

The event needs a stable operation name, experiment identifier, variant, tenant cohort, deployment identifier, and correlation identifier. It should not use raw tenant IDs, player IDs, error messages, or stack traces as metric labels. Prometheus instrumentation guidance warns that every unique label combination creates a new time series and specifically advises against high-cardinality labels such as user IDs. Keep detailed exception context in the error event; expose only bounded dimensions to counters.

This distinction matters for cost as well as diagnosis. A stack trace is valuable when inspecting one failure. It is a terrible aggregation key.

The tempting first design is one counter named inventory_failures_total. It is quick to ship, cheap to query, and almost useless for this decision. If publisher traffic dominates control while small studios dominate treatment, a raw comparison mixes assignment, cohort size, and application behavior into one number. Splitting by five bounded fields costs more series, but it buys a rollback signal that can actually distinguish the experiment from its traffic mix. I would accept that trade-off before adding any player-level dimension. The latter can't improve a cohort decision and creates the cardinality problem the instrumentation guidance warns about.

Use one envelope at both execution boundaries

The following TypeScript keeps the reporting contract generic. The adapter can write to an internal collector or another backend without changing application code. More important, the route and action call the same boundary helper, so their fields cannot drift quietly.

type Variant = "control" | "treatment";
type Cohort = "small_studio" | "mid_market" | "publisher";

type ExperimentContext = {
  experiment: string;
  variant: Variant;
  cohort: Cohort;
  deployment: string;
  correlationId: string;
};

type ExceptionEvent = ExperimentContext & {
  operation: string;
  error: unknown;
};

interface ExceptionSink {
  capture(event: ExceptionEvent): Promise<void>;
}

async function runObserved<T>(
  sink: ExceptionSink,
  operation: string,
  context: ExperimentContext,
  work: () => Promise<T>,
): Promise<T> {
  try {
    return await work();
  } catch (error) {
    await sink.capture({ ...context, operation, error });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Rethrowing is deliberate. Reporting must not convert a failed inventory mutation into success. The framework boundary can then produce the intended error response, while the caller of a server action can present an appropriate UI state. If the sink itself can fail, its adapter should have an explicit delivery policy; application behavior should not be left to an accidental nested exception.

Use the helper where the request has enough trusted context to classify the experiment. For an API handler, derive cohort and variant from server-owned assignment data, not arbitrary request fields. For a server action, do the same before calling the domain operation. The focused shape is identical:

async function reconcileInventory(
  sink: ExceptionSink,
  context: ExperimentContext,
): Promise<{ revision: number }> {
  return runObserved(sink, "inventory.reconcile", context, async () => {
    return applyInventoryMutation();
  });
}
Enter fullscreen mode Exit fullscreen mode

Do not report again inside applyInventoryMutation. Add context there only by preserving the error cause, then let the owning boundary emit the event. One failure should remain one failure.

Count once.

Separate debugging detail from experiment evidence

Exception events and experiment metrics serve different access patterns. The former retain the error type, stack, correlation ID, and deployment so an engineer can reconstruct one invocation. The latter answer a bounded comparison question across many invocations.

Signal Dimensions Rollback use
Invocation counter operation, experiment, variant, cohort, deployment Denominator for each group
Failure counter operation, experiment, variant, cohort, deployment, error class Compare failure proportions
Exception event correlation ID plus detailed error context Diagnose representative failures
Latency distribution operation, experiment, variant, cohort, deployment Catch slow treatment behavior

The denominator is non-negotiable. Ten treatment failures and five control failures say little without invocation counts. Likewise, compare cohorts separately before looking at a blended total. The rollback rule should be written before exposure grows and should account for sample size; the cited sources do not define a universal statistical threshold, so this article does not invent one.

Feature assignment also belongs in server-owned context. An experimentation system may manage allocation, but error telemetry should record the resolved assignment rather than query a second system after the exception. That avoids turning incident analysis into a reconstruction exercise when assignments change.

Failure modes worth testing before exposure

The happy path proves almost nothing here. Inject a domain exception into both entry points and verify that each invocation creates exactly one event with the expected operation, variant, cohort, and deployment. Then verify that the client sees failure rather than a false success.

Test the awkward cases too:

  1. The exception value is not an Error instance. The sink should serialize it defensively.
  2. Event delivery is slow or unavailable. The chosen timeout and buffering policy must not make the mutation outcome ambiguous.
  3. Cohort resolution fails. Record a bounded unknown value or reject the operation according to policy; never substitute a raw tenant identifier.
  4. A retry repeats the invocation. Preserve an invocation or correlation key so operators can distinguish repeated attempts from unrelated failures.
  5. Deployment changes during the experiment. Keep deployment as a bounded dimension so a code change does not masquerade as a treatment effect.

There is a real trade-off between durable reporting and request latency. A ship-first implementation can await a sink with a tight, documented timeout, but high-volume systems may need an in-process buffer or local collector. The right choice depends on the loss budget for telemetry and the rollback window. Measure it.

Measure this before copying the pattern

Start with telemetry completeness: the fraction of known injected failures that produce one, and only one, exception event. Track the added boundary latency, event delivery failures, series count for the bounded metric dimensions, and the share of events classified as unknown. Those numbers expose whether the setup is operationally trustworthy without pretending observability is free.

For the experiment itself, inspect invocation count, failure proportion, and latency by variant within each tenant cohort and deployment. A rollback decision should use the predeclared rule and preserve the evidence that triggered it. Avoid making cost the primary criterion: missing or duplicated events can make a cheap pipeline expensive at exactly the wrong moment.

This setup is intentionally boring. Stable dimensions support comparison, detailed events support diagnosis, and a single reporting owner prevents double counting. That is enough structure to decide whether the game experiment is safe to continue without coupling the decision to one telemetry vendor.

Further reading

Top comments (0)