Skip to content

日志系统

一、日志级别与使用场景

error  — 需要立即处理的错误(未捕获异常、数据库连接失败)
warn   — 潜在问题(重试成功、配置缺失但有默认值)
log    — 业务关键事件(用户注册、订单创建)
debug  — 调试信息(SQL 查询、函数参数)— 生产环境关闭
verbose — 详细的跟踪信息 — 仅开发时使用

二、NestJS 内置 Logger

typescript
// 在 Service 中使用
@Injectable()
export class UsersService {
  // 用类名作为 context,方便定位日志来源
  private readonly logger = new Logger(UsersService.name);

  async create(dto: CreateUserDto) {
    this.logger.log(`创建用户: email=${dto.email}`);

    try {
      const user = await this.userRepo.save(dto);
      this.logger.log(`用户创建成功: id=${user.id}`);
      return user;
    } catch (err) {
      // error 的第二参数是堆栈信息
      this.logger.error(`用户创建失败: ${err.message}`, err.stack);
      throw err;
    }
  }
}

// 在 main.ts 中控制日志级别
const app = await NestFactory.create(AppModule, {
  logger: process.env.NODE_ENV === 'production'
    ? ['error', 'warn', 'log']     // 生产:只记录重要日志
    : ['error', 'warn', 'log', 'debug', 'verbose'],  // 开发:全量
});

三、Winston 集成(生产级结构化日志)

bash
npm install nest-winston winston winston-daily-rotate-file
typescript
// logger/logger.module.ts
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import 'winston-daily-rotate-file';

const { combine, timestamp, json, colorize, printf, errors } = winston.format;

// 开发环境:彩色可读格式
const devFormat = combine(
  colorize(),
  timestamp({ format: 'HH:mm:ss' }),
  errors({ stack: true }),  // 包含堆栈信息
  printf(({ level, message, timestamp, context, traceId, ...meta }) => {
    const ctx = context ? `[${context}]` : '';
    const tid = traceId ? `[${traceId}]` : '';
    const extra = Object.keys(meta).length ? JSON.stringify(meta) : '';
    return `${timestamp} ${level} ${ctx}${tid} ${message} ${extra}`;
  }),
);

// 生产环境:JSON 格式(便于 ELK/Datadog 解析)
const prodFormat = combine(
  timestamp(),
  errors({ stack: true }),
  json(),
);

export const winstonConfig = {
  transports: [
    // 控制台输出
    new winston.transports.Console({
      format: process.env.NODE_ENV === 'production' ? prodFormat : devFormat,
    }),

    // 错误日志文件(日志滚动,自动删除 30 天前的文件)
    new winston.transports.DailyRotateFile({
      filename: 'logs/error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      maxFiles: '30d',
      format: prodFormat,
    }),

    // 全量日志文件
    new winston.transports.DailyRotateFile({
      filename: 'logs/combined-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      maxFiles: '14d',
      format: prodFormat,
    }),
  ],
};
typescript
// app.module.ts 中集成
@Module({
  imports: [
    WinstonModule.forRoot(winstonConfig),
  ],
})
export class AppModule {}

// main.ts 替换 NestJS 默认 Logger
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';

const app = await NestFactory.create(AppModule);
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));

四、请求日志拦截器

每个请求自动记录方法、路径、状态码、耗时、用户:

typescript
// common/interceptors/logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger('HTTP');

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    const { method, url, ip, body } = req;
    const userId = req.user?.id ?? 'anonymous';
    const traceId = req.traceId ?? '-';
    const startTime = Date.now();

    return next.handle().pipe(
      tap((data) => {
        const res = context.switchToHttp().getResponse();
        const duration = Date.now() - startTime;

        this.logger.log(
          `${method} ${url} ${res.statusCode} ${duration}ms`,
          { userId, traceId, ip, duration },
        );
      }),
      catchError((err) => {
        const duration = Date.now() - startTime;
        this.logger.error(
          `${method} ${url} ERROR ${duration}ms: ${err.message}`,
          { userId, traceId, stack: err.stack },
        );
        throw err;  // 重新抛出,让异常过滤器处理
      }),
    );
  }
}
typescript
// 全局注册
app.useGlobalInterceptors(new LoggingInterceptor());

五、TraceId 追踪链路

为每个请求生成唯一 ID,方便在日志中追踪完整链路:

typescript
// common/middleware/trace.middleware.ts
import { randomUUID } from 'crypto';

@Injectable()
export class TraceMiddleware implements NestMiddleware {
  use(req: Request & { traceId?: string }, res: Response, next: () => void) {
    // 支持从上游(nginx、CDN)传递的 traceId
    req.traceId = (req.headers['x-trace-id'] as string) ?? randomUUID();
    // 将 traceId 透传到响应头,方便前端报错时定位
    (res as any).setHeader('X-Trace-Id', req.traceId);
    next();
  }
}

// app.module.ts
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TraceMiddleware).forRoutes('*');
  }
}
typescript
// 在 Service 中使用 AsyncLocalStorage 传递 traceId(不需要每个函数传参)
import { AsyncLocalStorage } from 'async_hooks';

export const requestContext = new AsyncLocalStorage<{ traceId: string }>();

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);

  create(dto: CreateUserDto) {
    const { traceId } = requestContext.getStore() ?? {};
    this.logger.log('创建用户', { traceId, email: dto.email });
  }
}

六、结构化日志最佳实践

typescript
// ✅ 结构化日志:把变量作为独立字段,而不是字符串插值
this.logger.log('用户登录', {
  userId: user.id,
  email: user.email,
  ip: clientIp,
  userAgent: req.headers['user-agent'],
});

// ❌ 字符串插值:难以在日志平台过滤和统计
this.logger.log(`用户 ${user.id}(${user.email})从 ${clientIp} 登录`);

// ✅ 错误日志包含足够上下文
this.logger.error('支付失败', {
  orderId: order.id,
  amount: order.amount,
  userId: order.userId,
  errorCode: paymentError.code,
  stack: paymentError.stack,
});

// ✅ 敏感信息脱敏
this.logger.log('用户注册', {
  email: email.replace(/(.{3}).*(@.*)/, '$1***$2'),  // a***@example.com
  // 不要记录密码、Token、银行卡号等
});

七、生产日志配置建议

环境控制台文件级别格式
开发彩色人类可读debug+printf
测试简洁(不干扰测试输出)error简单
生产JSON滚动文件warn+JSON
typescript
// 测试环境关闭日志(避免污染测试输出)
const app = await NestFactory.create(AppModule, {
  logger: process.env.NODE_ENV === 'test' ? false : undefined,
});

可运行 Demo: practice/07-extensions — NestJS Logger 在 Gateway/Scheduler/Queue 中的用法


常见错误

错误原因解决
日志中没有 traceId未使用 AsyncLocalStorage 传播 context在中间件中 store.run({ traceId }, next);Logger 中读取 store.getStore()
生产日志文件过大未配置日志轮转DailyRotateFile 设置 maxSize: '50m'maxFiles: '30d'
结构化日志被当字符串打印Logger.log('xxx', { key: val })Winston 用 logger.info({ message: 'xxx', key: val }) 输出 JSON
测试中充满日志噪音测试环境未关闭日志NestFactory.create(AppModule, { logger: false }) 在测试中静默

NestJS 深度学习体系