iT邦幫忙

2024 iThome 鐵人賽

DAY 16
2
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 23

nestjs 系統設計 - 活動訂票管理系統 - 結構化 logger

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - 結構化 logger

目標

前面幾章,大致上把原本活動訂票管理系統的主要功能完成了。接著,會針對一些非功能性需求來作處理。今天主要會來實做結構化 logger。

概念

logger 在系統中的作用,主要是用來紀錄系統發生過的事件。在正式環境中,往往一系列的事件會一起發生,假設系統沒有一個標準的格式。那會造成紀錄難以讀取,容易遺漏的問題。因此,標準化的紀錄格式是一個對蒐集與解析 logger 的人或是服務能夠提昇可觀測性的重要一步。

image

要能紀錄一個事件,關鍵要素有以下幾點:

  1. 時間:事件發生的時間
  2. 可識別的資訊:一般來說,為了方便關聯,會把一個 request 整個生命週期使用同一個識別 id
  3. 事件發生的相關參數: 比如傳入的 request 之類,而如果是例外可能是相關的 error stack
  4. 紀錄 log 本身的環境之類:比如假設是伺服器使用 docker build 也許可以列印出該 image 的 tag 或是 hash 可供追尋的資訊
  5. 紀錄的類別: 這主要是可以用來分類所要處理的事件類別,大致上會是用 log level 來分別該 logger 屬於哪種等級,該處理的優先順序等等

今天會透過 pino 這個套件來實做這件事情。

安裝相關套件

pnpm i -S nest-pino pino-http 

設定關於 PinoLogger 的設定

新增全域的 logger

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { Logger } from 'nestjs-pino';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    bufferLogs: true,
  });
  app.useLogger(app.get(Logger));
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    transform: true,
  }))
  await app.listen(3000);
}
bootstrap();

這個設定是當整個 app 被 NestFactory 建立出來後才取出 pino logger 當作全域的 logger 。這個設定要能正常運作要有一個前提,就是 NestFactory 建制要能成功且正常運作。假設在建制過程失敗,則 logger 無法啟用,沒有資訊。

這個問題的解法,在下一章的 graceful shutdown 部份,會做出一個解法。今天先專注在於 pino logger 的設定。

在 appModule 設定 pinologger Module

在 imports 的欄位加入以下設定

LoggerModule.forRootAsync({
  useFactory: async () => {
	return {
	  pinoHttp: {
		autoLogging: false,
		base: null,
		quietReqLogger: true,
		genReqId: (request: Request) => request.headers['x-correlation-id'] || crypto.randomUUID(),
		level: 'info',
		formatters: {
		  level (label)  {
			return {level: label }
		  }
		}
	  },
	}
  }
}),

在 pinoHttp 這個欄位,代表示透過 http request 進來的 logger 都套用以這個欄位的設定。

在 pinoHttp 中,有幾個比較重要的欄位在以下作個別說明:

  1. level: 這個欄位代表顯示哪個 logLevel 以上的 log 。為了避免被干擾,所以一般會設置一下來過濾一下比如 debug 的 log 來忽略不必要的 log。其中,log level 的預設等級值是 debug < info < warn < error < fatal 。
  2. formatters: 這個是用來設定 log 顯示的欄位,比如範例中針對 level 欄位作設定,讓回傳值是 label 而不是原本的數字。
  3. base: 這個欄位域社會去抓取比如執行 nodejs 的 process Id,以及 hostname 等等,如果不想要顯示就可以填入 null 。
  4. genReqId: 這個就是用來關聯每個 logger 對應的 request 值的 id,這邊設置是可以從 request header 接收 x-correlation-id 或當源頭是自行產生。
  5. autoLogging: 這個值是決定要不要啟用 pino-http 預設的 logger request ,為了能夠更客製化。只顯示開發所使用的 logger 這邊設定為 false。
  6. quietReqLogger: 這個值用來是否顯示 request 帶入的 client 資訊,比如 header 內的相關值,user-agent 等等。

設定 LoggerInceptor 針對每個抓取每個 request 的處理資訊

import { CallHandler, ExecutionContext, HttpException, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { catchError, Observable, tap, throwError } from 'rxjs';

@Injectable()
export class LoggerInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggerInterceptor.name);
  intercept(context: ExecutionContext, next: CallHandler<unknown>): Observable<unknown> | Promise<Observable<unknown>> {
    const request: Request = context.switchToHttp().getRequest();
    const userAgent = request.get('user-agent') ?? '';
    const { ip, method, path: url } = request;
    this.logger.log({method, url, userAgent, ip, name: context.getClass().name, handler: context.getHandler().name, type: 'Invoke' });
    const now = Date.now();
    return next.handle().pipe(
      tap((res) => {
        const response = context.switchToHttp().getResponse();
        const { statusCode } = response;
        const contentLength = response.get('content-length');
        this.logger.log({
          method, url, statusCode, contentLength, userAgent, 
          requestInMillis: Date.now() - now,
          res
        });
      }),
      catchError((error: unknown) => {
        const response: Response = context.switchToHttp().getResponse();
        const httpError = error as HttpException;
        const statusCode = httpError.getStatus() ?? response.statusCode ?? undefined;
        this.logger.error({
          method, url, statusCode, userAgent, ip, class: context.getClass().name, 
          handler: context.getHandler().name,
          requestInMillis: Date.now() - now 
        });
        return throwError(() => error);
      })
    );
  }
}

範例

故意註冊一個已經存在的 email 來看看錯誤的 logger

image

logger 如下:
image

可以看到基本上,上述所講的 reqId 與 logger level 以及相對應的資訊,都可以從 以相同格式顯示出來。這樣在後續的 debug 與追查相關事件就能方便找出可能的原因。

備註

當其他 Service 要引用該 logger 時,只需要使用原本的 nestjs/common 內的 Logger 類別,如下
image

好處是在全域配置之後,所有其他的 Logger 都是自動在建制時,被套用為 pinoLoger 。也因此,剛剛作的 logger 設定都能自動被套用,不用額外作其他複寫之類。

這也是 nestjs 的 DI container 的強大之處。

結論

結構化 logger 雖然算是非功能性的需求,然而在日常開發運維的系統上十分重要。可以說是能簡化尋找 bug ,以及找尋效能問題的基礎。因此,使用相對標準的設定與彈性的套件很重要。

這邊使用的 pino logger 算是近年來 nodejs 相對比較輕量化的 logger , 與 winston 與 morgan 等成熟的套件來說, pino 優點在於其套件 size 小,與配置上的簡單。當然有人說 pino 強化了一些序列化的效能,也許是因為其本身的 size 沒這樣大,所以載入記憶體時的耗損沒這樣大。

基本上,如果是使用 nodejs 開發,最常使用就是 pino, winston, morgan 這三種。

另外,有一點要注意的是, logger 不代表回應的值。這看似理所當然的事情,在我過去待過得公司,還是會有人把所有 logger 上的資訊直接往外拋。這後來雖然有透過 sonarcube 來 scan 的來抓出不當的寫法。但實務上比較踏實的作法就是,開發者還是要有這樣的概念 - logger event 是用來觀測系統,理應跟回應分開作處理。


上一篇
nestjs 系統設計 - 活動訂票管理系統 - Ticket Module part 3
下一篇
nestjs 系統設計 - 活動訂票管理系統 - graceful shutdown
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加26
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言