Maciej Cieślar
Follow
A JavaScript developer and a blogger
· 6 min read

Creating a logger in Node.js from scratch

Learn how to create a single logger, in which, by calling - for example - logger.info, the message would be automatically logged into our console, and saved into two files.

Creating a logger in Node.js from scratch

Logging is undoubtedly one of the most important parts of our application. Now, console is a very powerful tool, yes, but what if we wanted to log not only to console but also to a file?

We could try to write a function logToFile and call it right after console.log. This, however, is not the most DRY (don't repeat yourself) way to go about it.

What we actually want to achieve is to have a single logger, in which, by calling - for example - logger.info, the message is automatically logged into our console, saved into two files, and whatever else we might need at the time.

Libraries like Winston, which provide logging in our applications, are very good at what they do, so we don't really need to reinvent the wheel. Still, I believe that implementing such a library ourselves often provides a lot of insight into how they work. Also, we may want to do a few things differently and add a feature or two.

Go ahead and clone the repository and let's get started by having a look at what we want to achieve:

createLogger

Here is the interface for the createLogger's config:

Let's break it down.

Level

A level is a string with a numeric value assigned to it.

Here are our levels and their numeric values:

As you can see, the lower the level the more important the message.

No logs with a level lower than the one provided to the config will be accepted.

Transports

We provide an array of transports which are different ways of displaying the message.transports.console is going to log the message into our console, and transports.file -- into a file. We could even create our own transport and use it to save each message inside a database.

Transport

A transport has to be an instance of a class called Transport so that it can inherit all the necessary methods.

Let's take a look at a config passed to each transport:

FormatChanging an expression to a string with the built-in function .toString() may sometimes return results such as [object Object]. It tells us literally nothing and we would like to avoid that, thus we are using our custom-build function to handle changing the expression into a string-based representation.

The message is going to be passed like this:

logger.info`This is a collection ${collection} and it is very nice. This is a number ${numb} and it is also very nice.`;

Here, our function would be called twice. First time with collection passed in as value, and the second time with numb.

Note that I did not include parentheses ( ) after calling the info method. This is an example of what we call a tag function - you can read more about this here. I chose to use tag functions to try something different and, also, it is actually the easiest way to pass variables inside our message.

Here is what the call would look like if we did not use a tag function:

console.info('This is a collection', collection, ' and it is very nice. This is a number ', numb, ' and it is also very nice');

Our format function can be, for example, JSON.stringify.

Level

This level, aside from it being transport-specific, works exactly the same as the one inside the logger config.

Template

A template is a function which takes, as arguments, functions called Formatters. Each Formatter returns a function that creates a chunk of our message by taking the Info object as an argument and returning a string.

Inside the Info object, we can find a lot of useful information. For example, for logger.info`This is message` that would be:

  • Level - info
  • The message  - This is a message
  • Date of calling log - new Date()
  • Place in the code where logger.info was called - log (/Users/primq/Repositories/loqqer/build/index.js:115:17).

Inside format.text we use the node-emoji library, which lets us get the Unicode of emojis. They then can be rendered correctly in our terminal, our file, or anywhere else.

So, Here is a message :heart:, becomes Here is a message ❤️.

It adds a little flavor to our logs and, for me, simply looks good.

Place in the code where logger.info was called...

Whenever we log something we may forget where the log was located - I know it is not a problem to find it - but still, it is interesting how one would go about finding it without searching manually.

If you think about it, we have this one way of revealing all the called functions just before the one we are in right now - it's what we call a stack. We can gain access to the stack by throwing an error.

Here is how this is going to work:

  1. We throw an error inside our function
  2. We catch the error immediately and check its stack
  3. We split the stack by new line and have as a result an array with each line of the stack being a separate element. Now we filter the array to get only those lines starting with 'at' since we are only interested in locations
  4. We either get the location at the index provided to the function or at the first one (default), which means any function that was called before getLocation(). You can look at the locations like: [getLocation, functionThatCalledGetLocation (the default one), functionThatCalledFunctionThatCalledGetLocation, ...].

Now that we have talked about the config, let's implement the Transport class.

The format and getMessage methods are using the config's methods. The log method acts here as a fallback in case a subclass does not define one of their own. The isAllowed method simply checks whether the provided level of a given message is sufficient enough to be logged in our transport.

Built-in transports

Before we can create our logger, we have to create some transports. I think it would be nice to provide one or two as built-ins. We are going to create two transports that are going to be used in literally every application - a console and a file transport.

transports.console

format

The util module provides us with a function called inspect which creates a string-based representation of an object. As the third argument, we can pass the number of how many objects deep we would like to go.

log

We try to use a method from console if there is one for our level. So, if the level is info, the console's info method will be used.We also want to check whether the output should be colorized - if it should, we are going to use the colors package to do so. We may also want to include colors as a static property in our class so that it can be changed manually if needed.

transports.file

Inside the FileTransport constructor we create a writeStream property which we then use to store each message into a file.

Here, we are also using the inspect function, but now we do not need to limit ourselves - we can show all the properties.

Summary

Let us just add the code for createLogger based on the previously defined transport API.

Now, finally, we can create an instance of our logger.

Let's run it and check the results.

In important.log file:

In not-so-important.log file:


Plug: LogRocket, a DVR for web apps

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.