iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💤

NestJS Custom Logger Sample

に公開

I'm sharing this because there aren't many opportunities to create custom loggers in NestJS and code samples are scarce.

In my case, I had migration code from Express, so I built it from scratch, but I feel it might be faster to find and use a NestJS-compatible logging library.

I referred to this article. Thank you.
https://zenn.dev/ozen/articles/79e2bfc49a4b5a

Official Documentation Reference
https://docs.nestjs.com/techniques/logger

Three ways to customize logs in NestJS

NestJS provides three ways to customize logging.

  1. Pass options to the built-in logger.
  2. Implement the LoggerService interface.
  3. Extend the built-in ConsoleLogger.

Passing options to the built-in logger

You can disable the logger,

const app = await NestFactory.create(AppModule, {
  logger: false,
});
await app.listen(3000);

or specify the output levels.

const app = await NestFactory.create(AppModule, {
  logger: ['error', 'warn'],
});
await app.listen(3000);

Pros

  • Simple

Cons

  • You cannot configure anything other than disabling the logger or specifying output levels.

Implementing the LoggerService interface

NestJS provides the LoggerService interface, so you implement log, error, warn, debug, and verbose respectively.

import { LoggerService } from '@nestjs/common';

export class MyLogger implements LoggerService {
  /**
   * Write a 'log' level log.
   */
  log(message: any, ...optionalParams: any[]) {}

  /**
   * Write an 'error' level log.
   */
  error(message: any, ...optionalParams: any[]) {}

  /**
   * Write a 'warn' level log.
   */
  warn(message: any, ...optionalParams: any[]) {}

  /**
   * Write a 'debug' level log.
   */
  debug?(message: any, ...optionalParams: any[]) {}

  /**
   * Write a 'verbose' level log.
   */
  verbose?(message: any, ...optionalParams: any[]) {}
}
main.ts
const app = await NestFactory.create(AppModule, {
  logger: new MyLogger(), // Specify
});
await app.listen(3000);

Pros

  • Easy to extend.

Cons

  • NestJS handles logs in a unique way.

When you create your own logger, you'll want to support NestJS logs as well. NestJS handles logs in a unique way.

For example, suppose you specify three arguments with logger.log as shown below.

import {
  Logger,
} from '@nestjs/common';

const logger = new Logger('context');

// For example, if you write it like this...
logger.log('test1', 'test2', 'test3');

This will be output to standard output in three lines.

// Output in 3 lines
// context test1
// context test2
// context test3

If you build your own logger, you have to reproduce this behavior as well. This was quite a challenge. I will explain it later.

Extending the built-in ConsoleLogger

Rather than writing everything completely from scratch, there are times when you want to partially extend an existing logger. In those cases, you extend ConsoleLogger.

import { ConsoleLogger } from '@nestjs/common';

export class MyLogger extends ConsoleLogger {
  error(message: any, stack?: string, context?: string) {
    // add your tailored logic here
    super.error(...arguments);
  }
}

Pros

  • Easy to extend.

Cons

  • NestJS handles logs in a unique way.

Trying to output JSON by extending ConsoleLogger

The reason I wanted to create a custom logger this time was that I wanted to leverage existing code during the process of migrating code from Express to NestJS.

In the previous code, the log level, a message as the first argument, and JSON as the second argument were passed as follows:

const logger = new Logger('hoge');
logger.info('message', { requestId: 'rejakjgkjak4jet', userId: 1 });

This was output to standard output in JSON format like this:

{
  "level": "info",
  "message": "message",
  "requestId": "rejakjgkjak4jet",
  "userId": 1
}

Since I wanted to support this way of writing as well, I extended ConsoleLogger. However, as mentioned in the previous chapter, it was quite difficult because I also had to support NestJS's unique way of using logs.

Actual Implementation

I am using Winston for the original logger.
I will make it compatible with both the original writing style and the NestJS writing style.

const logger = new Logger('hoge');

// If you write it like this...
logger.log('test1', 'test2', 'test3');
// It will be output in 3 lines
// { "level": "info", "context": "hoge", "message": "test1" }
// { "level": "info", "context": "hoge", "message": "test2" }
// { "level": "info", "context": "hoge", "message": "test3" }

// If you write it like this
logger.log('message', { requestId: 'rejakjgkjak4jet', userId: 1 });
// It will be output in 1 line
// { "level": "info", "context": "hoge", "message": "message", "requestId": "rejakjgkjak4jet", "userId": 1 }

Type checking utility. Used for NestJS logger compatibility.

share/util
export const isString = (val: any): val is string => {
  return typeof val === 'string';
};

export const isUndefined = (obj: any): obj is undefined =>
  typeof obj === 'undefined';
export const isNil = (val: any): val is null =>
  isUndefined(val) || val === null;

export const isObject = (fn: any): fn is object =>
  !isNil(fn) && typeof fn === 'object';

export const isPlainObject = (fn: any): fn is object => {
  if (!isObject(fn)) {
    return false;
  }
  const proto = Object.getPrototypeOf(fn);
  if (proto === null) {
    return true;
  }
  const ctor =
    Object.prototype.hasOwnProperty.call(proto, 'constructor') &&
    proto.constructor;

  return (
    typeof ctor === 'function' &&
    ctor instanceof ctor &&
    Function.prototype.toString.call(ctor) ===
      Function.prototype.toString.call(Object)
  );
};

This is the logger service. If isConventionalLog is true, it determines that the writing style is the one I used to use; if false, it is the NestJS writing style. I wasn't entirely sure about the NestJS internal implementation, so I looked into the source code and borrowed some logic.

logger/myLogger.sevice.ts
/* eslint-disable @typescript-eslint/ban-types */

/* eslint-disable @typescript-eslint/no-explicit-any */
import { ConsoleLogger, ConsoleLoggerOptions } from '@nestjs/common';
import winston from 'winston';

import { isString, isPlainObject } from '../share/util';

export class MyLoggerService extends ConsoleLogger {
  private logger: winston.Logger;

  // Context can be specified. const logger = new Logger('context');
  constructor(context: string | undefined, options: ConsoleLoggerOptions) {
    super(context ?? 'no detect', options);
    const transports = [
      new winston.transports.Console({
        silent: process.env.LOG_SILENT === 'true',
        format: winston.format.combine(winston.format.json()),
        level: process.env.LOG_LEVEL || 'info',
      }),
    ];

    this.logger = winston.createLogger({
      transports,
    });
  }

  log(comment: string, ...optionalParams: [...any, string?]): void {
    if (this.isConventionalLog(optionalParams)) {
      this.printLikeConventional(comment, 'info', optionalParams[0]);
    } else {
      const { messages, context } = this.localGetContextAndMessagesToPrint([
        comment,
        ...optionalParams,
      ]);
      this.printLikeNest(messages, 'info', context);
    }
  }

  error(comment: string, ...optionalParams: [...any, string?, string?]): void {
    if (this.isConventionalLog(optionalParams)) {
      this.printLikeConventional(comment, 'error', optionalParams[0]);
    } else {
      const { stack, messages, context } =
        this.localGetContextAndStackAndMessagesToPrint([
          comment,
          ...optionalParams,
        ]);
      this.printLikeNest(messages, 'error', context);
      if (stack) {
        this.logger
          .child({
            context: context ?? this.context,
          })
          .error(stack);
      }
    }
  }

  debug(comment: string, ...optionalParams: [...any, string?]): void {
    if (this.isConventionalLog(optionalParams)) {
      this.printLikeConventional(comment, 'debug', optionalParams[0]);
    } else {
      const { messages, context } = this.localGetContextAndMessagesToPrint([
        comment,
        ...optionalParams,
      ]);
      this.printLikeNest(messages, 'debug', context);
    }
  }

  warn(comment: string, ...optionalParams: [...any, string?]): void {
    if (this.isConventionalLog(optionalParams)) {
      this.printLikeConventional(comment, 'warn', optionalParams[0]);
    } else {
      const { messages, context } = this.localGetContextAndMessagesToPrint([
        comment,
        ...optionalParams,
      ]);
      this.printLikeNest(messages, 'warn', context);
    }
  }

  private isConventionalLog(
    optionalParams: [...any, string?, string?],
  ): boolean {
    return (
      optionalParams.length > 0 &&
      optionalParams.length <= 2 && // Since the base class automatically adds the context to the end of optionalParams when specified, two elements are possible.
      isPlainObject(optionalParams[0])
    );
  }

  // Output on multiple lines.
  private printLikeNest(
    messages: string[],
    level: 'info' | 'warn' | 'error' | 'debug',
    context?: string,
  ): void {
    switch (level) {
      case 'info':
      default:
        messages.forEach((message) => {
          this.logger
            .child({
              context: context ?? this.context,
            })
            .info(message);
        });
        break;
      case 'warn':
        messages.forEach((message) => {
          this.logger
            .child({
              context: context ?? this.context,
            })
            .warn(message);
        });
        break;
      case 'error':
        messages.forEach((message) => {
          this.logger
            .child({
              context: context ?? this.context,
            })
            .error(message);
        });
        break;
      case 'debug':
        messages.forEach((message) => {
          this.logger
            .child({
              context: context ?? this.context,
            })
            .debug(message);
        });
        break;
    }
  }

  // Conventional output method
  private printLikeConventional(
    comment: string,
    level: 'info' | 'warn' | 'error' | 'debug',
    options: object,
  ): void {
    switch (level) {
      case 'info':
      default:
        this.logger
          .child({
            context: this.context,
            ...options,
          })
          .info(comment);
        break;
      case 'warn':
        this.logger
          .child({
            context: this.context,
            ...options,
          })
          .warn(comment);
        break;
      case 'error':
        this.logger
          .child({
            context: this.context,
            ...options,
          })
          .error(comment);
        break;
      case 'debug':
        this.logger
          .child({
            context: this.context,
            ...options,
          })
          .debug(comment);
        break;
    }
  }

  /**
   * Extracted from the base class.
   * @param args
   * @returns
   */
  private localGetContextAndStackAndMessagesToPrint(
    args: [...any, string?, string?],
  ): {
    stack?: string;
    messages: string[];
    context?: string;
  } {
    const { messages, context } = this.localGetContextAndMessagesToPrint(args);

    const lastElement = messages[messages.length - 1];
    const isStack = isString(lastElement);
    if (!isStack) {
      return { messages, context };
    }

    return {
      stack: lastElement,
      messages: messages.slice(0, messages.length - 1),
      context,
    };
  }

  /**
   * Extracted from the base class.
   * @param args
   * @returns
   */
  private localGetContextAndMessagesToPrint(args?: [...any, string?] | null): {
    context?: string;
    messages: string[];
  } {
    if (!args || args.length <= 0) {
      return { messages: [], context: this.context };
    }
    const lastElement = args[args.length - 1];
    const isContext = isString(lastElement);
    if (!isContext) {
      return { messages: args, context: this.context };
    }

    return {
      context: lastElement,
      messages: args.slice(0, args.length - 1),
    };
  }
}
app.module.ts
import { MyLoggerModule } from './myLogger/myLogger.module';

@Module({
  imports: [
    MyLoggerModule,
  ],
})
export class AppModule
logger/myLogger.module.ts
import { Module } from '@nestjs/common';

import { MyLoggerService } from './myLogger.service';

@Module({
  providers: [MyLoggerService],
  exports: [MyLoggerService],
})
export class MyLoggerModule {}
main.ts
  const app = await NestFactory.create(AppModule);
  app.useLogger(app.get(MyLoggerService));

Discussion