Short answer: capture exceptions once at the API route or server action boundary, attach a small allowlist of request context plus release and environment, and pair those events with a low-cardinality metric that detects scheduled imports producing no results.
An exception answers “why did this execution fail?” It cannot answer “why did nothing execute?” A B2B SaaS import can stop producing rows because a request threw, because the scheduler never invoked it, or because a valid run returned zero useful results. The design therefore needs two signals: bounded error events for diagnosis and an outcome metric for silence.
Keep those jobs separate. It improves signal quality, limits stored bytes, and prevents tenant or request identifiers from turning one useful metric into millions of time series.
ADR status: accepted. The design has three invariants. Every handled execution records an outcome. Every unhandled exception crosses exactly one capture boundary. Every captured event carries controlled release and environment values, while volatile identifiers remain event context rather than metric labels. These are architectural rules, not library features.
The first failure boundary is the public API route. It owns HTTP context, so it can select safe headers, retain the original error stack, record the outcome, and then preserve the application's response behavior. The second boundary is the server action. It has business-operation context but should use the same normalization and capture function. A lower database or import-parser layer may add structured context before rethrowing, but it should not emit a second copy of the same exception. Duplicate capture inflates storage and can split one defect across slightly different event shapes. Now follow one awkward sequence: a scheduler invokes an API route, the route starts an import, the parser rejects a malformed row, the service annotates the error, and the route converts it to the application's documented response. Capturing in the parser, service, and route produces three events with one operational cause; capturing only in the route loses nothing if the inner layers preserve the cause and useful fields. The boundary rule is about ownership, not about discarding context.
There is a harder boundary: absence. If a scheduled import does not start, no stack trace exists. A separate observer must compare the expected cadence with the latest successful or completed run. For a job scheduled every 15 minutes, a 16-minute threshold will page on ordinary jitter; a threshold derived from cadence and normal run duration is quieter. A team might begin at 40 minutes for that hypothetical job, then revise it from observed duration and lateness distributions. I'm not sure a fixed multiplier is defensible across both five-minute syncs and nightly bulk loads—the data should settle that.
| Option | Detects thrown errors | Detects no execution | Cardinality pressure | Diagnostic detail |
|---|---|---|---|---|
| Error events only | Yes | No | Moderate if context is bounded | Stack and request context |
| Outcome metrics only | Indirectly | Yes | Low with controlled labels | Limited |
| Boundary errors plus outcome metrics | Yes | Yes | Predictable when identities stay out of labels | Detailed on failures |
| Full traces for every run | Yes | Yes, with an external expectation check | Highest data volume | Rich execution path |
The combined option wins for this system because the primary decision axis is signal quality versus noise. It does not claim that every import deserves a full trace, nor does it make an exception stream pretend to be a scheduler monitor.
Silence has no stack.
How should Next.js API routes and server actions capture stack traces?
Treat each framework entry point as an adapter around one application-level error envelope. In an API route, invoke the domain operation inside one outer error boundary. On failure, normalize the thrown value: preserve the name, message, and stack when it is an Error; represent a non-Error throw without inventing a stack. Add the operation name, a stable error category, the release, the environment, and a correlation identifier. Capture once, record a failed outcome, then follow the route's established response contract.
A server action follows the same sequence, but it should receive explicit operation context rather than assume that all HTTP request fields are available or useful. This keeps error grouping consistent across API routes and server actions. Stack-based grouping can join repeated manifestations of one code defect, while an explicit fingerprint is appropriate only when the default grouping would merge failures that require different owners or split failures that share one remediation. Fingerprints need restraint—adding a tenant ID creates a group per tenant, which is usually a storage policy disguised as debugging context.
Headers require an allowlist. content-type, user-agent, and a generated x-request-id may help reproduce or correlate a failure; authorization, cookie, raw forwarding headers, and arbitrary user-supplied values do not belong in a default error event. Redaction after capture is a weak safety boundary because the sensitive bytes have already entered the pipeline. Select first.
Release and environment should come from deployment configuration, not from request input. Keep their vocabulary finite: a release identifier tied to a deploy artifact and a small environment set such as production, staging, and development. Don't put a release identifier on a long-lived metric label unless the query truly requires it. Each deployment adds another label value, while an error event can carry the release without creating a permanent time series.
One subtle implementation mistake is catching at every layer. Suppose an import parser annotates a malformed record, the service layer captures it, and the route captures it again. A single bad execution now looks like two incidents. Instead, inner layers should add a cause or structured field and rethrow; the outer boundary owns emission. Short rule: enrich inside, capture outside.
Proving the release at the application boundary
The most useful executable example is the request contract at the application boundary. It can be exercised in deployment tests without coupling the test to an error-tracking SDK. This illustrative endpoint starts one scheduled-import execution while providing a correlation ID; the release and environment remain server-controlled.
curl --request POST 'https://app.example.test/api/imports/run' \
--header 'content-type: application/json' \
--header 'x-request-id: req_01J6Y4N8K2' \
--data '{"job":"scheduled-import","source":"crm","expected_window":"2026-08-18T08:00:00Z"}'
The test should assert behavior at three observable points. First, the route returns the application's documented failure response when the domain operation fails; error tracking must not silently change that contract. Second, exactly one error event contains the original stack, the correlation ID, the configured release and environment, and only allowlisted headers. Third, the outcome metric advances with outcome=failed. A separate test skips invocation entirely and verifies that the stale-result alert fires after its configured window. That last test matters most: no request means no request error.
Do not force a production exception merely to test the pipeline. Exercise the error branch in a controlled test environment with a deterministic fixture, then verify production through a non-error deployment marker and the normal outcome stream. The point is to validate routing and metadata without manufacturing operational noise.
For the successful path, record both execution and useful-result outcomes. An import that runs cleanly and emits zero rows may be correct for one source and alarming for another, so “zero” needs a policy attached to the job definition. The monitoring layer should know whether zero results are allowed, how many consecutive empty windows matter, and which team owns the source contract. Otherwise a generic zero-result alert will train responders to ignore it.
Why tracing every import was rejected
The rejected design is “trace every import and alert from traces.” It stores more execution detail than this decision requires, and it still needs an expectation model to distinguish a missing schedule from a quiet trace pipeline. For a high-volume multi-tenant SaaS system, that data volume competes directly with retention and may encourage broad sampling that removes the rare failure the team needed.
It is still a valid choice for a low-volume, high-value workflow where each execution crosses several services and the full causal path is required for audit or diagnosis. Stick with full tracing when the investigation question is routinely “which downstream hop consumed the deadline?” rather than “did the scheduled import produce a result?” The limitation runs in the other direction too: metrics plus boundary errors are not suitable when per-step latency and cross-service causality are core requirements.
No universal winner exists.
Budgeting evidence after the reliability decision
Metrics work because repeated observations share a bounded set of dimensions. For scheduled imports, a compact counter can use labels such as job family, environment, and outcome. Tenant ID, import ID, request ID, source URL, error message, stack text, and release commit belong elsewhere. If 2,000 tenants, 4 outcomes, 3 environments, and 20 releases become labels on one metric, the Cartesian upper bound is 480,000 combinations before job names or instances enter the picture. Most combinations may never appear, but the multiplication exposes the risk.
Count before shipping.
Retention should follow the question each signal answers. A recent error event needs enough stack and context to diagnose a regression; an aggregate outcome series may need a longer window to reveal missed schedules and baseline changes. Keeping both at the richest fidelity for the longest period is easy to explain and expensive to operate. A defensible policy estimates daily event count, average encoded event bytes, replication, and retention days, then documents which investigations become impossible when a field or sample is dropped.
Sampling changes meaning. Uniformly sampling rare failures can discard the only example of a new defect, while retaining every repeated failure can let one noisy fingerprint consume the budget. A practical policy keeps the first occurrences of a group and high-severity outcomes, then samples repeated events under an explicit cap. Your mileage may vary when traffic is highly seasonal, so inspect kept-versus-dropped counts rather than trusting a nominal percentage. Metrics that drive the silence alert should not be sampled; aggregation already controls their volume.
This is also where error grouping affects cost and response quality. Stack-based grouping is useful when code location represents remediation. A custom fingerprint can instead encode a stable operational cause, but it must never include unbounded request data. The catch is that aggressive normalization can merge distinct defects. Keep enough stable structure to route ownership, and preserve volatile detail inside the sampled event for diagnosis.
The decision is therefore narrow on purpose. Use a low-cardinality outcome signal to detect silence, capture one rich error at each framework boundary, and reserve high-cardinality context for events with a written retention and sampling policy. That combination makes a stopped import visible without turning every request header or tenant into a permanent dimension.
Top comments (0)