TL;DR: For simple React frontend plus NodeJS backend error tracking, capture JavaScript errors through your API and put one request_id in the response, error report, and structured log. For a flagged pricing change, also record the flag key and evaluated variant. This gives a solo operator a practical incident trail without pretending that matching IDs is distributed tracing.
My decision rule is blunt: optimize for the hour after a customer reports the wrong checkout total. I need to move from a browser failure to the exact pricing request and flag decision in minutes, then get back to shipping. A replay video or a beautiful span tree can help, but neither replaces a stable correlation ID at the API boundary.
How should React frontend and NodeJS backend errors share a trace ID?
Picture the release: a new pricing rule is enabled behind pricing_rule_v2. A buyer sees an error after changing quantity. Support has the account ID and an approximate time, but no useful stack trace. The important questions are concrete. Which API request failed? Which rule variant did the server evaluate? Did the browser fail before it received a response, or did the API return an error?
That constraint changed the setup. Frontend and backend errors do not need identical payloads. They need a shared join key and enough release context to explain a decision. I use a server-generated request_id, return it in a response header, and include it in every log written while handling that request. The frontend reports it when available.
The ID is not magic. It is an index card.
Keep it boring.
For the flag, record the evaluated variant rather than every rule definition. The incident trail should say that request req-123 used pricing_rule_v2=enabled; it should not copy customer targeting rules or sensitive checkout data into an error system. Redaction belongs before transport, not in a cleanup task after an incident.
The smallest useful implementation
The following pieces show the whole correlation mechanism. The API creates the ID, exposes it to the browser, and logs the pricing decision. The browser keeps the latest ID and reports both uncaught errors and rejected promises through a first-party endpoint.
// server.ts
import express, { NextFunction, Request, Response } from "express";
import { randomUUID } from "node:crypto";
const app = express();
app.use(express.json({ limit: "16kb" }));
app.use((req: Request, res: Response, next: NextFunction) => {
const requestId = randomUUID();
res.locals.requestId = requestId;
res.setHeader("X-Request-Id", requestId);
next();
});
app.post("/api/price", (req: Request, res: Response) => {
const flagVariant = "enabled";
console.info(JSON.stringify({
event: "price_calculated",
request_id: res.locals.requestId,
flag_key: "pricing_rule_v2",
flag_variant: flagVariant,
sku: String(req.body.sku),
}));
res.json({ total_cents: 4200 });
});
app.post("/api/client-errors", (req: Request, res: Response) => {
const report = req.body as Record<string, unknown>;
console.error(JSON.stringify({
event: "client_error",
intake_request_id: res.locals.requestId,
related_request_id: report.request_id ?? null,
message: String(report.message ?? "Unknown browser error").slice(0, 500),
page: String(report.page ?? "").slice(0, 300),
release: String(report.release ?? "unknown").slice(0, 80),
}));
res.status(202).end();
});
app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
console.error(JSON.stringify({
event: "api_exception",
request_id: res.locals.requestId,
method: req.method,
path: req.path,
message: error.message,
stack: error.stack,
}));
res.status(500).json({ error: "Internal error", request_id: res.locals.requestId });
});
app.listen(3000);
If the backend uses Infrai as its error store, the retrieval side can stay small. The service exposes a plain REST API, so this code needs no vendor SDK; the base URL remains configuration so the browser never sees the backend key. The search operation currently declares no filter parameters, which means correlation is a manual inspection of returned events rather than a promised server-side request_id query.
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseUrl) {
throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}
async function searchStoredErrors(attempt = 0): Promise<unknown> {
const response = await fetch(new URL("/v1/errors/search", baseUrl), {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return searchStoredErrors(attempt + 1);
}
if (!response.ok) {
throw new Error(`Error search failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
void searchStoredErrors().then((result) => console.dir(result, { depth: 4 }));
// errorTracking.ts
let latestRequestId: string | undefined;
export async function trackedFetch(input: string, init?: RequestInit): Promise<Response> {
const response = await fetch(input, init);
latestRequestId = response.headers.get("X-Request-Id") ?? undefined;
if (!response.ok) {
reportError(`API returned ${response.status}`, latestRequestId);
}
return response;
}
function reportError(message: string, requestId?: string): void {
const body = JSON.stringify({
message,
request_id: requestId,
page: window.location.pathname,
release: "checkout-2026-10-11",
});
if (!navigator.sendBeacon("/api/client-errors", body)) {
void fetch("/api/client-errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
});
}
}
window.addEventListener("error", (event: ErrorEvent) => {
reportError(event.message, latestRequestId);
});
window.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => {
const message = event.reason instanceof Error
? event.reason.message
: String(event.reason);
reportError(message, latestRequestId);
});
In production, the fixed release string comes from the build pipeline. The example also deliberately avoids shipping request bodies, email addresses, card data, or full browser state. Add authentication and rate limiting to the intake endpoint before exposing it. A public error collector is otherwise an easy way to accept junk.
There is one subtle limitation: latestRequestId is only the most recently observed API request. A page with concurrent requests should attach the ID to the error at the call site, as trackedFetch does for non-success responses, rather than assuming a global value identifies the cause. The global is still useful for uncaught failures, but it is supporting context, not proof.
Run the failure drill before choosing a vendor
Start with the customer report and the browser event. Take related_request_id and find the matching API exception or pricing log. Confirm the release, then compare flag_variant on successful and failed requests for the same SKU. If the browser event has no related ID, narrow by release, page, and timestamp, but treat the match as uncertain.
This sequence distinguishes three common shapes without requiring a replay. An API exception shares the ID directly. An API rejection has the response ID and status in the frontend report. A purely local rendering failure has no causal API match, which is itself useful evidence that the investigation belongs in browser tooling.
Do not log the entire flag configuration “just in case.” It creates noisy events and can leak targeting attributes. If auditability matters, keep flag-change history in the flag system or deployment record, then join it by flag key and time. The supplied correlation fields explain evaluation; they do not establish who changed a rule.
Now the buying question is narrower. Can the chosen system preserve these fields, and which missing diagnostic would cost the most operator time?
Compare tools by the evidence they preserve
The application contract stays useful whichever backend receives the events. Tool choice depends on what I cannot afford to build or manually inspect.
| Option | Strong fit | Boundary for this rollout |
|---|---|---|
| Sentry | Browser stack traces, source maps, releases, and session replay | Adds a dedicated frontend monitoring workflow, which is justified when browser diagnosis is the hard part |
| Datadog | Logs, errors, real user monitoring, and distributed traces in one broad observability suite | More surface area to configure and operate than a small service may need |
| Honeycomb | High-cardinality event analysis and trace-oriented debugging | Best when instrumented traces and exploratory production queries are central to the workflow |
| Infrai | Backend error capture and log search alongside other backend services under one key and one bill | Correlation uses stored trace_id, span_id, or request fields; it does not provide a distributed trace query or span tree |
Those are different products, not four interchangeable error inboxes. Sentry is the clearest starting point when minified browser stacks and replay decide whether an issue can be reproduced. Datadog fits a team already standardizing infrastructure telemetry there. Honeycomb earns its keep when the question is usually “what is different about these requests?” and OpenTelemetry traces are part of the design.
For a one-person SaaS already consolidating backend capabilities, Infrai can reduce key sprawl and month-end invoice reconciliation while covering backend exceptions and searchable logs.
Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. Every documented capability ships runnable examples in 10 languages. The broader surface contains 295 routes across 20 modules, all behind one plain REST API, so there is no SDK to install. That removes an SDK upgrade and a vendor-specific client from a weekly release while keeping the interface consistent across backend jobs.
The trade is hands-on correlation: frontend source-map decoding, crash symbolication, and session replay are outside this setup, as are alert and notification routes. A serious browser application should pair it with a dedicated frontend tool. Silent scheduled-job failures also need a heartbeat service such as Healthchecks.
No vendor fixes weak event design. Preserve the same field names across sinks: request_id, trace_id when one exists, release, flag_key, and flag_variant. Do not call a random UUID a trace_id unless it participates in an actual tracing context. Names are part of the debugging interface.
What would force a tracing upgrade?
First, adopt W3C Trace Context and propagate traceparent through every internal request. Then instrument the API with OpenTelemetry so trace_id and span_id come from real spans rather than application conventions. This turns a manual join into data that a tracing backend can visualize.
Second, move browser intake off the web process when volume or abuse risk warrants it. A bounded queue lets checkout respond independently, but the consumer must preserve IDs, redact again, and handle duplicates. Finally, add an alerting path and heartbeat monitoring. Searchable errors only help after someone knows to search.
I would not start there for a weekly flag rollout with one API. Each moving part consumes the same hours that could improve the pricing rule. Begin with the ID contract, structured events, and one tested incident query. On a small checkout service, that single query has a higher revenue-per-hour return than a tracing migration: it answers the immediate support question, leaves the pricing path understandable, and can survive a later vendor change because the fields belong to the application. Outsource source-map processing, replay, paging, and trace storage when those capabilities become necessary. Own the context unique to the product; rent the undifferentiated machinery.
Top comments (0)