@lensmcp/node-instrumentation 1.16.29 → 1.16.31
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 +3 -1
- package/index.d.ts.map +1 -1
- package/index.js +6 -1
- package/lib/emit.d.ts +23 -0
- package/lib/emit.d.ts.map +1 -1
- package/lib/emit.js +75 -23
- package/lib/flood-guard.d.ts +28 -0
- package/lib/flood-guard.d.ts.map +1 -0
- package/lib/flood-guard.js +70 -0
- package/package.json +2 -2
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;
|
|
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":"
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 :
|
|
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
|
-
|
|
46
|
+
const bytesSinceCheck = new Map();
|
|
36
47
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* counter so we `statSync` at most ~once per MB,
|
|
40
|
-
* refs,
|
|
41
|
-
* is atomic, so a concurrent rotate that loses the
|
|
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
|
|
44
|
-
bytesSinceCheck
|
|
45
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
renameSync(exports.EVENT_FILE, `${exports.EVENT_FILE}.1`);
|
|
89
|
+
renameSync(file, `${file}.1`);
|
|
51
90
|
}
|
|
52
91
|
catch {
|
|
53
|
-
/*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.16.31",
|
|
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.
|
|
18
|
+
"@lensmcp/nest-instrumentation": "1.16.31",
|
|
19
19
|
"tslib": "^2.3.0"
|
|
20
20
|
},
|
|
21
21
|
"optionalDependencies": {
|