Better Error Stack Trace with Node.js Source Maps and Sentry

When building Node.js applications, reducing the bundle size is essential for some applications such ass Serverless applications impacting the cold start; and Single Page Applications impacting the first page load.

One method for reducing the bundle size is to minify the JavaScript code, but it does not allow you to view a detailed stack of an error that occurs in production. Fixing a production bug as fast as possible is essential, and knowing where the code is throwing that error is the first step in fixing it.

This post will show how to bundle a Node.js project with generated source maps and upload it to Sentry. We will use Esbuild to bundle the project, but this tutorial can be applied to any other Node.js build tool.

Prerequisites

You need these tools installed on your computer to follow this tutorial.

  • Node.js 20+ - Follow the installation guide I wrote.
  • NPM or Yarn - I will use Yarn.
  • A Sentry account - A free trial is enough.

Set up the project

To start with a working Node.js project, we will use the one we built on the below blog post:

Deploy a Node.js application with PM2 and Nginx
This tutorial will show how to deploy a Node.js application using PM2 and do a reverse proxy with Nginx to make the application accessible to the world.

The source code of this Node.js application can be found in this GitHub repository; let's clone it locally using the Git sparse-checkout.


git clone --no-checkout https://github.com/tericcabrel/blog-tutorials.git

cd blog-tutorials

git sparse-checkout init --cone

git sparse-checkout set deploy-node-app

git checkout @

The project is now available locally, launch it by running the following commands:


cd deploy-node-app

cp .env.example .env

yarn install

yarn start

Open your browser and navigate to http://localhost:4500.

The Node.js application running locally.

The project is built with TypeScript, which transpiles the TS files into JavaScript files.

Build the project with Esbuild

Esbuild is an extremely fast JavaScript bundler and minifier that improves web development through efficient tree-shaking and parallel processing.

Bundle a Node.js project with Esbuild outputs a single JavaScript file containing the source code and all Node dependencies required to run the project. This has the following benefits:

  • No need to install the Node.js dependencies when deploying the Node.js application in production.
  • A standalone JavaScript file is easy to manage and share.
  • It reduces the start-up time (cold start) for Serverless functions because of the light bundle size.
  • It is easier to cache the file through a CDN.

Let's install Esbuild as a dev dependency


yarn add -D esbuild

Run the command below to bundle the project:


yarn esbuild ./src/index.ts --bundle --minify --platform=node --outfile=build/index.js

The above command created a folder named "build" with one file inside named "index.js".

Bundle a Node.js project with Esbuild.

Execute the file with Node.js by running the command below:


node build/index.js

The Node.js application is still running as expected, but let's add a function that intentionally throws an error.

Visualize the error stack trace in a Node.js application

Create a file called "src/route.ts" and add the code below:


import { Application } from 'express';

export const setupRoute = (app: Application) => {
  app.get('/error', (req, res) => {
    if (Math.random() > 0.5) {
      throw new Error('Ka booom!');
    }

    return res.json({ message: 'No error!' });
  });
};

Call the "setupRoute()" function in the "src/index.ts" file to register the route created.


// Existing code ...........

setupRoute(app);

app.listen(PORT, () => {
  console.log(`Application started on URL ${HOST}:${PORT} ๐ŸŽ‰`);
});

Run the application with the command yarn start, navigate to http://localhost:4500/error and refresh the page until you get the error.

The stack trace of an error thrown in a non standalone bundle file.

The error stack trace indicates the error occurred in the file "src/routes.ts" at line 6, column 13. This makes it easy to locate and fix a bug when working locally.

Let's build the application with Esbuild and run it


yarn esbuild ./src/index.ts --bundle --minify --platform=node --outfile=build/index.js

node build/index.js

Go to the browser and test again:

The stack trace of an error thrown in a standalone bundle file.

The stack trace indicates the error in the file "index.js" at line 50, column 995. Let's see what line 50 looks like:

The content of a standalone bundle JavaScript file.

Not easy to read, right ๐Ÿ˜…? This is because we minimized the bundle using the flag --minify of Esbuild, which has the benefits of reducing the size.

We can debug our code locally, but when deployed in production, we cannot easily find the line where the bug occurred, which can increase our application's MTTR.

Generate source maps files in Node.js

When bundling the application with Esbuild, we can pass a flag to generate the source maps; It creates a mapping between the minified and actual source codes.

On the previous command used to build the application with Esbuild, add the flag --sourcemap to generate the source map.


yarn esbuild ./src/index.ts --bundle --minify --sourcemap --platform=node --outfile=build/index.js

We get the following output:

Bundle a Node.js project with Esbuild outputing the source map file.

A "index.js.map" file is generated in the "build" folder. Now, we can run the bundled code with the source maps so that the Node.js runtime can use them to find the actual error line when a bug occurs.

As of version 12, Node.js natively supports source map, meaning you can run your application with the source map by passing the flag --enable-source-maps.

Run the project with source maps using the following command:


node --enable-source-maps build/index.js

Navigate to the browser and see the result:

The stack trace of an error thrown in a standalone bundle file with source maps.

Now we have a clear stack trace of the error in our source code.

So we can deploy the bundle with the source map in production ๐Ÿ˜Œ......... Hmmm, not really, because running a Node.js application in production with the source map is not recommended.

The problem with the source maps

Source maps slow the application in production, especially when their size increases. As you can testify with the build with the source map we did earlier, the source maps file size (1.5 MB) is usually bigger than the source code bundle (778 KB).

I will not cover the slowness of the application by the source maps, but here are the GitHub issues that discuss the problem and people made benchmarks so check it out:

--enable-source-maps is unnecessarily slow with large source files ยท Issue #41541 ยท nodejs/node
Version v16.13.2 Platform 5.13.0-22-generic #22-Ubuntu x86_64 x86_64 x86_64 GNU/Linux Subsystem No response What steps will reproduce the bug? I know --enable-source-maps is experimental and it&#39...

This Twitter thread shows that the author found out the source maps added latency to the request.

Fix the Node.js Source Maps problem with Sentry

Sentry is an application monitoring tool that easily tracks errors in various applications. Sentry also provides a feature to handle the source maps of your application bundle.

We will see how to handle source maps with Sentry in five steps.

Step 1: Create an organization account

If you already have an account, you can proceed to step two. Otherwise, click the link below to create an account and follow the guide.

Sign up for Sentry to start your free trial.
Debug any software issue, onboard your team, and integrate with your systems. You get 14 days free on our Business plan to start โ€” no credit card required.

Once the account is created and your email address is verified, go to the next step.

Step 2: Create a Sentry project

Replace <org_name> by your organization's name in this link "https://<org_name>.sentry.io/projects" and open it in your browser.

Create a new Sentry project.

In the top-right of the page, click on the button "Create Project". You will be redirected to a page asking for three information for a new project:

  1. The platform: select Node.js
  2. The alert settings: keep the default settings or change them as you want.
  3. The project name: Give a name for the project.

Click on the button to create the project.

Configure a new Sentry project for a Node.js application.

Once you click "Create Project" button, a modal dialog will appear asking you to select a Framework. Since we use the Express framework, select it and click "Configure SDK".

Select the Express Framework for Sentry project.

On the next page, you will find steps to configure Sentry. You can skip this step because we will do it next.

Step 3: Configure Sentry in a Node.js application

To configure Sentry for our Express application, we must install the Sentry dependency for Node.js. Run the command below to install it.


yarn add @sentry/node

Create a file "src/instrument.ts" and add the code below


import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: <SENTRY_PROJECT_DSN>,
  tracesSampleRate: 1.0, //  Capture 100% of the transactions
});

Replace the "<SENTRY_PROJECT_DSN>" by the Sentry DSN of the project we created earlier.

To retrieve your Sentry DSN project, find or follow the project settings link "https://<org_name>.sentry.io/settings/projects/<project_name>".

On the project left menu, click on "Client Keys (DSN)" to copy the project DSN.

Retrieve the Sentry DSN for a project.

Set the project DSN in the "src/instrument.ts" file.

It is not recommended to hardcode sensitive values in the application; instead, load them from the environment variables. Read my blog post below to learn how to do that.

Read environment variables in a Node.js application
This post shows how to load and read environment variables in a Node.js application written in JavaScript first and then in TypeScript. We will see how to validate the environment variables at the application launch.

In the "src/index.ts" file, we will import the "src/instrument.ts" file, which must be the first import of the file. We will also configure global middleware for Sentry to capture exceptions.

Replace the code of the "src/index.ts" with the code below:


import './instrument';

import * as Sentry from '@sentry/node';
import express from 'express';
import dotenv from 'dotenv';

import { setupRoute } from './route';

dotenv.config();

const HOST = process.env.HOST || 'http://localhost';
const PORT = parseInt(process.env.PORT || '4500');
let requestCount = 0;

const app = express();


app.use(express.urlencoded({ extended: true }));
app.use(express.json());

app.get('/', (req, res) => {
  requestCount++;

  return res.json({
    message: 'Hello World!',
    numberOfCall: requestCount,
  });
});

setupRoute(app);

// The error handler must be registered before any other error middleware and after all controllers
Sentry.setupExpressErrorHandler(app);

app.listen(PORT, () => {
  console.log(`Application started on URL ${HOST}:${PORT} ๐ŸŽ‰`);
});

Step 4: capture exception and send it to Sentry

With the setup we did in the previous step, Sentry can now capture uncaught exceptions in our application; but for errors that occur in a code wrapped with a try { } catch () block, we must manually send the error to Sentry.

Let's update the file "src/route.ts" to capture the error when we call the route "/error"


import { captureException } from '@sentry/node';
import { Application } from 'express';

export const setupRoute = (app: Application) => {
  app.get('/error', (req, res) => {
    try {
      if (Math.random() > 0.5) {
        throw new Error('Ka booom!');
      }

      return res.json({ message: 'Success!' });
    } catch (error) {
      captureException(error);

      return res.status(400).json({ message: 'Ops error!' });
    }
  });
};

Run the application with the command "yarn start" and navigate to http://localhost:4500/error. Refresh the page until it sends an error message, then go to the Sentry project to see the issue.

View in Sentry the error stack trace thrown in a Node.js application.

We can clearly see where the error occurred in our code; we would expect the same experience in production bundled with Esbuild.

Build the application with Esbuild and run it.


yarn esbuild ./src/index.ts --bundle --minify --sourcemap --platform=node --outfile=build/index.js

node build/index.js

You can see that the bundle size increased because we added the Sentry dependency, and the source maps file is almost four times bigger than the source code file.

Ignore the Sentry message about Express not being instrumented.

Run the standalone file of a Node.js application having Sentry configured.

Refresh the URL again until you get the error, then go to Sentry to see the issue.

View in Sentry the error stack trace of the minified bundle of a Node.js application.

Running the application with Source maps enabled can fix this, but as we said before, it is not recommended, so let's use Sentry to fix it.

Upload source maps to Sentry

With Sentry, you can upload the application's source maps. When an error stack trace is sent to Sentry, the uploaded source maps are used to resolve it, following the real structure of the application's source code.

We will use the Sentry CLI to create a release and upload the source maps, so let's install it on our computer.


curl -sL https://sentry.io/get-cli/ | bash

sentry-cli --version

To create a release, you need an authorization token, go to the project, click on the menu "Release"

Retrieve the authorization token to use with the Sentry CLI.

Copy your authorization token and keep it somewhere; we will use it later.

The commands to create a release with source maps slightly differ from those in the picture above. Run the commands below.


export SENTRY_AUTH_TOKEN=<your_sentry_auth_token>
export SENTRY_ORG=<org_name>
export SENTRY_PROJECT=<project_name>
export VERSION=1.0.0

# Workflow to create releases
sentry-cli releases new "$VERSION"

# Upload source maps
sentry-cli sourcemaps inject ./build
sentry-cli sourcemaps upload --release=$VERSION ./build

# Finish the release
sentry-cli releases finalize "$VERSION"

Replace the SENTRY_AUTH_TOKEN, SENTRY_ORG and SENTRY_PROJECT by your own.

๐Ÿ’ก
Note 1: You must be at the project root directory before running the command above.

Note 2: The command sentry-cli sourcemaps upload --release=$VERSION ./build uploads everything in the build folder, and links the source maps to the release.

We get the following output:

Create a release and upload source maps with the Sentry CLI.

You can see the releases by clicking on the menu "Releases" of the project

Re-run the application bundled for production without enabling sourcemaps


node build/index.js

Navigate to http://localhost:4500/error and refresh the page until an error message appears. Then, go to the project's issues in Sentry.

View in Sentry the error stack trace of the Node.js application.

We can now see a code source that threw the error.

๐Ÿ’ก
We use the Sentry CLI to upload source maps but Sentry offers a plugin for most of build tools which helps automatically handle the source maps upload during the code bundling. Check out the Sentry documentation.

Associate a Sentry release to the application release

To resolve the stack trace of an error, Sentry will use the most recent source maps uploaded event if it is not associated with a release. This can cause problems if you deploy a new code in production, but do not upload the source maps.

To prevent this, you can set a release version when initializing Sentry in the application and then use that same version when creating a release with source maps.

Let's say you want to do a new release with the version 1.1.0

Update the "src/instrument.ts" file and set the release in the Sentry initialization; the value is read from the environment variable "APP_VERSION"; this assumes the release version is the same as the application version.


import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: '<SENTRY_PROJECT_DSN>',
  tracesSampleRate: 1.0, //  Capture 100% of the transactions
  release: process.env.APP_VERSION,
});

Build the application with Esbuild and create the release 1.1.0 as we saw earlier. Once done, run the application with the following code:


APP_VERSION=1.1.0 node build/index.js

Refresh the browser page until you get the error and go to Sentry to see the issue.

View the Sentry release version in the error captured.

Now, once a bug occurs,

  • You can view the detailed error, the file, and the line raising the error.
  • You can see the release version that introduced the bug.
๐Ÿ’ก
The release version can be the commit hash, the semantic version number, a combination of both or whatever rule that works for you.

Automate the release with GitHub Actions

Associating a Sentry release to the application source maps must succeed in production to have a good stack trace, but it's easy to make a mistake when doing it manually.

To ensure the release is done automatically, we will build a CI/CD pipeline on GitHub Actions to perform this action every time we release our application.

Every time we merge on the "main" branch, we want to

  • Get the new release version,
  • Build the project for production,
  • Create a release and upload the source maps to Sentry
  • Package the application with the release version injected as an environment variable.

Create a ".github/workflows/build.yaml" and add the code below:


name: Build and release the project
on:
  push:
    branches: [ main ]

env:
  SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

concurrency:
  group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}"
  cancel-in-progress: true

jobs:
  build-and-release:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Cache Node.js modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: "${{ runner.os }}-yarn-${{ hashFiles('**/*.yarn.lock') }}"
          restore-keys: ${{ runner.os }}-yarn-

      - name: Set up Node 22
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          registry-url: 'https://registry.npmjs.org'

      - name: Install Node,js dependencies
        run: yarn install

      - name: Lint the projects
        run: yarn lint

      - name: Build the projects
        run: yarn tsc -b

      - name: Run tests
        run: echo "No tests to run"

      - name: Generate Application Version
        run: |
          RELEASE_VERSION=$(cat package.json | jq -r '.version')
          echo "APP_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV

      - name: Build Project for Production
        run: |
          yarn esbuild ./src/index.ts --bundle --minify --sourcemap --platform=node --outfile=build/index.js

      - name: Install Sentry CLI
        run: |
          curl -sL https://sentry.io/get-cli/ | bash
          echo "export PATH=$PATH:$HOME/.local/bin" >> $GITHUB_ENV

      - name: Release and upload source maps
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: tericcabrel
          SENTRY_PROJECT: my-node-app
          SENTRY_RELEASE: ${{ env.APP_VERSION }}
        run: |
          sentry-cli releases new "$SENTRY_RELEASE"
          sentry-cli sourcemaps inject ./build
          sentry-cli sourcemaps upload --release=$SENTRY_RELEASE ./build
          sentry-cli releases finalize "$SENTRY_RELEASE"

      - name: Build the Docker image
        run: |
          docker build --build-arg APP_VERSION=${{ env.APP_VERSION }} -t tericcabrel/my-node-app:latest .
          # TODO: tag and push the image to a Docker repository

          

The above GitHub Action reads the environment variable from the GitHub repository secrets, so you must add it before running the workflow.

0:00
/0:18

Add the Sentry Auth token to the GitHub Actions secrets.

Create a Git commit, push on the main branch and wait for the CI to complete. You get the following output:

Create a release and upload source maps from a GitHub Actions workflow.

You can find the Docker file used in the final code

Wrap up

Reducing bundle size to improve performance often comes with difficulty debugging when an error occurs. By using Sentry, you can get the best of both worlds by uploading the source maps of your bundle.

While you can upload source maps alone, a better approach is to create a Sentry release and link the source maps to it so that when an error is thrown, Sentry can show you a detailed error stack and point to the release containing the bug.

Automating the Sentry release and uploading source maps in the CI/CD pipeline is essential to prevent a mismatch between the source maps used to build the detailed error stack and the code running in production.

You can find the code source on the GitHub repository.

Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.