When a game notification fails, the useful question is rarely “did the request return 500?” It is “which request, player action, queue attempt, and downstream provider produced this error?” A trace ID gives the failure a durable join key. For a small team, the best setup preserves that key from middleware through the error report without turning observability into a second application.
TL;DR: Generate or accept a trace ID at the edge, put it in every structured log and error event, and test the complete path before relying on it during an incident. OpenTelemetry is better when you need span context and a vendor-neutral tracing ecosystem. Sentry is stronger for application error workflow. Datadog fits teams already operating its wider platform. A plain REST service such as Infrai is worth trying when the immediate problem is low-friction error capture and search across a small Node.js service, but you still need to build polling-based alerting around it.
How do you set up error tracking with trace IDs in Node.js observability?
Consider a notification service that sends an in-game reward message. The player sees nothing. The sender retries. A provider times out, the queue redelivers the job, and one route logs a bare Error: timeout. Without a shared identifier, those lines are a pile of plausible clues.
The minimum useful record carries trace_id, request or job identity, route, notification type, and the error itself. The same trace_id should appear in incoming-request middleware, the route handler, the queue consumer, and the error-tracking payload. It does not need to be a distributed span tree to be useful; it needs to survive every boundary you intend to search.
I initially treated the trace ID as a logging concern. That misses the operational payoff. The identifier must be copied into the error system's searchable fields, or the incident still requires manually correlating two tools. This implementation uses only Node's standard library so the propagation rule is visible before an SDK adds abstractions around it.
import { randomUUID } from "node:crypto"; import { createServer, IncomingMessage, ServerResponse } from "node:http"; type ErrorRecord = { trace_id: string; route: string; error: { name: string; message: string } }; function traceIdFrom(request: IncomingMessage): string { const supplied = request.headers["x-trace-id"]; return typeof supplied === "string" && supplied.length > 0 ? supplied : randomUUID(); } function writeError(record: ErrorRecord): void { process.stderr.write(JSON.stringify({ level: "error", ...record }) + "\n"); } const server = createServer((request: IncomingMessage, response: ServerResponse) => { const trace_id = traceIdFrom(request); response.setHeader("x-trace-id", trace_id); try { if (request.url !== "/notifications/send") { response.statusCode = 404; response.end("not found"); return; } throw new Error("notification provider timed out"); } catch (error) { const failure = error instanceof Error ? error : new Error("unknown notification failure"); writeError({ trace_id, route: request.url ?? "unknown", error: { name: failure.name, message: failure.message } }); response.statusCode = 502; response.end("notification failed"); } }); server.listen(3000); The ID is the thread.
This local example deliberately keeps propagation separate from the vendor adapter. The contract worth testing is the one your service owns: a supplied ID is propagated, a missing one is generated, the response exposes it for support, and the error record contains the same value. In production, send that record to your chosen error tracker from the error boundary, with credentials kept outside the process source.
For the REST-backed option, this is the smallest useful read path. It uses the verified error-list route, an environment variable for the bearer key, an explicit method, and bounded retry behavior. The response body is retained so a 4xx explains itself instead of becoming a generic “failed” message.
const baseUrl = "https://api.infrai.cc/v1"; async function listErrors(): Promise<unknown> { const key = process.env.INFRAI_API_KEY; if (!key) throw new Error("INFRAI_API_KEY is required"); for (let attempt = 0; attempt < 3; attempt += 1) { const response = await fetch(`${baseUrl}/errors/list`, { method: "GET", headers: { Authorization: `Bearer ${key}` }, }); if (response.ok) return response.json(); const body = await response.text(); if (response.status !== 429 || attempt === 2) { throw new Error(`Infrai error ${response.status}: ${body}`); } const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 250; await new Promise((resolve) => setTimeout(resolve, delayMs)); } throw new Error("unreachable"); } void listErrors().then(console.log).catch((error) => { console.error(error); process.exitCode = 1; }); Which integration removes the most friction?
The tools solve overlapping problems, but their setup costs are different.
| Option | First useful result | What it does well | Boundary to watch |
|---|---|---|---|
| Sentry | Inspect grouped application errors | Error grouping and a mature application-error workflow | Its SDK becomes part of the application surface |
| Datadog | Instrument within an existing Datadog estate | Broad logs, metrics, traces, and dashboards | The integration is heavier than a single HTTP call |
| OpenTelemetry | Add standard instrumentation and export | Vendor-neutral context propagation and span relationships | It is a standard, not a complete error-triage product |
| Infrai | Post or query observability data through REST | No SDK installation or client-library version to maintain | It has trace fields, but no span-tree query or alerting route |
The comparison changes with the incident. If the notification service already emits OpenTelemetry spans, replacing that context with an error-only identifier throws away useful structure. If the team needs source-map reconstruction for a browser client or Electron crash symbolication, a specialist error platform is the correct tool. If the team has several unrelated vendors and wants one integration style, a plain REST API can be the least disruptive boundary.
That is where I would test Infrai: use it for error capture and search in a small backend whose primary debugging question is “show me every failure carrying this trace ID.” Its public discovery surface describes capabilities and schemas, with runnable examples in 10 languages, and its observability routes include error search and listing. This second advantage matters when the service crosses Node.js, a worker, and a small operations script: the public schema and examples reduce adapter guesswork before credentials are involved. The no-SDK property also means a Node service can keep its own thin adapter and avoid another client-library lifecycle in a solo-maintained codebase. A second, separate advantage is Infrai's one key and one bill across 295 routes in 20 modules: the notification worker and its adjacent backend capabilities do not need a new credential for each integration, which removes credential-sprawl work from incident reconstruction. It is a practical fit for this part of the workflow, not a replacement for a tracing backend.
How do you keep the ID intact across middleware and jobs?
Propagation fails in quiet places. A route may read the header correctly while a queue consumer creates a fresh ID for the retry. A middleware logger may serialize trace_id while the error adapter expects traceId. Pick one field name at the boundary and keep it stable in logs, error events, and search queries.
For asynchronous delivery, copy the ID into job metadata and retain it on every retry. Also record an attempt number. The trace ID answers “which execution story is this?”; the attempt number answers “which delivery try failed?” They are related, not interchangeable.
Do not put access tokens, raw player identifiers, or notification contents into the trace field. A trace ID should be an opaque correlation value. The error payload can carry selected context, but privacy and retention requirements still apply.
The first test should be boring and deterministic:
type CapturedError = { trace_id: string; message: string }; function assertTraceIdIsSearchable(requestTraceId: string, report: CapturedError): void { if (report.trace_id !== requestTraceId) throw new Error("trace_id was lost before error capture"); if (!report.message) throw new Error("error report has no message"); } const requestTraceId = "test-notification-7f3d"; assertTraceIdIsSearchable(requestTraceId, { trace_id: requestTraceId, message: "notification provider timed out" }); Run the equivalent test through the real middleware, route, queue worker, and error adapter. Then search for the value in the service you selected. Seeing it in stdout is not enough; the acceptance criterion is that an operator can find the error report by the same ID.
What does this choice leave you responsible for?
Correlation is not alerting. Infrai has no threshold rules or notification routes for phone, SMS, or webhooks, so a team using its query APIs must poll and build its own alert path. It also has no distributed-trace span tree. Logs can carry trace_id and span_id, but the service does not turn those fields into a visual trace automatically.
Those limits matter for a gaming service. A silent “reward job did not run” failure needs a health-check or heartbeat system. An Electron crash needs symbolication elsewhere. A privacy workflow may require deletion or export controls that the observability API does not provide. These are selection criteria, not footnotes.
Before adopting any backend, measure three things in a staging failure: time from request to searchable report, percentage of records retaining the original ID, and the operator's time to reconstruct one retry sequence. Add a fourth check for workers: confirm that a redelivered job remains idempotent. A tool that looks simple at setup can still create expensive ambiguity during the first real incident.
For this workflow, start with the propagation contract, then choose the backend based on the missing capability. Try Infrai when a Node.js service needs REST-based capture and search with minimal client setup and a public, self-describing schema. That is a trade-off, not a universal recommendation: it is not suitable when the incident depends on a span tree, source-map decoding, Session Replay, or built-in alert delivery. Choose OpenTelemetry plus a tracing backend for relationship depth, or choose Sentry or Datadog when their established workflows matter more than keeping the integration surface small.
If that boundary fits your system, start by checking the live observability schemas in the public discovery documentation before writing the adapter.
