@lensmcp/node-instrumentation 1.0.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/lib/tap-fs.js ADDED
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tapFs = tapFs;
4
+ const tslib_1 = require("tslib");
5
+ /**
6
+ * fs tap: counts real read/write traffic (sync, callback, promise and stream
7
+ * APIs) and publishes a 1-second aggregate (source `fs`, kind `fs-ops`) —
8
+ * per-op events would drown the bus; an aggregate keeps the canvas honest
9
+ * about which services touch the disk and how hard.
10
+ *
11
+ * Self-noise is excluded by construction: the lens writer captured the
12
+ * ORIGINAL appendFileSync before this tap patches fs, `isLensWriting()`
13
+ * guards re-entry, and bus/node_modules/tmp/dist paths are skipped.
14
+ */
15
+ const os = tslib_1.__importStar(require("node:os"));
16
+ const emit_1 = require("./emit");
17
+ const bucket = {
18
+ reads: 0,
19
+ writes: 0,
20
+ bytesRead: 0,
21
+ bytesWritten: 0,
22
+ paths: new Map(),
23
+ };
24
+ let timer = null;
25
+ const TMP = os.tmpdir();
26
+ const CWD = process.cwd();
27
+ const SKIP = ['node_modules', '/.nx/', '/dist/', '/.git/', '.lensmcp'];
28
+ function trackedPath(p) {
29
+ if ((0, emit_1.isLensWriting)())
30
+ return null;
31
+ const s = typeof p === 'string' ? p
32
+ : Buffer.isBuffer(p) ? p.toString()
33
+ : p instanceof URL ? p.pathname
34
+ : null;
35
+ if (!s || s === emit_1.EVENT_FILE)
36
+ return null;
37
+ if (s.startsWith(TMP))
38
+ return null;
39
+ if (SKIP.some((k) => s.includes(k)))
40
+ return null;
41
+ return s;
42
+ }
43
+ function record(kind, p, bytes = 0) {
44
+ const s = trackedPath(p);
45
+ if (!s)
46
+ return;
47
+ if (kind === 'read') {
48
+ bucket.reads += 1;
49
+ bucket.bytesRead += bytes;
50
+ }
51
+ else {
52
+ bucket.writes += 1;
53
+ bucket.bytesWritten += bytes;
54
+ }
55
+ const rel = s.startsWith(CWD) ? s.slice(CWD.length + 1) : s;
56
+ if (bucket.paths.size < 50)
57
+ bucket.paths.set(rel, (bucket.paths.get(rel) ?? 0) + 1);
58
+ if (!timer) {
59
+ timer = setTimeout(flush, 1000);
60
+ timer.unref?.();
61
+ }
62
+ }
63
+ function flush() {
64
+ timer = null;
65
+ const { reads, writes, bytesRead, bytesWritten } = bucket;
66
+ if (reads + writes === 0)
67
+ return;
68
+ const top = [...bucket.paths.entries()]
69
+ .sort((a, b) => b[1] - a[1])
70
+ .slice(0, 3)
71
+ .map(([p]) => p);
72
+ bucket.reads = bucket.writes = bucket.bytesRead = bucket.bytesWritten = 0;
73
+ bucket.paths.clear();
74
+ (0, emit_1.lensEmit)('fs', 'info', `fs ${reads} reads · ${writes} writes`, { name: 'fs', kind: 'fs-ops', reads, writes, bytesRead, bytesWritten, top }, `fs:${emit_1.PROJECT}`);
75
+ }
76
+ const byteLen = (d) => typeof d === 'string' ? Buffer.byteLength(d) : Buffer.isBuffer(d) ? d.length : 0;
77
+ function wrap(obj, key, make) {
78
+ const orig = obj?.[key];
79
+ if (!obj || typeof orig !== 'function' || orig.__lensmcp)
80
+ return;
81
+ const wrapped = make(orig);
82
+ wrapped.__lensmcp = true;
83
+ try {
84
+ obj[key] = wrapped;
85
+ }
86
+ catch {
87
+ /* frozen namespace — skip */
88
+ }
89
+ }
90
+ function tapFs() {
91
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
92
+ const fs = require('node:fs');
93
+ wrap(fs, 'readFileSync', (orig) => function (p, ...rest) {
94
+ const r = orig.call(this, p, ...rest);
95
+ record('read', p, byteLen(r));
96
+ return r;
97
+ });
98
+ wrap(fs, 'writeFileSync', (orig) => function (p, data, ...rest) {
99
+ const r = orig.call(this, p, data, ...rest);
100
+ record('write', p, byteLen(data));
101
+ return r;
102
+ });
103
+ wrap(fs, 'appendFileSync', (orig) => function (p, data, ...rest) {
104
+ const r = orig.call(this, p, data, ...rest);
105
+ record('write', p, byteLen(data));
106
+ return r;
107
+ });
108
+ // Callback forms — measure on success via the wrapped callback.
109
+ wrap(fs, 'readFile', (orig) => function (p, ...rest) {
110
+ const cb = rest[rest.length - 1];
111
+ if (typeof cb === 'function') {
112
+ rest[rest.length - 1] = (err, data) => {
113
+ if (!err)
114
+ record('read', p, byteLen(data));
115
+ cb(err, data);
116
+ };
117
+ }
118
+ return orig.call(this, p, ...rest);
119
+ });
120
+ for (const key of ['writeFile', 'appendFile']) {
121
+ wrap(fs, key, (orig) => function (p, data, ...rest) {
122
+ const cb = rest[rest.length - 1];
123
+ if (typeof cb === 'function') {
124
+ rest[rest.length - 1] = (err) => {
125
+ if (!err)
126
+ record('write', p, byteLen(data));
127
+ cb(err);
128
+ };
129
+ }
130
+ return orig.call(this, p, data, ...rest);
131
+ });
132
+ }
133
+ // Promise forms (fs.promises === require('node:fs/promises') — same object).
134
+ const fsp = fs.promises;
135
+ wrap(fsp, 'readFile', (orig) => async function (p, ...rest) {
136
+ const r = await orig.call(this, p, ...rest);
137
+ record('read', p, byteLen(r));
138
+ return r;
139
+ });
140
+ for (const key of ['writeFile', 'appendFile']) {
141
+ wrap(fsp, key, (orig) => async function (p, data, ...rest) {
142
+ const r = await orig.call(this, p, data, ...rest);
143
+ record('write', p, byteLen(data));
144
+ return r;
145
+ });
146
+ }
147
+ // Streams: count the open; byte accounting per chunk isn't worth the heat.
148
+ wrap(fs, 'createReadStream', (orig) => function (p, ...rest) {
149
+ record('read', p, 0);
150
+ return orig.call(this, p, ...rest);
151
+ });
152
+ wrap(fs, 'createWriteStream', (orig) => function (p, ...rest) {
153
+ record('write', p, 0);
154
+ return orig.call(this, p, ...rest);
155
+ });
156
+ }
@@ -0,0 +1,15 @@
1
+ export interface CapturedChildApp {
2
+ handler: unknown;
3
+ serviceConfig: {
4
+ port: number | string;
5
+ };
6
+ close: () => Promise<void>;
7
+ }
8
+ /**
9
+ * Default `createChildApp` for zero-touch bundles. Installed BEFORE the
10
+ * bundle is imported; a legacy dual-mode main.ts overwrites it at import
11
+ * time and takes precedence automatically.
12
+ */
13
+ export declare function installChildAppBridge(): void;
14
+ export declare function nestTap(exportsObject: unknown): boolean;
15
+ //# sourceMappingURL=tap-nest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tap-nest.d.ts","sourceRoot":"","sources":["../../src/lib/tap-nest.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE;QAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACzC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAwCD;;;;GAIG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAM5C;AAED,wBAAgB,OAAO,CAAC,aAAa,EAAE,OAAO,GAAG,OAAO,CAiDvD"}
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installChildAppBridge = installChildAppBridge;
4
+ exports.nestTap = nestTap;
5
+ /**
6
+ * NestJS tap — the piece that makes a completely ORDINARY main.ts
7
+ * (`NestFactory.create(AppModule)` … `app.listen(port)`) both fully
8
+ * instrumented and pod-servable, with zero lens imports in host source:
9
+ *
10
+ * 1. `NestFactory.create` is patched to graft `LensmcpModule.forRoot` (class
11
+ * traces via autoInstrumentMethods, request flows, memory tracking) around
12
+ * the user's root module — exactly what `createLensmcpNestApp` did, minus
13
+ * the import. Roots already wrapped (legacy `createLensmcpNestApp`) carry
14
+ * the `Symbol.for('lensmcp.wrappedRoot')` marker and are left alone.
15
+ *
16
+ * 2. Under the cluster devserver (`APP_RUNNER=1`), `app.listen(port)` is
17
+ * hijacked: instead of binding TCP, the app is `init()`ed and its raw
18
+ * request handler is pushed to the capture queue. The bridge installs a
19
+ * default `global.createChildApp` that drains that queue — so the
20
+ * devserver's existing contract is satisfied by the RUNNER, not by
21
+ * dual-mode boilerplate in the host's main.ts. Legacy bundles that still
22
+ * define `global.createChildApp` simply overwrite the default at import
23
+ * and win.
24
+ */
25
+ const emit_1 = require("./emit");
26
+ const WRAPPED = Symbol.for('lensmcp.wrappedRoot');
27
+ const captures = [];
28
+ const waiters = [];
29
+ function pushCapture(c) {
30
+ const w = waiters.shift();
31
+ if (w) {
32
+ clearTimeout(w.timer);
33
+ w.resolve(c);
34
+ }
35
+ else {
36
+ captures.push(c);
37
+ }
38
+ }
39
+ function nextCapture(timeoutMs = 60_000) {
40
+ const ready = captures.shift();
41
+ if (ready)
42
+ return Promise.resolve(ready);
43
+ return new Promise((resolve, reject) => {
44
+ const w = {
45
+ resolve,
46
+ reject,
47
+ timer: setTimeout(() => {
48
+ const i = waiters.indexOf(w);
49
+ if (i >= 0)
50
+ waiters.splice(i, 1);
51
+ reject(new Error('[lensmcp] no NestJS app captured within 60s — does main.ts call app.listen()? ' +
52
+ '(zero-touch pods boot by running your main and intercepting listen)'));
53
+ }, timeoutMs),
54
+ };
55
+ w.timer.unref?.();
56
+ waiters.push(w);
57
+ });
58
+ }
59
+ /**
60
+ * Default `createChildApp` for zero-touch bundles. Installed BEFORE the
61
+ * bundle is imported; a legacy dual-mode main.ts overwrites it at import
62
+ * time and takes precedence automatically.
63
+ */
64
+ function installChildAppBridge() {
65
+ if (process.env['APP_RUNNER'] !== '1')
66
+ return;
67
+ const g = global;
68
+ const factory = () => nextCapture();
69
+ g['createChildApp'] = factory;
70
+ g['__lensmcpCreateChildApp'] = factory;
71
+ }
72
+ function nestTap(exportsObject) {
73
+ const core = exportsObject;
74
+ const NF = core?.NestFactory;
75
+ const orig = NF?.['create'];
76
+ // Partial exports (@nestjs/core's own circular require mid-load) → not yet.
77
+ if (!NF || typeof orig !== 'function')
78
+ return false;
79
+ if (orig.__lensmcp)
80
+ return true;
81
+ const wrapped = async function (moduleCls, ...rest) {
82
+ let root = moduleCls;
83
+ const mc = moduleCls;
84
+ const alreadyWrapped = !!(mc?.[WRAPPED] || mc?.['module']?.[WRAPPED]);
85
+ if (process.env['LENSMCP_NEST'] !== '0' && !alreadyWrapped) {
86
+ try {
87
+ root = await buildLensmcpRoot(moduleCls);
88
+ }
89
+ catch {
90
+ root = moduleCls; // instrumentation unavailable — boot uninstrumented, never block the app
91
+ }
92
+ }
93
+ return orig.call(this ?? NF, root, ...rest);
94
+ };
95
+ wrapped.__lensmcp = true;
96
+ NF['create'] = wrapped;
97
+ // Pod capture happens at CLASS level: NestFactory.create returns Nest's
98
+ // exception-zone Proxy whose set trap rejects every property write, so a
99
+ // per-instance `app.listen = …` assignment is impossible — patch
100
+ // NestApplication.prototype.listen once instead. `this` inside is the
101
+ // proxy; method CALLS through it (init/getHttpAdapter/close) are fine.
102
+ if (process.env['APP_RUNNER'] === '1') {
103
+ const proto = core?.NestApplication?.prototype;
104
+ const origListen = proto?.['listen'];
105
+ if (proto && typeof origListen === 'function' && !origListen.__lensmcp) {
106
+ const listenWrapped = async function (port, ...rest) {
107
+ return captureApp(this, port).catch((err) => {
108
+ // Capture failed — fall back to a real TCP listen so standalone
109
+ // runs (or exotic adapters) still come up.
110
+ console.error('[lensmcp] pod capture failed, falling back to TCP listen:', err);
111
+ return origListen.call(this, port, ...rest);
112
+ });
113
+ };
114
+ listenWrapped.__lensmcp = true;
115
+ proto['listen'] = listenWrapped;
116
+ }
117
+ }
118
+ return true;
119
+ }
120
+ async function buildLensmcpRoot(userModule) {
121
+ // Dynamic import: @lensmcp/nest-instrumentation ships ESM; this lib is CJS.
122
+ const ni = await import('@lensmcp/nest-instrumentation');
123
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
124
+ const common = require('@nestjs/common');
125
+ const lensmcp = ni.LensmcpModule.forRoot({
126
+ projectName: emit_1.PROJECT,
127
+ trace: { autoInstrumentMethods: true },
128
+ memory: { mode: 'light', scope: 'all' },
129
+ });
130
+ class LensmcpZeroTouchRoot {
131
+ }
132
+ LensmcpZeroTouchRoot[WRAPPED] = true;
133
+ // Lensmcp FIRST: APP_INTERCEPTORs run in registration order, and the trace
134
+ // interceptor must be OUTERMOST — a user interceptor that short-circuits
135
+ // (e.g. a cache hit) would otherwise bypass tracing entirely: no
136
+ // server-request event and no ALS context for the taps it does hit.
137
+ common.Module({ imports: [lensmcp, userModule] })(LensmcpZeroTouchRoot);
138
+ return LensmcpZeroTouchRoot;
139
+ }
140
+ async function captureApp(app, port) {
141
+ await app['init']();
142
+ const adapter = app['getHttpAdapter']?.();
143
+ let handler = adapter?.['getInstance']?.();
144
+ if (typeof handler !== 'function') {
145
+ // Fastify adapter: getInstance() is the fastify instance, not a listener.
146
+ const instance = handler;
147
+ if (instance?.ready)
148
+ await instance.ready();
149
+ if (typeof instance?.routing === 'function') {
150
+ handler = (req, res) => instance.routing(req, res);
151
+ }
152
+ }
153
+ if (typeof handler !== 'function') {
154
+ throw new Error('[lensmcp] cannot derive a request handler from the HTTP adapter');
155
+ }
156
+ pushCapture({
157
+ handler,
158
+ serviceConfig: { port: port ?? process.env['PORT'] ?? 0 },
159
+ close: () => Promise.resolve(app['close']()).then(() => undefined),
160
+ });
161
+ // The user's `await app.listen(port)` resolves with the (unbound) server,
162
+ // so post-listen log lines in main.ts keep working.
163
+ return app['getHttpServer']?.();
164
+ }
@@ -0,0 +1,2 @@
1
+ export declare function pgTap(exportsObject: unknown): boolean;
2
+ //# sourceMappingURL=tap-pg.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tap-pg.d.ts","sourceRoot":"","sources":["../../src/lib/tap-pg.ts"],"names":[],"mappings":"AAqDA,wBAAgB,KAAK,CAAC,aAAa,EAAE,OAAO,GAAG,OAAO,CAuErD"}
package/lib/tap-pg.js ADDED
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pgTap = pgTap;
4
+ /**
5
+ * pg tap (source `db`): wraps `Client.prototype.query` — Pool.query checks
6
+ * out a Client and delegates, so one patch point covers both. Emits
7
+ * kind `db-query` with op/table parsed from the SQL text, row count and
8
+ * duration — the same shape the canvas's postgres node reduces.
9
+ *
10
+ * Transaction plumbing (BEGIN/COMMIT/…) is skipped: with pools it's pure
11
+ * noise that says nothing about what the service actually does.
12
+ */
13
+ const emit_1 = require("./emit");
14
+ const flow_context_1 = require("./flow-context");
15
+ const SKIP_OPS = new Set(['BEGIN', 'COMMIT', 'ROLLBACK', 'SET', 'SHOW', 'DEALLOCATE', 'DISCARD']);
16
+ function parseSql(text) {
17
+ const op = (text.match(/^\s*(\w+)/)?.[1] ?? 'query').toUpperCase();
18
+ const m = text.match(/\b(?:from|into|update|join|table(?:\s+if\s+not\s+exists)?)\s+"?([\w$.]+)"?/i);
19
+ const table = m?.[1]?.split('.').pop();
20
+ return { op, ...(table ? { table } : {}) };
21
+ }
22
+ const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
23
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
24
+ /** Param values, readable but bounded (dev tool: values matter, walls of JSON don't). */
25
+ function paramPreview(values) {
26
+ if (!Array.isArray(values) || values.length === 0)
27
+ return undefined;
28
+ return values.slice(0, 10).map((v) => {
29
+ if (v === null || v === undefined)
30
+ return String(v);
31
+ const s = typeof v === 'string' ? v : (() => { try {
32
+ return JSON.stringify(v);
33
+ }
34
+ catch {
35
+ return String(v);
36
+ } })();
37
+ return clip(s, 80);
38
+ });
39
+ }
40
+ const rowsOf = (res) => res?.rowCount ?? res?.rows?.length ?? 0;
41
+ /** What came BACK: the first rows, readable and bounded. */
42
+ function resultPreview(res) {
43
+ const rows = res?.rows;
44
+ if (!Array.isArray(rows) || rows.length === 0)
45
+ return undefined;
46
+ try {
47
+ return clip(JSON.stringify(rows.slice(0, 2)), 240);
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ function pgTap(exportsObject) {
54
+ const pg = exportsObject;
55
+ const proto = pg?.Client?.prototype;
56
+ const orig = proto?.['query'];
57
+ if (!proto || typeof orig !== 'function')
58
+ return false; // partial exports (circular require) — retry
59
+ if (orig.__lensmcp)
60
+ return true;
61
+ const wrapped = function (config, values, callback) {
62
+ const cfg = config;
63
+ const text = typeof cfg === 'string' ? cfg : cfg?.text;
64
+ // Submittables (cursors, query streams) drive their own lifecycle — pass through.
65
+ if (!text || (cfg && typeof cfg === 'object' && typeof cfg.submit === 'function')) {
66
+ return orig.call(this, config, values, callback);
67
+ }
68
+ const { op, table } = parseSql(text);
69
+ if (SKIP_OPS.has(op))
70
+ return orig.call(this, config, values, callback);
71
+ const opl = op.toLowerCase();
72
+ const t0 = Date.now();
73
+ // Snapshot flow context + the actual statement AT the call site (the
74
+ // request's ALS scope), not at promise resolution.
75
+ const ctx = (0, flow_context_1.flowContext)();
76
+ const live = (0, flow_context_1.liveFlowContext)();
77
+ if (live)
78
+ live.dbCallCount = (live.dbCallCount ?? 0) + 1; // feeds the N+1 loop detector
79
+ const query = clip(oneLine(text), 500);
80
+ const params = paramPreview(Array.isArray(values) ? values : config?.values);
81
+ const done = (err, res) => {
82
+ const durationMs = Date.now() - t0;
83
+ if (err) {
84
+ (0, emit_1.lensEmit)('db', 'error', `${opl}${table ? ' ' + table : ''} failed (${durationMs}ms)`, {
85
+ name: 'postgres', kind: 'db-query', op: opl, ...(table ? { table } : {}),
86
+ query, ...(params ? { params } : {}), startedAt: t0,
87
+ error: String(err?.message ?? err), durationMs,
88
+ }, 'db:postgres', ctx);
89
+ }
90
+ else {
91
+ const rows = rowsOf(res);
92
+ const result = resultPreview(res);
93
+ (0, emit_1.lensEmit)('db', 'info', `${opl} ${table ?? 'query'} (${rows} rows, ${durationMs}ms)`, {
94
+ name: 'postgres', kind: 'db-query', op: opl, ...(table ? { table } : {}),
95
+ query, ...(params ? { params } : {}), ...(result ? { result } : {}),
96
+ startedAt: t0, rows, durationMs,
97
+ }, 'db:postgres', ctx);
98
+ }
99
+ };
100
+ const cb = typeof callback === 'function' ? callback : typeof values === 'function' ? values : undefined;
101
+ if (cb) {
102
+ const wrappedCb = (err, res) => {
103
+ try {
104
+ done(err, res);
105
+ }
106
+ catch { /* ignore */ }
107
+ cb(err, res);
108
+ };
109
+ return typeof values === 'function'
110
+ ? orig.call(this, config, wrappedCb)
111
+ : orig.call(this, config, values, wrappedCb);
112
+ }
113
+ const ret = orig.call(this, config, values);
114
+ if (ret && typeof ret.then === 'function') {
115
+ ret.then((res) => done(undefined, res), (err) => done(err, undefined));
116
+ }
117
+ return ret;
118
+ };
119
+ wrapped.__lensmcp = true;
120
+ proto['query'] = wrapped;
121
+ return true;
122
+ }
@@ -0,0 +1,2 @@
1
+ export declare function redisTap(exportsObject: unknown): boolean;
2
+ //# sourceMappingURL=tap-redis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tap-redis.d.ts","sourceRoot":"","sources":["../../src/lib/tap-redis.ts"],"names":[],"mappings":"AAyCA,wBAAgB,QAAQ,CAAC,aAAa,EAAE,OAAO,GAAG,OAAO,CAoBxD"}
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.redisTap = redisTap;
4
+ /**
5
+ * ioredis tap (source `redis`): wraps `Redis.prototype.sendCommand` — the
6
+ * single funnel every command passes through. Emits kind `redis-op` with the
7
+ * `cache` role (matching the canvas's redis-by-role grouping).
8
+ *
9
+ * Two deliberate exclusions keep the picture truthful:
10
+ * - BullMQ traffic (eval/evalsha + any `bull:`-prefixed key) is OWNED by the
11
+ * queue tap — surfacing it here would paint the cache node with queue load.
12
+ * - Housekeeping (info/ping/hello/…) says nothing about the app.
13
+ * A 30-events/sec cap protects the bus from hot loops; severity stays a
14
+ * sampling concern, not a correctness one (counts live in the dashboard).
15
+ */
16
+ const emit_1 = require("./emit");
17
+ const flow_context_1 = require("./flow-context");
18
+ const HOUSEKEEPING = new Set([
19
+ 'info', 'ping', 'hello', 'auth', 'select', 'client', 'cluster', 'command',
20
+ 'subscribe', 'psubscribe', 'unsubscribe', 'punsubscribe', 'echo', 'quit',
21
+ 'wait', 'script', 'config', 'debug', 'memory', 'readonly', 'readwrite',
22
+ ]);
23
+ const MAX_PER_SEC = 30;
24
+ let emitted = 0;
25
+ let windowStart = 0;
26
+ function isBullTraffic(command) {
27
+ const op = String(command.name ?? '').toLowerCase();
28
+ if (op === 'eval' || op === 'evalsha')
29
+ return true;
30
+ const args = command.args ?? [];
31
+ for (let i = 0; i < Math.min(args.length, 4); i++) {
32
+ const s = String(args[i] ?? '');
33
+ if (s.startsWith('bull:') || s.startsWith('{bull'))
34
+ return true;
35
+ }
36
+ return false;
37
+ }
38
+ function redisTap(exportsObject) {
39
+ const mod = exportsObject;
40
+ const Redis = (mod?.['default'] ?? mod);
41
+ const proto = Redis?.prototype;
42
+ const orig = proto?.['sendCommand'];
43
+ if (!proto || typeof orig !== 'function')
44
+ return false; // partial exports (circular require) — retry
45
+ if (orig.__lensmcp)
46
+ return true;
47
+ const wrapped = function (command, stream) {
48
+ const ret = orig.call(this, command, stream);
49
+ try {
50
+ observe(command);
51
+ }
52
+ catch {
53
+ /* never break redis */
54
+ }
55
+ return ret;
56
+ };
57
+ wrapped.__lensmcp = true;
58
+ proto['sendCommand'] = wrapped;
59
+ return true;
60
+ }
61
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
62
+ /** The actual command line, values size-annotated: `SET me:default …(1.2kb) EX 15`. */
63
+ function commandLine(op, args) {
64
+ const parts = args.slice(0, 6).map((a, i) => {
65
+ const s = String(a ?? '');
66
+ // first arg is the key — keep it readable; long VALUES become size notes
67
+ if (i > 0 && s.length > 48)
68
+ return `…(${(s.length / 1024).toFixed(1)}kb)`;
69
+ return clip(s, 64);
70
+ });
71
+ return [op.toUpperCase(), ...parts].join(' ');
72
+ }
73
+ function observe(command) {
74
+ const op = String(command?.name ?? '').toLowerCase();
75
+ if (!op || HOUSEKEEPING.has(op) || isBullTraffic(command))
76
+ return;
77
+ // Count EVERY real op for the loop detector BEFORE the event-rate cap — the
78
+ // cap throttles bus chatter, but a 200-GET fan-out must still register as
79
+ // 200 calls (capping the counter would blind the N+1 detector at peak load).
80
+ const live = (0, flow_context_1.liveFlowContext)();
81
+ if (live)
82
+ live.redisCallCount = (live.redisCallCount ?? 0) + 1;
83
+ const now = Date.now();
84
+ if (now - windowStart > 1000) {
85
+ windowStart = now;
86
+ emitted = 0;
87
+ }
88
+ if (++emitted > MAX_PER_SEC)
89
+ return;
90
+ const args = command?.args ?? [];
91
+ const key = String(args[0] ?? '');
92
+ const ctx = (0, flow_context_1.flowContext)(); // snapshot at SEND time — the request's ALS scope
93
+ const cmd = commandLine(op, args);
94
+ const base = {
95
+ name: 'cache', kind: 'redis-op', op, key, command: cmd, startedAt: now,
96
+ };
97
+ if (op === 'get' && command.promise && typeof command.promise.then === 'function') {
98
+ const t0 = now;
99
+ command.promise.then((res) => {
100
+ const hit = res != null;
101
+ (0, emit_1.lensEmit)('redis', 'info', `cache ${hit ? 'hit' : 'miss'} ${key}`, {
102
+ ...base, hit, durationMs: Date.now() - t0,
103
+ ...(hit ? { value: clip(String(res), 120) } : {}),
104
+ }, 'redis:cache', ctx);
105
+ }, () => { });
106
+ return;
107
+ }
108
+ if (op === 'set') {
109
+ const strArgs = args.map((a) => String(a));
110
+ const exIdx = strArgs.findIndex((a) => a.toUpperCase() === 'EX');
111
+ const ttlSec = exIdx >= 0 ? Number(strArgs[exIdx + 1]) : undefined;
112
+ (0, emit_1.lensEmit)('redis', 'info', ttlSec ? `cache set ${key} (ttl ${ttlSec}s)` : `cache set ${key}`, {
113
+ ...base, ...(ttlSec ? { ttlSec } : {}),
114
+ }, 'redis:cache', ctx);
115
+ return;
116
+ }
117
+ (0, emit_1.lensEmit)('redis', 'info', `cache ${op} ${key}`, base, 'redis:cache', ctx);
118
+ }
@@ -0,0 +1,2 @@
1
+ export declare function tapStdout(): void;
2
+ //# sourceMappingURL=tap-stdout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tap-stdout.d.ts","sourceRoot":"","sources":["../../src/lib/tap-stdout.ts"],"names":[],"mappings":"AAuCA,wBAAgB,SAAS,IAAI,IAAI,CA0BhC"}
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tapStdout = tapStdout;
4
+ /**
5
+ * stdout/stderr tap — the service's ACTUAL log output (the Nest `Logger`,
6
+ * `console`, raw writes — exactly what shows in the pod terminal) mirrored into
7
+ * the lens as `runtime` log events, so it surfaces in the dashboard Logs view.
8
+ *
9
+ * The original write still runs (terminal output is unchanged), and `[lensmcp]`
10
+ * lines are skipped so the lens never feeds on its own chatter. The emit path is
11
+ * fs-tap-safe (lensLog binds the pristine appendFileSync + sets `isLensWriting`),
12
+ * so a log line can never recurse into instrumentation. Disable with LENSMCP_LOGS=0.
13
+ */
14
+ const emit_1 = require("./emit");
15
+ // Strip ANSI SGR colour codes (Nest's colourised ConsoleLogger). Build the ESC
16
+ // byte at runtime so there's no control character literal in source.
17
+ const ANSI = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
18
+ /** Best-effort level from a Nest-formatted line; falls back to the stream default. */
19
+ function severityOf(line, fallback) {
20
+ if (/\bFATAL\b/.test(line))
21
+ return 'fatal';
22
+ if (/\bERROR\b/.test(line))
23
+ return 'error';
24
+ if (/\bWARN\b/.test(line))
25
+ return 'warning';
26
+ if (/\bDEBUG\b/.test(line) || /\bVERBOSE\b/.test(line))
27
+ return 'debug';
28
+ if (/\bLOG\b/.test(line))
29
+ return 'info';
30
+ return fallback;
31
+ }
32
+ function emitChunk(s, fallback) {
33
+ for (const rawLine of s.split('\n')) {
34
+ const line = rawLine.replace(ANSI, '').replace(/\s+$/, '');
35
+ if (!line.trim())
36
+ continue;
37
+ (0, emit_1.lensLog)(severityOf(line, fallback), line);
38
+ }
39
+ }
40
+ function tapStdout() {
41
+ if (process.env['LENSMCP_LOGS'] === '0')
42
+ return;
43
+ // Only tap ACTUAL service pods (APP_RUNNER=1) — never the gateway/devserver parent,
44
+ // whose high-volume proxy logging would swamp the bus with synchronous appends.
45
+ if (process.env['APP_RUNNER'] !== '1')
46
+ return;
47
+ const targets = [
48
+ [process.stdout, 'info'],
49
+ [process.stderr, 'error'],
50
+ ];
51
+ for (const [stream, fallback] of targets) {
52
+ if (stream.write.__lensmcp)
53
+ continue;
54
+ const orig = stream.write.bind(stream);
55
+ const wrapped = function (chunk, ...args) {
56
+ try {
57
+ if (!(0, emit_1.isLensWriting)()) {
58
+ const s = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : '';
59
+ if (s && s.indexOf('[lensmcp]') === -1)
60
+ emitChunk(s, fallback);
61
+ }
62
+ }
63
+ catch {
64
+ /* never break the host's stdout */
65
+ }
66
+ return orig(chunk, ...args);
67
+ };
68
+ wrapped.__lensmcp = true;
69
+ stream.write = wrapped;
70
+ }
71
+ }