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 would be 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:

400: Invalid request

createLogger

Here is the interface for the createLogger’s config:

400: Invalid request

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:

400: Invalid request

As we can see, the lower the level the more important the message is.

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 us take a look at a config passed to each transport:

400: Invalid request

Format

Changing 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.

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`Thisisamessage`

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).

400: Invalid request

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.

400: Invalid request

Here is how this is going to work:

We throw an error inside our function.

We catch the error immediately and check its stack.

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.

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, ...].

400: Invalid request

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

400: Invalid request

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

400: Invalid request

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’sinfo 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

400: Invalid request

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.