Adding Structured Logging to a Node.js REST API With Pino

Logging is one of the most direct ways to understand what your application is doing at runtime. A good logging layer can save hours of debugging by preserving the sequence of events that led to a specific state. This article walks through integrating Pino-logger into an Express-based Node.js CRUD application, from basic setup to custom log levels, pretty-printing, and writing logs to a dedicated file.

Prerequisites

To follow along, you’ll need:

  • Familiarity with Express for building servers
  • Experience setting up a REST API without authentication
  • Comfort with command-line tools or an integrated terminal in your editor

Having Postman (or a similar tool) installed is also recommended for testing your API endpoints.

Project Foundation

Since logging is best demonstrated in a realistic codebase, start with a basic CRUD application. You can set one up by following the “How To Perform CRUD Operations with Mongoose and MongoDB Atlas” tutorial, which gives you create, read, update, and delete routes using Express and Mongoose.

To avoid manually restarting the server after each edit, install nodemon globally:

npm install -g --force nodemon

Installing the Logging Dependencies

From the project root, install the packages needed for logging: Pino, Express-Pino-logger, and Pino-pretty.

npm install [email protected] [email protected] [email protected]

Creating a Basic Logger Service

Create a services directory in the root folder:

mkdir services

Inside that directory, create a file named loggerService.js with the following content:

const pino = require('pino')
module.exports = pino({})

At this stage, the exported pino function accepts two optional arguments—options and destination—and returns a logger instance. Without passing any options, the log output is raw JSON, which is not very human-friendly. To make it readable in the terminal, add the prettyPrint option to the pino function call:

const pino = require('pino')
module.exports = pino(
  {
    prettyPrint: true,
  },
)

Now connect the logger to your Express server. In your server.js file, add the following imports:

const expressPinoLogger = require('express-pino-logger');
const logger = require('./services/loggerService');

Next, configure express-pino-logger with your logger service immediately after const app = express();:

// ...

const loggerMidlleware = expressPinoLogger({
  logger: logger,
  autoLogging: true,
});

app.use(loggerMidlleware);

// ...

The expressPinoLogger function creates a middleware that uses your custom loggerService. The second argument, autoLogging, controls whether Pino logs the full JSON response for each request. For now, keep it as true so you can see the complete output.

To test the service, revisit foodRoutes.js and import the logger service at the top:

const logger = require('../services/loggerService')

Then, at the beginning of your existing GET route controller callback, place the following line:

// ...

app.get("/food", async (request, response) => {
  logger.info('GET route is accessed')
  // ...
});

// ...

These are the default log level methods that come with Pino: fatal, error, warn, info, debug, trace, or silent. You can pass any string as the message argument.

For reference, here is the complete server.js file so far:

const express = require("express");
const expressPinoLogger = require('express-pino-logger');
const logger = require('./services/loggerService');
const mongoose = require("mongoose");
const foodRouter = require("./routes/foodRoutes.js");
const app = express();
// ...
const loggerMidleware = expressPinoLogger({
  logger: logger,
  autoLogging: true,
});
app.use(loggerMidleware);
// ...
app.use(express.json());
mongoose.connect(
  "mongodb+srv://madmin:<password>@clustername.mongodb.net/<dbname>?retryWrites=true&w=majority",
  {
    useNewUrlParser: true,
    useFindAndModify: false,
    useUnifiedTopology: true
  }
);
app.use(foodRouter);

app.listen(3000, () => {
  console.log("Server is running...");
});

Restart the server:

nodemon server.js

When you hit the API route (via Postman or your browser), you should see output similar to this in the terminal:

Showing a black terminal window with output, including a first line in bright yellow, a second line in green and rest of the information in white. The information indicates the tool is watching files, starting the node server, when the GET route is accessed, and different API endpoints.

The log entry contains the timestamp, the log level (info), a completion message for the request, and the full JSON response for that request.

Defining Custom Log Levels

Pino’s default levels are enough for many cases, but you can also define your own. Add a JavaScript object to loggerService.js after importing the pino package:

// ...
const levels = {
  http: 10,
  debug: 20,
  info: 30,
  warn: 40,
  error: 50,
  fatal: 60,
};
// ...

The keys are the names of your custom levels, and the values are their numerical severity (higher numbers are more severe). To use them, rewrite the exported pino function so it passes that object in the options argument:

module.exports = pino({
  prettyPrint: true,
  customLevels: levels, // our defined levels
  useOnlyCustomLevels: true,
  level: 'http',
})

This configuration sets:

  • customLevels: levels — makes your custom levels available as methods
  • useOnlyCustomLevels: true — disables Pino’s defaults
  • level — must point to one of your custom levels, since the default info is no longer valid

To see the custom level in action, add a log statement with one of them in foodRoutes.js:

// ...

app.get"/foods", async (request, response) => {
    logger.http('GET route is accessed')
});

// ...

Also disable the middleware’s automatic JSON logging now that you no longer need the full response in every log. Set autoLogging to false in server.js:

const pino = require('pino')
const levels = {
  http: 10,
  debug: 20,
  info: 30,
  warn: 40,
  error: 50,
  fatal: 60,
};
module.exports = pino(
  {
    prettyPrint: true,
    customLevels: levels, // our defined levels
    useOnlyCustomLevels: true,
    level: 'http',
  },
)

After testing, the terminal output should show only your log entry:

A black terminal window that shows the node server starting in green, a note that the server is running, and a timestamp for when the GET route is accessed.

Prettifying Log Output

Raw JSON in the terminal can be difficult to scan. Pino can format the logs with color and timestamps that are easier on the eyes. Add the prettyPrint option to the pino function:

module.exports = pino({
  customLevels: levels, // our defined levels
  useOnlyCustomLevels: true,
  level: 'http',
  prettyPrint: {
    colorize: true, // colorizes the log
    levelFirst: true,
    translateTime: 'yyyy-dd-mm, h:MM:ss TT',
  },
})

Inside prettyPrint:

  • colorize — assigns distinct colors to different log levels
  • levelFirst — shows the level name before the date and time
  • translateTime — converts the epoch timestamp into a human-readable date

To see the difference between levels, add more than one log statement to an endpoint:

// ...

app.get("/foods", async (request, response) => {
  logger.info('GET route is accessed')
  logger.debug('GET route is accessed')
  logger.warn('GET route is accessed')
  logger.fatal('GET route is accessed')

// ...

After calling the API, the terminal will show a colored, legible log stream:

A black terminal window with the same information as before, but with colored labels for different lines of information, like a red label for a fatal message.

At this point, the logger is configured well enough for a production-ready application.

Writing Logs to a File

Capturing logs in a file is useful for later review or debugging. Pino handles this through the destination option in the pino function. Update the function call:

module.exports = pino(
  {
    customLevels: levels, // the defined levels
    useOnlyCustomLevels: true,
    level: 'http',
    prettyPrint: {
      colorize: true, // colorizes the log
      levelFirst: true,
      translateTime: 'yyyy-dd-mm, h:MM:ss TT',
    },
  },
  pino.destination(`${__dirname}/logger.log`)
)

pino.destination accepts a path to the log file. The __dirname variable in this context resolves to the services directory. The file logger.log is created automatically; if it is not, create it manually in the directory.

Here is the full loggerService.js:

const pino = require('pino')
const levels = {
  http: 10,
  debug: 20,
  info: 30,
  warn: 40,
  error: 50,
  fatal: 60,
};
module.exports = pino(
  {
    customLevels: levels, // our defined levels
    useOnlyCustomLevels: true,
    level: 'http',
    prettyPrint: {
      colorize: true, // colorizes the log
      levelFirst: true,
      translateTime: 'yyyy-dd-mm, h:MM:ss TT',
    },
  },
  pino.destination(`${__dirname}/logger.log`)
)

Testing the API again will write the logs into logger.log instead of printing them to the terminal.

Considerations for Production Logging

When designing a logging strategy, keep these points in mind:

  • Context: A log entry should carry relevant context—application, timestamp, and surrounding data—so it is meaningful on its own.
  • Purpose: Assign each log a clear role. For example, debug logs intended only for development should be removed before committing.
  • Format: Keep a consistent, readable format across all log statements so they are simple to parse by both humans and tooling.

For further customization options, the official Pino documentation covers configuration in more depth.