Guides
Basic Logging

How to Get Started with Logging in Node.js

Better Stack Team
Updated on July 29, 2022

Logging refers to the process of recording some detail about application behavior and storing it in a persistent medium. Having a good logging system is a key feature that helps developers, sysadmins, and support teams to monitor the behavior of an application in production, and solve problems as they appear.

Troubleshooting is not the only valid reason to log. Product teams and designers also use logs to track user behavior (such as A/B testing), and the marketing department can measure the impact of a specific marketing campaign through the logs.

In short, logging can provide value for every department in an organization provided that the correct things are logged in the right format, and analyzed using a specialized tool.

This tutorial will explain the basics of logging in Node.js starting from the built-in console module, then proceed to topics like choosing a logging framework, using the right log format, structuring your messages and sending them to a log management system for long-term storage and further analysis.

Prerequisites

Before you proceed with this article, ensure that you have a recent version of Node.js and npm installed locally on your machine. Also, you can sign up for a free Logtail account if you'd like to centralize your application logs in one place but this is not required to follow through with this tutorial.

What should you log?

Before we discuss the mechanics of Node.js logging, let's discuss the general things that you should be logging in a Node.js application. It's possible to log too much or too little so these general guidelines are helpful when determining what to log:

  • Think about the critical aspects of your program and identify which information you will want to debug an issue in production.
  • Log as much as possible in development at the appropriate level and turn off the superfluous details in production through an environmental variable. You can always turn them back on if you need to trace a problem more closely.
  • Log data that can help you profile your code in the absence of specialized tools.
  • Log your errors, whether they are operational or not.
  • Log uncaught exceptions and unhandled promise rejections at the highest log level so that it can be fixed promptly.

It might also be helpful to think about what not to log:

  • Don't log sensitive user information such as passwords, credit card details.
  • Avoid logging anything that can cause you to fall afoul of any relevant regulations in places where your business operates.

Following these simple rules will help if you're just getting started with logging. As your application evolves, you'll figure out how valuable your logs are and update your logging strategy accordingly.

Logging using the console module

The most common way to log in Node.js is by using methods on the console module (such as log()). It's adequate for basic debugging, and it's already present in the global scope of any Node.js program. All the methods provided on the console module log to the console, but there are ways to redirect the output to a file as you'll see shortly. These are the console methods commonly used for logging in Node.js:

  • console.error(): used for serious problems that occurred during the execution of the program.
  • console.warn(): used for reporting non-critical unusual behavior.
  • console.trace(): used for debugging messages with extended information (a stack trace) about application processing.
  • console.info(), console.log(): used for printing informative messages about the application.

Let's look at a quick example of using the logging methods on the console object. Create a main.js file in the root of your project, and populate it with the following code:

const fruits = [
  'apple',
  'banana',
  'grapefruit',
  'mango',
  'orange',
  'melon',
  'pear',
];

const basket = [];

function addToBasket(item) {
  if (basket.length < 5) {
    // log the action
    console.info(`Putting "${item}" in the basket!`);
    basket.push(item);
  } else {
    // log an error if the basket is full
    console.error(`Trying to put "${item}" in the full basket!`);
  }
}

for (const fruit of fruits) {
  addToBasket(fruit);
}

// log the current basket state
console.log('Current basket state:', basket);
Copied!

Save the file, then run the program using the command below:

node main.js
Copied!

You should observe the following output:

Output
Putting "apple" in the basket!
Putting "banana" in the basket!
Putting "grapefruit" in the basket!
Putting "mango" in the basket!
Putting "orange" in the basket!
Trying to put "melon" in the full basket!
Trying to put "pear" in the full basket!
Current basket state: [ 'apple', 'banana', 'grapefruit', 'mango', 'orange' ]

Now that we can log to the console, let's look at a way to store our log output in a log file for further processing. You can do this by redirecting the output of the program to a file as shown below:

node main.js > combined.log
Copied!

You'll notice that the following error logs were printed to the console:

Output
Trying to put "melon" in the full basket!
Trying to put "pear" in the full basket!

Meanwhile, you'll also notice that a new combined.log file is present in the current working directory. If you inspect the file in your editor or with cat, you'll see the following contents:

Output
Putting "apple" in the basket!
Putting "banana" in the basket!
Putting "grapefruit" in the basket!
Putting "mango" in the basket!
Putting "orange" in the basket!
Current basket state: [ 'apple', 'banana', 'grapefruit', 'mango', 'orange' ]

The reason why the error logs were printed to the console instead of being sent to the combined.log file is that the error() method prints its messages to the standard error (stderr) and the > operator works for messages printed to the standard output (stdout) alone (both info() and log() print to stdout).

To ensure that error logs are also placed in a file, you need to use the 2> operator as shown below:

node main.js > main.log 2> error.log
Copied!

Using > main.log lets you redirect the stdout contents to the main.log file while 2> error.log redirects the contents of stderr to the error.log file. You can inspect the contents of both files using cat as shown below:

cat main.log
Copied!

This outputs the following:

Output
Putting "apple" in the basket!
Putting "banana" in the basket!
Putting "grapefruit" in the basket!
Putting "mango" in the basket!
Putting "orange" in the basket!
Current basket state: [ 'apple', 'banana', 'grapefruit', 'mango', 'orange' ]

Next, display the contents of the error.log file:

cat main.log
Copied!

Which should yield the following output:

Output
Trying to put "melon" in the full basket!
Trying to put "pear" in the full basket!

If you want to log both types of messages to a single file, you can do the following:

node main.js > app.log 2>&1
Copied!

This would redirect the stdout file descriptor to the app.log and redirect stderr to stdout.

cat app.log
Copied!
Output
Putting "apple" in the basket!
Putting "banana" in the basket!
Putting "grapefruit" in the basket!
Putting "mango" in the basket!
Putting "orange" in the basket!
Trying to put "melon" in the full basket!
Trying to put "pear" in the full basket!
Current basket state: [ 'apple', 'banana', 'grapefruit', 'mango', 'orange' ]

To learn more about input or output redirection, you can read more about file descriptors on the wooledge pages. Don't forget to check out the Node.js Console documentation to learn more about the other features of the console module.

Why you need a logging framework

Using the methods on the console module is a good way to get started with Node.js logging, but it's not adequate when designing a logging strategy for production applications due to its lack of convenience features like log levels, structured JSON logging, timestamps, logging to multiple destinations, and more. These are all features that a good logging framework takes care of so that you can focus on the problem you're trying to solve instead of logging details.

There are a lot of options out there when it comes to logging frameworks for Node.js. They mostly offer similar features so choosing between them often boils down to the one whose API you love the most. Here's a brief overview of the most popular logging packages on NPM that you can check out:

  • Winston: the most popular and comprehensive logging framework for Node.js
  • Pino: offers an extensive feature-set and claims to be faster than competing libraries.
  • Bunyan: provides structured JSON logging out of the box.
  • Roarr: use this if you need a single library for logging in Node.js and the browser.

In this tutorial, we'll be demonstrating some basic features of a logging framework through Winston since it remains the most popular logging framework for Node.js at the time of writing.

Getting started with Winston

Winston is a multi-transport async logging library for Node.js with rich configuration abilities. It's designed to be a simple and universal logging library with maximal flexibility in log formatting and transports (storage). You can install Winston in your project through npm:

npm install winston
Copied!

After installing Winston, you can start logging right away by using the default logger which is accessible on the winston module. Clear your main.js file before populating it once more with the following code:

const winston = require('winston');

const consoleTransport = new winston.transports.Console();

winston.add(consoleTransport);

winston.info('Getting started with Winston');
winston.error('Here is an error message');
Copied!

Before you can use the default logger, you need to set at least one transport (storage location) on it because none are set by default. In the snippet above, we've set the Console transport which means that subsequent log messages will be outputted to the Node.js console. Run the program to see this in action:

node main.js
Copied!

You should observe the following output:

{"level":"info","message":"Getting started with Winston"}
{"level":"error","message":"Here is an error message"}
Copied!

Notice that the default logger has been configured to format each log message as JSON instead of plain text. This is done to ensure that log entries are structured in a consistent manner that allows them to be easily searched, filtered and organised by a log management system.

Without structured logging, finding and extracting the useful data that is needed from your logs will be a tedious experience because you'll likely need to write a custom parsing algorithm for extracting relevant data attributes from plain text messages, and this task can become quite complicated if the formatting of each message varies from entry to entry.

Winston uses JSON by default, but it provides some other predefined options like simple, cli, and logstash which you can investigate further. You can also create a completely custom format by using winston.format. Under the hood, this uses the logform module to format the messages.

Since JSON is both human and machine-readable, it remains the go-to format for structured logging in most Node.js applications. We recommend that you stick with it unless you strongly prefer some other structured format (such as logfmt for example).

Understanding Log levels

In the previous code block, you'll notice the presence of the level property in each log entry. The value of this property indicates how important the message is to the application. Notably, this is absent in the native Console module, and it's one of the major reasons why its methods are unsuitable for serious production-ready applications.

In general, log levels indicate the severity of the logging message. For example, an info message is just informative, while a warn message indicates an unusual but not critical situation. An error message indicates that something failed but the application can keep working, while a fatal or emergency message indicates that a non-recoverable error occurred and immediate attention is needed to resolve the issue.

The exact log levels available to you will depend on your framework of choice, although this is usually configurable. Winston provides six log levels on its default logger and six corresponding methods which are ordered from the most severe to the least severe below:

const levels = {
  error: 0,
  warn: 1,
  info: 2,
  http: 3,
  verbose: 4,
  debug: 5,
  silly: 6
};
Copied!

Severity ordering in Winston conforms to the order specified by the RFC5424 document in which the most severe level is numbered 0, and each subsequent level ascends numerically ensuring that the least severe level has the highest number.

winston.error('error');
winston.warn('warn');
winston.info('info');
winston.verbose('verbose');
winston.debug('debug');
winston.silly('silly');
Copied!

The log level for an entry has an important consequence when logging. It determines if the entry will be emitted by the logger during program execution. You can test this out by placing each of the six logging methods above in your main.js file and executing it. You'll notice that only the first three appear in the Node.js console:

node main.js
Copied!
Output
{"level":"error","message":"error"}
{"level":"warn","message":"warn"}
{"level":"info","message":"info"}

That's because the default logger is set to log at the info level by default. This means that only messages with a minimum severity of info (or a maximum number of 2) will be logged to the configured transport (the console in this case). This behavior can be changed by customizing the level property on the transport as shown below:

. . .

winston.add(consoleTransport);

consoleTransport.level = 'silly';

winston.error('error');
winston.warn('warn');
winston.info('info');
winston.verbose('verbose');
winston.debug('debug');
winston.silly('silly');
Copied!

With the minimum severity level now set to silly, all the logging methods above will now produce some output:

node main.js
Copied!
Output
{"level":"error","message":"error"}
{"level":"warn","message":"warn"}
{"level":"info","message":"info"}
{"level":"verbose","message":"verbose"}
{"level":"debug","message":"debug"}
{"level":"silly","message":"silly"}

It's important to log at the appropriate level so that it's easy to distinguish between purely informative events and potentially critical problems that need to be addressed immediately. Log levels also help to reduce the verbosity of logging so that some messages are essentially turned off where they are not needed. Usually, production environments will run the application at the info level by default while testing or debugging environments typically run at the debug or the lowest level in the hierarchy.

This setting is usually controlled through an environmental variable to avoid modifying the application code each time the log level needs to be changed.

consoleTransport.level = process.env.LOG_LEVEL;
Copied!

A starting point for your log entries

A good log entry should consist of at least the following three fields:

  • timestamp: the time at which the entry was created so that we can filter entries by time.
  • level: the log level, so that we can filter by severity.
  • message: the log message that contains the details of the entry.

Using the default Winston logger gives us only two of the three properties, but we can easily add the third by creating a custom logger. Update your main.js file as shown below:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});

logger.info('Info message');
Copied!

Three basic things to configure on a custom logger are the minimum log level, the format of the log messages, and where the logs should be outputted. This logger above does not behave too differently from the default logger at the moment, but we can easily customize it further.

For example, let's add the missing timestamp field on all log entries. The way to do this is by creating a custom format that combines the timestamp() and json() formats as shown below:

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()],
});

logger.info('Info message');
Copied!

After configuring the logger as shown above, a timestamp field will be included in each entry:

Output
{"level":"info","message":"Info message","timestamp":"2022-01-22T08:24:44.305Z"}

You can also configure the format of the datetime value in the timestamp filed by passing an object to the timestamp() method as shown below. The string accepted by the format property must be one that can be parsed by the fecha module.

winston.format.timestamp({
  format: 'YYYY-MM-DD HH:mm:ss',
})
Copied!

This yields the following output:

{"level":"info","message":"Info message","timestamp":"2022-01-23 13:46:35"}
Copied!

Writing good log messages

The way messages are crafted is essential to good logging practices. The whole point of logging is to help you understand what is happening in your application, so it's necessary to adequately describe the details of each entry using detailed and concise language so that your logs don't turn out to be useless when you need them the most. Some examples of bad log messages include the following:

Output
Something happened
Transaction failed
Couldn't open file
Failed to load resource
Task failed successfully

Here are examples of better log messages:

Output
Failed to open file 'abc.pdf': no such file or directory
Cache hit for image '59AIGo0TMgo'
Transaction 3628122 failed: cc number is invalid

Adding context to your log entries

Another important way to furnish your log entries with useful details is by adding extra fields to each JSON object aside from the three already discussed. A good starting point for the data points that you can add to your logs include the following:

  • HTTP request data such as the route path or verb.
  • IP addresses.
  • Session identifiers.
  • Order or transaction IDs.
  • Exception details.

You can do so by passing an object as the second argument to each logging method:

logger.info('Starting all recurring tasks', {
  tag: 'starting_recurring_tasks',
  id: 'TaskManager-1234729',
  module: 'RecurringTaskManager',
});
Copied!

This yields the following output:

Output
{"id":"TaskManager-1234729","level":"info","message":"Starting all recurring tasks","module":"RecurringTaskManager","tag":"starting_recurring_tasks","timestamp":"2022-01-23 14:51:17"}

If you add context to your log entries in this manner, you won't need to repeat the information in the message itself. This also makes it easy to filter your logs, or to find a specific entry based on some criteria.

Storing logs in files

Logging to the console may be good enough in development, but it's important to record the entries into a more permanent location when deploying to production. Winston provides a File transport to help you direct entries to a file. You can use it via the transports property as shown below

const logger = winston.createLogger({
  levels: logLevels,
  level: 'trace',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.File({ filename: 'combined.log' })],
});
Copied!

This replaces the Console transport with the File transport so all emitted entries will now be placed in the combined.log file. You can log to more than one transport at once so you can log to both the console and a file using the snippet below:

transports: [
  new winston.transports.Console(),
  new winston.transports.File({ filename: 'combined.log' }),
]
Copied!

To prevent a log file from getting too big, you should rotate them through a transport like the winston-daily-rotate-file. You can also use an external tool like logrotate if you're deploying to a Linux-based operating system.

Aggregating your logs

Once you're used to writing and reading logs, you'll want to aggregate them in a specialized log management tool. This helps you centralize your logs in one place, and filter them to debug an issue or gather insights from them in various ways. You can even discover usage patterns that could come in handy when debugging specific issues, or create alerts to get notified when a specific event occurs.

Logtail is a specialized log management tool that integrates perfectly with several Node.js logging frameworks. To use it with Winston, you'll need to install the @logtail/node and @logtail/winston packages:

npm install @logtail/node @logtail/winston
Copied!

Afterward, you can set Logtail as one of the transport options on your Winston logger and log as normal. Note that you'll need to sign up for Logtail to retrieve your source token. Ensure to replace the <your_source_token> placeholder below with this token string.

const winston = require('winston');
const { Logtail } = require('@logtail/node');
const { LogtailTransport } = require('@logtail/winston');

// Create a Logtail client
const logtail = new Logtail('<your_source_token>');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console(), new LogtailTransport(logtail)],
});

logger.info('Info message');
Copied!

After executing the above snippet, you should see the following output in the Live tail section on the Logtail dashboard.

Logtail screenshot

For more information on how Logtail integrates with Node.js applications, please see the full documentation.

Summary

  • Logging is a necessary task in application development and it should be taken seriously.
  • Due to the deficiencies of the Node.js console module, it is recommended that a suitable logging framework is employed for this task.
  • Structured logging is key for automated processing (such as for alerting or auditing).
  • Use JSON format for log entries to maintain human and machine readability.
  • Always log at the appropriate level and turn off superfluous levels in production.
  • Ensure all log entries have a timestamp, log level, and message.
  • Improve your log entries with contextual information.
  • Use a log management solution, such as Logtail, to aggregate and monitor your logs as this can help you drastically improve the speed at which issues are resolved.

Conclusion and next steps

We hope this article has helped you learn enough to get started with logging in your Node.js applications. As the title suggests, this is only the starting point of your logging journey, so feel free to do some more research on this topic as needed. We also have specialized guides that provide more detail on everything you can do with logging frameworks like Winston and Pino so ensure to check those out as well.

Thanks for reading, and happy coding!

Centralize all your logs into one place.
Analyze, correlate and filter logs with SQL.
Create actionable
dashboards with Grafana.
Share and comment with built-in collaboration.
Got an article suggestion? Let us know
Next article
A Complete Guide to Pino Logging in Node.js
Learn how to start logging with Pino in Node.js and go from basics to best practices in no time.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.