StackPractices
intermediate By Mathias Paulenko

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);
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:

flowchart diagram: Request

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.js loaded 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 release to 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 beforeSend to drop noise (health checks, static asset 404s, extension errors).
  • Set tracesSampleRate to 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: setupExpressErrorHandler before 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.0 in 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

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 ignoreErrors and beforeSend to filter known noise
  • Keep shouldHandleError at the 5xx default so 4xx doesn't eat quota
  • Set maxBreadcrumbs to 20 instead of the default 100