Node.js error tracking with structured stack traces.
Node.js errors arrive from several directions at once: unhandled promise rejections, uncaught exceptions, framework request cycles, and setTimeout callbacks that swallow their own failures. This guide covers how to wire up @sentry/node so every failure path reaches your error tracker, how V8 stack traces work and what source maps change about them, how async context travels through AsyncLocalStorage, and how to point the DSN at urgentry instead of Sentry.
20 seconds. Create an instrument.js file that calls Sentry.init with your DSN. Launch Node with --import ./instrument.js (Node 18.19+) or --require ./instrument.js (older). Add the Sentry error handler middleware after your routes in Express. Swap the DSN to your urgentry DSN and nothing else changes.
60 seconds. The instrumentation file must execute before any other import. This is not a convention, it is a technical requirement: the SDK patches Node's built-in http and https modules at init time. If any module loads before Sentry.init runs, that module runs without instrumentation. The --import flag solves this at the process level, no matter how your app is structured. Express and Fastify both have first-party integrations. Hono and other edge-first frameworks use the manual Sentry.captureException path.
The three Node gotchas that catch every team: calling process.on('uncaughtException') without exiting after the handler puts the process in undefined state; EventEmitter instances throw if they emit an error event with no listener; and third-party libraries sometimes throw strings instead of Error instances, which produces events with no stack trace. All three are covered below.
Where Node errors come from
JavaScript has one event loop and one call stack, but Node adds several distinct error surfaces that require different handling strategies.
The uncaughtException event fires on the process object when an exception propagates out of the event loop without anything catching it. This is the last resort before the process crashes. By default, Node prints the stack to stderr and exits with code 1. The @sentry/node SDK registers a handler for this event that captures the error and flushes the event buffer before exit. You should not add a second process.on('uncaughtException') handler alongside the SDK unless you flush and re-exit explicitly.
The unhandledRejection event fires when a Promise rejects and nothing in the microtask queue catches the rejection. In Node 15 and later, an unhandled rejection crashes the process the same way an uncaught exception does. In Node 14 and earlier, it prints a warning but the process continues, which means your application may keep running in a corrupted state. The SDK handles this event too, capturing the rejection reason as an event.
The difference between the two matters for triage. An uncaughtException is always a synchronous throw that nothing caught. An unhandledRejection is a Promise rejection that escaped the async call chain. Both signal bugs, but they point to different code paths.
Framework request cycles add a third surface. Express processes a request through a middleware chain that calls next(err) to forward errors to the next error-handling middleware. Fastify uses hooks and a centralized setErrorHandler. Hono follows a similar middleware model. Each framework has its own error propagation path, and the SDK's integrations wire into those paths rather than relying on process-level handlers.
The fourth surface, and the most treacherous, is callbacks inside setTimeout, setInterval, and setImmediate. An exception thrown inside a timer callback escapes the call stack that scheduled it. Nothing in the code that called setTimeout can catch it. The exception propagates to the event loop and fires uncaughtException unless the SDK or another handler intercepts it. This is why timer callbacks should always wrap their bodies in try/catch.
Install @sentry/node
The SDK ships as a single package that covers all Node.js runtimes. Install it with your package manager of choice:
npm install @sentry/node
# or
pnpm add @sentry/node
# or
yarn add @sentry/node
Version compatibility: @sentry/node 8.x supports Node 18, 20, and 22. Node 16 reached end of life in September 2023 and is not supported by the 8.x SDK. If you are on Node 16, upgrade before adding the SDK. The 7.x SDK supports Node 14 through 18, but 7.x is no longer maintained.
Check the engines field in the package.json of the SDK version you install. The SDK pins a minimum Node version and will throw at startup if the runtime is too old. This is a deliberate design decision: running an instrumented SDK on an unsupported Node version produces unreliable results.
The instrumentation file pattern
The most important rule in Node.js SDK setup is this: Sentry.init must run before any other module loads. The SDK patches http, https, net, and other built-in modules at init time to enable distributed tracing and request context. A module that loads before init runs carries no instrumentation.
The correct pattern is a dedicated instrument.js (or instrument.ts) file that contains only the init call:
// instrument.js
const Sentry = require("@sentry/node");
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
});
Or in ESM:
// instrument.mjs
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
});
Then pass the file to Node before your entry point using the --import flag (Node 18.19+ for ESM, or --require for CJS):
# ESM (Node 18.19+)
node --import ./instrument.mjs src/index.js
# CommonJS
node --require ./instrument.js src/index.js
Update your package.json start script to include the flag:
{
"scripts": {
"start": "node --import ./instrument.mjs src/index.js",
"start:cjs": "node --require ./instrument.js src/index.js"
}
}
Do not import the instrumentation file at the top of your entry file. That pattern looks correct but is subtly wrong: it relies on module resolution order, which differs between ESM and CJS and can change when bundlers reorder imports. The --import flag guarantees execution order at the process level.
If you use TypeScript and compile to JavaScript before running, point the flag at the compiled .js output. If you use ts-node or tsx for direct TypeScript execution, use the --require flag with the TypeScript source file and ensure the TypeScript transformer runs before Sentry's module patches are applied.
Express integration
Express has a first-party integration in @sentry/node. The integration adds request context to every event and registers an error handler that captures exceptions from your routes.
The init call in instrument.js handles SDK initialization. In your main Express file, import your framework after the SDK has initialized:
// src/app.js
// Do NOT call Sentry.init here. It runs in instrument.js before this file loads.
const express = require("express");
const Sentry = require("@sentry/node");
const app = express();
app.use(express.json());
// Your routes
app.get("/", (req, res) => {
res.json({ status: "ok" });
});
app.get("/error", (req, res) => {
// This throws and is caught by the Sentry error handler below
throw new Error("Something broke");
});
app.get("/async-error", async (req, res) => {
const result = await fetchFromDatabase();
res.json(result);
});
// Sentry error handler โ must come AFTER all routes and BEFORE any other error middleware
Sentry.setupExpressErrorHandler(app);
// Your own error handler (optional, runs after Sentry captures)
app.use((err, req, res, next) => {
res.status(500).json({ error: "Internal server error" });
});
app.listen(3000);
The order of middleware matters here. Sentry.setupExpressErrorHandler installs a middleware that captures errors forwarded via next(err) or thrown synchronously in route handlers. It must come after all your routes and before any other error-handling middleware you define. If you put your own error handler before it, Sentry never sees the errors your handler swallows.
What the integration captures automatically from an Express request:
- The HTTP method, URL, and query string
- Request headers (excluding cookies and authorization headers by default)
- The matched route pattern (e.g.
/users/:id, not the actual URL with the ID value) - The response status code at the time of capture
- User context if you call
Sentry.setUserin a middleware before the error occurs
Async route handlers in Express 4 do not automatically forward rejected Promises to next(err). Express 5 does. If you are on Express 4, wrap async handlers with a try/catch or use a helper:
// Express 4: wrap async handlers
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.get("/users/:id", asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
}));
Express 5 handles this natively. If you are on Express 4, the express-async-errors package patches Express to do the same thing without the wrapper.
Fastify integration
Fastify has a built-in Sentry integration that registers through the SDK's OpenTelemetry layer. The setup follows the same instrumentation file pattern:
// instrument.js (add fastify to integrations)
const Sentry = require("@sentry/node");
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
integrations: [
// Fastify instrumentation is included in @sentry/node's default integrations
// No additional import required for basic error capture
],
});
// src/app.js
const Sentry = require("@sentry/node");
const Fastify = require("fastify");
const app = Fastify({ logger: true });
// Set up Sentry's Fastify error handler
Sentry.setupFastifyErrorHandler(app);
app.get("/", async (request, reply) => {
return { status: "ok" };
});
app.get("/error", async (request, reply) => {
throw new Error("Fastify route error");
});
app.listen({ port: 3000 });
Sentry.setupFastifyErrorHandler registers a Fastify error hook that captures errors before Fastify's built-in error serialization runs. The hook fires for all errors including those from plugins, lifecycle hooks, and route handlers. You get the full request context attached to every event.
For custom error classes that you want to suppress or enrich, use Fastify's setErrorHandler alongside the Sentry handler:
app.setErrorHandler((error, request, reply) => {
if (error.statusCode >= 400 && error.statusCode < 500) {
// Client errors: do not send to Sentry, just respond
reply.status(error.statusCode).send({ error: error.message });
return;
}
// Server errors: Sentry already captured these via setupFastifyErrorHandler
// You can add more context here if needed
request.log.error({ err: error }, "unhandled server error");
reply.status(500).send({ error: "Internal server error" });
});
Hono and other modern frameworks
Hono, Elysia, and similar edge-first frameworks do not have first-party Sentry integrations in the @sentry/node package. These frameworks are built for the WinterCG-compatible runtime model, where the runtime may be Cloudflare Workers, Deno, Bun, or Node. The Sentry SDK for each of those runtimes differs.
For Hono running on Node.js specifically, use the manual capture pattern with middleware:
// instrument.js โ same as above, Sentry.init must run first
const Sentry = require("@sentry/node");
Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0.1 });
// src/app.js
const { Hono } = require("hono");
const { serve } = require("@hono/node-server");
const Sentry = require("@sentry/node");
const app = new Hono();
// Sentry middleware: wrap every request, capture errors
app.use("*", async (c, next) => {
try {
await next();
} catch (err) {
Sentry.captureException(err, {
extra: {
method: c.req.method,
path: c.req.path,
},
});
throw err; // re-throw so Hono's error handler responds to the client
}
});
app.get("/", (c) => c.json({ status: "ok" }));
app.get("/error", () => {
throw new Error("Hono route error");
});
serve({ fetch: app.fetch, port: 3000 });
The re-throw after capture is important. If you swallow the error in the middleware, Hono cannot send a proper error response to the client. Capture first, then re-throw.
For Hono on Cloudflare Workers, use @sentry/cloudflare instead of @sentry/node. For Hono on Deno, use @sentry/deno. The Node-specific instrumentation in @sentry/node uses APIs that are not available in the Workers or Deno runtimes.
Capturing handled exceptions and adding context
The SDK captures unhandled errors automatically, but most production bugs come from handled errors: exceptions you caught, logged, and converted into user-facing messages. Capture those too with Sentry.captureException:
const Sentry = require("@sentry/node");
async function processPayment(orderId, amount) {
try {
const result = await paymentProvider.charge({ orderId, amount });
return result;
} catch (err) {
if (err instanceof PaymentDeclinedError) {
// Expected outcome: do not track
return { declined: true };
}
// Unexpected provider failure: track it
Sentry.captureException(err, {
tags: {
"payment.provider": "stripe",
"order.id": orderId,
},
extra: {
amount,
providerCode: err.code,
},
});
throw err;
}
}
For identifying which user experienced an error, call Sentry.setUser early in the request lifecycle, before any error occurs:
// In an auth middleware
app.use(async (req, res, next) => {
const user = await getAuthenticatedUser(req);
if (user) {
Sentry.setUser({
id: user.id,
email: user.email,
});
}
next();
});
For one-off context that applies only to a single capture, use Sentry.withScope to avoid polluting the global scope:
Sentry.withScope((scope) => {
scope.setTag("job.type", job.type);
scope.setExtra("job.payload", job.payload);
scope.setLevel("warning");
Sentry.captureException(err);
});
Tags are indexed and filterable in urgentry. Use them for values you will search by: error codes, feature flags, user plan tiers. Use setExtra for everything else.
Stack traces in Node
V8, the JavaScript engine in Node, generates stack traces through a mechanism called Error.prepareStackTrace. By default, this produces the familiar string format: at functionName (file.js:line:column). The SDK reads structured stack frames from V8's native API to build the frame list attached to each event.
For plain JavaScript, the stack trace in urgentry will reference your actual source files and line numbers. For TypeScript compiled to JavaScript, the stack will reference the compiled output unless you upload source maps.
Source maps for Node.js work the same way as for browser code. The SDK reads the sourceMappingURL comment in compiled files at runtime to resolve frames locally, or you can upload maps at build time and let urgentry resolve them server-side. Server-side resolution is more reliable and does not require the maps to be accessible on the server at runtime.
Upload source maps with the Sentry CLI during your CI build:
npx @sentry/cli sourcemaps upload \
--org your-org-slug \
--project your-project-slug \
--url-prefix "~/" \
./dist
Set the environment variables for the CLI before running:
SENTRY_URL=https://errors.example.com # your urgentry base URL
SENTRY_AUTH_TOKEN=your_urgentry_auth_token
Two toolchains produce different stack trace quality in development. ts-node uses source-map-support to rewrite stack traces inline before Node sees them, so traces in the terminal show TypeScript line numbers. tsx uses esbuild's transform and applies source map rewriting at the runtime level. Both produce readable traces locally. The difference appears in production when source maps are not available: ts-node projects that compile with tsc need map upload; tsx projects that use esbuild need esbuild's --sourcemap flag plus map upload.
Async context and AsyncLocalStorage
Node 12.17+ ships AsyncLocalStorage, a mechanism that stores data in an async context and makes it available to all code that runs within that context, including code in nested callbacks, Promises, and await expressions. The @sentry/node SDK uses AsyncLocalStorage internally to propagate the current scope, breadcrumbs, and user context across async boundaries.
This means that if you call Sentry.setUser inside an Express middleware, that user context travels with the request through every await in the route handler and into any helper functions those awaits call. The context does not leak between concurrent requests because each request starts in its own async context.
Node 18+ makes this context propagation reliable for most patterns. Node 16 has known issues with context loss across certain async boundaries. If you are on Node 16 and see events arriving without the user or breadcrumb context you set, upgrade to Node 18 or later.
There is one await pattern that drops context. If you use Promise.all to fan out multiple async operations and one of them throws, the rejection is handled in the context of the Promise.all call site, not in the context of the individual operation that failed. The breadcrumbs and scope from the individual operation may not attach to the event:
// Context may be lost for whichever operation throws first
const [users, orders] = await Promise.all([
fetchUsers(), // if this throws...
fetchOrders(), // ...the rejection lands here, outside both operation's contexts
]);
// Safer: capture within each operation
const [users, orders] = await Promise.all([
fetchUsers().catch(err => {
Sentry.captureException(err);
return [];
}),
fetchOrders().catch(err => {
Sentry.captureException(err);
return [];
}),
]);
The SDK's breadcrumb buffer follows the async context tree. Breadcrumbs added in a parent context are visible in child contexts. Breadcrumbs added in a child context (a single Promise.all branch) do not appear in sibling contexts.
Point the DSN at urgentry
In your urgentry instance, create a project and copy its DSN. The format is identical to a Sentry DSN:
https://<public_key>@errors.example.com/<project_id>
Set the environment variable in your deployment:
SENTRY_DSN=https://<public_key>@errors.example.com/<project_id>
That is the only change. The @sentry/node SDK sends the same Sentry envelope protocol it always has. urgentry receives that envelope, parses it, and stores the event. Every captured exception, every breadcrumb trail, every user context tag arrives in urgentry's UI with the same structure it would have in Sentry.
For source map upload, set SENTRY_URL to your urgentry base URL alongside the auth token. The CLI uses SENTRY_URL to determine where to upload artifacts. Without it, maps go to sentry.io while events go to urgentry, and stack traces remain unresolved in production.
Three Node gotchas
1. process.on('uncaughtException') without exiting
When an uncaught exception fires, the Node process is in an undefined state. The event loop was supposed to unwind to the scheduler, but it threw instead. Any state that was being mutated at the time of the throw may be corrupt. File handles may be open. Database transactions may be uncommitted. In-memory caches may hold stale data.
The correct response to an uncaught exception is to capture the event, flush the SDK buffer, and exit the process. Let your process manager (systemd, Docker, PM2) restart it. What you should not do is catch the exception, log it, and continue running. The SDK's built-in handler does the right thing. If you add your own handler, call process.exit(1) after the capture:
// Only add this if you need custom logic beyond what the SDK provides
process.on("uncaughtException", async (err) => {
await Sentry.flush(2000);
process.exit(1);
});
The Sentry.flush call is necessary here because the SDK sends events asynchronously. Without it, the event queue may not drain before exit.
2. EventEmitter error events without listeners
Node's EventEmitter has a special contract for the error event: if an emitter emits error and there are no listeners for that event, Node throws the error object as an uncaught exception, crashing the process. This is different from all other events, where no listener is simply a no-op.
Streams are EventEmitter instances. HTTP responses, TCP sockets, database connections, and file system streams all emit error events when something goes wrong. If you create a stream and do not attach an error listener, a network failure or a write error will crash your entire process, and the SDK will capture it as an uncaught exception with a confusing origin.
// Missing error listener โ crashes on network failure
const readable = fs.createReadStream("/path/to/large-file.csv");
// Correct: always attach an error listener
readable.on("error", (err) => {
Sentry.captureException(err);
readable.destroy();
});
The pipeline function from stream/promises handles error forwarding between streams automatically and is a better choice than manual pipe chains for this reason.
3. Third-party libraries throwing strings
Some older or poorly written libraries throw string literals instead of Error instances: throw "something went wrong" instead of throw new Error("something went wrong"). When the SDK captures a string throw, it creates an event with the string as the message but no stack trace, because strings are not Error objects and do not carry a call stack.
If you see events in urgentry with a message but no stack, check whether the library you are wrapping throws strings. The fix is to wrap the library call and re-throw as an Error:
try {
await legacyLibrary.doSomething();
} catch (err) {
if (typeof err === "string") {
throw new Error(err); // now it has a stack trace
}
throw err;
}
You can also use a beforeSend hook in Sentry.init to normalize string throws globally, but wrapping at the call site is cleaner because it preserves the stack at the throw point.
FAQ
Does @sentry/node capture uncaughtException and unhandledRejection automatically?
Yes, when Sentry.init runs before any other code, the SDK registers handlers for both process events. The uncaughtException handler captures the error and flushes before exit. The unhandledRejection handler captures the rejection reason. Do not add your own handlers for these events alongside the SDK unless you understand the flushing requirements.
Why must the instrumentation file come before every other import?
The SDK patches Node's built-in http, https, and other modules at init time to add tracing and context propagation. Any module that loads before Sentry.init runs carries no instrumentation. The --import flag (Node 18.19+) and the --require flag (older Node) inject the instrumentation file before user code at the process level.
What is the difference between Sentry.captureException and throwing inside a handler?
Throwing inside an Express or Fastify handler passes the error to the framework's error pipeline, which the SDK's error handler captures automatically. Sentry.captureException is for handled errors: exceptions you catch with try/catch that you want to record without propagating up the stack. Both produce events in urgentry.
How do I get readable stack traces from TypeScript in production?
Upload source maps to urgentry at build time using the Sentry CLI. Set SENTRY_URL to your urgentry base URL and SENTRY_AUTH_TOKEN to your auth token before running the upload command. Set the release option in Sentry.init to the same value you pass to the upload command. With maps uploaded, urgentry resolves compiled .js frames back to the original .ts file and line number.
Does urgentry work with @sentry/node without code changes?
The only change is the DSN. Set SENTRY_DSN to your urgentry project DSN. Every integration, every captureException call, and every SDK option continues to work without modification. urgentry implements the same Sentry envelope ingest and store endpoints that @sentry/node targets.
Sources
- Node.js process events documentation — the canonical reference for
uncaughtException,unhandledRejection, and the recommendation to exit after an uncaught exception. - @sentry/node SDK documentation — installation, the instrumentation file pattern, Express and Fastify integrations, and all
Sentry.initoptions. - Node.js AsyncLocalStorage documentation — the async context API that underlies the SDK's scope propagation across async boundaries.
- Node.js V8 stack trace API —
Error.prepareStackTraceand the structured stack frame format the SDK reads to build event frames. - Express error handling documentation — the
next(err)pattern and the four-argument error middleware signature referenced in the Express section. - FSL-1.1-Apache-2.0 license text — the FSL license under which urgentry is distributed.
- urgentry SDK compatibility matrix and setup documentation — DSN creation, source map upload configuration, and the OTLP endpoint for mixed Sentry/OTel deployments.
Ready to point your Node.js app at urgentry?
The instrumentation file pattern, the Express error handler, the DSN swap: every step in this guide works without any urgentry-specific changes. Create a project in urgentry, copy the DSN, and your first Node.js error lands in a UI you control on hardware you own.