@yixinshuke/node-log 0.0.0-stage → 0.0.1
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 +449 -0
- package/README.md +195 -2
- package/package.json +38 -6
- package/src/buffer.ts +108 -0
- package/src/config.ts +112 -0
- package/src/index.ts +16 -0
- package/src/lock.ts +69 -0
- package/src/logger.ts +427 -0
- package/src/path.ts +124 -0
- package/src/time.ts +46 -0
- package/src/writer.ts +239 -0
package/src/buffer.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 环形内存缓冲(阶段一:写日志先进这里,同步入队后**立即返回**)。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点:
|
|
5
|
+
* - **按文件(等价于按 bizKey)分队列**:某个文件写失败时只回填该队列,不影响其他文件;
|
|
6
|
+
* - **全局容量固定**:写满时覆盖**最老的一条**(按各队列队首的时间比较),
|
|
7
|
+
* 绝不无限增长(OOM),也绝不抛错阻断业务(AGENTS.md §0.2 / §2.1);
|
|
8
|
+
* - `drainByKey(limit)` 是「取出并移除」;写失败用 `pushBack()` 回填到**队首**,保持原有顺序。
|
|
9
|
+
*/
|
|
10
|
+
import type { LogContext } from './logger.js';
|
|
11
|
+
import type { LogLevel } from './config.js';
|
|
12
|
+
|
|
13
|
+
export interface LogEntry {
|
|
14
|
+
level: LogLevel;
|
|
15
|
+
context: LogContext;
|
|
16
|
+
message: string;
|
|
17
|
+
timestamp: Date;
|
|
18
|
+
metadata?: Record<string, any>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RingBuffer {
|
|
22
|
+
/** key = 相对文件路径;Map 保持插入顺序,天然轮转、不会饿死后来的 key */
|
|
23
|
+
private readonly queues = new Map<string, LogEntry[]>();
|
|
24
|
+
private readonly capacity: number;
|
|
25
|
+
private total = 0;
|
|
26
|
+
private dropped = 0;
|
|
27
|
+
|
|
28
|
+
constructor(capacity: number = 10000) {
|
|
29
|
+
this.capacity = Math.max(1, Math.floor(capacity));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get size(): number {
|
|
33
|
+
return this.total;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 因缓冲满而被覆盖丢弃的条数(监控用) */
|
|
37
|
+
get droppedCount(): number {
|
|
38
|
+
return this.dropped;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
push(key: string, entry: LogEntry): void {
|
|
42
|
+
const queue = this.queues.get(key);
|
|
43
|
+
if (queue) queue.push(entry);
|
|
44
|
+
else this.queues.set(key, [entry]);
|
|
45
|
+
this.total += 1;
|
|
46
|
+
this.evictIfOverflow();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 取出并移除最多 limit 条,按 key 聚合(保持每个 key 内部的 FIFO 顺序) */
|
|
50
|
+
drainByKey(limit: number): Map<string, LogEntry[]> {
|
|
51
|
+
const drained = new Map<string, LogEntry[]>();
|
|
52
|
+
if (limit <= 0) return drained;
|
|
53
|
+
|
|
54
|
+
let remaining = limit;
|
|
55
|
+
for (const [key, queue] of this.queues) {
|
|
56
|
+
if (remaining <= 0) break;
|
|
57
|
+
const take = queue.splice(0, remaining);
|
|
58
|
+
if (take.length === 0) continue;
|
|
59
|
+
drained.set(key, take);
|
|
60
|
+
this.total -= take.length;
|
|
61
|
+
remaining -= take.length;
|
|
62
|
+
if (queue.length === 0) this.queues.delete(key);
|
|
63
|
+
}
|
|
64
|
+
return drained;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 写失败回填:放回该 key 的**队首**并保持原有顺序(下轮优先重试) */
|
|
68
|
+
pushBack(key: string, entries: LogEntry[]): void {
|
|
69
|
+
if (!entries || entries.length === 0) return;
|
|
70
|
+
const queue = this.queues.get(key);
|
|
71
|
+
if (queue) queue.unshift(...entries);
|
|
72
|
+
else this.queues.set(key, [...entries]);
|
|
73
|
+
this.total += entries.length;
|
|
74
|
+
this.evictIfOverflow();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
clear(): void {
|
|
78
|
+
this.queues.clear();
|
|
79
|
+
this.total = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 缓冲满 → 覆盖最老的一条(比较各队列队首的时间戳) */
|
|
83
|
+
private evictIfOverflow(): void {
|
|
84
|
+
while (this.total > this.capacity) {
|
|
85
|
+
let oldestKey: string | null = null;
|
|
86
|
+
let oldestTime = Infinity;
|
|
87
|
+
for (const [key, queue] of this.queues) {
|
|
88
|
+
const head = queue[0];
|
|
89
|
+
if (!head) continue;
|
|
90
|
+
const t = head.timestamp?.getTime?.() ?? 0;
|
|
91
|
+
if (t < oldestTime) {
|
|
92
|
+
oldestTime = t;
|
|
93
|
+
oldestKey = key;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!oldestKey) {
|
|
97
|
+
// 理论上不可达(total>0 必有非空队列);兜底防死循环
|
|
98
|
+
this.total = 0;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const queue = this.queues.get(oldestKey)!;
|
|
102
|
+
queue.shift();
|
|
103
|
+
this.total -= 1;
|
|
104
|
+
this.dropped += 1;
|
|
105
|
+
if (queue.length === 0) this.queues.delete(oldestKey);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志配置 —— AGENTS.md §6。
|
|
3
|
+
*
|
|
4
|
+
* 说明:`baseDir` 就是普通本地目录,底层是本地磁盘、OSS 挂载还是 NAS 挂载,
|
|
5
|
+
* 组件不感知也不做特殊处理(挂载方式属于部署事项)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
9
|
+
|
|
10
|
+
/** 复用锁库的类型契约(避免 config 反向依赖 lock 模块造成循环) */
|
|
11
|
+
export interface LockLike {
|
|
12
|
+
runDedupe<T>(key: string, fn: () => Promise<T>, options?: { ttlMs?: number }): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 文件粒度:shared = 多机多进程写同一文件;host = 每台机器各写各的 */
|
|
16
|
+
export type FileScope = 'shared' | 'host';
|
|
17
|
+
|
|
18
|
+
export interface LoggerConfig {
|
|
19
|
+
/** 日志根目录,默认 ./logs */
|
|
20
|
+
baseDir: string;
|
|
21
|
+
/** 最低记录级别,默认 info */
|
|
22
|
+
minLevel?: LogLevel;
|
|
23
|
+
/** 环形缓冲容量(条),满则覆盖最老,默认 10000 */
|
|
24
|
+
maxBufferSize?: number;
|
|
25
|
+
/** 节流刷盘间隔(毫秒),默认 3000 */
|
|
26
|
+
flushIntervalMs?: number;
|
|
27
|
+
/** 定时器随机抖动(毫秒),默认 500;避免多进程同相位抢锁 */
|
|
28
|
+
flushJitterMs?: number;
|
|
29
|
+
/** 单批最大条数,默认 100 */
|
|
30
|
+
flushBatchSize?: number;
|
|
31
|
+
/** 单批字节上限,达到即提前刷盘,默认 64KB */
|
|
32
|
+
maxFlushBytes?: number;
|
|
33
|
+
/** ≥ 该级别的日志立即触发刷盘,默认 error */
|
|
34
|
+
immediateLevel?: LogLevel;
|
|
35
|
+
/** 单次 open/write 超时(毫秒),默认 5000;挂载异常可能表现为 hang */
|
|
36
|
+
writeTimeoutMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* 分布式写入锁:**由使用方注入 `@yixinshuke/node-lock` 的 `RedisLock` 实例**。
|
|
39
|
+
* 组件不自建 Redis 连接、不感知 Redis 配置;不传 = 无锁直写(单进程或不介意时使用)。
|
|
40
|
+
*/
|
|
41
|
+
lock?: LockLike | null;
|
|
42
|
+
/** 写入锁 TTL(毫秒),默认 10000;必须大于 writeTimeoutMs */
|
|
43
|
+
lockTtlMs?: number;
|
|
44
|
+
/** 文件粒度,默认 shared */
|
|
45
|
+
fileScope?: FileScope;
|
|
46
|
+
/** 写失败退避基数(毫秒),默认 3000 */
|
|
47
|
+
retryBackoffMs?: number;
|
|
48
|
+
/** 主机名字段值;缺省取 NODE_LOG_HOST / POD_NAME / os.hostname() */
|
|
49
|
+
host?: string;
|
|
50
|
+
/**
|
|
51
|
+
* 单文件尺寸上限(字节);达到即滚动到新文件(`<stem>.1.log` / `<stem>.2.log` …),
|
|
52
|
+
* 默认 1MB(1048576)。超出后旧文件只读保留,由外部滚动删除清理(见 AGENTS.md §8)。
|
|
53
|
+
* 设为 ≤0 关闭分割(单 bizKey 单文件,旧行为)。
|
|
54
|
+
* 注意:滚动基于追加前 stat 当前文件大小 + 本批字节,不依赖 rename(避免网络挂载下 rename 风险)。
|
|
55
|
+
*/
|
|
56
|
+
maxFileSizeBytes?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_CONFIG: Required<LoggerConfig> = {
|
|
60
|
+
baseDir: './logs',
|
|
61
|
+
minLevel: 'info',
|
|
62
|
+
maxBufferSize: 10000,
|
|
63
|
+
flushIntervalMs: 3000,
|
|
64
|
+
flushJitterMs: 500,
|
|
65
|
+
flushBatchSize: 100,
|
|
66
|
+
maxFlushBytes: 65536,
|
|
67
|
+
immediateLevel: 'error',
|
|
68
|
+
writeTimeoutMs: 5000,
|
|
69
|
+
lock: null,
|
|
70
|
+
lockTtlMs: 10000,
|
|
71
|
+
fileScope: 'shared',
|
|
72
|
+
retryBackoffMs: 3000,
|
|
73
|
+
host: '',
|
|
74
|
+
maxFileSizeBytes: 1048576,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** 缓冲容量下限(配置 ≤0 时兜底) */
|
|
78
|
+
const MIN_BUFFER_SIZE = 1000;
|
|
79
|
+
|
|
80
|
+
export function resolveMaxBufferSize(config: Required<LoggerConfig>): number {
|
|
81
|
+
const configured = Number(config.maxBufferSize);
|
|
82
|
+
return Number.isFinite(configured) && configured > 0
|
|
83
|
+
? Math.floor(configured)
|
|
84
|
+
: MIN_BUFFER_SIZE;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function mergeConfig(overrides: LoggerConfig): Required<LoggerConfig> {
|
|
88
|
+
return {
|
|
89
|
+
...DEFAULT_CONFIG,
|
|
90
|
+
...overrides,
|
|
91
|
+
lock: overrides.lock ?? null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 配置校验:**仅 init()/updateConfig() 允许抛错**(启动期暴露配置问题优于运行期静默降级)。
|
|
97
|
+
* 运行期一切写路径都不抛错(AGENTS.md §0.2)。
|
|
98
|
+
*/
|
|
99
|
+
export function validateConfig(config: Required<LoggerConfig>): void {
|
|
100
|
+
if (!config.baseDir || !String(config.baseDir).trim()) {
|
|
101
|
+
throw new Error('[node-log] baseDir 不能为空');
|
|
102
|
+
}
|
|
103
|
+
if (config.flushIntervalMs < 50) {
|
|
104
|
+
throw new Error(`[node-log] flushIntervalMs 不能小于 50(当前 ${config.flushIntervalMs})`);
|
|
105
|
+
}
|
|
106
|
+
if (config.lock && config.lockTtlMs <= config.writeTimeoutMs) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[node-log] lockTtlMs(${config.lockTtlMs}) 必须大于 writeTimeoutMs(${config.writeTimeoutMs}),` +
|
|
109
|
+
`否则锁会先于写入超时失效,可能造成并发追加写`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { GlobalLogger } from './logger.js';
|
|
2
|
+
export type { LogContext, WriteOptions, LoggerStats } from './logger.js';
|
|
3
|
+
|
|
4
|
+
export type { LogLevel, LoggerConfig, LockLike, FileScope } from './config.js';
|
|
5
|
+
export { DEFAULT_CONFIG, mergeConfig, validateConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
// 内部模块(进阶用法 / 测试)
|
|
8
|
+
export { RingBuffer } from './buffer.js';
|
|
9
|
+
export type { LogEntry } from './buffer.js';
|
|
10
|
+
export { FileWriter } from './writer.js';
|
|
11
|
+
export type { FileWriterOptions, WriteStatus } from './writer.js';
|
|
12
|
+
export { beijingTimestamp, beijingDateKey, beijingDateParts } from './time.js';
|
|
13
|
+
export { buildRelPath, resolveBizDate, sanitizeBizKey, sanitizeCategory, sanitizeId, sanitizeSystem } from './path.js';
|
|
14
|
+
export type { PathInput } from './path.js';
|
|
15
|
+
export { createLocker, writeLockKey, LOCK_KEY_PREFIX } from './lock.js';
|
|
16
|
+
export type { WriteLocker } from './lock.js';
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写入互斥(AGENTS.md §5)。
|
|
3
|
+
*
|
|
4
|
+
* **组件不自建 Redis 连接**:初始化时由使用方直接把 `@yixinshuke/node-lock` 的
|
|
5
|
+
* `RedisLock` 实例传进来,组件只负责用它抢锁 —— Redis 怎么连、用什么库,是宿主项目的事。
|
|
6
|
+
*
|
|
7
|
+
* 语义(与「日志绝不阻塞业务」一致):
|
|
8
|
+
* - 抢到锁 → 执行写入,返回 true;
|
|
9
|
+
* - 抢不到(busy,`runDedupe` 返回 false)→ 返回 false,**调用方保留缓冲下轮再写**,绝不排队等待;
|
|
10
|
+
* - 锁不可用(Redis 挂等异常)→ **降级为无锁直写**并告警一次
|
|
11
|
+
* (不写 = 真丢日志,比并发 append 更糟);
|
|
12
|
+
* - 未注入锁实例 → 无锁直写(不告警,属于预期用法)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 最小依赖契约:与 `@yixinshuke/node-lock` 的 `RedisLock` 一致 */
|
|
16
|
+
export interface LockLike {
|
|
17
|
+
runDedupe<T>(key: string, fn: () => Promise<T>, options?: { ttlMs?: number }): Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 写入互斥钩子。
|
|
22
|
+
* @returns true = 已持锁执行;false = 锁繁忙,未执行(调用方应保留缓冲)
|
|
23
|
+
*/
|
|
24
|
+
export type WriteLocker = (rel: string, fn: () => Promise<void>, ttlMs: number) => Promise<boolean>;
|
|
25
|
+
|
|
26
|
+
/** 锁键前缀:node-log:write:<相对路径> */
|
|
27
|
+
export const LOCK_KEY_PREFIX = 'node-log:';
|
|
28
|
+
|
|
29
|
+
export function writeLockKey(rel: string, prefix: string = LOCK_KEY_PREFIX): string {
|
|
30
|
+
return `${prefix}write:${rel}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 是否「锁繁忙」(区别于锁组件不可用) */
|
|
34
|
+
function isBusy(err: any): boolean {
|
|
35
|
+
return err?.kind === 'busy' || /busy/i.test(String(err?.message ?? ''));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 把注入的锁实例包装成写入互斥钩子。
|
|
40
|
+
*
|
|
41
|
+
* @param lock 使用方传入的 node-lock 实例;不传 → 返回 null(无锁直写)
|
|
42
|
+
* @returns WriteLocker;可为 null,调用方据此判定「无互斥」
|
|
43
|
+
*/
|
|
44
|
+
export function createLocker(
|
|
45
|
+
lock: LockLike | null | undefined,
|
|
46
|
+
defaultTtlMs: number,
|
|
47
|
+
onWarn?: (msg: string) => void
|
|
48
|
+
): WriteLocker | null {
|
|
49
|
+
if (!lock || typeof lock.runDedupe !== 'function') return null;
|
|
50
|
+
|
|
51
|
+
return async (rel, fn, ttlMs) => {
|
|
52
|
+
try {
|
|
53
|
+
// runDedupe:抢不到锁立即返回 false,不排队(绝不阻塞业务)
|
|
54
|
+
return await lock.runDedupe(writeLockKey(rel), () => fn(), { ttlMs: ttlMs || defaultTtlMs });
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
if (isBusy(err)) return false; // 理论上不会走到(runDedupe 已吞 busy),兜底
|
|
57
|
+
// 锁组件不可用 ≠ 不写日志:降级为无锁直写并告警一次
|
|
58
|
+
try {
|
|
59
|
+
onWarn?.(
|
|
60
|
+
`[node-log] 锁不可用,已降级为无锁直写(不阻断业务):${err?.message ?? err}`
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
/* 告警本身绝不抛错 */
|
|
64
|
+
}
|
|
65
|
+
await fn();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|