Enhancing Pino to Support (Message, Payload) Logging Without Breaking Pretty Logs
Victor A. Barzana

Victor A. Barzana @vbarzana

About: 🎒 Digital Nomad • 👨🏼‍💻 Software Engineer • 🌏 100% Remote

Location:
Cesis, Latvia
Joined:
Feb 18, 2019

Enhancing Pino to Support (Message, Payload) Logging Without Breaking Pretty Logs

Publish Date: Mar 19
0 0

Introduction

Pino is a blazing-fast logging library for Node.js, built for structured logs and high performance. However, one common frustration developers face is that Pino does not natively support logging a message alongside an object the way console.log does.

Note: this is my approach and a solution that works well for me. If you have any suggestions, improvements, or use a different method in your projects, I’d love to hear your thoughts!

Migrating to Pino: Common Pitfalls and Issues

When migrating an existing codebase to Pino—whether from Winston, another logging framework, or even the raw console API—you may expect it to work similarly. However, Pino handles logging differently, requiring adjustments to your log statements.

1️⃣ See for example, logs that work in console or Winston but fail in Pino

console.log('User created', { userId: 123 });
logger.info('User created', { userId: 123 }); // Winston
Enter fullscreen mode Exit fullscreen mode

Expected Output:

INFO  [12:30:45]  User created { userId: 123 }
Enter fullscreen mode Exit fullscreen mode

Pino Output (Incorrect):

INFO  [12:30:45]  User created
Enter fullscreen mode Exit fullscreen mode

🚨 The second argument is ignored because Pino expects an object as the first argument.


2️⃣ Using %j to Force JSON Logging

logger.debug('User created %j', { userId: 123 });
Enter fullscreen mode Exit fullscreen mode

🚨 While this works, it flattens the JSON into a string:

DEBUG  [12:30:45]  User created {"userId": 123}
Enter fullscreen mode Exit fullscreen mode

This prevents structured log processors from parsing the data properly.


3️⃣ Moving Messages Inside JSON

To properly log both the message and object, you might be forced to rewrite logs like this:

logger.info({ msg: 'User created', userId: 123 });
Enter fullscreen mode Exit fullscreen mode

✅ This works with structured logging but requires changing every log statement in your codebase.


4️⃣ The %j Issue With Errors

If you log errors using %j:

logger.debug('hello %j', { error: new Error('some crazy error') });
Enter fullscreen mode Exit fullscreen mode

🚨 The error loses its stack trace and serializes incorrectly:

DEBUG  [12:30:45]  hello {"error":{}}
Enter fullscreen mode Exit fullscreen mode

With our fix:

logger.debug('hello', { error: new Error('some crazy error') });
Enter fullscreen mode Exit fullscreen mode

✅ Logs correctly in dev mode with pino-pretty:

DEBUG  [12:30:45]  hello 
{
  "error": {
    "type": "Error",
    "message": "some crazy error",
    "stack": "Error: some crazy error
 at file.js:12:34"
  }
}
Enter fullscreen mode Exit fullscreen mode

Fixed Behavior: Error Serializer Works in Dev Mode

With our fix:

logger.debug('hello', new Error('some crazy error'));
Enter fullscreen mode Exit fullscreen mode

🎉 Logs correctly in development environments with pino-pretty:

DEBUG  [12:30:45]  hello 
{
  "error": {
    "type": "Error",
    "message": "some crazy error",
    "stack": "Error: some crazy error
 at file.js:12:34"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Preserves the error stack trace.
  • Works seamlessly in pino-pretty and JSON logs.

The Right Fix: Supporting (message, payload) While Preserving Pino's Structure

To fix this behavior, we need to:

  • Detect when the first argument is a string and second is an object.
  • Ensure %s, %d, and %j placeholders still work correctly.
  • Preserve Pino’s structured JSON logging.
  • Ensure compatibility with pino-pretty, transports, and serializers.

Let's fix your logger.ts file with just a few lines

import pino, { LogFn, Logger, LoggerOptions } from 'pino';

const isDevelopment = process.env.NEXT_PUBLIC_NODE_ENV === 'development' || process.env.NODE_ENV === 'development';

const pinoConfig: LoggerOptions = {
  level: process.env.LOG_LEVEL || 'debug',
  // We recommend using pino-pretty in development environments only
  ...(isDevelopment
    ? {
        transport: {
          target: 'pino-pretty',
          options: { colorize: true, translateTime: 'HH:MM:ss Z', sync: true },
        },
      }
    : {}),
  hooks: { logMethod },
  timestamp: pino.stdTimeFunctions.isoTime,
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
  },
};

const logger = pino(pinoConfig);

function logMethod(this: Logger, args: Parameters<LogFn>, method: LogFn) {
  // If two arguments: (message, payload) -> Format correctly
  if (args.length === 2 && typeof args[0] === 'string' && typeof args[1] === 'object' && !args[0].includes('%')) {
    const payload = { msg: args[0], ...args[1] };

    // If the object is an Error, serialize it properly
    if (args[1] instanceof Error) {
      payload.error = payload.error ?? args[1];
    }

    method.call(this, payload);
  } else {
    // any other amount of parameters, or order of parameters will be considered here
    method.apply(this, args);
  }
}

export default logger;
Enter fullscreen mode Exit fullscreen mode

How This Fix Works

(Message, Payload) Now Works as Expected

logger.info('User created', { userId: 123 });
Enter fullscreen mode Exit fullscreen mode

➡️ Logs correctly:

INFO  [12:30:45]  User created { userId: 123 }
Enter fullscreen mode Exit fullscreen mode

Before: ❌ Second argument ignored

Now: ✅ Structured properly.


Preserves String Interpolation

When a string interpolation is present in the message, we fallback to the default behavior of Pino to handle string interpolations. See pino docs for more details: https://github.com/pinojs/pino/blob/main/docs/api.md#logmethod

logger.info('User %s created with ID %d', 'John', 123);
Enter fullscreen mode Exit fullscreen mode

➡️ Logs:

INFO  [12:30:45]  User John created with ID 123
Enter fullscreen mode Exit fullscreen mode

Ensures Error Serialization Works

logger.error('Something went wrong', new Error('Oops!'));
Enter fullscreen mode Exit fullscreen mode

➡️ Logs:

ERROR [12:30:45]  Something went wrong { error: { msg: 'Oops!', stack: '...' } }
Enter fullscreen mode Exit fullscreen mode

Before: ❌ Pino didn't include the error in the logs
Now: ✅ Uses Pino's error serializer.


Why I believe This Fix is The Right Approach

  • Respects Pino’s structured logging (doesn’t mutate logs).
  • Fully supports pino-pretty formatting.
  • Doesn’t break child loggers, transports, or serializers.
  • Makes Pino more intuitive without hacks.

Final Thoughts

With this fix:

  • Pino behaves the way you expect.
  • Messages and objects log together properly.
  • Pino remains fast, structured, and developer-friendly.
  • If you have a big codebase based on console logging API or Winston, your transition to Pino will be very smooth.

Happy coding!

Comments 0 total

    Add comment