五.日志
服务端经常要排查问题,慢sql等。不像客户端可以直接debug,服务端得依赖日志来分析。我们是否能准确快捷的查询到我们想要知道的信息,分析的效率对服务端来说非常的重要。所以在选用日志架构时我们必须重视日志的收集与检索。
下面我们来集成Winston收集日志,cls-rtracer生成全链路traceId方便检索,阿里日志服务查找分析日志
阿里日志服务大家也可以根据实际情况替换(例如sentry、elasticsearch)
1.日志格式
type LogInfo = {
level: 'info' | 'debug' | 'warn' | 'error',
context: any,
message: string,
'X-Request-Id': string,
timestamp: string,
stack?: string,
}
// 使用
logger.log('测试', { a: 1 });
// 打印
{
message: '测试',
context: { a: 1 },
level: 'info',
...
}
其中level,X-Request-Id,timestamp会自动生成
stack 在logger.error时会自动记录
2.日志收集
一.首先下载我们所需依赖包
pnpm i nest-winston winston cls-rtracer
二.配置日志组件winston
app.modules.ts
import { WinstonModule, utilities } from 'nest-winston';
import * as winston from 'winston';
//...略
@Module({
imports: [
WinstonModule.forRoot({
transports: [new winston.transports.Console()],
})
]
})
//...
main.ts
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
await app.listen(3000);
}
bootstrap();
配置好了之后,我们就能在业务代码中使用winston来记录日志了
三.记录请求响应日志
1.新建拦截器
src\common\interceptor\http-record\http-record.interceptor.ts
2.使用winston记录日志
import { Inject, LoggerService, CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { Response } from 'express';
import { Request } from 'express';
import { Observable, tap } from 'rxjs';
import { StreamableFile } from '@nestjs/common';
@Injectable()
export class HttpRecordInterceptor implements NestInterceptor {
@Inject(WINSTON_MODULE_NEST_PROVIDER)
private readonly logger: LoggerService;
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
this.logger.log('请求参数', {
url: request.url,
method: request.method,
body: request.body,
query: request.query,
params: request.params
})
return next.handle().pipe(
tap((res: Response) => {
if (res instanceof StreamableFile) return
this.logger.log('响应参数', {
response: res,
statusCode: response.statusCode
})
})
)
}
}
3.在app.module.ts中注册为全局拦截器
import { APP_INTERCEPTOR } from '@nestjs/core';
providers: [
{
provide: APP_INTERCEPTOR,
useClass: HttpRecordInterceptor,
}
// ...
],
四.生成全链路ID与自动记录全链路id
1.自动为每个请求生成唯一id
在根模块去配置链路ID,在该请求链路上,通过rTracer.id()能拿到此链路id。同时自动给响应头加上链路id
app.modules.ts
import * as rTracer from 'cls-rtracer';
// ...
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(rTracer.expressMiddleware({
echoHeader: true, // 自动给响应头加上x-request-id
})).forRoutes('*');
}
}
2.自动给每个打印方法绑定该链路id
新建winston插件 src\utils\log\console-trace.ts
const { format } = require('logform');
import * as rTracer from 'cls-rtracer';
export default format((info, opts) => {
const rid = rTracer.id()
if (rid) {
info['X-Request-Id'] = rid;
}
return info;
});
然后在winston配置该插件,同时配置时间戳,及其它格式化插件
app.modules.ts
import { WinstonModule, utilities } from 'nest-winston';
import * as winston from 'winston';
import consoleTrace from './utils/log/console-trace';
import context2String from './utils/log/context-2-string';
import consoleReport from './utils/log/console-report';
const isDev = !process.env.npm_lifecycle_script.includes('nest start');
const combine = [
winston.format.timestamp(),
winston.format.ms(),
consoleTrace(),
context2String()
]
isDev && combine.push(...[utilities.format.nestLike('MyApp', {
colors: true,
prettyPrint: true,
processId: true,
appName: true,
})]) // 开发模式下打印带颜色
!isDev && combine.push(consoleReport())
@Module({
imports: [
WinstonModule.forRoot({
transports: [new winston.transports.Console({
format: winston.format.combine(
...combine
),
})],
})
]
})
五.如何在业务代码里面引用winston
1.导入下面的包
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { LoggerService } from '@nestjs/common';
2.注入属性
@Inject(WINSTON_MODULE_NEST_PROVIDER)
private readonly logger: LoggerService;
3.使用
this.logger.log('ossExpiration', yourdata)
6.验证日志打印与全链路id
使用POSTMAN并发两个请求,看看他们的链路ID是否和请求绑定
结果:验证成功
同时我们的响应头也分别带上了链路ID
响应一
响应二
三.日志上报
日志服务SDK提供全流程的日志管理。其使用基本流程大致如下:
- 开通日志服务。
- 获取访问密钥。
- 创建项目Project和日志库Logstore。
- 日志采集并存储至Logstore。
- 为日志创建索引。
- 查询和分析日志,可视化展示。
- 对日志数据进行加工、投递和告警等操作
1.阿里云日志服务环境搭建
阿里云产品都有一个月免费试用期,我们可以来白嫖一个日志服务。
- 首先进入官网使用日志服务 free.aliyun.com/?spm=5176.2…
2.然后在日志服务中创建project sls.console.aliyun.com/lognext/pro… (project,logstore全部点下一步就好,直接用默认配置就行)
3.然后在创建logstore
4.创建好后会来到查询与分析配置面板,我们稍等在来配置
5.先来给我们的角色授权(角色授权步骤可见《基于前端视角的web服务端搭建(一)》,这里只是给的权限点不一样)
2.node sdk 采集日志
授权完后我们来创建winston插件来自动上报数据
阿里云上报有格式要求,所以我们先创建winston插件处理格式
src\utils\log\context-2-string.ts
const { format } = require('logform');
const jsonStringify = require('safe-stable-stringify');
export default format((info, opts) => {
info.context && (info.context = jsonStringify(info.context))
info.stack && (info.stack = jsonStringify(info.stack))
return info;
});
然后在创建上报插件src\utils\log\console-report.ts
const ALY = require('aliyun-sdk')
const { format } = require('logform');
let arr = [] // 缓存日志,用来批量上传,这里推荐用文件存储来代替,否则可能会出现内存问题
var sls = new ALY.SLS({
// 本示例从环境变量中获取AccessKey ID和AccessKey Secret。
"accessKeyId": process.env.ACCESS_KEY_ID,
"secretAccessKey": process.env.ACCESS_KEY_SECRET,
//日志服务的域名。此处以杭州为例,其它地域请根据实际情况填写。
endpoint: 'http://cn-shenzhen.log.aliyuncs.com',
//SDK版本号,固定值。
apiVersion: '2015-06-01'
})
// 必选,Project名称。
const projectName = process.env.LOG_PROJECT_NAME
// 必选,Logstore名称。
const logstoreName = process.env.LOG_STORE_NAME
// 写入日志。
function writeLog(logs) {
const param = {
projectName,
logStoreName: logstoreName,
logGroup: {
// 必选,写入的日志数据。格式固定
logs: logs.map(info => {
return {
time: info.timestamp ? Math.floor(new Date(info.timestamp).getTime() / 1000) : Math.floor(new Date().getTime() / 1000),
contents: Object.keys(info).map((key) => {
return {
key,
value: info[key]
}
})
}
}),
topic: 'nest-logs',
source: '127.0.0.1'
}
}
sls.putLogs(param, function (err, data) {
if (err) {
console.error('error:', err)
arr.push(logs)
} else {
// console.log('写入日志成功', data)
}
})
}
// 定时批量上报
setInterval(() => {
const logs = arr.splice(0, 100)
if (logs.length) {
writeLog(logs)
}
}, 5000)
export default format((info, opts) => {
arr.push(info) // 每次打印时添加日志到缓存
return info;
});
创建完后配置给winston即可自动处理日志上报
import consoleTrace from './utils/log/console-trace';
import context2String from './utils/log/context-2-string';
import consoleReport from './utils/log/console-report';
const isDev = !process.env.npm_lifecycle_script.includes('nest start');
const combine = [
winston.format.timestamp(),
winston.format.ms(),
consoleTrace(),
context2String()
]
isDev && combine.push(...[utilities.format.nestLike('MyApp', {
colors: true,
prettyPrint: true,
processId: true,
appName: true,
})]) // 开发模式下打印带颜色
!isDev && combine.push(consoleReport())
@Module({
imports: [
WinstonModule.forRoot({
transports: [new winston.transports.Console({
format: winston.format.combine(
...combine
),
})],
})
]
})
3.检验日志上报
接着我们来打印并上报一条请求, 此时我们可以看到控制台打印了日志,并上报成功
然后回到查询分析配置面板,可以看到面板成功收集到了我们的请求,接着我们再来配置索引,方便查询日志
(可以直接根据请求自动生成索引,然我们再来做微调就好,非常方便)
索引配置好后,我们点击下一步,此时一个完整的日志收集与查询服务就搭建好了。我们多造几条数据,来查询看看
总结
本节演示了日志的采集与查询。实际项目中我们还有很多优化的地方,比如慢sql的采集,全局错误的采集等。采集方式也有优化的地方,使用文件来缓存打印而不是内存来缓存,或者使用一些第三方工具来采集都可以。最后我们也可以通过监控日志来进行告警来完成整个闭环。本文对这些优化就不一一演示了,大家感兴趣可以自己去看文档来一一完善。