DEV Community

rasmusberg6592
rasmusberg6592

Posted on

How to Apply Practices for Uncaught Exceptions in Node.js Error Tracking — Game Imports

Short answer: the best practices for handling an uncaught Node.js exception in a scheduled game-data import are to treat it as a failed batch, preserve it as one bounded error-tracking event, and charge the resulting telemetry to the import job rather than to a vague service total. The practical sequence is: emit a heartbeat before work starts, record a completion marker only after the output is committed, capture the uncaught exception once, and page on missing completion markers instead of on raw exception volume.

That distinction matters in gaming. A nightly catalog or entitlement import can stop producing results while the process still looks healthy to a generic uptime check. The alert must tell the on-call engineer which job stopped, which dataset was affected, and how much telemetry that job generated. Otherwise, the team pays the operational cost of investigating a signal that cannot reconstruct the failure.

The signal has to be boring.

Evidence first.

How should practices guide handling uncaught exceptions for Node.js error tracking?

An uncaught exception is a process-boundary event, not proof that every row in the input is bad. In Node.js, the process-level uncaughtException event exists for last-resort synchronous cleanup; it is not a recovery boundary. Continuing normal execution after an uncaught exception can leave application state undefined, so the handler should record context, request termination, and let the scheduler or supervisor start a fresh attempt.

For an import, define three states explicitly: started, committed, and failed. A started marker includes job_id, run_id, dataset name, source revision, and a deadline. A committed marker is written only after the result is durable. A failed marker carries the normalized exception class, a stack-trace fingerprint, and the same identifiers. The alert condition is a started run with no committed or failed marker by its deadline.

This is the first operational limitation: a completion marker can prove that a job reached its commit boundary, but it cannot prove that the imported business data is semantically correct. A separate validation step is still required.

This catches the silent case: the worker disappears before it reports completion. It also prevents a noisy exception counter from becoming the only health signal. A retry can produce a second exception, but it should retain the same run_id or an explicit attempt number so that operators can distinguish one failing run from two unrelated failures.

A useful event envelope stays small. Keep high-cardinality values such as a raw user identifier, full input payload, or unbounded stack text out of labels. Put them in a controlled log body, redact secrets before export, and use stable attributes for aggregation. OpenTelemetry describes logs as records with a body and attributes; that separation is useful here because the job identity belongs in attributes while the diagnostic detail belongs in the body.

How do you capture the failure without creating a second failure?

Install the process-level handler before scheduling any import work. Make it synchronous from the caller's point of view: normalize the error, emit one record, flush the bounded sink, and set a non-zero exit status. Do not restart the import from inside the handler. The supervisor owns restart policy; the handler owns evidence.

The following Go example models the boundary and is intentionally transport-neutral. It can sit beside a Node.js worker as a small sidecar or be adapted to the team's existing telemetry process. The important behavior is the ordering, not the endpoint.

That ordering is the practice.

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "runtime/debug"
    "time"
)

type Failure struct {
    JobID       string    `json:"job_id"`
    RunID       string    `json:"run_id"`
    Dataset     string    `json:"dataset"`
    Kind        string    `json:"exception_kind"`
    Fingerprint string    `json:"fingerprint"`
    Occurred    time.Time `json:"occurred_at"`
    Stack       string    `json:"stack"`
}

func recordAndExit(jobID, runID, dataset string, recovered any) {
    failure := Failure{
        JobID: jobID, RunID: runID, Dataset: dataset,
        Kind: fmt.Sprintf("%T", recovered),
        Fingerprint: fmt.Sprintf("%T", recovered),
        Occurred: time.Now().UTC(),
        Stack: string(debug.Stack()),
    }
    data, err := json.Marshal(failure)
    if err == nil {
        _, _ = os.Stderr.Write(append(data, '\n'))
    }
    os.Exit(1)
}

func main() {
    defer func() {
        if recovered := recover(); recovered != nil {
            recordAndExit("catalog-import", "run-2026-10-29-01", "catalog", recovered)
        }
    }()
    panic("decoder stopped before commit")
}
Enter fullscreen mode Exit fullscreen mode

The example does not pretend that a stack string is a stable identity. In production, fingerprint the exception class and a normalized top frame, then cap stack size before export. A logger that fails while reporting the original error must not replace the original evidence with a second panic; the ignored write error in this small boundary is deliberate, while the process exit remains deterministic.

For Node.js specifically, wire uncaughtException and unhandledRejection to the same failure envelope, but keep their causes distinct. An unhandled rejection often identifies an asynchronous path that missed an error boundary; collapsing it into a generic uncaught exception makes triage harder. The import runner should still finish the current cleanup path, mark the attempt failed if that write is safe, and exit.

How should cost attribution shape the telemetry schema?

Cost attribution is a data-model problem before it is a storage problem. I want every exported record to answer four questions: which job emitted it, which run produced it, what signal class it belongs to, and why it was retained. Those fields support an allocation report without requiring the report to parse arbitrary message text.

Use a small fixed vocabulary: heartbeat, commit, exception, and scheduler_timeout. Attach team, game, environment, job_id, and run_id only when those values are bounded and governed. Put the input revision and exception fingerprint in the event body or a bounded attribute. Never use a player ID, request URL with query parameters, or raw stack frame as a metric label.

Sampling is unsafe for the first exception in a run, because that event may be the only evidence of a missing output. It is reasonable to retain one representative event per fingerprint after the first occurrence, while keeping counters for volume and an unsampled count for the alert path. Retention rules should distinguish audit evidence from debugging detail: a short-lived stack trace and a longer-lived completion marker solve different operational questions.

A simple allocation function can be tested without a telemetry vendor.

package main

import "fmt"

type Event struct {
    JobID string
    Signal string
    Bytes int64
}

func Attribute(events []Event) map[string]int64 {
    byJob := make(map[string]int64)
    for _, event := range events {
        if event.JobID == "" || event.Bytes < 0 {
            continue
        }
        byJob[event.JobID] += event.Bytes
    }
    return byJob
}

func main() {
    events := []Event{
        {JobID: "catalog-import", Signal: "exception", Bytes: 1800},
        {JobID: "catalog-import", Signal: "heartbeat", Bytes: 240},
        {JobID: "entitlement-import", Signal: "heartbeat", Bytes: 220},
    }
    fmt.Println(Attribute(events))
}
Enter fullscreen mode Exit fullscreen mode

The output is not a bill. It is an allocation input. The team can join these bytes, records, or processing units to an internal cost model later without changing the failure detector. That separation keeps accounting changes from weakening the alert contract.

This model is a poor fit when the scheduler cannot provide a stable run identity or when the sink drops records during process shutdown. In those environments, improve durability and identity first; adding more error tracking fields only creates a more detailed report of missing evidence.

Build or buy the operating boundary?

The decision should follow on-call load and attribution fidelity, not a feature checklist. A managed collector can reduce maintenance, while a self-hosted collector can keep event routing and retention policy under the platform team's control. Neither choice fixes an ambiguous job identity.

Choice Operational advantage Boundary to test Cost attribution question
Managed telemetry service Less collector patching and capacity work Can it preserve bounded job and run attributes through export and retention? Can the team export an allocation view without parsing vendor-specific message fields?
Self-hosted collector Routing, redaction, and retention remain locally governed Who owns upgrades, queue capacity, and the failure path when the collector is unavailable? Can collector resource use be assigned to jobs without hiding shared overhead?
Minimal structured logs plus scheduler metrics Small initial surface and clear ownership Does the sink provide enough durability and querying for incident reconstruction? Can bytes and processing time be separated by job and signal class?

This is a capacity decision. Estimate peak concurrent imports, worst-case exception bursts, queue size during an exporter outage, and the bytes produced by stack traces. A single failed run can create a surprising burst if every retry emits the full payload. Cap retries, cap stack size, and keep the failure envelope smaller than the normal diagnostic log.

Do not hide the queue.

The mistake I see most often is assigning all telemetry to the service that hosts the worker. That is convenient and analytically wrong when several game imports share the process. Attribution belongs at the job boundary, while shared collector overhead should be reported as shared overhead rather than quietly charged to whichever job happened to fail.

What do you verify before trusting the alert?

Test the complete lifecycle with a fixture that has a known dataset revision. First, force a synchronous exception before commit. Then force an asynchronous rejection. Finally, terminate the worker between processing and the commit marker. Each case should produce one failed attempt or one scheduler timeout, preserve the same job and run identity, and cause a non-zero worker exit. A healthy run should produce exactly one commit marker and no failure marker.

Check the negative cases too. A late commit must not clear a timeout that has already paged without an explicit late-run state. A duplicate failure must not inflate the incident count. A malformed event must be rejected without taking down the collector. A missing run_id must be visible as instrumentation debt, not silently attributed to a default service name.

Rollback is straightforward if the schema is additive. Stop emitting the new signal class, leave the existing completion marker in place, and keep the scheduler timeout rule active. Do not delete historical events during rollback; retain enough evidence to explain why the allocation view changed. Once the worker is stable, replay a bounded fixture and compare started, committed, failed, and timed-out counts before re-enabling the new exporter path.

The runbook's success criterion is modest: an import that dies is noticed, its evidence is bounded, and its telemetry can be attributed to the job that created it. That is more useful than a dashboard full of uncategorized exceptions, and it remains valid as the storage or collection implementation changes.

References

Top comments (0)