@yixinshuke/node-log 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +468 -0
- package/README.md +195 -2
- package/package.json +38 -6
- package/src/buffer.ts +108 -0
- package/src/config.ts +115 -0
- package/src/index.ts +16 -0
- package/src/lock.ts +69 -0
- package/src/logger.ts +428 -0
- package/src/path.ts +124 -0
- package/src/time.ts +46 -0
- package/src/writer.ts +239 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# AGENTS.md — node-log 日志组件约定
|
|
2
|
+
|
|
3
|
+
本文件是本项目的「活约定」:所有代码必须遵循本文档的结构、命名与安全约束。
|
|
4
|
+
任何改动(新增 API、新增配置项)都按本文档执行。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 0. 两条最高原则(先看这里)
|
|
9
|
+
|
|
10
|
+
### 0.1 写日志的过程
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
业务调用 logger.info(ctx, msg)
|
|
14
|
+
│
|
|
15
|
+
├─ ① 写入「环形内存缓冲」,立即返回(不落盘、不阻塞、不等待任何 IO)
|
|
16
|
+
│
|
|
17
|
+
└─ ② 后台定时/攒批,在互斥保护下把缓冲批量「追加写」到目标目录的文件
|
|
18
|
+
(写完即关;一个业务单据一个文件)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **环形内存缓冲**:固定容量,写满覆盖最老的一条;入队是纯内存操作,**同步立即返回**;
|
|
22
|
+
- **节流刷盘**:定时(默认 3s,带随机抖动)或攒够批次才写一次文件,绝不逐条写;
|
|
23
|
+
- **目标目录就是普通本地目录**(`baseDir`)。
|
|
24
|
+
**它底层是本地磁盘、OSS 挂载还是 NAS 挂载,与组件代码无关,组件不感知、不做任何特殊处理**;
|
|
25
|
+
是否挂载、如何挂载属于部署事项(见 §12)。
|
|
26
|
+
|
|
27
|
+
### 0.2 写日志的失败绝不影响业务
|
|
28
|
+
|
|
29
|
+
> **日志是非关键路径。**
|
|
30
|
+
> 写文件失败、写超时、目录不可写、Redis 不可用、锁抢不到、序列化失败……
|
|
31
|
+
> **任何异常都必须被吞掉并告警,绝不允许向上抛错、绝不允许阻塞业务。**
|
|
32
|
+
> 业务进程照常运行,订单照样支付 —— 最坏结果只是"这段日志没记下来"。
|
|
33
|
+
|
|
34
|
+
由此推出的**强制约束**(违反即视为缺陷):
|
|
35
|
+
|
|
36
|
+
| # | 约束 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| 1 | 所有运行期写路径(`info/warn/error/debug`、`flush`)**永不抛错**;业务代码**不需要** `try/catch` 包 logger |
|
|
39
|
+
| 2 | **永不阻塞**:禁止同步重试、禁止锁排队(抢不到锁就下轮再写) |
|
|
40
|
+
| 3 | **背压优先于抛错**:缓冲满则覆盖最老,绝不报错、绝不无限堆积 |
|
|
41
|
+
| 4 | **部分失败隔离**:一个文件写失败不影响其他文件 |
|
|
42
|
+
| 5 | **异常必须可见**:吞掉 ≠ 静默;必须 `console.warn` 一次 + 记录降级计数 |
|
|
43
|
+
| 6 | **只追加不破坏**:只以 `'a'` 打开;严禁 `truncate`、`'w'` 覆盖、删除文件 |
|
|
44
|
+
|
|
45
|
+
**唯一例外**:`GlobalLogger.init()` 允许因**配置错误**抛错(启动期暴露配置问题,优于运行期静默降级)。
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 1. 组件定位
|
|
50
|
+
|
|
51
|
+
面向**分布式、多进程**场景的 Node.js 日志组件:
|
|
52
|
+
|
|
53
|
+
- 业务侧**只写内存**,零 IO 等待;
|
|
54
|
+
- 后台**节流攒批 + 互斥追加**写文件;
|
|
55
|
+
- 一个业务单据(订单)一个文件,按日期分层目录;
|
|
56
|
+
- 多进程 / 多机可安全写同一文件;
|
|
57
|
+
- 时间统一**北京时间**,每条日志必含**时间**与**主机名称**。
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 2. 写日志链路(强制约定)
|
|
62
|
+
|
|
63
|
+
### 2.1 阶段一:写环形内存(同步、立即返回)
|
|
64
|
+
|
|
65
|
+
- 每条日志先格式化为一行 JSONL,按 `bizKey` 聚合进**环形缓冲**;
|
|
66
|
+
- 环形缓冲容量固定(`maxBufferSize`),**满则覆盖最老的一条**;
|
|
67
|
+
- 此阶段**不做任何 IO、不 `await`、不抛错**;
|
|
68
|
+
- `immediate: true` 或级别 ≥ `immediateLevel` 的日志,标记后立即触发一次刷盘(仍走后台,不阻塞)。
|
|
69
|
+
|
|
70
|
+
### 2.2 阶段二:节流刷盘(异步、互斥、追加写)
|
|
71
|
+
|
|
72
|
+
| 触发 | 配置 | 默认 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| 定时 | `flushIntervalMs` | `3000` |
|
|
75
|
+
| 定时器抖动 | `flushJitterMs` | `500` |
|
|
76
|
+
| 攒够条数 | `flushBatchSize` | `100` |
|
|
77
|
+
| 攒够字节 | `maxFlushBytes` | `65536` |
|
|
78
|
+
| 关键日志 | `immediate` / `immediateLevel` | `error` |
|
|
79
|
+
| 手动 | `await logger.flush()` | — |
|
|
80
|
+
|
|
81
|
+
- **必须攒批**:一次 flush = 一次 `open` + 一次 `write` + 一次 `close`;单批建议 ≥ 4KB;
|
|
82
|
+
- **抖动是必须的**:多个进程的定时器若严格同相位,会每周期同时抢同一把锁,人为制造冲突。
|
|
83
|
+
|
|
84
|
+
### 2.3 写入实现
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
async function flushGroup(rel: string, chunk: string): Promise<void> {
|
|
88
|
+
const abs = join(baseDir, rel);
|
|
89
|
+
await ensureDir(dirname(abs)); // 结果内存缓存;EEXIST 视为成功
|
|
90
|
+
await queue.run(rel, async () => { // ① 进程内:按文件路径串行
|
|
91
|
+
await withWriteLock(rel, async () => { // ② 跨进程/跨机互斥(锁不可用 → 直写,不写=丢日志)
|
|
92
|
+
const fh = await withTimeout(open(abs, 'a'), writeTimeoutMs); // 只追加
|
|
93
|
+
try {
|
|
94
|
+
await writeAll(fh, chunk, writeTimeoutMs); // 循环补全部分写
|
|
95
|
+
} finally {
|
|
96
|
+
await fh.close().catch(() => {}); // 写完即关:fd 不常驻
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 必须循环补全:网络文件系统单次 write 可能只写一部分 */
|
|
103
|
+
async function writeAll(fh: FileHandle, chunk: string, timeoutMs: number): Promise<void> {
|
|
104
|
+
const buf = Buffer.from(chunk, 'utf8');
|
|
105
|
+
let off = 0;
|
|
106
|
+
while (off < buf.length) {
|
|
107
|
+
const { bytesWritten } = await withTimeout(fh.write(buf, off), timeoutMs);
|
|
108
|
+
if (bytesWritten <= 0) throw new Error('write 返回 0 字节,放弃本批');
|
|
109
|
+
off += bytesWritten;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**关键不变量(必须单元测试覆盖)**
|
|
115
|
+
|
|
116
|
+
1. 只以 `'a'` 打开,绝不 `truncate` / `'w'`;
|
|
117
|
+
2. 写完即关(fd 不常驻);
|
|
118
|
+
3. 写失败 / 超时:**保留缓冲**,下轮退避重试;告警一次;**绝不抛给业务**;
|
|
119
|
+
4. 同批只做一次 `open/close`,不逐行写;
|
|
120
|
+
5. 每次 `open`/`write` 必须带**超时**(挂载异常可能表现为长时间 hang,而非报错);
|
|
121
|
+
6. `write` 必须**循环补全**(只调一次会静默截断日志);
|
|
122
|
+
7. `mkdir` 遇 `EEXIST` 视为成功(多进程会并发创建同一目录)。
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 3. 目录结构与文件命名(强制约定)
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
{baseDir}/{年}/{年月}/{年月日}/商户{merchantId}/门店{storeId}/{system}/{bizKey}.log
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
示例:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
/data/logs/2026/202609/20260929/商户1/门店1/支付系统/202609290001.log
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**路径标准由调用方(业务层)定义,组件不内置任何「系统/商户/门店/代理/软件」之类的业务层级。**
|
|
139
|
+
|
|
140
|
+
- 组件只负责「根目录 `baseDir` + 日期目录 `{年}/{年月}/{年月日}/` + 调用方给的**相对路径(category)** + `bizKey.log`」这层机械拼装;
|
|
141
|
+
- 不传 `category` 时走上面的默认「商户/门店/system」兜底结构(向后兼容),但**新接入方应使用 `category` 自行定义业务路径规范**(详见各宿主项目的日志文档,如 node-server 的 `docs/日志.md`);
|
|
142
|
+
- `category` 允许多级子目录(如 `db/sql`、`node-server/商户1/门店1/shardorm`),组件只做安全清洗(防路径穿越),不解读其业务含义。
|
|
143
|
+
|
|
144
|
+
规则:
|
|
145
|
+
|
|
146
|
+
1. **文件名不带 `host-pid`、不带状态后缀**:多机多进程写的是**同一个文件**;
|
|
147
|
+
2. **日期目录 = 单据创建日期**(本项目无跨天订单),保证同一单据永远写到同一文件;
|
|
148
|
+
3. 可选 `fileScope: 'host'`(默认 `'shared'`):改为 `{bizKey}.log.<host>`,各机各写各的、彻底免锁,
|
|
149
|
+
代价是同一订单出现多个文件(查询侧需合并),默认不开;
|
|
150
|
+
4. 单目录文件数建议 < 5000,必要时按 `bizKey` 末位再分一层子目录;
|
|
151
|
+
5. **按文件尺寸自动分割(rotate)**:单文件达到 `maxFileSizeBytes`(默认 1MB)即滚动到
|
|
152
|
+
`<stem>.1.log` / `<stem>.2.log` …(如 `boot.log` → `boot.1.log` → `boot.2.log`)。
|
|
153
|
+
滚动基于「追加前 stat 当前文件大小 + 本批字节」,不依赖 rename(规避网络挂载下 rename 风险);
|
|
154
|
+
旧文件只读保留,由外部滚动删除策略清理(见 §8)。设 `maxFileSizeBytes ≤ 0` 可关闭分割(单 bizKey 单文件)。
|
|
155
|
+
|
|
156
|
+
### 3.1 滚动文件顺序约定(查询侧合并用)
|
|
157
|
+
|
|
158
|
+
- 活跃写入顺序:`xxx.log` → `xxx.1.log` → `xxx.2.log` …(数字越大越新);
|
|
159
|
+
- 查询某 bizKey 的全部日志时,按「base 优先、再按序号升序」拼接即得完整时间序;
|
|
160
|
+
- 文件名数字是后缀(`xxx.N.log`),不是前缀,避免与 `xxx.log.<host>`(host 粒度)混淆。
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 4. 日志格式(JSONL 单行,强制字段)
|
|
165
|
+
|
|
166
|
+
宿主可用 `blankLineCategories` 指定需要空行分隔的类别:每条 JSON 记录后追加 `\n\n`,其余类别仍为 `\n`。分隔符与记录作为整体参与批次字节计数、切块和失败回填;按行读取时跳过空行。类别含义由宿主定义,组件不硬编码业务类别。
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{"timestamp":"2026-09-29T10:00:00.123+08:00","host":"pos-node-03","pid":1234,"level":"info","merchantId":1,"storeId":1,"system":"支付系统","bizKey":"202609290001","message":"订单创建成功"}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 4.1 必含字段
|
|
173
|
+
|
|
174
|
+
| 字段 | 说明 |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `timestamp` | **北京时间** ISO-8601 毫秒,固定 `+08:00` 偏移 |
|
|
177
|
+
| `host` | **主机名称**(`os.hostname()`;容器里是 Pod 名),可用 `NODE_LOG_HOST` / `POD_NAME` 覆盖 |
|
|
178
|
+
| `pid` | 进程号(同机多进程区分) |
|
|
179
|
+
| `level` / `merchantId` / `storeId` / `system` | 定位字段 |
|
|
180
|
+
| `bizKey` | 业务主键(订单号),行内冗余以便检索 |
|
|
181
|
+
| `message` | 正文 |
|
|
182
|
+
|
|
183
|
+
可选:`userId`、`requestId`、`extra`、`metadata`。
|
|
184
|
+
|
|
185
|
+
### 4.2 时间规则(强制)
|
|
186
|
+
|
|
187
|
+
- 组件内**固定 +08:00**,**不依赖运行环境 `TZ`**(Docker 默认 UTC,会整体差 8 小时):
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
function beijingTimestamp(d: Date = new Date()): string {
|
|
191
|
+
return new Date(d.getTime() + 8 * 3600 * 1000).toISOString().replace(/Z$/, '+08:00');
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- **目录日期也必须用北京时间**取(否则每天 00:00–08:00 的日志会落到前一天目录);
|
|
196
|
+
- 全部节点统一北京时间 ⇒ 字符串字典序 == 时间先后,多机合并可直接按 `timestamp` 排序。
|
|
197
|
+
|
|
198
|
+
### 4.3 序列化必须容错
|
|
199
|
+
|
|
200
|
+
单条日志序列化失败(循环引用 / BigInt / 特殊对象)时,**只把这一条降级为占位文本**,
|
|
201
|
+
绝不能让整批刷盘失败:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
function stringifySafe(value: unknown): string { /* 循环引用→[Circular],BigInt→字符串,异常→占位 */ }
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 5. 互斥与并发正确性
|
|
210
|
+
|
|
211
|
+
场景:A 机 2 进程 + B 机 2 进程,共 4 个进程写同一订单文件。
|
|
212
|
+
|
|
213
|
+
### 5.1 互斥机制
|
|
214
|
+
|
|
215
|
+
| 层级 | 机制 |
|
|
216
|
+
|---|---|
|
|
217
|
+
| 进程内 | 按**文件路径**的串行队列(promise chain),同一文件绝不并发写 |
|
|
218
|
+
| 同机多进程 / 跨机 | 分布式锁:`node-lock` 锁键 `node-log:write:<相对路径>`,`runDedupe` **不排队** |
|
|
219
|
+
|
|
220
|
+
- **锁 TTL `lockTtlMs`(默认 10000)必须 > `writeTimeoutMs`(默认 5000)并留余量**;
|
|
221
|
+
- 释放锁必须校验 token(只释放自己持有的锁,由 `node-lock` 保证);
|
|
222
|
+
- **抢不到锁**:缓冲保留,下轮再写,**绝不排队阻塞业务**。
|
|
223
|
+
|
|
224
|
+
### 5.1 锁怎么来:**外部注入实例,组件不连 Redis**
|
|
225
|
+
|
|
226
|
+
组件**不自建 Redis 连接、不接受任何 Redis 配置** —— 使用方初始化时直接把自己项目里的
|
|
227
|
+
`@yixinshuke/node-lock` 实例传进来即可(该实例通常全项目共用一个):
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import { RedisLock } from '@yixinshuke/node-lock';
|
|
231
|
+
import { GlobalLogger } from '@yixinshuke/node-log';
|
|
232
|
+
|
|
233
|
+
const 锁 = new RedisLock(项目里的 ioredis 实例); // 怎么连 Redis 是宿主项目的事
|
|
234
|
+
|
|
235
|
+
GlobalLogger.init({
|
|
236
|
+
baseDir: '/mnt/oss/logs',
|
|
237
|
+
lock: 锁, // 传了才互斥;不传 = 无锁直写(单进程部署或不介意时用)
|
|
238
|
+
lockTtlMs: 10000,
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
组件只依赖一个方法契约:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
interface LockLike {
|
|
246
|
+
runDedupe<T>(key: string, fn: () => Promise<T>, options?: { ttlMs?: number }): Promise<boolean>;
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
> **为什么这样设计**:Redis 地址/密码/集群方式是宿主项目的既有配置,
|
|
251
|
+
> 日志组件不该再建一套连接(多一份连接池、多一处配置、多一个故障点)。
|
|
252
|
+
|
|
253
|
+
### 5.2 必须防住的并发漏洞
|
|
254
|
+
|
|
255
|
+
| # | 漏洞 | 防护 |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| 1 | **锁超时导致真并发**(写耗时 > TTL → 两个写入者同时 append) | TTL > 写超时并留余量;写超时**强制放弃**本批;token 校验释放 |
|
|
258
|
+
| 2 | **目标目录若是网络挂载(OSS/NAS FUSE)**,客户端缓存旧文件长度会让后一个写入者从**旧 offset** 开始写 → 覆盖前一段 | 部署时关闭属性缓存;**上线前必测**:多进程串行写完 N 批后,断言「文件总字节 == 各批之和」;测不过则改用 `fileScope:'host'` |
|
|
259
|
+
| 3 | **部分写**(单次 `write` 只写一部分) | `writeAll` 循环补全(见 §2.3) |
|
|
260
|
+
|
|
261
|
+
### 5.3 已知取舍(接受)
|
|
262
|
+
|
|
263
|
+
| 项 | 说明 |
|
|
264
|
+
|---|---|
|
|
265
|
+
| 进程重启 / 崩溃 / OOM / 被 kill | 丢环形缓冲内 ≤ `flushIntervalMs` 的日志;正常退出务必 `await shutdown()` |
|
|
266
|
+
| 目标目录长时间不可写 | 缓冲写满后**覆盖最老**(不重启也丢);靠告警发现,不阻断业务 |
|
|
267
|
+
| 日志顺序 | 多进程各自 flush,文件内是"到达顺序";展示侧按 `timestamp` 排序 |
|
|
268
|
+
| 进程暂停 > 锁 TTL | 概率极低;最坏是重复/错序几行,不丢数据 |
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 6. 配置约定
|
|
273
|
+
|
|
274
|
+
### 6.1 配置表
|
|
275
|
+
|
|
276
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
277
|
+
|---|---|---|---|
|
|
278
|
+
| `baseDir` | `string` | `./logs` | 日志根目录(**普通目录**,不关心底层是本地盘还是挂载) |
|
|
279
|
+
| `minLevel` | `LogLevel` | `info` | 最低记录级别 |
|
|
280
|
+
| `maxBufferSize` | `number` | `10000` | 环形缓冲容量(条),满则覆盖最老 |
|
|
281
|
+
| `flushIntervalMs` | `number` | `3000` | 节流刷盘间隔 |
|
|
282
|
+
| `flushJitterMs` | `number` | `500` | 定时器随机抖动,避免多进程同相位抢锁 |
|
|
283
|
+
| `flushBatchSize` | `number` | `100` | 单批最大条数 |
|
|
284
|
+
| `maxFlushBytes` | `number` | `65536` | 单批字节上限(达到即提前刷) |
|
|
285
|
+
| `immediateLevel` | `LogLevel` | `error` | ≥ 该级别的日志立即触发刷盘 |
|
|
286
|
+
| `writeTimeoutMs` | `number` | `5000` | 单次 `open`/`write` 超时 |
|
|
287
|
+
| `lock` | `LockLike \| null` | `null` | 外部注入的 **`node-lock` 实例**;不传 = 无锁直写(见 §5.1) |
|
|
288
|
+
| `lockTtlMs` | `number` | `10000` | 写入锁 TTL,**必须 > `writeTimeoutMs`** |
|
|
289
|
+
| `fileScope` | `'shared' \| 'host'` | `shared` | 文件名是否带 host(见 §3) |
|
|
290
|
+
| `retryBackoffMs` | `number` | `3000` | 写失败退避(指数递增,上限 5 分钟) |
|
|
291
|
+
| `host` | `string` | `os.hostname()` | 主机名字段值,可覆盖 |
|
|
292
|
+
| `maxFileSizeBytes` | `number` | `1048576` | 单文件尺寸上限(字节),达到即滚动 `<stem>.N.log`;见 §3.1 |
|
|
293
|
+
|
|
294
|
+
### 6.2 示例
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import { RedisLock } from '@yixinshuke/node-lock';
|
|
298
|
+
|
|
299
|
+
GlobalLogger.init({
|
|
300
|
+
baseDir: '/data/logs',
|
|
301
|
+
flushIntervalMs: 3000,
|
|
302
|
+
writeTimeoutMs: 5000,
|
|
303
|
+
lock: new RedisLock(项目现有的 ioredis 实例), // 可选:不传则无锁直写
|
|
304
|
+
lockTtlMs: 10000,
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## 7. 降级与错误处理(fail-open)
|
|
311
|
+
|
|
312
|
+
| 情况 | 行为 |
|
|
313
|
+
|---|---|
|
|
314
|
+
| 未注入 `lock` 实例 | **无锁直写**,不告警(预期用法,见 §5.1) |
|
|
315
|
+
| 注入了但锁不可用(Redis 挂) | **照常直写**,告警一次;**绝不因锁挂了就不写**(不写 = 真丢日志) |
|
|
316
|
+
| 抢不到锁(busy) | 缓冲保留,下轮再写;**绝不排队** |
|
|
317
|
+
| 写失败(EIO / ENOENT / ENOSPC / EACCES / 目录不可写) | 吞错 + `console.warn` 一次 + 保留缓冲退避重试 |
|
|
318
|
+
| 写超时 | 同写失败处理(强制关 fd,绝不无限等) |
|
|
319
|
+
| 创建目录失败 | 本轮放弃该文件,不冒泡 |
|
|
320
|
+
| 序列化失败 | 该条降级为占位文本,不影响同批(见 §4.3) |
|
|
321
|
+
|
|
322
|
+
> **告警铁律**:组件自身的告警必须**同时 `console.warn`**。
|
|
323
|
+
> 否则目标目录不可写时,告警本身也写不进文件,故障会变成静默的。
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## 8. 数据生命周期
|
|
328
|
+
|
|
329
|
+
- 组件**不删除任何文件**(删文件 = 删生产数据,风险极高);
|
|
330
|
+
- 过期由**外部负责**(对象存储生命周期规则 / 运维清理脚本),组件不实现清理逻辑;
|
|
331
|
+
- 组件**不做目录扫描**(网络挂载上扫描极慢);
|
|
332
|
+
- 组件只负责「把日志追加写到文件」,**写文件即终点**,不做任何后续处理。
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## 9. 安全约束
|
|
337
|
+
|
|
338
|
+
1. **路径遍历防护**:`merchantId`/`storeId` 只保留数字;`system` 只保留 `A-Za-z0-9._-` 并去前导点;`bizKey` 同样过滤(禁止 `/`、`..`)
|
|
339
|
+
2. **只追加不破坏**:一律 `'a'` 打开;严禁 `truncate`、`'w'` 模式、删除文件
|
|
340
|
+
3. **文件权限**:创建时 `0o600`(网络挂载上可能不生效,属已知限制)
|
|
341
|
+
4. **脱敏**:由业务侧在调用 logger 前完成,组件不改写日志内容
|
|
342
|
+
5. **日志注入**:输出为 JSONL,字段统一 `JSON.stringify`,天然避免换行注入
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## 10. API 约定
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
// lock 可选:传入项目自己的 node-lock 实例才有跨机互斥;不传 = 无锁直写
|
|
350
|
+
GlobalLogger.init({ baseDir: '/mnt/oss/logs', lock: new RedisLock(项目 redis) });
|
|
351
|
+
const logger = GlobalLogger.getInstance();
|
|
352
|
+
|
|
353
|
+
logger.info(ctx, '消息'); // 写环形内存,立即返回
|
|
354
|
+
logger.error(ctx, '关键错误'); // ≥ immediateLevel,立即触发刷盘
|
|
355
|
+
logger.error(ctx, '消息', { immediate: true }); // 显式立即触发
|
|
356
|
+
await logger.flush(); // 手动刷盘(不抛错)
|
|
357
|
+
GlobalLogger.updateConfig({ minLevel: 'debug' });
|
|
358
|
+
await GlobalLogger.shutdown(); // 退出前必须 await:刷盘 + 等待在途写(有超时上限)
|
|
359
|
+
logger.stats(); // 监控:{ buffered, dropped, writeFailures, lockBusy }
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
interface LogContext {
|
|
364
|
+
merchantId: string | number;
|
|
365
|
+
storeId: string | number;
|
|
366
|
+
system: string; // 业务系统名,如「支付系统」
|
|
367
|
+
bizKey: string; // 业务主键(订单号),决定文件路径
|
|
368
|
+
bizDate?: string; // 单据日期 YYYYMMDD,缺省时从 bizKey 前 8 位解析
|
|
369
|
+
userId?: string;
|
|
370
|
+
requestId?: string;
|
|
371
|
+
extra?: Record<string, any>;
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## 11. 源码结构
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
src/
|
|
381
|
+
├── index.ts # 导出入口
|
|
382
|
+
├── logger.ts # 单例、环形缓冲、节流调度、API、告警
|
|
383
|
+
├── config.ts # 配置与默认值、校验
|
|
384
|
+
├── buffer.ts # 环形缓冲(按 bizKey 聚合,满则覆盖最老)
|
|
385
|
+
├── path.ts # 目录/文件拼装 + 安全过滤
|
|
386
|
+
├── writer.ts # 写入器:mkdir 缓存 + 串行队列 + O_APPEND + 写完即关 + 超时 + 部分写补全
|
|
387
|
+
├── lock.ts # 外部注入的 node-lock 实例适配:busy → 保留缓冲;锁不可用 → 直写
|
|
388
|
+
└── time.ts # 北京时间(固定 +08:00,不依赖 TZ)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## 12. 部署事项(与代码无关,但上线前必须确认)
|
|
394
|
+
|
|
395
|
+
1. `baseDir` 指向的目录**存在且进程可写**(本地盘 / OSS 挂载 / NAS 挂载均可,组件不感知);
|
|
396
|
+
2. 若是网络挂载(OSS/NAS FUSE):**关闭属性缓存**,并先做 §5.2 第 2 条的字节和校验;
|
|
397
|
+
3. 写入互斥所需的 `node-lock` 实例由使用方注入;组件本身不连 Redis;
|
|
398
|
+
4. 跑 `npm run demo` / `npm run demo:multi`(见 `demo/`)可在真实多进程下自检落盘结果;
|
|
399
|
+
4. 数据过期由外部机制负责(生命周期规则 / 清理脚本);
|
|
400
|
+
5. 告警能落到 stdout(目标目录不可写时仍可见);
|
|
401
|
+
6. 监控:写入失败率、写超时次数、缓冲覆盖丢弃条数、单目录文件数。
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## 13. 实施顺序
|
|
406
|
+
|
|
407
|
+
1. `time.ts` 北京时间 + 目录日期统一
|
|
408
|
+
2. `path.ts` 安全过滤 + 目录/文件规则
|
|
409
|
+
3. `buffer.ts` 环形缓冲(按 bizKey 聚合,满则覆盖最老)
|
|
410
|
+
4. `writer.ts` 串行队列 + `O_APPEND` + 整批写 + 写完即关 + 超时 + 部分写补全 + mkdir 缓存
|
|
411
|
+
5. `lock.ts` 分布式锁(外部注入 node-lock 实例;锁不可用 → 直写 / busy → 保留缓冲下轮写)
|
|
412
|
+
6. `logger.ts` 接节流调度(`flushIntervalMs` + jitter / `immediate` / `flush`)与告警计数
|
|
413
|
+
7. 单元测试(**必测清单,新增功能必须同步补**):
|
|
414
|
+
- 环形缓冲:覆盖最老、跨 key 淘汰全局最老、写失败回填队首保持顺序、`drainByKey` limit;
|
|
415
|
+
- 节流与批次:定时才落盘、`immediate`/`immediateLevel`、`maxFlushBytes` 切块后每行仍完整;
|
|
416
|
+
- 写入器:只以 `'a'` 打开、写完即关、同路径串行不交错、部分写循环补全、
|
|
417
|
+
写超时(含超时后仍关 fd)、`mkdir` EEXIST 视为成功;
|
|
418
|
+
- 铁律(§0.2):目标目录不可写时 `info/flush` 不抛错、保留缓冲、告警落到 `console.warn`、
|
|
419
|
+
告警去重只刷一次、部分失败互相隔离、背压覆盖最老、手动 `flush()` 不被退避挡住;
|
|
420
|
+
- 锁:注入实例才互斥、busy 保留缓冲下轮补写、锁不可用 fail-open 直写、锁键与 TTL;
|
|
421
|
+
- **多进程模拟**(`test/concurrency.test.ts`):两个 writer 共享一把锁 →
|
|
422
|
+
同一时刻只有一个写入者、**总字节 == 各批之和**、每行完整(对应 §5.2 漏洞 2);
|
|
423
|
+
- 格式与路径:单行 JSONL(消息含换行也不破坏)、北京时间 `+08:00`、host/pid 必含、
|
|
424
|
+
单据日期目录、路径遍历防护。
|
|
425
|
+
8. 同步 `docs/使用教程/*`
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## 14. 常见坑速查
|
|
430
|
+
|
|
431
|
+
| 现象 | 原因 / 处理 |
|
|
432
|
+
|---|---|
|
|
433
|
+
| 目录日期差一天(凌晨日志落到前一天) | 取日期必须用北京时间,不能用容器本地时间(默认 UTC) |
|
|
434
|
+
| 写文件极慢、请求数暴涨 | 没攒批;调大 `flushIntervalMs` / `flushBatchSize`,单批 ≥ 4KB |
|
|
435
|
+
| 请求被拖住甚至挂死 | 目标目录 hang(常见于网络挂载);检查 `writeTimeoutMs` 是否生效 |
|
|
436
|
+
| 文件内容被覆盖 / 少了一段 | 用了 `'w'` 模式或 truncate;组件只允许 `'a'` |
|
|
437
|
+
| 多进程写同一文件错乱 | 锁未生效(未注入 `lock` 实例,或 `lockTtlMs` 小于 `writeTimeoutMs`) |
|
|
438
|
+
| 日志整段缺失(尾部截断) | `write` 未循环补全部分写 |
|
|
439
|
+
| 目录不可写却没人知道 | 告警必须同时 `console.warn` |
|
|
440
|
+
| 业务被打断 / 报错来自日志组件 | 违反 §0.2:运行期写路径绝不能向外抛错,补上漏掉的 `catch` |
|
|
441
|
+
| 日志莫名其妙没了 | 组件严禁删除;检查是否有人另加了清理逻辑 |
|
|
442
|
+
|
|
443
|
+
## 错误提示铁律(最高优先级)
|
|
444
|
+
|
|
445
|
+
**严禁用「未知错误」「系统错误」「暂时无法连接,请稍后重试」这类笼统兜底提示掩盖真实错误。**
|
|
446
|
+
|
|
447
|
+
- 这类空话不说明任何原因,一旦出问题,排查者只能从入口开始一步一步跟踪调试,代价极高;
|
|
448
|
+
- 任何错误在被捕获、包装、向上抛出或返回给调用方时,**必须携带具体、可定位的信息**:实际出错的操作、关键参数、原始错误(message / 错误码 / SQL / HTTP 状态码等至少其一);
|
|
449
|
+
- 日志与返回信息要让排查者「一眼看出是哪一步、什么数据、什么条件下出的错」,而不是把所有失败都归一成一句空话;
|
|
450
|
+
- 允许降级 / 重试 / 返回默认值等容错处理,但**容错的同时必须把具体原因记下来**(日志告警或在错误信息里附带原始错误),不得静默吞掉真实错误、只留一句「请稍后重试」;
|
|
451
|
+
- 若需对外部用户隐藏内部细节,应「对外给友好文案 + 对内保留完整错误链」,两者不可只取前者而丢弃后者。
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## 三个环境(全工作区标准)
|
|
456
|
+
|
|
457
|
+
权威出处:`../docs/0业务逻辑/003-开发测试生产环境.md`(改动环境口径先改那份,再同步本节)。
|
|
458
|
+
|
|
459
|
+
| 环境 | 地址 |
|
|
460
|
+
| --- | --- |
|
|
461
|
+
| 开发 | `127.0.0.1:3101 ~ 3105` |
|
|
462
|
+
| 测试 | `1dl-test` / `2sh-test` / `3pay-test` / `4hy-test` / `5pos-test`.yixinshuke.com |
|
|
463
|
+
| 生产·线路1 | `1dl` / `2sh` / `3pay` / `4hy` / `5pos`.yixinshuke.com |
|
|
464
|
+
| 生产·线路2 | 上列域名各加 `2`(`1dl2` / `2sh2` / `3pay2` / `4hy2` / `5pos2`.yixinshuke.com) |
|
|
465
|
+
|
|
466
|
+
- 服务器端:`.env` 取值 `dev`(默认) / `test` / `prod`;设置优先级 CLI `--env` > `.env` 的 `NODE_ENV` > 操作系统 `NODE_ENV`;启动时在控制台打印当前环境。
|
|
467
|
+
- 网页端:URL query `?env=dev/test/prod`,不带参数 = 生产;登录卡片下方与主界面右上角可选线路——开发环境显示「开发 线路1 线路2」,测试环境显示「测试 线路1 线路2」,生产环境显示「线路1 线路2」;线路1/线路2 始终是生产环境的线路地址。
|
|
468
|
+
- 桌面端:默认生产模式;`pos.exe --env=dev/test/prod`,或 EXE 同目录 `.env` 的 `env=dev/test/prod`;优先级 启动参数 > `.env` > 默认生产模式;线路选择 UI 与网页端相同。
|
package/README.md
CHANGED
|
@@ -1,3 +1,196 @@
|
|
|
1
|
-
#
|
|
1
|
+
# node-log 日志组件
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node.js 日志组件库,提供按商户/门店/业务系统分层的日志管理。
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- 按日期分文件夹的日志目录结构
|
|
8
|
+
- 日志自动切割(单文件超过 1MB 自动分片)
|
|
9
|
+
- 日志滚动删除(保留最近 30 天)
|
|
10
|
+
- 节流缓冲模式(内存/Redis 缓冲,定时刷盘)
|
|
11
|
+
- 全局单例,多模块共享同一实例
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install node-log
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
可选依赖(分布式场景):
|
|
20
|
+
```bash
|
|
21
|
+
npm install ioredis
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 快速开始
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { GlobalLogger } from 'node-log';
|
|
28
|
+
|
|
29
|
+
// 初始化(应用启动时调用一次)
|
|
30
|
+
GlobalLogger.init({
|
|
31
|
+
baseDir: './logs',
|
|
32
|
+
bufferStrategy: 'memory',
|
|
33
|
+
flushIntervalMs: 5000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 获取实例
|
|
37
|
+
const logger = GlobalLogger.getInstance();
|
|
38
|
+
|
|
39
|
+
// 定义日志上下文
|
|
40
|
+
const context = {
|
|
41
|
+
merchantId: 1001,
|
|
42
|
+
storeId: 2002,
|
|
43
|
+
system: 'order-system',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// 写入日志
|
|
47
|
+
logger.info(context, '订单创建成功');
|
|
48
|
+
logger.warn(context, '库存不足');
|
|
49
|
+
logger.error(context, '支付失败', { immediate: true });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 日志目录结构
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
logs/
|
|
56
|
+
2026/
|
|
57
|
+
09/
|
|
58
|
+
07/
|
|
59
|
+
商户1001/
|
|
60
|
+
门店2002/
|
|
61
|
+
order-system/
|
|
62
|
+
info-20260907.log
|
|
63
|
+
warn-20260907.log
|
|
64
|
+
error-20260907.log
|
|
65
|
+
error-20260907-1.log # 切割后的分片
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 配置项
|
|
69
|
+
|
|
70
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
71
|
+
|---|---|---|---|
|
|
72
|
+
| `baseDir` | `string` | `./logs` | 日志根目录 |
|
|
73
|
+
| `rotateSizeMB` | `number` | `1` | 单文件大小阈值(MB) |
|
|
74
|
+
| `retentionDays` | `number` | `30` | 滚动删除保留天数 |
|
|
75
|
+
| `bufferStrategy` | `'memory' \| 'redis'` | `'memory'` | 缓冲策略 |
|
|
76
|
+
| `flushIntervalMs` | `number` | `5000` | 缓冲刷盘间隔(毫秒) |
|
|
77
|
+
| `flushBatchSize` | `number` | `100` | 每次刷盘最大条数 |
|
|
78
|
+
| `minLevel` | `LogLevel` | `'info'` | 最低记录级别 |
|
|
79
|
+
| `redisConfig` | `object \| null` | `null` | Redis 连接配置 |
|
|
80
|
+
| `enableCleanup` | `boolean` | `true` | 启动时自动清理过期日志 |
|
|
81
|
+
| `cleanupIntervalHours` | `number` | `24` | 定时清理间隔(小时),`0` = 仅启动时清理一次 |
|
|
82
|
+
| `useFileLock` | `boolean` | `false` | 分布式文件写锁(需 `redisConfig`,多进程共享目录必开) |
|
|
83
|
+
| `fileLockTtlMs` | `number` | `5000` | 文件锁 TTL(毫秒),超时自动释放 |
|
|
84
|
+
| `maxBufferSize` | `number` | `0`(自动) | 内存缓冲上限,超出丢弃最老的日志;`0` = 自动推导 `max(flushBatchSize*2, 1000)` |
|
|
85
|
+
|
|
86
|
+
## 日志级别
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `debug`: 调试信息
|
|
93
|
+
- `info`: 一般信息
|
|
94
|
+
- `warn`: 警告信息
|
|
95
|
+
- `error`: 错误信息
|
|
96
|
+
|
|
97
|
+
## 写入模式
|
|
98
|
+
|
|
99
|
+
### 缓冲写入(默认)
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
logger.info(context, '普通操作日志');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
进入缓冲队列,定时批量刷盘,减少 I/O 压力。
|
|
106
|
+
|
|
107
|
+
### 立即写入
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
logger.error(context, '关键异常', { immediate: true });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
绕过缓冲,直接落盘,确保关键日志不丢失。
|
|
114
|
+
|
|
115
|
+
## 运行时配置
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// 动态更新配置
|
|
119
|
+
GlobalLogger.updateConfig({ flushIntervalMs: 10000 });
|
|
120
|
+
|
|
121
|
+
// 关闭日志器
|
|
122
|
+
await GlobalLogger.shutdown();
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Redis 缓冲配置
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
GlobalLogger.init({
|
|
129
|
+
bufferStrategy: 'redis',
|
|
130
|
+
redisConfig: {
|
|
131
|
+
host: process.env.REDIS_HOST || '127.0.0.1',
|
|
132
|
+
port: Number(process.env.REDIS_PORT || 6379),
|
|
133
|
+
db: Number(process.env.REDIS_DB || 0),
|
|
134
|
+
password: process.env.REDIS_PASSWORD || '',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 日志格式
|
|
140
|
+
|
|
141
|
+
默认 JSON 格式:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"timestamp": "2026-09-07T06:10:00.000Z",
|
|
146
|
+
"level": "info",
|
|
147
|
+
"merchantId": 1001,
|
|
148
|
+
"storeId": 2002,
|
|
149
|
+
"system": "order-system",
|
|
150
|
+
"message": "订单创建成功",
|
|
151
|
+
"userId": "U123456",
|
|
152
|
+
"requestId": "req-001",
|
|
153
|
+
"extra": {
|
|
154
|
+
"orderId": "ORD20260907001"
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## 安全特性
|
|
160
|
+
|
|
161
|
+
- 路径遍历防护:商户 ID、门店 ID 只做数字过滤
|
|
162
|
+
- 日志注入防护:对用户输入做转义
|
|
163
|
+
- 文件权限:日志文件创建时使用 `0o600` 权限
|
|
164
|
+
|
|
165
|
+
## 性能考量
|
|
166
|
+
|
|
167
|
+
| 场景 | 推荐配置 |
|
|
168
|
+
|---|---|
|
|
169
|
+
| 高频日志(1000+ 条/秒) | `flushIntervalMs: 100`, `flushBatchSize: 500` |
|
|
170
|
+
| 低频日志(< 10 条/秒) | `flushIntervalMs: 5000`, `flushBatchSize: 100` |
|
|
171
|
+
| 关键业务日志 | 使用 `immediate: true` |
|
|
172
|
+
|
|
173
|
+
## 测试
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm test
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
测试目录建在系统临时目录下(每个用例一个唯一子目录),默认不清理,避免删除被环境拦截;
|
|
180
|
+
需要清理时设置环境变量:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
NODE_LOG_TEST_CLEAN=1 npm test
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 构建
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
npm run build
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## 依赖
|
|
193
|
+
|
|
194
|
+
- TypeScript 5.x
|
|
195
|
+
- Node.js 22+
|
|
196
|
+
- ioredis(可选,Redis 缓冲支持)
|