本文介绍了如何设计并实现高效日志系统,介绍了一个有效的日志系统需要考虑的关键问题,强调了日志在系统调试和监控中的重要性。原文:Design And Building A Logging System

作为开发者,经常需要在调试时查看检查日志,缺乏日志或者不清楚如何通过日志分析问题,就无法定位出错的代码。

对于每天为成千上万甚至上百万用户提供服务的系统来说,日志必不可少,因为:

  • 日志可以帮助我们找到影响最终用户的错误。
  • 日志可以跟踪系统的 "健康状况",在系统出问题之前察觉到某些 "异常迹象"。
  • ……等等

由此可见,在开发或运行系统时,日志至关重要,因此,设计和实施完善的日志系统有助于简化监控工作。

本文将分享我在设计和构建日志系统方面的经验和理解。希望通过这篇文章,你能:

  • 了解在操作系统中记录日志的重要性。
  • 可以作为实施日志系统时的参考。

日志策略

下面列出了我们在实施日志系统之前应该问自己的问题。

  • Why(为什么):日志记录的目的是什么?
  • Who(谁): 哪个模块将生成日志?
  • When(何时):何时输出日志?
  • Where(哪里):在哪里输出日志(发送到 Slack 或 BigQuery 等)
  • What(什么):日志能提供什么信息?
  • How(如何): 如何输出日志?

日志级别

了解日志的目的后,应该对日志进行分级

Log levelConceptHow to handleExample
FATALThis Level hinder the operating of the systemHave to fix immediatelyCan not connect to the DB
ERRORUnexpected errors occurShould be fixed as soon as you canCan not send the email
WARNNot an error, but are some problems like unexpected input or unexpected executing unexpected input or unexpected executingShould be refactored regularlyRegularly delete data API
INFONotification when starting or ending an executing or a transaction.Maybe outputting another needed informationDo not need to fix Output the body of the request or response
DEBUGThe information that relating to system statusDo not output in the production environmentCan be put inside a function
TRACEInformation that is more detailed than DEBUGDo not output in the production environment

案例

定义日志级别后,必须明确要输出的日志类型。

本节将针对每种日志类型回答以下六个问题。

  • Why(为什么)
  • Who(谁)
  • When(何时)
  • Where(哪里)
  • What(什么)
  • How(如何)
系统日志(System Log)
  • Why: 当系统出现错误时,系统日志将用于调试。
  • Who: 系统本身将输出日志。
  • When:出错时输出日志。
  • Where:

    1. FATAL / ERROR:通知开发人员立即处理。
    2. WARN / INFO:在系统或日志管理工具中输出。
    3. DEBUG / TRACE:输出到预发环境中的 console.log
  • What:

    1. FATAL / ERROR:堆栈跟踪。
    2. WARN / INFO / DEBUG/ TRACE:要通知的内容。
  • How:

    1. FATAL / ERROR:通过日志管理工具或 Slack、SMS......(推模式)输出。
    2. WARN / INFO / DEBUG / TRACE:通过日志管理工具或系统内部输出(拉模式)。
访问日志(Access Log)
  • Why: 输出日志以跟踪发送和接收请求的过程。
  • Who: 系统本身或基础设施。
  • When: 在发送或接收请求时输出。
  • Where: 在 INFO 级别和拉模式中。由于日志量可能很大,必须注意查找日志的速度。
  • What: 输出谁、如何、何时进入系统。
  • How: 根据目的不同,可能会有一些差异。
操作日志(Action Log)
  • Why: 分析用户操作,从而在此基础上改进服务。
  • Who: 系统本身或外部工具。
  • When: 某些操作发生时。
  • Where: 日志分析工具(BigQuery 等)。
  • What: 取决于目的。
  • How: 根据目的不同,可能会有一些差异。
认证日志(Auth Log)
  • Why: 跟踪用户验证的输出。
  • Who: 系统本身。
  • When: 验证用户。
  • Where: 在 INFO 级别和拉模式中。
  • What: 输出认证的时间、用户、方式。
  • How: 根据认证方法不同,可能会有一些差异。

示例

概念就介绍到这里,下面来看一个示例项目。

有关代码的更多详情,请参阅Github

选择日志库

我选择 log4js 库,原因很简单,因为 log4js 构建日志级别的方式与我的想法一致。

实施

步骤 1 - 定义日志类

首先定义日志类:


class Logger {
  public default: log4js.Logger;
  public system: log4js.Logger;
  public api: log4js.Logger;
  public access_req: log4js.Logger;
  public access_res: log4js.Logger;
  public sql: log4js.Logger;
  public auth: log4js.Logger;

  public fatal: log4js.Logger;
  public error: log4js.Logger;
  public warn: log4js.Logger;
  public info: log4js.Logger;
  public debug: log4js.Logger;
  public trace: log4js.Logger;

  constructor() {
    log4js.configure(loggerConfig);

    this.system = log4js.getLogger('system');
    this.api = log4js.getLogger('api');
    this.access_req = log4js.getLogger('access_req');
    this.access_res = log4js.getLogger('access_res');
    this.sql = log4js.getLogger('sql');
    this.auth = log4js.getLogger('auth');

    this.fatal = log4js.getLogger('fatal');
    this.fatal.level = log4js.levels.FATAL;

    this.error = log4js.getLogger('error');
    this.error.level = log4js.levels.ERROR;

    this.warn = log4js.getLogger('warn');
    this.warn.level = log4js.levels.WARN;

    this.info = log4js.getLogger('info');
    this.info.level = log4js.levels.INFO;

    this.debug = log4js.getLogger('debug');
    this.debug.level = log4js.levels.DEBUG;

    this.trace = log4js.getLogger('trace');
    this.trace.level = log4js.levels.TRACE;
  }
}

Logger 类中定义了日志级别:

  • fatal
  • error
  • warn
  • info
  • debug
  • trace

基于此,我又定义了日志类型:

  • system
  • api
  • access_req
  • access_res
  • sql
  • auth

第 2 步 - 将 Logger 应用到项目中

Logger 类应用到由 NestJS 框架实现的项目中。

通过 NestJS 的 Interceptor拦截器)功能,将日志类注入到项目中。

选择 Interceptor 的原因是 NestJS 拦截器不仅能封装请求流,还能封装从 API 输入和输出的响应流,因此使用拦截器是捕获请求日志和响应日志的最简单方法。我是这样定义 LoggerInterceptor 类的:

export class LoggerInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>
  ): Observable<any> | Promise<Observable<any>> {
    // intercept() method will "wrap" request/ response stream

    /*
     * Get request object from context
     * After that, pass request object to "requestLogger" function
     * to output the log
     */
    const request = context.switchToHttp().getRequest();
    requestLogger(request);

    /*
     * Get response object from context
     * After that pass response object to "responseLogger" & "responseErrorLogger" functions for ouputting the log or
     * error log
     */
    const response = context.switchToHttp().getResponse();

    return next.handle().pipe(
      // 200 - Success Response
      map((data) => {
        responseLogger({ requestId: request._id, response, data });
      }),
      // 4xx, 5xx - Error Response
      tap(null, (exception: HttpException | Error) => {
        try {
          responseErrorLogger({ requestId: request._id, exception });
        } catch (e) {
          logger.access_res.error(e);
        }
      })
    );
  }
}

定义了三种方法:

  • requestLogger: 用于记录请求信息。
  • responseLogger: 用于记录响应信息。
  • responseErrorLogger: 用于记录错误信息。

像这样:


const MaskField = {
  Email: 'email',
  Password: 'password',
} as const;

type MaskField = (typeof MaskField)[keyof typeof MaskField];

const _maskFields = (object: FixType, fields: MaskField[]): FixType => {
  const maskOptions = {
    maskWith: '*',
    unmaskedStartCharacters: 0,
    unmaskedEndCharacters: 0,
  };

  for (let i = 0; i < fields.length; i++) {
    switch (fields[i]) {
      case MaskField.Email: {
        object[MaskField.Email] = maskData.maskEmail2(
          object[MaskField.Email],
          maskOptions
        );
      }
      case MaskField.Password: {
        object[MaskField.Password] = maskData.maskPassword(
          object[MaskField.Password],
          maskOptions
        );
      }
    }
  }

  return object;
};

export const requestLogger = (request: Request) => {
  const { ip, originalUrl, method, params, query, body, headers } = request;

  // logTemplate includes: now(time), ip, http_method, url, request_object
  const logTemplate = '%s %s %s %s %s';
  const now = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');

  const logContent = util.formatWithOptions(
    { colors: true },
    logTemplate,
    now,
    ip,
    method,
    originalUrl,
    JSON.stringify({
      method,
      url: originalUrl,
      userAgent: headers['user-agent'],
      body: _maskFields(body, [MaskField.Email, MaskField.Password]),
      params,
      query,
    })
  );

  // Using access_req logger object have been defined before.
  logger.access_req.info(logContent);
};

// Ouptput success response log
export const responseLogger = (input: {
  requestId: number;
  response: Response;
  data: any;
}) => {
  const { requestId, response, data } = input;

  const log: ResponseLog = {
    requestId,
    statusCode: response.statusCode,
    data,
  };

  // Using access_res logger object have been defined before.
  logger.access_res.info(JSON.stringify(log));
};

// Ouptput error response log
export const responseErrorLogger = (input: {
  requestId: number;
  exception: HttpException | Error;
}) => {
  const { requestId, exception } = input;

  const log: ResponseLog = {
    requestId,
    statusCode:
      exception instanceof HttpException ? exception.getStatus() : null,
    message: exception?.stack || exception?.message,
  };

  // Using access_res logger object have been defined before.
  logger.access_res.info(JSON.stringify(log));
  logger.access_res.error(exception);
};

定义完 LoggerInterceptor 后,将此拦截器应用到应用程序中:

const app = await NestFactory.create(AppModule);

app.useGlobalInterceptors(new LoggerInterceptor());

在 NestJS 应用程序中应用自定义拦截器并不难,因为这是 NestJS 的内置功能。

对于 fataldebug 日志,我将在用例层或基础架构层中使用,以达到以下目的:

  • 通知无法连接数据库等致命错误。
  • 当用户遇到问题时进行调试。

只要这样做:

logger.fatal.error('Error message');

可以将 fatal 日志输出到控制台或 Slack 等通知管道......

结果如下:

首先是访问请求日志和响应日志(当没有发生错误时)。

可以看到,与请求相关的信息,如 methodbody 等都已清晰显示。

如果出错:

同时显示错误类型和错误信息。

fatal 日志会是这样的:

同样会输出错误信息和错误类型。

结论

本文分享了如何设计和实施一个基本的日志系统。

通过简单的示例,希望你能理解建立日志系统的重要性和必要性,这将有助于系统的运行和调试。


你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

本文由mdnice多平台发布


俞凡
21 声望14 粉丝

你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起...