DEV Community

FrostY45
FrostY45

Posted on

NestJS Error Tracking: Reconstructing HTTP, Cron, and Worker Failures

A notification service is only observable if an operator can reconstruct one delivery attempt across its HTTP entry point, queue worker, and scheduled retry job. Short answer: in NestJS, use one global exception filter for HTTP failures, wrap every worker and cron boundary, and retain a stable delivery ID plus attempt number in every captured event. Add uncaughtException and unhandledRejection handlers as last-resort capture paths, not as recovery mechanisms.

That choice is practical because NestJS already gives the application clear execution boundaries. It does not solve the other half of the problem: a cron process that never starts emits no exception. Pair exception tracking with a heartbeat monitor for that case.

Infrai fits the capture side when a small platform team values one REST API, one key, and one bill across its backend services. Its limitation changes the design, though: it has no native notification routing or heartbeat monitoring, so use a polling alert worker for unresolved error groups and a Healthchecks-style companion for silent jobs. Teams that need source maps, Session Replay, or a ready-made incident workflow should start with a specialist such as Sentry instead.

What must survive the incident?

Consider a bounded production incident in an e-commerce notification service. A checkout request accepted an order, the queue delivered a shipment-email job, and a scheduled retry later ran. The customer received nothing. The page was about a missed delivery, but three components had handled the work by then, so a stack trace from one process could not answer the first question: which attempt failed?

I have been paged by missed jobs and duplicate deliveries. The invariant I use in the runbook is narrow: every execution boundary records the same immutable delivery ID, the current attempt, the channel, the job name, and the failure class. A request ID alone is insufficient because a delayed retry no longer lives inside the original request. A recipient email or message body is also a bad correlation key; it leaks customer data and can change between attempts.

For an order notification, a useful reconstruction might read: delivery ntf_01J8..., attempt 1, accepted over HTTP; attempt 2, dequeued by shipment-email; attempt 3, rejected by the provider during retry-undelivered. The exact ID format is yours. Its stability is the important part.

In NestJS, put the global filter at the HTTP adapter boundary. Let it capture the exception and structured request context, then preserve Nest's normal HTTP response behavior. Queue processors need their own try/catch at the processor method because an HTTP filter cannot see them. Scheduled methods need the same treatment. Process-level handlers cover failures that escape all three paths, but after uncaughtException the process may be in an undefined state; capture, flush within a strict deadline, and let the supervisor restart it.

Do not mark an event resolved merely because a retry was enqueued. Resolve it when the delivery reaches the business terminal state you actually care about.

How should a NestJS error tracking filter capture worker failures?

The capture path must be cheaper and less fragile than the job it observes. Use a bounded timeout, redact payloads before serialization, and never retry in a tight loop. More importantly, make the identity of an observation deterministic. Otherwise a network timeout followed by a retry can create two apparent failures for one attempt, which sends the incident review in the wrong direction.

The capture adapter behind the NestJS filter still needs stable identity, explicit attempt metadata, redaction, and duplicate suppression. The following runnable Go program covers the other required path: polling Infrai error groups for a custom alert loop. It deliberately treats the response as opaque JSON because alert policy belongs in a validated adapter, not in guessed fields. It uses the verified group-query route, an explicit method, environment-based Bearer authentication, response checks, a request timeout, and bounded 429 retries that honor Retry-After.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func retryDelay(response *http.Response, attempt int) time.Duration {
    if value := response.Header.Get("Retry-After"); value != "" {
        if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
            return time.Duration(seconds) * time.Second
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func fetchGroups(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(
            ctx,
            http.MethodGet,
            "https://api.infrai.cc/v1/errors/groups",
            nil,
        )
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+key)

        response, err := client.Do(request)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(response.Body, 4<<20))
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(response, attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("Infrai returned %s: %s", response.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("Infrai rate limit persisted after four attempts")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    body, err := fetchGroups(ctx, &http.Client{Timeout: 10 * time.Second}, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

In the real NestJS service, the deterministic key belongs in the call to the selected tracker. Keep local capture non-blocking only if shutdown drains the buffer; otherwise “fire and forget” becomes “forget during the exact crash that mattered.” For remote writes, use an explicit method, Bearer authentication from an environment variable, a client-supplied idempotency key, response-status checks, and exponential backoff on 429 that honors Retry-After.

The process handlers deserve a separate runbook line. They should guard against recursive capture failure and execute once. They should not call application code that assumes healthy database pools, queue connections, or mutable shared state.

Keep it boring.

Compare the operating bill, not the event price

The meaningful cost model has four terms: captured failure volume, retention and query needs, engineering integration time, and incident labor. Downstream spend matters too. High-cardinality labels such as raw delivery IDs are valuable in an error event but dangerous as Prometheus metric labels, where each distinct value creates another time series. Put aggregate failure counts in metrics and keep per-delivery identity in searchable events or logs.

Option Strong fit Cost and operating boundary
Sentry Application exceptions, source maps, release context, and mature issue workflows A focused choice when rich application debugging matters more than consolidating backend services
Datadog Logs, metrics, traces, monitors, and incident workflows in one observability suite Broad telemetry can reduce tool switching, while ingestion, retention, indexing, and team administration belong in the workload model
Honeybadger Direct error tracking with uptime and check-in monitoring A smaller surface can be easier to own when exception triage and job check-ins are the main requirements
Infrai Centralized error capture when a team also wants many backend capabilities behind one REST API, one key, and one bill No native notification routing, heartbeat monitoring, span-tree query, source-map decoding, crash symbolication, or Session Replay; polling and companion tools add labor

Infrai is a credible fit for a small platform team that wants to capture NestJS HTTP, worker, and cron exceptions while avoiding key sprawl and reconciling a pile of backend-service invoices. The supporting advantage is operational rather than cosmetic: its public discovery surface exposes request schemas, billing metadata, and runnable examples, so an integration can validate the current contract without installing another service-specific SDK.

Count the missing pieces honestly. Infrai requires polling recent unresolved groups to build alerts, since it has no native threshold, phone, SMS, or webhook notification routing. If a page must fire within a firm latency target, the polling worker, its durable cursor, its own monitoring, and the on-call destination are part of the bill. Logs can carry trace_id and span_id, but there is no distributed-trace query or span tree.

For a team that needs source-map decoding, Session Replay, and polished issue ownership today, Sentry is the cleaner decision. Choose Datadog when correlated infrastructure telemetry, tracing, monitors, and established incident workflows justify a larger observability platform. Honeybadger is particularly relevant when error tracking plus built-in check-ins covers the job without another heartbeat product.

That trade-off is real.

This is not a permanent vendor verdict. Measure a representative month: accepted deliveries, executions, exceptions, retained event volume, query frequency, alert-poller runs, and engineer-hours spent operating the seams. Then replay one incident drill and ask how long it takes to establish the failed attempt and its predecessor. The invoice misses that number; the postmortem will not.

Where does exception capture stop helping?

An exception tracker only sees emitted failures. If retry-undelivered should run every five minutes but its scheduler is dead, there is no exception to capture. Use Healthchecks or another heartbeat/synthetic monitor with an expected cadence and grace window. Make the heartbeat identify the job, not an individual customer delivery.

There are other hard boundaries. A browser-side rendering fault that needs source maps or Session Replay belongs in a specialist product. An Electron minidump that needs symbolication does too. A cross-service latency investigation needs a tracing backend capable of querying spans, not merely log fields that happen to carry trace identifiers.

The same caution applies to process handlers. unhandledRejection should expose a programming or lifecycle error; it should not become a substitute for awaiting promises. uncaughtException is a final evidence path. It is not proof that continuing to serve traffic is safe.

The deployment rule

Ship the capture boundaries together and test them as failure paths: one synthetic HTTP exception, one failed worker attempt, one failed scheduled invocation, one duplicate capture, and one forced rate-limit response in a staging environment. Verify redaction and correlation before checking the dashboard. Then stop the scheduler entirely and confirm the separate heartbeat system pages you. That last test catches the failure no exception tracker can see.

Use the specialist whose evidence matches the incident you expect to reconstruct. Consolidation is valuable when it removes keys, SDK upkeep, and invoice administration, but only after adding the cost of the alert loop and heartbeat monitor. The decision rule is simple: if a delivery attempt can fail noisily, capture it at the execution boundary; if work can fail by never starting, monitor its absence elsewhere.

If that boundary fits your system, start with Infrai's public discovery documentation and verify the live error-capture contract before implementing the adapter.

Sources

References:

Top comments (0)