In today's tutorial, we're going to focus on a real-world application of extending the Error prototype with ES6 classes and how we can use this for effective error tracing.
We are going to use dotenv to hide away our Sentry endpoint.
.gitignore
Throw in the files we don't wish to store in Git.
.env
node_modules/
Setting up Sentry
While this won't go into details, we want to set up a new Nodejs project in our Sentry account.
New Sentry Nodejs project
Once it is set up, it will give you a dsn URL that we are going to add to our .env file:
SENTRY_ENDPOINT=<replace-with-your-url-here>
We are now ready to set up our custom error!
SentryError.js
Add the following to Sentry.js:
require("dotenv").config();
const Sentry = require("@sentry/node");
// initialises Sentry
Sentry.init({
dsn: process.env.SENTRY_ENDPOINT,
});
class SentryError extends Error {
constructor(errMessage, data, type = "error") {
// Passes errMessage to the Error super class,
// similar to call new Error(errMessage).
super(errMessage);
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SentryError);
}
this.name = "SentryError";
Sentry.addBreadcrumb({
category: "data",
message: errMessage,
data: data,
type: type,
level: Sentry.Severity.Debug,
});
Sentry.captureException(errMessage);
}
}
module.exports = { SentryError };
In the code, we are doing to following:
Requiring dotenv to read in our .env file to process.env.
Initialising Sentry.
Create a class that "extends" error. In layperson JavaScript terms, "extends" with mean that our new SentryError extends from the Error prototype. We can use the constructor to initialise all the properties we are inheriting from Error.
Initialising a new instance with a constructor.
The constructor itself is a method called whenever we call new SentryError() for a new SentryError instance.
We are telling it we take an error message (similar to new Error('error message')), data (which we will use to set breadcrumbs to help us debug) and an breadcrumb type that we use within the constructor (which defaults to error).
The first thing we do in the constructor is call super(errMessage), which is us calling up the chain to the Error prototype. This will set properties on this object that we expect to get from Error like name, message and stack (which we will see later).
Afterwards, we are essentially setting a breadcrumb and telling Sentry to capture an exception. You can read more about these on the Sentry docs but the tl;dr is that these calls will populate our telemetry data on Sentry.
With this alone, we are ready to roll!
Testing out our new error
In index.js, add the following:
const { SentryError } = require("./SentryError");
const main = (data) => {
try {
if (!data.nonExistentValue) {
throw new SentryError("nonExistentValue required", data);
}
} catch (err) {
console.error(err.name);
console.error(err.message);
console.error(err.stack);
}
};
// no data passed
main({
existentValue: 123,
name: "Hello, world!",
});
Here, we are simply doing to following:
Requiring our new error.
We have a main function that simply throws our new SentryError if data.nonExistentValue does not exist (which it won't).
Finally we are call main with an object of information that will be assigned to data.
Running the following will give us this:
> node index.js
SentryError
nonExistentValue required
SentryError: nonExistentValue required
at main (/Users/dennis.okeeffe/Project-Imposter/blog-repos/custom-error/index.js:6:13)
at Object.<anonymous> (/Users/dennis.okeeffe/Project-Imposter/blog-repos/custom-error/index.js:16:1)
at Module._compile (internal/modules/cjs/loader.js:1158:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1178:10)
at Module.load (internal/modules/cjs/loader.js:1002:32)
at Function.Module._load (internal/modules/cjs/loader.js:901:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:74:12)
at internal/main/run_main_module.js:18:47
From the catch block, you can see our new error has access to name, message and stack properties, which we mentioned will be the case from above thanks to use call super(errMessage) in our class to inherit the properties of the Error prototype.
If we head to Sentry, we can see our error has been logged!
New error
If we look at the basic settings, we can see that our breadcrumb was logged under data, the console logs are also tracked (this is configurable).
App error view
Those logs came from our catch block. If we change from "App only" to "Raw" you can see our stack trace also shows up in the exception:
Exception error
Handling sensitive information
As a company, we do not want Personally Identifiable Data from our customers being shared with the third-party.
These tools for us are a way to help debug and trace back through a user journey to improve our product, and they trust us not to share this information.
There are a few ways that we can go about protecting ourselves, but one example I will give today is how we can implement our own "deny" or "block" list.
Let's make some small updates to our SentryError.js and index.js files.
For index.js, let's update the info passed into main to include some dummy user data (and my public email):
Let's say that we do not wish to share the name, user email, user's manager email or their address, but we DO want to keep the ID for debugging issues. We can add a helper method to our class and set up a denyList that we can use in this method to recursively alter our breadcrumb data.
Why keep denyList outside or the class? There is no particular reason, but I find it makes it easier to write unit regex tests if this is abstracted and it can be used for other 3rd-party block lists you may want to set up. redactSensitiveInformation could also have been pulled out of the class for the same reason if it was re-useable elsewhere.
Update SentryError.js:
require("dotenv").config();
const Sentry = require("@sentry/node");
Sentry.init({
dsn: process.env.SENTRY_ENDPOINT,
});
// create a simple deny list
const denyList = [/email/gi, /name/gi, /address/gi];
class SentryError extends Error {
constructor(errMessage, data, type = "error") {
// Passes errMessage to the Error super class,
// similar to call new Error(errMessage).
super(errMessage);
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SentryError);
}
this.name = "SentryError";
Sentry.addBreadcrumb({
category: "data",
message: errMessage,
// update our data
data: this.redactSensitiveInformation(data),
type: type,
level: Sentry.Severity.Debug,
});
Sentry.captureException(errMessage);
}
// add in this new method
redactSensitiveInformation(data) {
const keys = Object.keys(data);
const safeData = {};
for (const key of keys) {
if (!Array.isArray(data[key]) && typeof data[key] === "object") {
// recursively check deep nested children
safeData[key] = this.redactSensitiveInformation(data[key]);
} else if (denyList.some((regex) => regex.test(key))) {
// redacted the data
safeData[key] = "[REDACTED]";
} else {
// assign data to object to send to Sentry
safeData[key] = data[key];
}
}
return safeData;
}
}
module.exports = { SentryError };
redactSensitiveInformation uses the power of recursion. We basically want it to recursively check through an object to redact information that match a regex.
denyList.some iterates through our Regex array and if any regex matches, it will return "true" - this helps us identify from our list which data to redact.
Let's run node index.js again and confirm this in Sentry.
Redacted info
Victory!
Conclusion
Today, we used ES6 classes to extend error. If anyone wants the "Why would you do that vs just extending the prototype?", my answer is that it is mainly preference.
I find classes in this case to be more readable and a better developer experience, but note that there is a cost if doing this in the web for transpiling back to ES5.
Today, we went with the idea of a "block" list. If you want a stronger alternative, go with the idea of an "allow" list where a property must be allowed before it will show up on Sentry.