@lensmcp/node-instrumentation 1.16.30 → 1.16.33

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.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- export { lensEmit, isLensWriting, PROJECT, EVENT_FILE } from './lib/emit';
1
+ export { lensEmit, isLensWriting, PROJECT, EVENT_FILE, EVENT_MAX_BYTES, EVENT_MAX_FILES, maybeRotateEventFile } from './lib/emit';
2
2
  export type { LensSeverity } from './lib/emit';
3
+ export { createEventFloodGuard } from './lib/flood-guard';
4
+ export type { FloodGuard, FloodGuardOptions, FloodAdmit } from './lib/flood-guard';
3
5
  export { installRequireHook } from './lib/require-hook';
4
6
  export type { ModuleTap } from './lib/require-hook';
5
7
  export { tapEgress } from './lib/tap-egress';
package/index.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC1E,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAChE,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,iCAAiC,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAClI,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,YAAY,EAAE,UAAU,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACnF,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAChE,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,iCAAiC,EAAE,MAAM,eAAe,CAAC"}
package/index.js CHANGED
@@ -1,11 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.installLensmcpNodeInstrumentation = exports.installChildAppBridge = exports.nestTap = exports.bullmqTap = exports.redisTap = exports.pgTap = exports.tapExec = exports.tapFs = exports.tapEgress = exports.installRequireHook = exports.EVENT_FILE = exports.PROJECT = exports.isLensWriting = exports.lensEmit = void 0;
3
+ exports.installLensmcpNodeInstrumentation = exports.installChildAppBridge = exports.nestTap = exports.bullmqTap = exports.redisTap = exports.pgTap = exports.tapExec = exports.tapFs = exports.tapEgress = exports.installRequireHook = exports.createEventFloodGuard = exports.maybeRotateEventFile = exports.EVENT_MAX_FILES = exports.EVENT_MAX_BYTES = exports.EVENT_FILE = exports.PROJECT = exports.isLensWriting = exports.lensEmit = void 0;
4
4
  var emit_1 = require("./lib/emit");
5
5
  Object.defineProperty(exports, "lensEmit", { enumerable: true, get: function () { return emit_1.lensEmit; } });
6
6
  Object.defineProperty(exports, "isLensWriting", { enumerable: true, get: function () { return emit_1.isLensWriting; } });
7
7
  Object.defineProperty(exports, "PROJECT", { enumerable: true, get: function () { return emit_1.PROJECT; } });
8
8
  Object.defineProperty(exports, "EVENT_FILE", { enumerable: true, get: function () { return emit_1.EVENT_FILE; } });
9
+ Object.defineProperty(exports, "EVENT_MAX_BYTES", { enumerable: true, get: function () { return emit_1.EVENT_MAX_BYTES; } });
10
+ Object.defineProperty(exports, "EVENT_MAX_FILES", { enumerable: true, get: function () { return emit_1.EVENT_MAX_FILES; } });
11
+ Object.defineProperty(exports, "maybeRotateEventFile", { enumerable: true, get: function () { return emit_1.maybeRotateEventFile; } });
12
+ var flood_guard_1 = require("./lib/flood-guard");
13
+ Object.defineProperty(exports, "createEventFloodGuard", { enumerable: true, get: function () { return flood_guard_1.createEventFloodGuard; } });
9
14
  var require_hook_1 = require("./lib/require-hook");
10
15
  Object.defineProperty(exports, "installRequireHook", { enumerable: true, get: function () { return require_hook_1.installRequireHook; } });
11
16
  var tap_egress_1 = require("./lib/tap-egress");
package/lib/emit.d.ts CHANGED
@@ -1,3 +1,26 @@
1
+ /** Per-generation cap for the events.jsonl bus (a long-running dev session hit 9 GB in the wild, and
2
+ * a 254 MB file OOM-crash-looped every ingesting consumer in the 2026-07-28 flood). Override with
3
+ * `LENSMCP_EVENT_MAX_BYTES` (default 50 MB). 0/invalid ⇒ the default. */
4
+ export declare const EVENT_MAX_BYTES: number;
5
+ /** Rotated generations kept as history (`events.jsonl.1` … `.N`, newest → oldest). With the live
6
+ * file that bounds a workspace to (N+1) × EVENT_MAX_BYTES (~300 MB at defaults). Override with
7
+ * `LENSMCP_EVENT_MAX_FILES`. */
8
+ export declare const EVENT_MAX_FILES: number;
9
+ /**
10
+ * Round-robin rotation for a bus file: when the live file exceeds the per-generation cap, drop the
11
+ * oldest generation, shift `.1` → `.2` → … → `.N`, and rename the live file to `.1` (the next append
12
+ * recreates it fresh). Throttled by a per-file byte counter so we `statSync` at most ~once per MB,
13
+ * not per event. Best-effort + fs-tap-safe (pristine refs, callers hold `writing`). Many processes
14
+ * append to the same path — the final `renameSync` is atomic, so a concurrent rotate that loses the
15
+ * race just throws ENOENT and no-ops (a racing shift can at worst mis-order HISTORY generations,
16
+ * never touch the live file). Exported so every OTHER writer to the shared bus (the cluster
17
+ * gateway's observability emit) enforces the same contract — the gateway was the one writer with NO
18
+ * rotation, which is how foodguard's bus outgrew every consumer. `caps` exists for tests.
19
+ */
20
+ export declare function maybeRotateEventFile(file: string, addedBytes: number, caps?: {
21
+ maxBytes?: number;
22
+ maxFiles?: number;
23
+ }): void;
1
24
  export type LensSeverity = 'info' | 'error';
2
25
  export type LogSeverity = 'debug' | 'info' | 'warning' | 'error' | 'fatal';
3
26
  /** Service identity stamped on every event (set by the cluster serve executor). */
package/lib/emit.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../../src/lib/emit.ts"],"names":[],"mappings":"AAiDA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,OAAO,CAAC;AAC5C,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAE3E,mFAAmF;AACnF,eAAO,MAAM,OAAO,EAAE,MACkD,CAAC;AAEzE,yEAAyE;AACzE,eAAO,MAAM,UAAU,EAAE,MACsD,CAAC;AAKhF,8EAA8E;AAC9E,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,kFAAkF;AAClF,MAAM,WAAW,OAAO;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,QAAQ,CACtB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,WAAW,CAAC,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE,OAAO,GAChB,IAAI,CAiCN;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CA2BlE"}
1
+ {"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../../src/lib/emit.ts"],"names":[],"mappings":"AAwBA;;0EAE0E;AAC1E,eAAO,MAAM,eAAe,QAGxB,CAAC;AACL;;iCAEiC;AACjC,eAAO,MAAM,eAAe,QAGxB,CAAC;AAIL;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9C,IAAI,CA+BN;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,OAAO,CAAC;AAC5C,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAE3E,mFAAmF;AACnF,eAAO,MAAM,OAAO,EAAE,MACkD,CAAC;AAEzE,yEAAyE;AACzE,eAAO,MAAM,UAAU,EAAE,MACsD,CAAC;AAYhF,8EAA8E;AAC9E,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,kFAAkF;AAClF,MAAM,WAAW,OAAO;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,QAAQ,CACtB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,WAAW,CAAC,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE,OAAO,GAChB,IAAI,CAoCN;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CA6BlE"}
package/lib/emit.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EVENT_FILE = exports.PROJECT = void 0;
3
+ exports.EVENT_FILE = exports.PROJECT = exports.EVENT_MAX_FILES = exports.EVENT_MAX_BYTES = void 0;
4
+ exports.maybeRotateEventFile = maybeRotateEventFile;
4
5
  exports.isLensWriting = isLensWriting;
5
6
  exports.lensEmit = lensEmit;
6
7
  exports.lensLog = lensLog;
@@ -20,37 +21,75 @@ const tslib_1 = require("tslib");
20
21
  */
21
22
  const nodeFs = tslib_1.__importStar(require("node:fs"));
22
23
  const node_path_1 = require("node:path");
24
+ const flood_guard_1 = require("./flood-guard");
23
25
  // Pristine references, frozen at load — immune to the fs tap's later patching.
24
26
  const appendFileSync = nodeFs.appendFileSync;
25
27
  const mkdirSync = nodeFs.mkdirSync;
26
28
  const statSync = nodeFs.statSync;
27
29
  const renameSync = nodeFs.renameSync;
28
- /** Cap the events.jsonl so a long-running dev session can't grow it to gigabytes (it hit 9 GB in the
29
- * wild). Override with `LENSMCP_EVENT_MAX_BYTES` (default 256 MB). 0/invalid ⇒ the default. */
30
- const MAX_EVENT_BYTES = (() => {
30
+ const unlinkSync = nodeFs.unlinkSync;
31
+ /** Per-generation cap for the events.jsonl bus (a long-running dev session hit 9 GB in the wild, and
32
+ * a 254 MB file OOM-crash-looped every ingesting consumer in the 2026-07-28 flood). Override with
33
+ * `LENSMCP_EVENT_MAX_BYTES` (default 50 MB). 0/invalid ⇒ the default. */
34
+ exports.EVENT_MAX_BYTES = (() => {
31
35
  const n = Number(process.env['LENSMCP_EVENT_MAX_BYTES']);
32
- return Number.isFinite(n) && n > 0 ? n : 256 * 1024 * 1024;
36
+ return Number.isFinite(n) && n > 0 ? n : 50 * 1024 * 1024;
37
+ })();
38
+ /** Rotated generations kept as history (`events.jsonl.1` … `.N`, newest → oldest). With the live
39
+ * file that bounds a workspace to (N+1) × EVENT_MAX_BYTES (~300 MB at defaults). Override with
40
+ * `LENSMCP_EVENT_MAX_FILES`. */
41
+ exports.EVENT_MAX_FILES = (() => {
42
+ const n = Number(process.env['LENSMCP_EVENT_MAX_FILES']);
43
+ return Number.isFinite(n) && n >= 1 && n <= 20 ? Math.floor(n) : 5;
33
44
  })();
34
45
  const ROTATE_CHECK_BYTES = 1024 * 1024; // statSync at most ~once per MB appended (cheap amortized)
35
- let bytesSinceCheck = 0;
46
+ const bytesSinceCheck = new Map();
36
47
  /**
37
- * Rotate the bus when it exceeds {@link MAX_EVENT_BYTES}: rename `events.jsonl` → `events.jsonl.1`
38
- * (keeping ONE previous generation; the next append recreates a fresh file). Throttled by a byte
39
- * counter so we `statSync` at most ~once per MB, not per event. Best-effort + fs-tap-safe (pristine
40
- * refs, called while `writing` is already true). Many processes append to the same path — `renameSync`
41
- * is atomic, so a concurrent rotate that loses the race just throws ENOENT and no-ops.
48
+ * Round-robin rotation for a bus file: when the live file exceeds the per-generation cap, drop the
49
+ * oldest generation, shift `.1` → `.2` → … → `.N`, and rename the live file to `.1` (the next append
50
+ * recreates it fresh). Throttled by a per-file byte counter so we `statSync` at most ~once per MB,
51
+ * not per event. Best-effort + fs-tap-safe (pristine refs, callers hold `writing`). Many processes
52
+ * append to the same path — the final `renameSync` is atomic, so a concurrent rotate that loses the
53
+ * race just throws ENOENT and no-ops (a racing shift can at worst mis-order HISTORY generations,
54
+ * never touch the live file). Exported so every OTHER writer to the shared bus (the cluster
55
+ * gateway's observability emit) enforces the same contract — the gateway was the one writer with NO
56
+ * rotation, which is how foodguard's bus outgrew every consumer. `caps` exists for tests.
42
57
  */
43
- function rotateIfLarge(lineBytes) {
44
- bytesSinceCheck += lineBytes;
45
- if (bytesSinceCheck < ROTATE_CHECK_BYTES)
58
+ function maybeRotateEventFile(file, addedBytes, caps) {
59
+ const sofar = (bytesSinceCheck.get(file) ?? 0) + addedBytes;
60
+ if (sofar < ROTATE_CHECK_BYTES && caps === undefined) {
61
+ bytesSinceCheck.set(file, sofar);
46
62
  return;
47
- bytesSinceCheck = 0;
63
+ }
64
+ bytesSinceCheck.set(file, 0);
65
+ const maxBytes = caps?.maxBytes ?? exports.EVENT_MAX_BYTES;
66
+ const maxFiles = caps?.maxFiles ?? exports.EVENT_MAX_FILES;
67
+ try {
68
+ if (statSync(file).size <= maxBytes)
69
+ return;
70
+ }
71
+ catch {
72
+ return; /* missing / already rotated by another writer — fine */
73
+ }
74
+ try {
75
+ unlinkSync(`${file}.${maxFiles}`);
76
+ }
77
+ catch {
78
+ /* no oldest generation to drop */
79
+ }
80
+ for (let gen = maxFiles - 1; gen >= 1; gen--) {
81
+ try {
82
+ renameSync(`${file}.${gen}`, `${file}.${gen + 1}`);
83
+ }
84
+ catch {
85
+ /* that generation doesn't exist (yet) */
86
+ }
87
+ }
48
88
  try {
49
- if (statSync(exports.EVENT_FILE).size > MAX_EVENT_BYTES)
50
- renameSync(exports.EVENT_FILE, `${exports.EVENT_FILE}.1`);
89
+ renameSync(file, `${file}.1`);
51
90
  }
52
91
  catch {
53
- /* missing / already rotated by another writer — fine */
92
+ /* lost the rotate race to a concurrent writer — its cascade already ran */
54
93
  }
55
94
  }
56
95
  /** Service identity stamped on every event (set by the cluster serve executor). */
@@ -59,6 +98,12 @@ exports.PROJECT = process.env['LENSMCP_PROJECT'] || process.env['SERVICE_NAME']
59
98
  exports.EVENT_FILE = process.env['LENSMCP_EVENT_FILE'] || `${process.cwd()}/.lensmcp/events.jsonl`;
60
99
  let writing = false;
61
100
  let dirReady = false;
101
+ // Flood guards: one hot fingerprint (a query fired in a loop, a component rendering per frame) keeps
102
+ // its first burst + a trickle with `floodSuppressed` counts, instead of flooding the bus file. Logs get
103
+ // a laxer bucket — boot storms of a few hundred lines are normal; sustained >20 lines/s is log-spam the
104
+ // bus shouldn't mirror unboundedly.
105
+ const emitGuard = (0, flood_guard_1.createEventFloodGuard)({ burst: 30, perSec: 5 });
106
+ const logGuard = (0, flood_guard_1.createEventFloodGuard)({ burst: 120, perSec: 20 });
62
107
  /** True while the lens itself is appending — the fs tap skips self-writes. */
63
108
  function isLensWriting() {
64
109
  return writing;
@@ -66,6 +111,10 @@ function isLensWriting() {
66
111
  function lensEmit(source, severity, title, raw, fingerprint, flowCtx) {
67
112
  try {
68
113
  writing = true;
114
+ const fp = fingerprint ?? `${source}:${String(raw['name'] ?? source)}`;
115
+ const admit = emitGuard.admit(fp);
116
+ if (!admit.ok)
117
+ return; // flood-guarded — the next admitted event carries the suppressed count
69
118
  if (!dirReady) {
70
119
  mkdirSync((0, node_path_1.dirname)(exports.EVENT_FILE), { recursive: true });
71
120
  dirReady = true;
@@ -84,11 +133,11 @@ function lensEmit(source, severity, title, raw, fingerprint, flowCtx) {
84
133
  ...(flowCtx?.requestId ? { requestId: flowCtx.requestId } : {}),
85
134
  ...(flowCtx?.originNodeId ? { originNodeId: flowCtx.originNodeId } : {}),
86
135
  },
87
- fingerprint: fingerprint ?? `${source}:${String(raw['name'] ?? source)}`,
136
+ fingerprint: fp,
88
137
  title,
89
- raw: { project: exports.PROJECT, ...raw },
138
+ raw: { project: exports.PROJECT, ...raw, ...(admit.suppressed > 0 ? { floodSuppressed: admit.suppressed } : {}) },
90
139
  }) + '\n';
91
- rotateIfLarge(line.length);
140
+ maybeRotateEventFile(exports.EVENT_FILE, line.length);
92
141
  appendFileSync(exports.EVENT_FILE, line);
93
142
  }
94
143
  catch {
@@ -107,6 +156,9 @@ function lensEmit(source, severity, title, raw, fingerprint, flowCtx) {
107
156
  function lensLog(severity, title) {
108
157
  try {
109
158
  writing = true;
159
+ const admit = logGuard.admit(`log:${exports.PROJECT}`);
160
+ if (!admit.ok)
161
+ return; // log-spam guard — the next admitted line carries the suppressed count
110
162
  if (!dirReady) {
111
163
  mkdirSync((0, node_path_1.dirname)(exports.EVENT_FILE), { recursive: true });
112
164
  dirReady = true;
@@ -121,9 +173,9 @@ function lensLog(severity, title) {
121
173
  context: { sessionId: 'pending', projectName: exports.PROJECT },
122
174
  fingerprint: `log:${exports.PROJECT}`,
123
175
  title,
124
- raw: { project: exports.PROJECT, kind: 'log' },
176
+ raw: { project: exports.PROJECT, kind: 'log', ...(admit.suppressed > 0 ? { floodSuppressed: admit.suppressed } : {}) },
125
177
  }) + '\n';
126
- rotateIfLarge(line.length);
178
+ maybeRotateEventFile(exports.EVENT_FILE, line.length);
127
179
  appendFileSync(exports.EVENT_FILE, line);
128
180
  }
129
181
  catch {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Per-fingerprint event flood guard — a token bucket per fingerprint, shared by
3
+ * every bus WRITER (lensEmit, the cluster gateway's observability emit, the
4
+ * bridge env sink mirrors it). One hot fingerprint (an app retrying a fetch in
5
+ * a loop, a valtio store churning per frame) must not be able to write hundreds
6
+ * of lines/sec into events.jsonl: the 2026-07-28 incident sustained ~680 ev/s,
7
+ * grew the bus file to 254 MB in two hours, and OOM-crash-looped every consumer
8
+ * that ingests it. The guard keeps the SIGNAL (the first burst, then a trickle
9
+ * with a `floodSuppressed` count) while dropping the redundant repetition.
10
+ */
11
+ export interface FloodAdmit {
12
+ ok: boolean;
13
+ /** Events dropped for this fingerprint since the last admitted one (0 normally). */
14
+ suppressed: number;
15
+ }
16
+ export interface FloodGuard {
17
+ admit(fingerprint: string): FloodAdmit;
18
+ }
19
+ export interface FloodGuardOptions {
20
+ /** Instantaneous burst allowance per fingerprint (bucket capacity). Default 30. */
21
+ burst?: number;
22
+ /** Sustained events/sec per fingerprint (bucket refill). Default 5. */
23
+ perSec?: number;
24
+ /** Max distinct fingerprints tracked before stale buckets are evicted. Default 2048. */
25
+ maxKeys?: number;
26
+ }
27
+ export declare function createEventFloodGuard(opts?: FloodGuardOptions): FloodGuard;
28
+ //# sourceMappingURL=flood-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flood-guard.d.ts","sourceRoot":"","sources":["../../src/lib/flood-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,oFAAoF;IACpF,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC;CACxC;AAED,MAAM,WAAW,iBAAiB;IAChC,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAmBD,wBAAgB,qBAAqB,CAAC,IAAI,CAAC,EAAE,iBAAiB,GAAG,UAAU,CA0C1E"}
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ /**
3
+ * Per-fingerprint event flood guard — a token bucket per fingerprint, shared by
4
+ * every bus WRITER (lensEmit, the cluster gateway's observability emit, the
5
+ * bridge env sink mirrors it). One hot fingerprint (an app retrying a fetch in
6
+ * a loop, a valtio store churning per frame) must not be able to write hundreds
7
+ * of lines/sec into events.jsonl: the 2026-07-28 incident sustained ~680 ev/s,
8
+ * grew the bus file to 254 MB in two hours, and OOM-crash-looped every consumer
9
+ * that ingests it. The guard keeps the SIGNAL (the first burst, then a trickle
10
+ * with a `floodSuppressed` count) while dropping the redundant repetition.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.createEventFloodGuard = createEventFloodGuard;
14
+ /** `LENSMCP_EVENT_MAX_EPS` overrides the sustained per-fingerprint rate; `0`/`off` disables the guard. */
15
+ function envPerSec() {
16
+ const raw = process.env['LENSMCP_EVENT_MAX_EPS'];
17
+ if (raw === undefined || raw === '')
18
+ return undefined;
19
+ if (raw === 'off')
20
+ return 'off';
21
+ const n = Number(raw);
22
+ if (!Number.isFinite(n))
23
+ return undefined;
24
+ return n <= 0 ? 'off' : n;
25
+ }
26
+ function createEventFloodGuard(opts) {
27
+ const env = envPerSec();
28
+ if (env === 'off')
29
+ return { admit: () => ({ ok: true, suppressed: 0 }) };
30
+ const perSec = env ?? opts?.perSec ?? 5;
31
+ const burst = Math.max(1, opts?.burst ?? Math.max(30, perSec * 4));
32
+ const maxKeys = opts?.maxKeys ?? 2048;
33
+ const buckets = new Map();
34
+ const evictStale = (now) => {
35
+ // A full bucket carries no state worth keeping — evict anything idle >60s.
36
+ for (const [k, b] of buckets) {
37
+ if (now - b.lastSeenAt > 60_000)
38
+ buckets.delete(k);
39
+ }
40
+ // Pathological cardinality (unique fingerprints per event) — reset rather than grow.
41
+ if (buckets.size > maxKeys)
42
+ buckets.clear();
43
+ };
44
+ return {
45
+ admit(fingerprint) {
46
+ const now = Date.now();
47
+ let b = buckets.get(fingerprint);
48
+ if (!b) {
49
+ if (buckets.size >= maxKeys)
50
+ evictStale(now);
51
+ b = { tokens: burst, lastRefillAt: now, suppressed: 0, lastSeenAt: now };
52
+ buckets.set(fingerprint, b);
53
+ }
54
+ b.lastSeenAt = now;
55
+ const elapsed = now - b.lastRefillAt;
56
+ if (elapsed > 0) {
57
+ b.tokens = Math.min(burst, b.tokens + (elapsed / 1000) * perSec);
58
+ b.lastRefillAt = now;
59
+ }
60
+ if (b.tokens >= 1) {
61
+ b.tokens -= 1;
62
+ const suppressed = b.suppressed;
63
+ b.suppressed = 0;
64
+ return { ok: true, suppressed };
65
+ }
66
+ b.suppressed += 1;
67
+ return { ok: false, suppressed: 0 };
68
+ },
69
+ };
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/node-instrumentation",
3
- "version": "1.16.30",
3
+ "version": "1.16.33",
4
4
  "main": "./index.js",
5
5
  "types": "./index.d.ts",
6
6
  "exports": {
@@ -15,7 +15,7 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@lensmcp/nest-instrumentation": "1.16.30",
18
+ "@lensmcp/nest-instrumentation": "1.16.33",
19
19
  "tslib": "^2.3.0"
20
20
  },
21
21
  "optionalDependencies": {