@zhushanwen/pi-extension-logger 0.2.1 → 0.3.0

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/index.ts CHANGED
@@ -1 +1 @@
1
- export { createLogger, getLogger, setPiHandle, type ExtensionLogger, type PiLike } from "./src/index.js";
1
+ export { createLogger, getLogger, setPiHandle, clearRateLimiterState, type ExtensionLogger, type PiLike } from "./src/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-extension-logger",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Shared logging helper for Pi extensions — three-channel routing (AI realtime / audit / debug) (shared library, not a Pi extension - no install.mjs needed)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -17,9 +17,10 @@
17
17
  "index.ts"
18
18
  ],
19
19
  "peerDependencies": {
20
- "@earendil-works/pi-coding-agent": "*"
20
+ "@earendil-works/pi-coding-agent": "^0.84.1"
21
21
  },
22
22
  "devDependencies": {
23
+ "@vitest/coverage-v8": "^4.1.9",
23
24
  "vitest": "^4.1.8"
24
25
  },
25
26
  "scripts": {
@@ -7,6 +7,7 @@ import {
7
7
  createLogger,
8
8
  getLogger,
9
9
  setPiHandle,
10
+ clearRateLimiterState,
10
11
  type PiLike,
11
12
  } from "../index.js";
12
13
 
@@ -23,6 +24,7 @@ describe("extension-logger", () => {
23
24
 
24
25
  afterEach(() => {
25
26
  setPiHandle(undefined);
27
+ clearRateLimiterState();
26
28
  vi.restoreAllMocks();
27
29
  // 还原环境变量,避免文件日志测试的 XYZ_AGENT_DEBUG/PI_CODING_AGENT_DIR 泄漏到其它用例
28
30
  process.env = { ...prevEnv };
@@ -269,4 +271,161 @@ describe("extension-logger", () => {
269
271
  rmSync(tmpAgentDir, { recursive: true, force: true });
270
272
  });
271
273
  });
274
+
275
+ // ============================================================
276
+ // Per-message 固定窗口限流(P3 防线)
277
+ // ============================================================
278
+ describe("per-message 固定窗口限流", () => {
279
+ beforeEach(() => {
280
+ vi.useFakeTimers();
281
+ });
282
+
283
+ afterEach(() => {
284
+ vi.useRealTimers();
285
+ });
286
+
287
+ it("前 10 条同 msg warn 直写 appendEntry", () => {
288
+ const logger = createLogger("ratelimit", pi);
289
+ for (let i = 0; i < 10; i++) {
290
+ logger.warn("hot path warning");
291
+ }
292
+ expect(appendSpy).toHaveBeenCalledTimes(10);
293
+ });
294
+
295
+ it("第 11-100 条同 msg warn 被抑制(计数仍 10)", () => {
296
+ const logger = createLogger("ratelimit", pi);
297
+ for (let i = 0; i < 100; i++) {
298
+ logger.warn("hot path warning");
299
+ }
300
+ expect(appendSpy).toHaveBeenCalledTimes(10);
301
+ });
302
+
303
+ it("fake timers 推进 61s 后下一条触发聚合摘要 + 本条直写", () => {
304
+ const logger = createLogger("ratelimit", pi);
305
+ // 先发 100 条(10 条直写 + 90 条抑制)
306
+ for (let i = 0; i < 100; i++) {
307
+ logger.warn("hot path warning");
308
+ }
309
+ expect(appendSpy).toHaveBeenCalledTimes(10);
310
+
311
+ // 推进 61s——窗口过期
312
+ vi.advanceTimersByTime(61_000);
313
+
314
+ // 下一条:先写聚合摘要(+90 suppressed),再写本条
315
+ logger.warn("hot path warning");
316
+ expect(appendSpy).toHaveBeenCalledTimes(12);
317
+
318
+ // 验证聚合摘要 entry 内容
319
+ const summaryCall = appendSpy.mock.calls[10]!;
320
+ const summaryData = summaryCall[1] as { message: string };
321
+ expect(summaryData.message).toContain("[+90 suppressed in last 60s]");
322
+
323
+ // 验证本条 entry 是正常 warn
324
+ const currentCall = appendSpy.mock.calls[11]!;
325
+ const currentData = currentCall[1] as { message: string; level: string };
326
+ expect(currentData.message).toBe("[ratelimit] hot path warning");
327
+ expect(currentData.level).toBe("warn");
328
+ });
329
+
330
+ it("不同 msg 独立计数互不影响", () => {
331
+ const logger = createLogger("ratelimit", pi);
332
+ for (let i = 0; i < 15; i++) {
333
+ logger.warn("msg-a");
334
+ }
335
+ for (let i = 0; i < 15; i++) {
336
+ logger.warn("msg-b");
337
+ }
338
+ // 各自前 10 条直写 = 20
339
+ expect(appendSpy).toHaveBeenCalledTimes(20);
340
+
341
+ // 推进 61s 后,各触发 1 条聚合摘要 + 1 条本条 = 再加 4 条
342
+ vi.advanceTimersByTime(61_000);
343
+ logger.warn("msg-a");
344
+ logger.warn("msg-b");
345
+ expect(appendSpy).toHaveBeenCalledTimes(24);
346
+ });
347
+
348
+ it("Map cap 512 超限清空——所有 key 窗口重置", () => {
349
+ const logger = createLogger("ratelimit", pi);
350
+ // 填充 512 个不同 key(各发 1 条触发窗口创建)
351
+ for (let i = 0; i < 512; i++) {
352
+ logger.warn(`msg-${i}`);
353
+ }
354
+ expect(appendSpy).toHaveBeenCalledTimes(512);
355
+
356
+ // 再发 1 条触发 cap 清空——此条也打开新窗口(count=1)
357
+ logger.warn("msg-after-cap");
358
+ expect(appendSpy).toHaveBeenCalledTimes(513);
359
+
360
+ appendSpy.mockClear();
361
+ // 推进时间让上面窗口过期,新窗口可直写 10 条
362
+ vi.advanceTimersByTime(61_000);
363
+ for (let i = 0; i < 10; i++) {
364
+ logger.warn("msg-after-cap");
365
+ }
366
+ // 第一条过期后 suppressed=0 → "allow" + count=1,后 9 条 count→10 全 allow
367
+ expect(appendSpy).toHaveBeenCalledTimes(10);
368
+ });
369
+
370
+ it("fileLog 通道不受限(XYZ_AGENT_DEBUG=1 时 100 条全写文件)", () => {
371
+ process.env.XYZ_AGENT_DEBUG = "1";
372
+ const tmpAgentDir = mkdtempSync(join(tmpdir(), "pi-ext-ratelimit-"));
373
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
374
+ vi.setSystemTime(new Date("2026-08-01T12:34:56.789Z"));
375
+
376
+ const logger = createLogger("rl-file", pi);
377
+ for (let i = 0; i < 100; i++) {
378
+ logger.warn("hot path warning");
379
+ }
380
+
381
+ // appendEntry 只被调用 10 次(限流生效)
382
+ expect(appendSpy).toHaveBeenCalledTimes(10);
383
+
384
+ // 但文件日志包含全部 100 条(fileLog 不受限流)
385
+ const logFile = join(tmpAgentDir, "logs", "rl-file-2026-08-01.log");
386
+ expect(existsSync(logFile)).toBe(true);
387
+ const content = readFileSync(logFile, "utf8");
388
+ const lines = content.split("\n").filter(Boolean);
389
+ expect(lines).toHaveLength(100);
390
+
391
+ rmSync(tmpAgentDir, { recursive: true, force: true });
392
+ });
393
+
394
+ it("appendEntry 抛错时降级不 throw(限流计数正常)", () => {
395
+ const throwingPi: PiLike = {
396
+ appendEntry: () => {
397
+ throw new Error("session disposed");
398
+ },
399
+ };
400
+ const logger = createLogger("rl-throw", throwingPi);
401
+
402
+ // 10 条同 msg——appendEntry 每次抛但不 throw
403
+ for (let i = 0; i < 10; i++) {
404
+ expect(() => logger.warn("disposable")).not.toThrow();
405
+ }
406
+ // appendEntry 被调了 10 次(每条都 try 了)
407
+ // 推进窗口过期,聚合摘要 + 本条 = 再调 2 次
408
+ vi.advanceTimersByTime(61_000);
409
+ expect(() => logger.warn("disposable")).not.toThrow();
410
+ // appendEntry 异常时限流计数仍正常(不 throw、后续状态机不被破坏)
411
+ });
412
+
413
+ it("error 与 warn 同参数限流(一套机制)", () => {
414
+ const logger = createLogger("ratelimit", pi);
415
+ for (let i = 0; i < 15; i++) {
416
+ logger.error("error path");
417
+ }
418
+ // error 前 10 条直写,第 11-15 条抑制
419
+ expect(appendSpy).toHaveBeenCalledTimes(10);
420
+
421
+ // 推进 61s 后聚合摘要 + 本条
422
+ vi.advanceTimersByTime(61_000);
423
+ logger.error("error path");
424
+ expect(appendSpy).toHaveBeenCalledTimes(12);
425
+
426
+ const summaryCall = appendSpy.mock.calls[10]!;
427
+ const summaryData = summaryCall[1] as { message: string };
428
+ expect(summaryData.message).toContain("[+5 suppressed in last 60s]");
429
+ });
430
+ });
272
431
  });
package/src/index.ts CHANGED
@@ -19,6 +19,117 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
19
  import { appendFileSync, mkdirSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
 
22
+ // ============================================================
23
+ // Per-message 固定窗口限流(P3 防线)
24
+ //
25
+ // 语义:同一个 (extName, level, msg) 三元组在 60s 窗口内,前 10 条 warn/error
26
+ // 直写 pi.appendEntry(session JSONL),第 11 条起抑制并在内存计数。窗口
27
+ // 过期后下一条到来时,先写 1 条聚合摘要("...[+M suppressed in last 60s]"),
28
+ // 再正常写本条并开新窗口。纯惰性实现——无 timer,全部状态在调用时检查
29
+ // (Date.now() 判断窗口是否过期)。
30
+ //
31
+ // 已知限制:
32
+ // - key = msg 原文(不包含 data 参数)。若调用方把动态 id 拼进 msg
33
+ // (如 `session=${id}`),每条 msg 不同则限流不命中。根治靠调用方把
34
+ // 动态值放 data 参数(D4 已声明)。
35
+ // - fileLog 通道全量不限流(XYZ_AGENT_DEBUG=1 时所有日志写文件)。
36
+ // - Map cap 512 超限时全量清空(简化策略,对齐 cap-1024 先例),
37
+ // 等价于所有 key 窗口重置,防无界增长。
38
+ //
39
+ // 设计依据:docs/todo/extension-log-cleanup-design.md §3.4 D4
40
+ // ============================================================
41
+
42
+ /** 同 key 每窗口允许直写 appendEntry 的最大条数。 */
43
+ const RATE_LIMIT_MAX = 10;
44
+ /** 固定窗口时长(ms)。 */
45
+ const RATE_LIMIT_WINDOW_MS = 60_000;
46
+ /** 限流状态 Map 的容量上限——超限全量清空(对齐 cap-1024 先例的简化策略)。 */
47
+ const RATE_LIMIT_STATE_CAP = 512;
48
+
49
+ interface RateLimiterEntry {
50
+ /** 当前窗口起始时间戳(ms)。 */
51
+ windowStart: number;
52
+ /** 当前窗口内已直写 appendEntry 的条数。 */
53
+ count: number;
54
+ /** 当前窗口内被抑制的条数(窗口过期后用于聚合摘要)。 */
55
+ suppressed: number;
56
+ }
57
+
58
+ /**
59
+ * per-msg 限流状态。
60
+ *
61
+ * key = `${extName}:${level}:${msg}`(msg 为原 msg,非 prefixed)。
62
+ * 同进程所有 logger 实例共享(模块级 singleton)。
63
+ *
64
+ * ⚠️ 生命周期与进程一致——xyz-agent 每 session 一个独立 pi 进程
65
+ * (process-manager.ts L142-143),故不存在跨 session 残留问题。
66
+ */
67
+ const rateLimiterState = new Map<string, RateLimiterEntry>();
68
+
69
+ /**
70
+ * 检查并更新 per-msg 限流状态。返回值:
71
+ * - "allow": 直写 appendEntry
72
+ * - "suppress": 抑制(不写 appendEntry,内存计数)
73
+ * - { emitSummary: M }: 窗口过期后首条——先写聚合摘要(M = 被抑制数),再写本条
74
+ *
75
+ * 纯惰性实现:无 timer,窗口过期判断在调用时通过 Date.now() 计算。
76
+ */
77
+ function checkRateLimiter(
78
+ key: string,
79
+ ): "allow" | "suppress" | { emitSummary: number } {
80
+ // 防无界增长:超限清空(等价所有 key 窗口重置)
81
+ if (rateLimiterState.size >= RATE_LIMIT_STATE_CAP) {
82
+ rateLimiterState.clear();
83
+ }
84
+
85
+ const now = Date.now();
86
+ const entry = rateLimiterState.get(key);
87
+
88
+ if (!entry) {
89
+ // 首次见到该 key:开新窗口,count=1(本条是第 1 条)
90
+ rateLimiterState.set(key, {
91
+ windowStart: now,
92
+ count: 1,
93
+ suppressed: 0,
94
+ });
95
+ return "allow";
96
+ }
97
+
98
+ const elapsed = now - entry.windowStart;
99
+
100
+ if (elapsed >= RATE_LIMIT_WINDOW_MS) {
101
+ // 窗口过期:本条触发新窗口。若有被抑制数,先返回聚合摘要。
102
+ const suppressed = entry.suppressed;
103
+ // 开新窗口(count=1 计入本条)
104
+ rateLimiterState.set(key, {
105
+ windowStart: now,
106
+ count: 1,
107
+ suppressed: 0,
108
+ });
109
+ if (suppressed > 0) {
110
+ return { emitSummary: suppressed };
111
+ }
112
+ return "allow";
113
+ }
114
+
115
+ // 窗口内
116
+ if (entry.count < RATE_LIMIT_MAX) {
117
+ entry.count++;
118
+ return "allow";
119
+ }
120
+
121
+ // 已满额:抑制,计数
122
+ entry.suppressed++;
123
+ return "suppress";
124
+ }
125
+
126
+ /**
127
+ * 清空限流状态(测试用导出,生产代码不调用)。
128
+ */
129
+ export function clearRateLimiterState(): void {
130
+ rateLimiterState.clear();
131
+ }
132
+
22
133
  /**
23
134
  * Pi ExtensionAPI 的最小子集——仅 appendEntry(持久化审计通道)。
24
135
  *
@@ -110,34 +221,74 @@ export function createLogger(extName: string, pi?: PiLike): ExtensionLogger {
110
221
  warn(msg: string, data?: unknown): void {
111
222
  const piResolved = resolvePi();
112
223
  const prefixed = prefixMsg(extName, msg);
113
- // appendEntry 不进 LLM 上下文(session-manager.js: custom entry 不参与 context
224
+ // appendEntry 通道:per-msg 固定窗口限流(debug 不限流——只走 fileLog
225
+ const rateLimitKey = `${extName}:warn:${msg}`;
226
+ const rateLimitResult = checkRateLimiter(rateLimitKey);
114
227
  try {
115
- piResolved?.appendEntry?.(`${extName}:log`, {
116
- timestamp: Date.now(),
117
- level: "warn",
118
- message: prefixed,
119
- data,
120
- });
228
+ if (rateLimitResult === "allow") {
229
+ piResolved?.appendEntry?.(`${extName}:log`, {
230
+ timestamp: Date.now(),
231
+ level: "warn",
232
+ message: prefixed,
233
+ data,
234
+ });
235
+ } else if (typeof rateLimitResult === "object") {
236
+ // 窗口过期后首条:先写聚合摘要
237
+ piResolved?.appendEntry?.(`${extName}:log`, {
238
+ timestamp: Date.now(),
239
+ level: "warn",
240
+ message: `${prefixed} ... [+${rateLimitResult.emitSummary} suppressed in last 60s]`,
241
+ });
242
+ // 再写本条
243
+ piResolved?.appendEntry?.(`${extName}:log`, {
244
+ timestamp: Date.now(),
245
+ level: "warn",
246
+ message: prefixed,
247
+ data,
248
+ });
249
+ }
250
+ // else: "suppress" — 不写 appendEntry
121
251
  } catch (appendErr) {
122
252
  // appendEntry 失败(session 已 disposed 等)→ 降级文件日志(下方 fileLog 兜底),不 throw
123
253
  void appendErr;
124
254
  }
255
+ // fileLog 全量不限流(XYZ_AGENT_DEBUG=1 排障时可见全部)
125
256
  fileLog(extName, "warn", prefixed, data);
126
257
  },
127
258
  error(msg: string, data?: unknown): void {
128
259
  const piResolved = resolvePi();
129
260
  const prefixed = prefixMsg(extName, msg);
261
+ // appendEntry 通道:per-msg 固定窗口限流(与 warn 同参数,一套机制)
262
+ const rateLimitKey = `${extName}:error:${msg}`;
263
+ const rateLimitResult = checkRateLimiter(rateLimitKey);
130
264
  try {
131
- piResolved?.appendEntry?.(`${extName}:log`, {
132
- timestamp: Date.now(),
133
- level: "error",
134
- message: prefixed,
135
- data,
136
- });
265
+ if (rateLimitResult === "allow") {
266
+ piResolved?.appendEntry?.(`${extName}:log`, {
267
+ timestamp: Date.now(),
268
+ level: "error",
269
+ message: prefixed,
270
+ data,
271
+ });
272
+ } else if (typeof rateLimitResult === "object") {
273
+ // 窗口过期后首条:先写聚合摘要
274
+ piResolved?.appendEntry?.(`${extName}:log`, {
275
+ timestamp: Date.now(),
276
+ level: "error",
277
+ message: `${prefixed} ... [+${rateLimitResult.emitSummary} suppressed in last 60s]`,
278
+ });
279
+ // 再写本条
280
+ piResolved?.appendEntry?.(`${extName}:log`, {
281
+ timestamp: Date.now(),
282
+ level: "error",
283
+ message: prefixed,
284
+ data,
285
+ });
286
+ }
137
287
  } catch (appendErr) {
138
288
  // 同 warn:appendEntry 失败降级文件日志,不 throw
139
289
  void appendErr;
140
290
  }
291
+ // fileLog 全量不限流
141
292
  fileLog(extName, "error", prefixed, data);
142
293
  },
143
294
  };