Express error tracking: keeping the request id on every capture
Four places an error escapes an Express app, one reporter, and an AsyncLocalStorage trick that keeps the request id and user id attached to all of them.
Express catches less than most people assume. Its error middleware sees a synchronous throw, and on Express 5 a rejected promise returned by an async handler — a throw inside a timer, or a promise nobody awaited, goes straight past it to the process. A backend setup that actually reports everything is four capture sites feeding one reporter, and on Infrai that reporter is a single POST /v1/errors/capture with no SDK in the chain.
The hard part isn’t the POST. Three of those four sites have no req in scope, so the request id and user id you want on the event aren’t there to read — and pasting them into the message text quietly wrecks grouping, because an event sent without a fingerprint is grouped on its raw message string. AsyncLocalStorage solves the first half. An explicit fingerprint solves the second.
Where an Express error actually escapes
| Escape route | Error middleware sees it? | Is req in scope? | Capture site |
|---|---|---|---|
throw in a sync handler | yes | yes | error middleware |
rejected promise from an async handler | Express 5 yes, Express 4 no | yes | error middleware |
throw inside setTimeout or an emitter callback | no | no | uncaughtException |
| promise rejected with nothing awaiting it | no | no | unhandledRejection |
anything thrown after res.end() | no | no | process hooks |
Four sites, one reporter. That’s the whole architecture.
On Express 4 the second row is the one that bites: the handler rejects, nothing forwards it, and the client hangs until a proxy times it out. That’s what the express-async-errors shim was for, and it’s the single best reason to move to Express 5.
One store, read from four places
AsyncLocalStorage from node:async_hooks gives you a per-request store that follows the async chain — through await, through setTimeout, into the callback that eventually explodes.
// request-context.mjs — Node 22 ESM, no dependencies.
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
const store = new AsyncLocalStorage();
export const context = () => store.getStore() ?? {};
export function requestContext(req, res, next) {
const requestId = req.get("x-request-id") ?? randomUUID();
res.setHeader("x-request-id", requestId);
store.run({ requestId, userId: null, path: req.path }, () => next());
}
export function identify(userId) {
const s = store.getStore();
if (s) s.userId = userId; // set it once your session middleware resolves
}
We checked the awkward case on Node 22.20 rather than assuming it: a store entered inside store.run() is still readable from both process.on('uncaughtException') and process.on('unhandledRejection'), as long as the failing async resource was created during that request. A throw at module load, or from a bare setInterval started at boot, has an empty store — which is why the reporter below defaults every field instead of crashing on undefined.
The reporter
// reporter.mjs — one POST, fire it from anywhere.
import { context } from "./request-context.mjs";
const ENDPOINT = "https://api.infrai.cc/v1/errors/capture";
function culprit(error) {
for (const line of String(error.stack ?? "").split("\n").slice(1)) {
if (!line.includes("/src/") || line.includes("node_modules")) continue;
return line.trim().replace(/^at\s+/, "").split(" (")[0].replace(/^(async|new)\s+/, "");
}
return "no-frame";
}
export async function report(err, { site }) {
const key = process.env.INFRAI_API_KEY;
if (!key) return null;
const { requestId = "-", userId = "-", path = "-" } = context();
const error = err instanceof Error ? err : new Error(String(err));
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({
message: `${error.name}: ${error.message}\n[site=${site} request_id=${requestId} user=${userId} path=${path}]\n${error.stack ?? ""}`.slice(0, 8000),
exception: error.name,
fingerprint: `${site}:${error.name}:${culprit(error)}`,
environment: process.env.NODE_ENV ?? "development",
release: process.env.APP_RELEASE ?? "dev",
}),
signal: AbortSignal.timeout(2000),
});
if (!res.ok) { console.error("[capture]", res.status, await res.text()); return null; }
return (await res.json()).data;
} catch (e) {
console.error("[capture] failed:", e.message); // never let the reporter throw
return null;
}
}
culprit() deliberately keeps the function symbol and drops the file:line:col tail. Line numbers move on every refactor, and a fingerprint containing them gives you a brand-new group each release for a bug that never went away. The request id goes in the message, never in the fingerprint — one variable id in there and you’d get one group per request.
Wiring it up
// app.mjs — Express 5.
import express from "express";
import { requestContext, identify } from "./request-context.mjs";
import { report } from "./reporter.mjs";
const app = express();
app.use(requestContext);
app.use((req, res, next) => {
const auth = req.get("authorization");
if (auth) identify(`u_${auth.slice(-6)}`); // replace with your real session lookup
next();
});
app.get("/orders/:id", async (req, res) => {
const order = await loadOrder(req.params.id);
res.json(order);
});
async function loadOrder(id) {
if (!/^\d+$/.test(id)) throw new TypeError("Cannot read properties of null (reading total)");
return { id, total: 42 };
}
app.use((err, req, res, next) => {
void report(err, { site: "http" }); // do NOT await: the client shouldn't wait on us
res.status(500).json({ error: "internal_error", request_id: res.getHeader("x-request-id") });
});
process.on("unhandledRejection", (reason) => { void report(reason, { site: "unhandledRejection" }); });
process.on("uncaughtException", async (err) => {
await report(err, { site: "uncaughtException" }); // here you DO await — the process is about to die
process.exit(1);
});
app.listen(3000, () => console.log("listening on :3000"));
Two different await policies in one file, on purpose. In the HTTP path the response is already owed to a user, so the capture runs unawaited and a slow network costs nobody anything. In the fatal path you own the exit, so you await the POST with its 2-second AbortSignal budget and only then call process.exit(1). Returning the request id in the 500 body is what makes the whole scheme pay off later.
What lands on the wire
Same payload as the reporter builds, so you can confirm the account is wired up before trusting it to a crash path:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"message": "TypeError: Cannot read properties of null (reading total)\n[site=http request_id=req_9f2c1b04 user=u_41ab77 route=GET /orders/:id]\n at applyCredit (/app/src/orders/credit.js:57:22)",
"exception": "TypeError",
"fingerprint": "http:TypeError:applyCredit",
"environment": "production",
"release": "web@2026.07.24"
}'
{
"ok": true,
"data": {
"event_id": "evt_err_PJZyiDbcxczADxF5sI4froZb",
"fingerprint": "9dd894b2ead73637c2e525bd26e088aa0064713a6318cb2e5690990426c6068f",
"error_group_id": "errgrp_zTqeoN8bHpR0TddhWgvaAXuX",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_PJZyiDbcxczADxF5sI4froZb"
}
}
The fingerprint that comes back is a SHA-256 of the string you sent, so grouping stays stable without exposing your naming scheme. Now the payoff: a customer quotes the request id from the 500 they saw, and one free read finds the event.
curl -sS "https://api.infrai.cc/v1/errors/search?q=req_9f2c1b04&limit=2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"event_id": "evt_err_PJZyiDbcxczADxF5sI4froZb",
"error_group_id": "errgrp_zTqeoN8bHpR0TddhWgvaAXuX",
"title": "TypeError: Cannot read properties of null (reading total)\n[site=http request_id=req_9f2c1b04 user=u_41ab77 route=GET /or",
"environment": "production",
"release": "web@2026.07.24"
}
],
"next_cursor": null,
"total": 1
}
}
Note the truncated title: it’s the first 120 characters of message, so put the exception line first and the context line second, or your group titles all read [site=http request_id=…. Search needs a non-empty q; the environment and level filters live on GET /v1/errors/list instead, which is a genuine wart.
What the store keeps, and what it drops
Whatever you pass as exception is normalised to {"type": "Message", "value": "…", "stacktrace": []}. There are no structured frames, so no source maps, no minified-frame resolution, no in-app markers — your stack survives purely as the text you put in message. Capture does keep more than the five documented fields: send tags, user_id or level alongside them and they come back on GET /v1/errors/get/{event_id}, with user_id feeding the group’s user_count.
That’s a real limitation, and it decides the choice for a lot of teams. If you want clickable frames from a bundled TypeScript service — click a frame in the dashboard, land on the original line of the original .ts file — buy Sentry, because that’s the one thing this route can’t do at any price, and their Node SDK also handles worker threads, release-artifact upload and a transport queue you’d otherwise write yourself. Rollbar is the reasonable middle option if you additionally want per-event ownership and a workflow around who’s fixing what. What you get here instead is a durable event store you can read with curl, grouping you control by hand, free reads, and no instrumented require chain sitting between your code and the socket — which is a fair trade when the stack text in message is enough to find the bug, and a bad one when it isn’t.
Be honest with yourself about which of those two you are.
What it costs to leave on
Captures are billable per call at $0.00005 as verified on 2026-07-26, with $2 of free credit on a new account — roughly 39,999 events before you pay anything — while every read route here (list, search, groups, group_detail, get) is free and rate-limited. Rates on this platform trend downward and campaigns run, so today’s figure may well be lower than the one printed here; check your own spend rather than trusting the number.
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The structural point outlives any rate: writes are metered, reads aren’t, and the same key already reaches queues, cron, storage and email — so paging someone or re-enqueuing the failed job after a capture doesn’t need a second vendor, a second SDK or a second invoice.