1 February, 20252 minute read

Logging errors in Node.js correctly

If you are using a structured logging format like JSON or logfmt with Node.js logging libraries, then you will have likely seen error log lines like the following in your observability tool:

Click to copy
{  "level": 50,  "time": 1738381994206,  "pid": 38355,  "hostname": "Sophias-MacBook-Pro-3.local",  "error": {},  "msg": "Failed to poll for job completion"}

The empty error object occurs when you try to log an Error object like so:

Click to copy
try {  const result = await pollJobForCompletion(jobId);  // ...} catch (error) {  if (error instanceof CouldNotPoll) {    logger.error({ error }, 'Failed to poll for job completion');    return;  }   // ...}

The root cause of the problem is that all of the interesting fields on an Error object are not enumerable, and the standard formatting libraries format objects by looping over their enumerable keys. You can verify this by running the following code in a REPL:

Click to copy
Object.keys(new Error()) // => []

In pino you can solve the problem by installing the excellent pino-std-serializers package and adding errWithCause to your serializers map. This assumes you always log errors under the error key, as pino does not run serializers against the entire log splat for performance reasons:

Click to copy
import { errWithCause } from 'pino-std-serializers';import pino from 'pino'; export const logger = pino({  // ...  serializers: {    error: errWithCause,  },})

If you are using winston then you can solve the problem by adding your own custom formatter (there's a winston.format.errors API but I had problems with it in the past alongside the JSON formatter—but maybe it is better now):

Click to copy
import * as winston from 'winston'; const errorFormatter = winston.format((info) => {  // assumption: you always log errors under the `error` key  if ('error' in info && info.error instanceof Error) {    // if you're worried about mutation, create an empty object    // and copy over enumerable properties + these special ones    Object.defineProperty(info.error, 'message', {      enumerable: true,      value: info.error.message,    });    Object.defineProperty(info.error, 'name', {      enumerable: true,      value: info.error.name,    });    Object.defineProperty(info.error, 'stack', {      enumerable: true,      value: info.error.stack,    });  }   return info;}); export const logger = winston.createLogger({  // ...  formatter: winston.format.combine(    winston.format.timestamp(),    errorFormatter,    winston.format.json(),  ),});

After doing this your log lines will be useful again:

Click to copy
{  "level": 50,  "time": 1738383562543,  "pid": 43552,  "hostname": "Sophias-MacBook-Pro-3.local",  "error": {    "type": "Error",    "message": "some error occurred",    "stack": "Error: some error occurred\n    at REPL22:1:20\n    at ContextifyScript.runInThisContext (node:vm:137:12)\n    at REPLServer.defaultEval (node:repl:598:22)\n    at bound (node:domain:432:15)\n    at REPLServer.runBound [as eval] (node:domain:443:12)\n    at REPLServer.onLine (node:repl:927:10)\n    at REPLServer.emit (node:events:531:35)\n    at REPLServer.emit (node:domain:488:12)\n    at [_onLine] [as _onLine] (node:internal/readline/interface:415:12)\n    at [_line] [as _line] (node:internal/readline/interface:886:18)"  },  "msg": "Failed to poll for job completion",}

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com