日志系统
这个目录是项目级日志基础设施。业务代码只注入
LoggerService写日志;落库、Seq 投递和查询接口都在这里完成。 依赖 Redis(BullMQ 队列)和 PostgreSQL,Seq 可选。
1. 整体链路
业务代码 / 拦截器 / 过滤器
│ logger.log / warn / error / debug
▼
LoggerService.write()
├─ 补全上下文:requestId、userId、ip、method、url(来自 AsyncLocalStorage)
├─ 同步:process.stdout ← 开发 TTY 多行文本,其他环境 CLEF JSON
└─ 异步:LogQueueService.enqueue()
│ 攒满 batchSize 或到 flushIntervalMs 就 flush 一批
▼
BullMQ 队列 log-queue(job persist-log-batch,attempts 3,固定退避 1s)
│
▼
LogProcessor.process()
├─ 1) LogService.insertIgnoreConflicts() → PostgreSQL log_records
└─ 2) SeqTransportService.send() → Seq /ingest/clef(SEQ_ENABLED 时)两条通道各管一件事:stdout 保证本地和容器里随时能看到日志;队列把落库和外发从请求路径上摘掉,业务请求不等待这些 IO。开发环境由终端直接运行时,stdout 输出按人类阅读习惯格式化的多行文本;非 TTY 和生产环境输出单行 CLEF JSON,方便容器采集与检索。
2. 目录结构
apps/server/src/shared/log/
├── README.md # 本文档
├── log.module.ts # @Global LogModule,导出 LoggerService / LogService
├── logger.service.ts # 业务写日志的入口
├── log-queue.service.ts # 内存缓冲 + 批量入队
├── log.processor.ts # BullMQ Worker:落库 + 投 Seq
├── log.service.ts # 落库、清理、查询
├── seq-transport.service.ts # Seq HTTP 投递
├── clef.ts # ILogEvent → CLEF JSON
├── console-log.formatter.ts # stdout 终端格式与 CLEF JSON 输出
├── log.controller.ts # GET /api/logs、GET /api/logs/:id
├── constants/log.constants.ts # 级别、类别、队列名
├── dto/query-log.dto.ts # 查询参数校验
├── entities/log-record.entity.ts
└── interfaces/log.interface.ts相关但不在本目录:
common/context/request-context.ts、common/middleware/request-context.middleware.ts:请求上下文common/interceptors/user-context.interceptor.ts:JWT 守卫之后把 userId 写进请求上下文common/interceptors/logging.interceptor.ts:每个成功请求记一条HTTP request completedcommon/filters/global-exception.filter.ts:每个失败请求记一条HTTP request failed(error 级,带堆栈)common/filters/ws-exception.filter.ts:WsException 记 warnmodules/scheduled-tasks/cleanup-expired-logs.scheduler.ts:每天 02:00 清理过期日志database/migrations/20260910190421-create-log-records.ts:建表与索引
3. 写日志
constructor(private readonly logger: LoggerService) {}
this.logger.log('Socket room joined', {
category: LOG_CATEGORY.SOCKET,
context: { socketId, room },
});
this.logger.error('HTTP request failed', exception, {
category: LOG_CATEGORY.HTTP,
statusCode: 500,
});- 级别:
debug/info(对应log())/warn/error。常量里还定义了fatal,LoggerService 暂未暴露对应方法。 - 类别
LOG_CATEGORY:HTTP、Auth、Socket、Job、ScheduledTask、Business(默认)。 error()第二个参数传原始异常,会自动填stack。options里显式传的requestId/userId/ip/method/url优先于请求上下文。
请求上下文
requestContextMiddleware 用 Node 内置 AsyncLocalStorage 为每个 HTTP 请求开一个 store:requestId(randomUUID)、startedAt、method、url、ip。UserContextInterceptor 在 JWT 守卫之后补上 userId。LoggerService 读 getStore() 自动填充,业务代码不用层层传参。
只有 HTTP 链路有 store。cron、BullMQ processor、WebSocket 处理函数里 getStore() 是 undefined,对应字段落成 null;需要关联时在 options 里显式传。
4. 存储与保留
表 log_records,实体 LogRecord 不继承 BaseEntity:日志只追加不修改,没有 updatedAt / deletedAt。
id:bigint 主键,雪花 ID,应用层生成level、category、message:varchar(16)、varchar(64)、textcontext:jsonb,业务自定义字段requestId、userId、ip、method、url、statusCode、duration、stack:可空,HTTP 相关字段timestamp:timestamptz,事件发生时间,不是入库时间
索引:timestamp、(level, timestamp)、(category, timestamp)、(userId, timestamp)、requestId。复合索引等值列在前、时间列在后,一个索引同时服务 WHERE level = ? AND timestamp BETWEEN ... 和 ORDER BY timestamp DESC 分页。
保留:CleanupExpiredLogsScheduler 每天 02:00 物理删除 timestamp < now - LOG_RETENTION_DAYS(默认 30 天)。不做软删除,日志量大,软删除只会让表和索引持续膨胀。
5. 查询接口与权限
GET /api/logs:按level、category、userId、requestId、startTime、endTime、keyword(message ILIKE)筛选,page/pageSize(最大 100),按 timestamp 倒序。GET /api/logs/:id:id 不是合法 bigint 或记录不存在都返回LOG_NOT_FOUND。- 控制器挂
SpecialRolesGuard,只允许developer/super_admin。 - 前端侧边栏按角色隐藏「日志」菜单,这只是体验优化;直接访问
/logs页面能打开,接口会返回 403。
6. Seq 与 CLEF
Seq 以及非 TTY / 生产环境的 stdout 使用 CLEF(Compact Log Event Format)JSON,clef.ts 负责映射;开发 TTY 的 stdout 是多行文本,不应按 CLEF JSON 解析:
@t:timestamp(ISO 8601)@l:level 首字母大写(Info、Error)@m:message@x:stack,有才写logId、requestId、userId、category、method、url、statusCode、duration、ip、context:普通属性
不用 CLEF 的 @i:Seq 把它当作事件类型(Serilog 约定为消息模板哈希)把同类事件归到一起,不是每条记录的唯一 ID;每条唯一的雪花 ID 放在普通字段 logId。
Seq 投递:POST {SEQ_URL}/ingest/clef,Content-Type: application/vnd.serilog.clef,配置了 API Key 时带 X-Seq-ApiKey,只接受 201。本地用 docker/docker-compose.seq.yml 启动 datalust/seq,UI 在 http://localhost:5341。
7. 配置
LOG_RETENTION_DAYS(默认 30):保留天数LOG_BATCH_SIZE(默认 50):攒多少条就 flushLOG_FLUSH_INTERVAL_MS(默认 5000):不满一批时最长等多久,毫秒SEQ_ENABLED(默认 false):为true且SEQ_URL可达时才投递SEQ_URL:例如http://localhost:5341SEQ_API_KEY:可选SEQ_TIMEOUT_MS(默认 5000):单次投递超时,毫秒
队列复用 JobQueueModule 的 Redis 连接,Redis 未配置时应用启动即报错。
8. 设计取舍
先落库,再投 Seq。 PostgreSQL 是唯一可信源,Seq 只是查看器。processor 先 insert 再 send,任一步抛错整个 job 按 attempts: 3 重试。重试意味着至少一次投递:落库靠 orIgnore() 吸收重复(同一雪花 ID 主键冲突被忽略),Seq 没有幂等机制,重试会出现重复事件。
ID 在应用层用雪花算法生成,且必须在入队前生成。 这样一条事件在重试中 ID 不变,orIgnore() 才有用。自增 ID 要等 insert 返回,重试前无法确定;UUID 可以提前生成,但不适合做 bigint 主键,随机分布也不利于索引局部性。雪花 ID 是 64 位、按时间递增的 bigint。
不继承 BaseEntity、不软删除。 日志不可变,updatedAt / deletedAt 没有意义;量大靠定时物理删除。
用 AsyncLocalStorage 传上下文,不显式传参。 Node 内置,不引入 cls-hooked 之类的依赖;代价是非 HTTP 链路拿不到 store(见第 3 节)。
内存缓冲 + 批量入队。 每条日志都 queue.add 会让 Redis 往返数和日志条数一样多;攒批把它降到每 50 条或每 5 秒一次。isFlushing 保证同一时刻只有一个 flush 在跑,onModuleDestroy 时把剩余事件冲掉。
9. 已知限制
- 队列长时间不可用时缓冲区没有上限,事件一直堆在内存里;进程被 SIGKILL 也来不及在
onModuleDestroy里 flush。这两种情况都会丢日志。 - Seq 侧无幂等,job 重试后会出现重复事件。
keyword用 ILIKE,走不了索引,全表扫,只用于管理员排查。- 非 HTTP 链路(cron、队列、WebSocket)没有 requestId / userId。
generateSnowflakeId()在LoggerService.write()的 try/catch 之外,系统时钟回拨时它会抛错并传给调用方。