Poor Man's Error Monitoring

It is crucial to provide a smooth experience to users for an app to be successful. If your app goes down, for whatever reason, you need to identify the issue and solve it as quickly as possible. That is when the error monitoring comes into play. There are many 3rd party application monitoring services, but they are very expensive. In this blog post we will create our own logging system with Node Streams to log errors and monitor health of our application.

Let's start with install express.

yarn add express

Now let's create a basic server. (I will be using the old JavaScript syntax for convince.)

const express = require("express");

const app = express();

app.get("/", (req, res) => {
  res.send("Hello World!");
});

app.listen("4000", () => {
  console.log("Server is running on port 4000!");
});

We will use Streams to write errors to a file. Streams in Node are infamous for being hard and obscure. But they provide us with two major benefits, namely Memory Efficiency and Time Efficiency. It is very common in development to use console.log() or console.error() to write logs to the terminal. But console.* functions are synchronous, so they are not suitable for production. That's why we are using Streams. Now, let's create a write stream:

// utils/createWriteStream.js
const fs = require("fs");

if (!fs.existsSync("./logs")) {
  fs.mkdirSync("./logs");
}

const writeableStream = fs.createWriteStream(`./logs/errors.log`, {
  flags: "a",
});

function errorStream(where, stackTrace) {
  const message = `[ERROR] ${new Date().toISOString()} [${where}] - ${stackTrace}\n`;
  writeableStream.write(message);
}

module.exports = errorStream;

Let's try to unpack what we have here. First, we create the directory logs if it does not exist. We will be storing the error logs in this directory. Then, we create write stream with flag: "a", which stands for append. If errors.log file exists, it will append at the end of it. Otherwise, the file will be created. At the bottom, we are exporting errorStream function, where the magic happens. We are logging where the error has occurred and the stack trace.

Now we are all set, and ready to test. Let's import the errorStream function and try to log an error.

// index.js
const errorStream = require("../utils/createWriteStream");

app.get("/", (req, res) => {
  try {
    throw new Error("I am an error");
  } catch (err) {
    res.status(500).send("Something went wrong.");

    // Here we are logging the error
    errorStream("GET /", err.message);
  }
});

For the sake of simplicity we are throwing an error and logging that error with the function we just exported above. The error.log file becomes:

[ERROR] 2020-11-13T18:02:00.331Z [GET /] - I am an error

Let's add just one more route to illustrate further:

app.get("/another-page", (req, res) => {
  try {
    throw new Error("I am an error");
  } catch (err) {
    res.status(500).send("Something went wrong.");

    errorStream("GET /another-page", err.message);
  }
});

When we visit /another-page page, our ./log/errors.log becomes:

[ERROR] 2020-11-13T18:02:00.331Z [GET /] - I am an error
[ERROR] 2020-11-13T18:18:02.813Z [GET /another-page] - I am an error

Live demo of this app can be found on CodeSandbox.

Now that we have the error logs, we can parse those logs and monitor our app by using, for example, AWS CloudWatch. In my Monitoring Nginx with CloudWatch article I explained how to configure AWS CloudWatch, fetch and filter logs and create graph.