DEV Community

Cover image for Tracking Errors in a Node.js Application
Rishabh Rawat for AppSignal

Posted on • Originally published at blog.appsignal.com

Tracking Errors in a Node.js Application

Production bugs slow down velocity and often affect the complete trajectory of your release roadmap. It helps if you have a robust error tracking setup to rely on.

In this article, we'll look at how to make tracking errors in your Node.js application more convenient, automated, and safe.

Let's begin!

The Two Error Types

Every application we build has an end goal. The code reflects that goal and allows the user to perform intended actions. Since the real world is complex, situations may arise that aren't handled well by an application. This leads to unexpected and unwanted outputs, defeating the end goal of that application. Diverging from the happy path is referred to as an error.

Errors can be put into two buckets:

  1. Operational errors
  2. Programming errors

Operational errors arise during the active use of your application. Also known as runtime errors, they help us find edge cases missed during development. For example, a user adds a negative age, gives negative tips on a food delivery app, or enters a malicious script in an input field, etc. These should be properly handled by developers.

Programming errors, on the other hand, arise when an error slips through due to a missing error handler in the code. Examples include:

  • Assigning a string to a number.
  • Not catching a rejected promise.
  • Accessing an object's non-existent properties.

What Is Error Tracking?

Error tracking is a process where the abnormal behavior of your application is observed and monitored with the goal of fixing it before it affects end users. Error tracking entails logging, monitoring, creating alerts, and anomaly detection based on historical usage patterns.

Proactively tracking errors and resolving them provides a smooth user experience and helps in building trust. Relying on your users to report errors is not only unreliable, but is bad for your business and brand.

How to Track Errors in Node.js

Tracking errors involves anticipating an error and writing code to handle unexpected/unwanted scenarios — in other words, situations when your application does not function how it is designed. It's the only (right) way to steer the correct course and ensure your application is on the right track.

There are various ways to identify errors in a Node.js application:

  1. Callbacks
  2. Promises
  3. Try-Catch

We'll use try-catch throughout this article. The basic idea is to run a piece of code and wrap it in the try block, and the error handler goes in the catch block. If your application behaves normally, the catch block will never run.

Here's an example snippet, using try-catch to handle such situations:

try {
  operationThatMightFail();
} catch (err) {
  log.error(err);
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Now that we've run through vanilla error handling, let's move on to the exciting part. The above error handler is a good place to start, but we can do better. As you might've noticed, it doesn't provide much value to us. Looking at that error message wouldn't necessarily help us during an incident. There's a better way. Let's explore that.

Creating a Custom Error Handler

We want every error to have sufficient context for it to be actionable. For example, if the stack trace of an error is overridden, debugging can take unreasonably long. Balancing rich error reports with concise syntax is key. Error handlers shouldn't distract us from the main application logic.

To tackle this, we can set educated defaults to avoid passing certain details in every error handler. Various common configs can be abstracted away, such as:

  • Passing error context to an APM (Application Performance Monitoring) tool
  • Logging the error
  • Setting custom flags that help during analysis and monitoring
  • Returning an HTTP status code to the client

For this, we can create a custom error class that extends Error and adds some nice defaults. Let's have a look at one:

const { sendError } = require("@appsignal/nodejs");
const { HTTP_STATUS_CODES } = require("../../constants");

class AppError extends Error {
  constructor(message, statusCode, isOperational = true) {
    super(message);

    this.name = "AppError";
    this.statusCode = statusCode || HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR;
    this.isOperational = isOperational;
    Error.captureStackTrace(this);

    // send to APM
    sendError(this);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can technically go ahead and plug this custom error handler into our code everywhere. It'll work. But it's not required. We will only use it in our controller layer. Any errors that happen in services/utils/helpers/etc., will be reported to the error handler at the controller layer.

This leverages event bubbling and avoids redundant error handlers. Moreover, it helps to avoid overriding error stack traces by accident.

Better Error Messages in Your Node.js App

While a "something went wrong" error message might be sufficient for your end users (hint: it's not), it doesn't help developers working on your application.

As a developer, you want an error message to at least give you a semblance of the root cause(s), if not to paint the complete picture. Again, the ultimate goal is to make your errors actionable.

Here are some best practices for writing error messages:

  1. Make them concise
  2. Avoid ambiguity
  3. Avoid acronyms/slang/technical jargon
  4. Make them human-readable
  5. Make sure they are actionable and help the developer in finding the root cause
  6. Avoid duplicate error messages

Some examples of a good error message can be:

  1. "Instance does not have SES:sendEmail permission".
  2. "Failed to establish database connection".
  3. "Failed to upload the file, size is greater than 10 MB".

Handling Programming Errors

We can now track all sorts of operational errors with the help of our newly created custom error class. It's time to handle programming errors.

Two such errors are uncaughtException and unhandledRejection.

process.on("uncaughtException", (err) => {
  logger.fatal(`uncaughtException error has occurred: ${err}`);
  process.exit(1);
});

process.on("unhandledRejection", (err) => {
  logger.fatal(`uncaughtRejection error has occurred: ${err}`);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

These handlers ensure that even the errors with no handling get tracked and get your much-needed attention. These errors must be acted upon with urgency (P0), because if they're overlooked, your service may go into an unexpected and less predictable state.

How to Choose Log Levels for Your Errors in Node.js

Our application looks much more resilient now. This section is a refresher and a general guide to help you choose log levels for your (error) logs. Every log level indicates a different purpose.

Trace

"I'm here" kind of logs. With trace logs, you generally don't want to track the state of the variables but want to know the control flow for a particular request.

Debug

Debug logs go one step further than trace logs: you are keen on tracking the state of the variables. For example, authenticated user details, params being passed to services, flags set in database queries, etc.

Info

These logs are for general info which doesn't require immediate action or attention. They do not represent an unwanted code state and so can be ignored under normal circumstances. Some examples of info logs can be loaders initializing successfully, successful events ingestion and drain logs, etc.

Warn

These are not errors but unwanted states. The warn log level can be used if it's okay for a request to fail due to an inappropriate payload. This is an unwanted state, but an expected unwanted state. For example, a request failure log due to an idempotency lock is an expected error.

Error

Contrary to warnings, error logs indicate an unexpected, unwanted code state. But a service can continue to function in case of such errors. For example, if a request fails due to the service host temporarily not being reachable, it's an error log. Error logs are actionable.

Fatal

These take the P0 priority. Fatal errors must be acknowledged and acted upon immediately as they (usually) bring a service down.

But Wait, What Errors Should I Track Exactly?

There's one important rule when logging errors: only include essential information. As much as it's important to provide sufficient context, it is also a developer's responsibility to avoid noise in logs. Why?

Every log needs to be stored on disk, and it has a cost. Processing logs requires disk IOPS, which can easily become a bottleneck if your service gets under bursty or high sustained load. The same IOPS quota is used to serve requests as well.

Here are some of the important things you should track:

  1. Request metadata
  2. Request payload (if too big, a key that can uniquely identify the payload)
  3. Success/failures at checkpoints (with necessary context)
  4. Final response

Restarting Gracefully When Needed

There are situations when a critical error hits, and there's no other option but to shut down and restart the application (e.g., your application's memory crosses its threshold).

A direct (and potentially dangerous way) to do this is to directly process.exit(1) and get moving with a fresh restart. But that can lead to issues if your storage layer or any other stateful component was left in an awkward situation.

The better way is to restart gracefully by closing all the open loops. Restart the application only when every component is ready.

We'll listen to SIGTERM and SIGINT on the process and call our utility to stop the server gracefully:

process.on("SIGTERM", (signal) => loaders.close(server));
process.on("SIGINT", (signal) => loaders.close(server));
Enter fullscreen mode Exit fullscreen mode

And here's the loader module:

module.exports = {
  run: () => {
    return new Promise((resolve, reject) => {
      const mongoClient = mongoose.getConnection();
      // other dependencies init goes here...

      resolve({ mongoClient });
    });
  },
  // runs before app crash/shutdown
  close: async (server) => {
    if (!server) {
      logger.warn("Cannot stop server, it's not running");
      process.exit(0);
    }

    // Fetching DB conn from loader as it is idempotent
    const { mongoClient } = await module.exports.run();

    server.close(async (err) => {
      // closing connection(s)
      await mongoClient.close();

      if (err) process.exit(1);
      process.exit(0);
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

Based on your specific use case, it may be okay not to exit the process. Also, process managers like PM2 allow automatic restarts if your application crashes.

Learning from Your Node.js Errors with AppSignal

Looking at your errors holistically gives a better perspective and helps you observe repetitive behaviors in your code that otherwise may not catch your attention.

AppSignal makes error tracking proactive and convenient, and you can sign up for a 30-day free trial (no credit card details required).

For example, I can see a cluster of errors on this graph. I can simply zoom in and check if those errors correlate to the release that went out that day / a vendor outage / anything else that might help me get to the root cause and decide on further actions.

AppSignal issues graph showing error details

You can even set up alerts to notify you if any anomalies are detected:

AppSignal alerts & triggers to be sus in the moment

AppSignal for Node.js also offers logging, host and uptime monitoring, and performance metrics.

Best Practices to Reduce Errors in Node.js

While tracking and monitoring errors is good, it's best to prevent them as much as possible.

Here are some best practices that can eliminate trivial errors:

  1. Use static type checking to catch trivial errors during the compilation stage (e.g., TypeError: Cannot assign number to string).
  2. Add pre-commit hooks and tests in your CI level to ensure breaking builds don't go out.
  3. Load test your services to observe how your application behaves under heavy load. There might be no errors on a normal load, but you may observe app memory spikes under bursty traffic patterns.
  4. Regularly review and remove logs that don't serve any specific purpose and add to the noise. This also saves disk space and helps in case of a limited/small IOPS.

Wrapping Up

In this article, we first defined error tracking and looked at the different types of errors. We then explored how to track your Node.js errors in the right way, including creating a custom error handler. We also saw how to avoid tracking trivial errors to reduce noise in your alerts.

Hopefully, you learned something that you can apply to your projects right now. You may also find our Node.js Error Handling: Tips and Tricks post useful.

Happy coding!

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Top comments (1)

Collapse
 
ebcefeti profile image
E. B. Cefeti

I will admit I have a lot of "rust envy" when working with these issues in JavaScript. Promises get us partway there, but there's something about formally routing on error conditions and unpacking alternative results that greatly appeals to me. Maybe it's something you could enforce with the right TypeScript extensions.