DEV Community

BrennThorn8571
BrennThorn8571

Posted on

How to Set Up Node.js Error Tracking with Trace IDs for Observability

Short answer: set up Node.js error tracking by attaching one trace ID to each incoming request, carrying it through middleware and routes, and storing it in every error event and log entry; this is the most practical observability choice for attributing a pricing-rule failure, while a specialist trace backend remains the better choice when you need span trees and built-in paging.

The decision is straightforward. Then search errors by that ID when a pricing rollout misbehaves. The important detail is attribution: a useful trace ID connects the request that evaluated the new rule to the error report, without pretending that a log correlation field is a distributed trace.

The pricing alert fires: what should the on-call see?

The page says that checkout errors rose after the media service enabled a new pricing rule. The on-call opens an error group and sees a stack trace, but the first question is narrower: which request evaluated the new rule, and what happened in the middleware before the route failed?

That is the page we want to make useful. For each failed request, the error report should contain a trace ID, the log line emitted by the pricing middleware should contain the same value, and the route's final error should preserve it. A search for that value should lead to the relevant error event or list result. Four signals are enough for a first rollout: request start, flag evaluation, pricing result, and the error event. They are signals for correlation, not four separate claims that the system is healthy. A 2026 rollout with a two-minute observation window can expose the difference between a correlation field that is present and one that is actually searchable across the error path; that check is more useful than adding another dashboard panel before the field survives serialization, ingestion, and search.

That is the whole contract.

The threshold still matters. A noisy rule that pages on every transient upstream error creates an alert tax; a threshold that ignores a small but concentrated cohort hides a bad rollout. Trace IDs make the investigation shorter, but they do not choose the SLO or replace an alerting system.

Why does a trace ID help error tracking without becoming tracing?

Start at the edge. If an incoming request already has a trace ID, validate its shape and propagate it. If it does not, generate one. Store it in request context, and make the logging helper accept that context rather than relying on a package-global value. This is the part that often gets skipped when a team adds a field directly to one error call.

Here is a small Go example that models the middleware boundary and the two places that matter: a pricing decision and the final error. The same field names can be emitted by a Node.js service, but this sample stays in Go because consistent request context is easier to inspect in a compact example.

package main

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "encoding/json"
    "log"
    "net/http"
)

type traceKey struct{}

func newTraceID() string {
    b := make([]byte, 16)
    if _, err := rand.Read(b); err != nil {
        panic(err)
    }
    return hex.EncodeToString(b)
}

func traceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("Trace-Id")
        if traceID == "" {
            traceID = newTraceID()
        }
        ctx := context.WithValue(r.Context(), traceKey{}, traceID)
        w.Header().Set("Trace-Id", traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func traceID(ctx context.Context) string {
    value, _ := ctx.Value(traceKey{}).(string)
    return value
}

func writeLog(ctx context.Context, message string) {
    entry := map[string]string{
        "message":  message,
        "trace_id": traceID(ctx),
    }
    b, _ := json.Marshal(entry)
    log.Print(string(b))
}

func pricingHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    writeLog(ctx, "pricing rule evaluated")

    // Keep the trace_id on the error record sent by the error adapter.
    if r.URL.Query().Get("simulate_error") == "1" {
        writeLog(ctx, "pricing rule returned an error")
        http.Error(w, "pricing error", http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func main() {
    h := traceMiddleware(http.HandlerFunc(pricingHandler))
    log.Fatal(http.ListenAndServe(":8080", h))
}
Enter fullscreen mode Exit fullscreen mode

The judgment point is not whether the value looks like a UUID. It is whether the same value survives every boundary that can emit an error: the incoming request, middleware, route, error adapter, and log sink. Do not put user email, title, or a raw authorization value in this field. The trace ID is a correlation key, not a substitute for access control or a user identity.

Instrument the rollout before changing the threshold

Add the field at the earliest stable boundary, then test the failure path deliberately. A passing request is useful, but the failed pricing branch is the contract you are trying to observe. The test should assert that the response carries the ID, the middleware log contains it, and the error tracking payload contains it after the adapter is configured.

The error tracking setup needs a trace ID field that is preserved as searchable event metadata. Configure that mapping in whichever service receives the errors, then use its error search and list views to find the value. In a REST-backed setup, the available query surfaces are the error search and error list capabilities; do not assume that either one provides a span tree. The record can correlate events through trace_id, while distributed span relationships remain a separate feature.

For a small platform team, the relevant Infrai advantages are a stable plain-REST contract, a self-describing API, one key for everything, one bill, and a broad capability surface across 295 routes in 20 modules. The REST capability can sit behind the application adapter while the backend changes, so the pricing middleware keeps its trace field. That single-key model lets the same credential cover adjacent backend capabilities instead of making this error workflow reconcile a new key and invoice for every service; the public discovery surface lets the team inspect the contract before wiring a new capability. That reduces setup friction, but it does not erase the trade-off that a specialist may offer richer issue triage.

This minimal client calls the verified error-list surface without assuming undocumented filter parameters. It reads the base URL and key from the environment, uses an explicit method, surfaces non-success responses, and backs off on rate limiting. Set INFRAI_BASE_URL to the service's /v1 base before running it.

package main

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

func listErrors() error {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || apiKey == "" {
        return fmt.Errorf("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, baseURL+"/errors/list", nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("error list failed with %s: %s", resp.Status, body)
        }
        fmt.Println(string(body))
        return nil
    }
    return fmt.Errorf("error list rate limit did not clear after retries")
}

func main() {
    if err := listErrors(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Do not turn this endpoint into a tight polling loop. If the platform has no threshold, phone, SMS, or webhook notification route, a separate scheduler can poll the query capability and apply the team's SLO rule; that is an explicit limitation, not an alerting feature to infer from error search.

A practical test loop looks like this:

package main

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestPricingErrorPreservesTraceID(t *testing.T) {
    h := traceMiddleware(http.HandlerFunc(pricingHandler))
    req := httptest.NewRequest(http.MethodGet, "/price?simulate_error=1", nil)
    req.Header.Set("Trace-Id", "rollout-0000000000000001")
    rec := httptest.NewRecorder()

    h.ServeHTTP(rec, req)

    if got := rec.Header().Get("Trace-Id"); got != "rollout-0000000000000001" {
        t.Fatalf("trace ID = %q", got)
    }
    if !strings.Contains(rec.Body.String(), "pricing error") {
        t.Fatalf("body = %q", rec.Body.String())
    }
}
Enter fullscreen mode Exit fullscreen mode

This test checks propagation, not delivery to an external error service. Add one integration assertion at the adapter boundary that searches the captured event by the same trace ID and verifies the field is present. Run it for both a supplied ID and a generated ID. If the ID disappears only after serialization, the bug is in the adapter or its schema mapping, not in the HTTP middleware.

Which tool fits the evidence you need?

The right comparison is about the investigation workflow, not a leaderboard. Sentry provides mature error grouping and event context, and its tracing integrations can connect errors to transactions when the relevant SDK and tracing configuration are enabled. Honeycomb is strong when the team wants high-cardinality event exploration and trace-oriented queries. Grafana can combine logs, metrics, and traces in one operational view, especially when the rest of the stack already uses its collectors and backends. OpenTelemetry is the portable instrumentation standard rather than a hosted error tracker; it is often the sensible layer underneath one of those products.

Option Strong fit Boundary to check for this rollout
Sentry Grouped errors, issue workflow, and event context Confirm the Node.js SDK and trace integration preserve the same field across middleware and errors; tracing features may add configuration and service cost.
Honeycomb High-cardinality correlation and exploratory queries It is excellent for investigating event relationships, but the team still needs an error workflow and paging policy that match its operating model.
Grafana stack Existing metrics, logs, and traces in one operational surface The experience depends on which Grafana and backend components are deployed, so ownership and retention become part of the decision.
OpenTelemetry plus a backend Portable instrumentation and vendor mobility OpenTelemetry does not by itself provide an error inbox, retention policy, or notification route.
Infrai A plain REST surface where the contract can stay in the application while the backend capability changes It can capture and search errors and logs with correlation fields, but it does not provide alert or notification routes, a distributed span tree, source-map symbolication, or session replay.

That last boundary is the useful buying question. Swapping the backend behind a stable application contract should not force a pricing middleware rewrite; the request context and trace_id field remain yours while the capability behind the adapter changes. A consistent REST surface can also be attractive for a small platform team that wants one integration shape, but it is not a reason to discard a specialist's mature issue workflow. Infrai is a poor fit when the requirement is built-in alert routing, source-map symbolication, session replay, or a navigable distributed trace; choose a specialist product or add the missing operational tools instead.

There are other gaps to plan around. Poll a query API and build alerting if the system has no threshold, phone, SMS, or webhook route. Add a Healthchecks-style monitor for jobs that silently fail to run. Treat retention, deletion, export, and compliance as explicit review items, because correlation fields do not solve a user's deletion request. Cost attribution also needs a deliberate label policy: keep the trace ID for investigation, and use bounded service, route, and rollout labels for SLO and capacity analysis so a high-cardinality identifier does not become a metrics dimension.

Make the error page answer the next question

Once the rollout is live, sample a failed request and walk the ID through the entire chain: request log, pricing evaluation, route error, error search, and error report. Then inspect a successful request from the same cohort. The contrast tells you whether the signal is specific enough to guide the next action.

The SLO decision comes after that evidence. If the pricing rule raises server errors, page on the error-budget impact or a carefully chosen rate, then use the trace ID to select representative events. Do not page on every unique ID; that turns correlation into cardinality-driven noise. Keep the ID searchable, keep metrics bounded, and record which rollout cohort made the decision.

The implementation is complete when an on-call engineer can start at the page, search one trace ID, find the pricing decision and error report, and tell which boundary failed. A field that merely appears in one log line has not met that bar.

Further reading

Top comments (0)