輕松打造高效日志系統
作為開發者,經常需要在調試時查看檢查日志,缺乏日志或者不清楚如何通過日志分析問題,就無法定位出錯的代碼。
對于每天為成千上萬甚至上百萬用戶提供服務的系統來說,日志必不可少,因為:
- 日志可以幫助我們找到影響最終用戶的錯誤。
- 日志可以跟蹤系統的 "健康狀況",在系統出問題之前察覺到某些 "異常跡象"。
- ……等等
由此可見,在開發或運行系統時,日志至關重要,因此,設計和實施完善的日志系統有助于簡化監控工作。
本文將分享我在設計和構建日志系統方面的經驗和理解。希望通過這篇文章,你能:
- 了解在操作系統中記錄日志的重要性。
- 可以作為實施日志系統時的參考。
一、日志策略
下面列出了我們在實施日志系統之前應該問自己的問題。
- Why(為什么):日志記錄的目的是什么?
- Who(誰): 哪個模塊將生成日志?
- When(何時):何時輸出日志?
- Where(哪里):在哪里輸出日志(發送到 Slack 或 BigQuery 等)
- What(什么):日志能提供什么信息?
- How(如何): 如何輸出日志?
二、日志級別
了解日志的目的后,應該對日志進行分級
Log level | Concept | How to handle | Example |
FATAL | This Level hinder the operating of the system | Have to fix immediately | Can not connect to the DB |
ERROR | Unexpected errors occur | Should be fixed as soon as you can | Can not send the email |
WARN | Not an error, but are some problems like unexpected input or unexpected executing unexpected input or unexpected executing | Should be refactored regularly | Regularly delete data API |
INFO | Notification when starting or ending an executing or a transaction. | Maybe outputting another needed information | Do not need to fix Output the body of the request or response |
DEBUG | The information that relating to system status | Do not output in the production environment | Can be put inside a function |
TRACE | Information that is more detailed than DEBUG | Do not output in the production environment |
三、案例
定義日志級別后,必須明確要輸出的日志類型。
本節將針對每種日志類型回答以下六個問題。
- Why(為什么)
- Who(誰)
- When(何時)
- Where(哪里)
- What(什么)
- How(如何)
1. 系統日志(System Log)
(1) Why: 當系統出現錯誤時,系統日志將用于調試。
(2) Who: 系統本身將輸出日志。
(3) When:出錯時輸出日志。
(4) Where:
- FATAL / ERROR:通知開發人員立即處理。
- WARN / INFO:在系統或日志管理工具中輸出。
- DEBUG / TRACE:輸出到預發環境中的 console.log。
(5) What:
- FATAL / ERROR:堆棧跟蹤。
- WARN / INFO / DEBUG/ TRACE:要通知的內容。
(6) How:
- FATAL / ERROR:通過日志管理工具或 Slack、SMS......(推模式)輸出。
- WARN / INFO / DEBUG / TRACE:通過日志管理工具或系統內部輸出(拉模式)。
2. 訪問日志(Access Log)
- Why: 輸出日志以跟蹤發送和接收請求的過程。
- Who: 系統本身或基礎設施。
- When: 在發送或接收請求時輸出。
- Where: 在 INFO 級別和拉模式中。由于日志量可能很大,必須注意查找日志的速度。
- What: 輸出誰、如何、何時進入系統。
- How: 根據目的不同,可能會有一些差異。
3. 操作日志(Action Log)
- Why: 分析用戶操作,從而在此基礎上改進服務。
- Who: 系統本身或外部工具。
- When: 某些操作發生時。
- Where: 日志分析工具(BigQuery 等)。
- What: 取決于目的。
- How: 根據目的不同,可能會有一些差異。
4. 認證日志(Auth Log)
- Why: 跟蹤用戶驗證的輸出。
- Who: 系統本身。
- When: 驗證用戶。
- Where: 在 INFO 級別和拉模式中。
- What: 輸出認證的時間、用戶、方式。
- How: 根據認證方法不同,可能會有一些差異。
四、示例
概念就介紹到這里,下面來看一個示例項目。
有關代碼的更多詳情,請參閱Github[2]。
1. 選擇日志庫
我選擇 log4js[3] 庫,原因很簡單,因為 log4js 構建日志級別的方式與我的想法一致。
2. 實施
步驟 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[4] 框架實現的項目中。
通過 NestJS 的 Interceptor(攔截器[5])功能,將日志類注入到項目中。
選擇 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',
} asconst;
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;
};
exportconst 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
exportconst 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
exportconst 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 的內置功能。
對于 fatal 和 debug 日志,我將在用例層或基礎架構層中使用,以達到以下目的:
- 通知無法連接數據庫等致命錯誤。
- 當用戶遇到問題時進行調試。
只要這樣做:
logger.fatal.error('Error message');
可以將 fatal 日志輸出到控制臺或 Slack 等通知管道......
結果如下:
首先是訪問請求日志和響應日志(當沒有發生錯誤時)。
可以看到,與請求相關的信息,如 method、body 等都已清晰顯示。
如果出錯:
同時顯示錯誤類型和錯誤信息。
fatal 日志會是這樣的:
同樣會輸出錯誤信息和錯誤類型。
五、結論
本文分享了如何設計和實施一個基本的日志系統。
通過簡單的示例,希望你能理解建立日志系統的重要性和必要性,這將有助于系統的運行和調試。
參考資料:
- [1] Design And Building A Logging System: https://levelup.gitconnected.com/design-and-building-a-logging-system-fd5dcad110ed
- [2] NewAnigram-BE-DDD: https://github.com/tuananhhedspibk/NewAnigram-BE-DDD
- [3] log4js: https://github.com/log4js-node/log4js-node
- [4] NestJS: https://docs.nestjs.com
- [5] NestJS Interceptor: https://docs.nestjs.com/interceptors