Error Tracking with Sentry in Express
How to integrate Sentry for error tracking in Node.js Express apps with the v8+ SDK — error handlers, performance spans, release tracking and source maps.
Overview
Sentry captures unhandled errors in your application, groups them by similarity, and alerts you with the stack trace, request context, user info, and breadcrumbs — the events that led to the error. In Express, the SDK auto-instruments your routes so any thrown error or unhandled rejection becomes an event, no manual try/catch plumbing required.
This recipe uses @sentry/node v8+ — a different API from v7 (Sentry.Handlers middleware is gone). The snippets below work with the SDK you get from npm install @sentry/node today.
When to Use
- Production applications where you need real-time error alerts
- Tracking error rates and regression detection across releases
- Capturing user-reported bugs with full stack traces and request context
- Monitoring performance (span tracing) alongside error tracking
- Triaging bugs by severity, frequency, and affected users
When NOT to Use
- Local development — use the debugger and console output
- Applications with no error handling — fix your try/catch blocks first
- Low-traffic internal tools — Sentry’s free tier has event limits
- Log shipping only — Sentry tracks errors and spans, not general logs (see structured logging with Pino for that)
Solution
Setup
npm install @sentry/node
Initialize before your app loads
The SDK patches http, express, and other modules at import time — so Sentry.init() must run before your application code. The clean way is a separate instrument.js file:
// instrument.js
const Sentry = require("@sentry/node");
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || "development",
tracesSampleRate: 0.1,
});
Load it first in app.js:
require("./instrument"); // must be the first require
const Sentry = require("@sentry/node");
const express = require("express");
const app = express();
app.get("/api/users/:id", (req, res) => {
if (req.params.id === "0") {
throw new Error("Invalid user ID");
}
res.json({ id: req.params.id, name: "Alice" });
});
// Register after all routes — captures errors that reach it
Sentry.setupExpressErrorHandler(app);
app.listen(3000);
For ESM projects, keep Sentry.init() in instrument.mjs and start with node --import ./instrument.mjs app.js — import order is guaranteed by the flag.
Manual error capture
Sentry.captureException(new Error("Something went wrong"));
Sentry.captureMessage("Rate limit approaching", "warning");
try {
JSON.parse("{invalid}");
} catch (err) {
Sentry.captureException(err);
}
Adding context to errors
// Set user context — attached to every event until cleared
Sentry.setUser({
id: 42,
email: "alice@example.com",
username: "alice",
});
// Tags are filterable in the Sentry UI
Sentry.setTag("page_locale", "en-US");
Sentry.setTag("feature_flag", "new_checkout");
// Extra context is searchable but not filterable
Sentry.setExtra("order_id", "ord-123");
Sentry.setExtra("cart_items", 3);
// Clear user after logout
Sentry.setUser(null);
Breadcrumbs for debugging
Sentry.addBreadcrumb({
category: "auth",
message: "User authenticated",
level: "info",
data: { method: "oauth", provider: "google" },
});
Sentry.addBreadcrumb({
category: "http",
message: "GET /api/users/42",
level: "info",
data: { status_code: 200, duration_ms: 45 },
});
// If an error happens later, the event shows these breadcrumbs
Custom span for performance monitoring
startTransaction was removed in v8 — use startSpan with a callback; the span ends automatically when the callback finishes, and nested spans become children:
await Sentry.startSpan(
{ name: "process_order", op: "function" },
async (span) => {
await Sentry.startSpan({ name: "validate_payment", op: "task" }, async () => {
// ... validate payment ...
});
await Sentry.startSpan({ name: "save_order", op: "db" }, async () => {
// ... save to database ...
});
span.setAttribute("order_id", "ord-123");
}
);
Release tracking
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: process.env.SENTRY_RELEASE || "1.0.0",
environment: process.env.NODE_ENV,
});
// Sentry now groups errors by release and flags regressions
Source maps for readable stack traces
npm install @sentry/cli --save-dev
Upload source maps during the build:
sentry-cli sourcemaps upload --release 1.0.0 ./dist
Or configure them in sentry.properties:
defaults.url=https://sentry.io
defaults.org=your-org
defaults.project=your-project
auth.token=your-auth-token
Keep the token in SENTRY_AUTH_TOKEN instead when possible — committed tokens leak.
Integrating with Winston
const winston = require("winston");
const Sentry = require("@sentry/node");
const sentryTransport = {
log(info) {
if (info.level === "error") {
Sentry.captureException(new Error(info.message));
}
return true;
},
};
const logger = winston.createLogger({
transports: [new winston.transports.Console(), sentryTransport],
});
logger.error("Database connection failed");
// Sends to both console and Sentry
For a production-grade Winston setup with file rotation, see Winston daily rotate.
Filtering and sampling
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1, // 10% of transactions
ignoreErrors: [
"ResizeObserver loop limit exceeded",
"Non-Error promise rejection captured",
],
beforeSend(event) {
if (event.request?.url?.includes("/health")) {
return null; // Don't send health check noise
}
return event;
},
});
To control which route errors setupExpressErrorHandler captures, pass shouldHandleError through the integration:
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [
Sentry.expressIntegration({
shouldHandleError(error) {
const status = Number(error.status ?? error.statusCode ?? 500);
return status >= 500; // default: only 5xx reaches Sentry
},
}),
],
});
Explanation
The v8 SDK works by auto-instrumentation: when @sentry/node is imported, it wraps http, express, and other modules so incoming requests, outbound calls, and thrown errors produce spans and events without touching your handlers. That’s why load order matters — if Express is imported before Sentry.init(), the instrumentation hooks attach too late and errors pass through silently. The instrument.js file (or --import flag for ESM) exists to guarantee the order.
setupExpressErrorHandler(app) registers Sentry’s error middleware at the end of your middleware chain. By default it captures only 5xx errors — 4xx statuses are treated as expected client errors and skipped, which keeps your quota for real failures. shouldHandleError widens or narrows that gate.
One edge case worth knowing: Express 4 doesn’t forward errors thrown inside async route handlers to the error middleware. Sentry’s integration covers most cases, but if you see missing events on Express 4, wrap handlers with an asyncHandler (see the FAQ) or upgrade to Express 5, which forwards rejections natively.
The flow looks like this:
tracesSampleRate controls the performance side only — errors are always sent at 100%. A rate of 0.1 keeps 10% of transactions, which is plenty for spotting slow spans without burning quota. beforeSend runs on every event before it leaves your process; returning null drops it, which is where health-check and scraper noise should die.
For end-to-end request correlation across services, the spans Sentry creates feed directly into distributed tracing.
Variants
Using Sentry with Fastify
Fastify has its own v8 integration — register setupFastifyErrorHandler once, no manual hooks:
require("./instrument");
const Sentry = require("@sentry/node");
const Fastify = require("fastify");
const fastify = Fastify();
// Registers hooks that capture errors from routes and plugins
Sentry.setupFastifyErrorHandler(fastify);
fastify.get("/api/error", async () => {
throw new Error("Fastify error");
});
Using Sentry with TypeScript (ESM)
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [Sentry.httpIntegration()],
tracesSampleRate: 0.1,
});
interface UserContext {
id: string;
email: string;
}
function setSentryUser(user: UserContext): void {
Sentry.setUser(user);
}
Best Practices
- For a deeper guide, see Sentry: Error Tracking, Triage, and Resolution.
- Initialize in a separate
instrument.jsloaded before your app — import order decides whether auto-instrumentation works. - Register
setupExpressErrorHandler(app)after all routes — it only sees errors that reach the end of the chain. - Set
releaseto your git commit SHA or version tag — it lets you spot regressions between deploys. - Upload source maps in CI — minified stack traces are useless without them.
- Use
beforeSendto drop noise (health checks, static asset 404s, extension errors). - Set
tracesSampleRateto 0.1 in production — 1.0 burns through your event quota fast. - Use
setUser()to identify who hit the error — invaluable when answering support tickets. - Add breadcrumbs for key actions (auth, API calls, state changes) to reconstruct the error path.
Common Mistakes
- Importing Sentry after the app: if Express loads before
Sentry.init(), auto-instrumentation never attaches and errors pass through unreported. - Registering the error handler too early:
setupExpressErrorHandlerbefore your routes only catches errors from middleware that runs after it. - Setting the DSN in code: use an environment variable. Committing the DSN exposes it.
- Not uploading source maps: minified code produces unreadable stack traces. Always upload in CI.
tracesSampleRate: 1.0in production: fine for staging, expensive at real traffic.- Not clearing user context on logout: errors after logout get attributed to the previous user.
Further Reading
- Sentry Express integration docs: official setup and options for the current SDK.
- Sentry JavaScript v8 migration guide: the full API changes if you’re coming from
Sentry.Handlers. - Sentry: Error Tracking, Triage, and Resolution: the long-form guide covering triage workflows and alert tuning.
Companion code: Express + Sentry starter project — instrument.js, a runnable app.js with the error handler wired in, and a .env.example.
Frequently Asked Questions
How do I test that Sentry is working?
Create a test route:
app.get("/debug-sentry", () => {
throw new Error("Sentry test error");
});
Visit the route and check your Sentry dashboard for the error.
What is the difference between captureException and captureMessage?
captureException takes an Error object and includes the stack trace. captureMessage takes a string and creates a message-level event. Use captureException for errors and captureMessage for warnings or info.
How do I handle async errors?
Express 5 forwards rejected promises from async handlers to error middleware automatically. On Express 4, wrap async handlers so rejections reach the error handler:
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/api/async", asyncHandler(async (req, res) => {
const data = await fetchData();
res.json(data);
}));
Can I use Sentry for frontend errors too?
Yes. Install @sentry/react or @sentry/vue for the frontend. Use the same organization and project — Sentry separates frontend and backend errors by platform.
How do I reduce Sentry costs?
- Lower
tracesSampleRate(0.01 for high-traffic apps) - Use
ignoreErrorsandbeforeSendto filter known noise - Keep
shouldHandleErrorat the 5xx default so 4xx doesn't eat quota - Set
maxBreadcrumbsto 20 instead of the default 100
Related Resources
High-Performance Logging with pino
How to use pino for fast structured JSON logging in Node.js, including log levels, child loggers, transports, and integration with Express and Fastify.
RecipeRotate Logs Daily with Winston
How to configure daily log rotation in Node.js using winston and winston-daily-rotate-file, including size limits, retention, compression, and transport combining.
GuideDistributed Tracing: OpenTelemetry, Jaeger, Zipkin
Master distributed tracing with OpenTelemetry, Jaeger, and Zipkin. Trace propagation across services, span context, sampling strategies, and production debugging.
RecipeLog Aggregation
Centralize logs from distributed services with ELK, Fluentd, and Loki for search, alerting, and troubleshooting in production.
RecipePrometheus API Monitoring
Monitor API performance and health with Prometheus metrics, custom collectors, and alerting rules.
RecipeStructured JSON Logging with structlog
How to emit structured JSON logs in Python using structlog, including context binding, log levels, processors, and integration with standard logging.