@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/LICENSE +202 -0
- package/README.md +76 -0
- package/index.d.ts +14 -0
- package/index.d.ts.map +1 -0
- package/index.js +27 -0
- package/lib/emit.d.ts +23 -0
- package/lib/emit.d.ts.map +1 -0
- package/lib/emit.js +101 -0
- package/lib/flow-context.d.ts +39 -0
- package/lib/flow-context.d.ts.map +1 -0
- package/lib/flow-context.js +57 -0
- package/lib/install.d.ts +2 -0
- package/lib/install.d.ts.map +1 -0
- package/lib/install.js +64 -0
- package/lib/require-hook.d.ts +21 -0
- package/lib/require-hook.d.ts.map +1 -0
- package/lib/require-hook.js +52 -0
- package/lib/tap-bullmq.d.ts +2 -0
- package/lib/tap-bullmq.d.ts.map +1 -0
- package/lib/tap-bullmq.js +221 -0
- package/lib/tap-egress.d.ts +2 -0
- package/lib/tap-egress.d.ts.map +1 -0
- package/lib/tap-egress.js +158 -0
- package/lib/tap-exec.d.ts +2 -0
- package/lib/tap-exec.d.ts.map +1 -0
- package/lib/tap-exec.js +111 -0
- package/lib/tap-fs.d.ts +2 -0
- package/lib/tap-fs.d.ts.map +1 -0
- package/lib/tap-fs.js +156 -0
- package/lib/tap-nest.d.ts +15 -0
- package/lib/tap-nest.d.ts.map +1 -0
- package/lib/tap-nest.js +164 -0
- package/lib/tap-pg.d.ts +2 -0
- package/lib/tap-pg.d.ts.map +1 -0
- package/lib/tap-pg.js +122 -0
- package/lib/tap-redis.d.ts +2 -0
- package/lib/tap-redis.d.ts.map +1 -0
- package/lib/tap-redis.js +118 -0
- package/lib/tap-stdout.d.ts +2 -0
- package/lib/tap-stdout.d.ts.map +1 -0
- package/lib/tap-stdout.js +71 -0
- package/package.json +46 -0
- package/register.d.ts +11 -0
- package/register.d.ts.map +1 -0
- package/register.js +14 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"require-hook.d.ts","sourceRoot":"","sources":["../../src/lib/require-hook.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;GAQG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,aAAa,EAAE,OAAO,KAAK,OAAO,GAAG,IAAI,CAAC;AAanE,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,IAAI,CA2BxE"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CJS require hook: the cluster's dev webpack config externalizes every real
|
|
4
|
+
* node_modules package (`commonjs <request>`), so at pod runtime `pg`,
|
|
5
|
+
* `ioredis`, `bullmq` and `@nestjs/core` arrive through `Module._load`. We
|
|
6
|
+
* intercept the EXACT package names and mutate their exports IN PLACE
|
|
7
|
+
* (prototype/static patches, never proxies) — ESM `import` of the same CJS
|
|
8
|
+
* package yields the same exports object, so both module systems observe the
|
|
9
|
+
* patched behavior.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.installRequireHook = installRequireHook;
|
|
13
|
+
const tapped = new Set();
|
|
14
|
+
function runTap(name, tap, exportsObject) {
|
|
15
|
+
try {
|
|
16
|
+
const installed = tap(exportsObject);
|
|
17
|
+
if (installed !== false)
|
|
18
|
+
tapped.add(name);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
tapped.add(name); // a THROWING tap must not retry forever
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function installRequireHook(taps) {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
26
|
+
const Module = require('node:module');
|
|
27
|
+
const origLoad = Module._load;
|
|
28
|
+
Module._load = function (request, ...rest) {
|
|
29
|
+
const exportsObject = origLoad.call(this, request, ...rest);
|
|
30
|
+
const tap = taps[request];
|
|
31
|
+
if (tap && !tapped.has(request))
|
|
32
|
+
runTap(request, tap, exportsObject);
|
|
33
|
+
return exportsObject;
|
|
34
|
+
};
|
|
35
|
+
// Anything already loaded before the hook installed gets tapped from cache.
|
|
36
|
+
for (const name of Object.keys(taps)) {
|
|
37
|
+
if (tapped.has(name))
|
|
38
|
+
continue;
|
|
39
|
+
const tap = taps[name];
|
|
40
|
+
if (!tap)
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
const resolved = require.resolve(name);
|
|
44
|
+
const cached = require.cache[resolved];
|
|
45
|
+
if (cached)
|
|
46
|
+
runTap(name, tap, cached.exports);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* package not installed in this workspace — fine */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tap-bullmq.d.ts","sourceRoot":"","sources":["../../src/lib/tap-bullmq.ts"],"names":[],"mappings":"AAoDA,wBAAgB,SAAS,CAAC,aAAa,EAAE,OAAO,GAAG,OAAO,CAczD"}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.bullmqTap = bullmqTap;
|
|
4
|
+
/**
|
|
5
|
+
* BullMQ tap (source `queue`): the queue node on the canvas, zero-touch.
|
|
6
|
+
*
|
|
7
|
+
* Everything hooks PROTOTYPES — bullmq's module exports are TypeScript
|
|
8
|
+
* getter-only bindings (`__createBinding`, non-configurable), so replacing
|
|
9
|
+
* `exports.Worker` with a subclass silently fails. Prototype mutation is the
|
|
10
|
+
* seam that actually works:
|
|
11
|
+
* - `Queue.prototype.add` → kind `queue-enqueued` (+ ensures the state
|
|
12
|
+
* publisher using THIS queue instance — no extra connection)
|
|
13
|
+
* - `Worker.prototype.emit` → observes the worker's own lifecycle events:
|
|
14
|
+
* `active`/`completed`/`failed` → kinds `queue-active/completed/failed`,
|
|
15
|
+
* and on first sight of a worker ensures the state publisher
|
|
16
|
+
* - per queue, a 2s-when-changed snapshot → kind `queue-state` with `counts`
|
|
17
|
+
* + the 8 most recent jobs ({id, name, state, to?, timestamp}) — the exact
|
|
18
|
+
* contract the dashboard's queue panel renders.
|
|
19
|
+
*/
|
|
20
|
+
const emit_1 = require("./emit");
|
|
21
|
+
const flow_context_1 = require("./flow-context");
|
|
22
|
+
function jobFlow(job) {
|
|
23
|
+
const stamp = job?.opts?.['__lensmcpFlow'];
|
|
24
|
+
return stamp && typeof stamp === 'object' ? stamp : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** The worker leg's own requestId — its block in the flow, distinct from the
|
|
27
|
+
* HTTP request that enqueued it. */
|
|
28
|
+
const jobLegId = (queue, job) => `job:${queue}#${job?.id ?? '?'}`;
|
|
29
|
+
const publishers = new Map();
|
|
30
|
+
function bullmqTap(exportsObject) {
|
|
31
|
+
const mod = exportsObject;
|
|
32
|
+
// Partial exports mid-load (circular require) — let the hook retry later.
|
|
33
|
+
if (typeof mod?.['Queue'] !== 'function' || typeof mod?.['Worker'] !== 'function')
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
tapQueueAdd(mod);
|
|
37
|
+
}
|
|
38
|
+
catch { /* shape drift — skip */ }
|
|
39
|
+
try {
|
|
40
|
+
tapWorkerEmit(mod);
|
|
41
|
+
}
|
|
42
|
+
catch { /* shape drift — skip */ }
|
|
43
|
+
try {
|
|
44
|
+
tapWorkerProcess(mod);
|
|
45
|
+
}
|
|
46
|
+
catch { /* shape drift — skip */ }
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The worker's PROCESSING leg: wrap `Worker.prototype.callProcessJob` so the
|
|
51
|
+
* job runs inside the flow context stamped at enqueue — every tap the
|
|
52
|
+
* processor hits (an egress call, a db write) inherits the flow — and emit a
|
|
53
|
+
* `queue-process` span that anchors the worker leg in the trace.
|
|
54
|
+
*/
|
|
55
|
+
function tapWorkerProcess(mod) {
|
|
56
|
+
const Worker = mod['Worker'];
|
|
57
|
+
const proto = Worker?.prototype;
|
|
58
|
+
const orig = proto?.['callProcessJob'];
|
|
59
|
+
if (!proto || typeof orig !== 'function' || orig.__lensmcp)
|
|
60
|
+
return;
|
|
61
|
+
const wrapped = async function (job, ...rest) {
|
|
62
|
+
const stamp = jobFlow(job);
|
|
63
|
+
if (!stamp?.flowId)
|
|
64
|
+
return orig.call(this, job, ...rest);
|
|
65
|
+
const queueName = String(this?.name ?? job?.queueName ?? 'queue');
|
|
66
|
+
const legId = jobLegId(queueName, job);
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
const fctx = { flowId: stamp.flowId, requestId: legId };
|
|
69
|
+
return (0, flow_context_1.runInFlowContext)(fctx, async () => {
|
|
70
|
+
try {
|
|
71
|
+
const result = await orig.call(this, job, ...rest);
|
|
72
|
+
(0, emit_1.lensEmit)('queue', 'info', `process ${job?.name}#${job?.id} (${Date.now() - t0}ms)`, {
|
|
73
|
+
queue: queueName, kind: 'queue-process', job: job?.name, jobId: job?.id,
|
|
74
|
+
startedAt: t0, durationMs: Date.now() - t0, status: 'ok',
|
|
75
|
+
}, `queue:${queueName}`, fctx);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
(0, emit_1.lensEmit)('queue', 'error', `process ${job?.name}#${job?.id} failed (${Date.now() - t0}ms)`, {
|
|
80
|
+
queue: queueName, kind: 'queue-process', job: job?.name, jobId: job?.id,
|
|
81
|
+
startedAt: t0, durationMs: Date.now() - t0, status: 'error',
|
|
82
|
+
error: String(err?.message ?? err),
|
|
83
|
+
}, `queue:${queueName}`, fctx);
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
wrapped.__lensmcp = true;
|
|
89
|
+
proto['callProcessJob'] = wrapped;
|
|
90
|
+
}
|
|
91
|
+
function tapQueueAdd(mod) {
|
|
92
|
+
const Queue = mod['Queue'];
|
|
93
|
+
const proto = Queue?.prototype;
|
|
94
|
+
const orig = proto?.['add'];
|
|
95
|
+
if (!proto || typeof orig !== 'function' || orig.__lensmcp)
|
|
96
|
+
return;
|
|
97
|
+
const wrapped = async function (name, ...rest) {
|
|
98
|
+
const t0 = Date.now();
|
|
99
|
+
const ctx = (0, flow_context_1.flowContext)(); // the enqueue happens inside the request's scope
|
|
100
|
+
if (ctx?.flowId) {
|
|
101
|
+
// Ride the flow on the job itself (opts survive the redis round trip),
|
|
102
|
+
// so the WORKER's processing joins this flow as its own leg.
|
|
103
|
+
const opts = (rest[1] ?? {});
|
|
104
|
+
rest[1] = { ...opts, __lensmcpFlow: { flowId: ctx.flowId, ...(ctx.requestId ? { originRequestId: ctx.requestId } : {}) } };
|
|
105
|
+
}
|
|
106
|
+
const job = await orig.call(this, name, ...rest);
|
|
107
|
+
try {
|
|
108
|
+
const queueName = String(this?.name ?? 'queue');
|
|
109
|
+
const data = rest[0];
|
|
110
|
+
let dataPreview;
|
|
111
|
+
try {
|
|
112
|
+
const s = JSON.stringify(data);
|
|
113
|
+
dataPreview = s && s !== 'undefined' ? (s.length > 300 ? s.slice(0, 299) + '…' : s) : undefined;
|
|
114
|
+
}
|
|
115
|
+
catch { /* unserializable payload */ }
|
|
116
|
+
(0, emit_1.lensEmit)('queue', 'info', `enqueued ${name}#${job?.id} → ${queueName}`, {
|
|
117
|
+
queue: queueName, kind: 'queue-enqueued', job: name, jobId: job?.id,
|
|
118
|
+
...(dataPreview ? { data: dataPreview } : {}),
|
|
119
|
+
startedAt: t0, durationMs: Date.now() - t0,
|
|
120
|
+
}, `queue:${queueName}`, ctx);
|
|
121
|
+
ensureStatePublisher(queueName, this); // producer side sees state too
|
|
122
|
+
}
|
|
123
|
+
catch { /* ignore */ }
|
|
124
|
+
return job;
|
|
125
|
+
};
|
|
126
|
+
wrapped.__lensmcp = true;
|
|
127
|
+
proto['add'] = wrapped;
|
|
128
|
+
}
|
|
129
|
+
function tapWorkerEmit(mod) {
|
|
130
|
+
const Worker = mod['Worker'];
|
|
131
|
+
const proto = Worker?.prototype;
|
|
132
|
+
const orig = proto?.['emit'];
|
|
133
|
+
if (!proto || typeof orig !== 'function' || orig.__lensmcp)
|
|
134
|
+
return;
|
|
135
|
+
const wrapped = function (event, ...args) {
|
|
136
|
+
try {
|
|
137
|
+
observeWorkerEvent(this, String(event), args);
|
|
138
|
+
}
|
|
139
|
+
catch { /* never break the worker */ }
|
|
140
|
+
return orig.call(this, event, ...args);
|
|
141
|
+
};
|
|
142
|
+
wrapped.__lensmcp = true;
|
|
143
|
+
proto['emit'] = wrapped;
|
|
144
|
+
}
|
|
145
|
+
function observeWorkerEvent(worker, event, args) {
|
|
146
|
+
if (event !== 'active' && event !== 'completed' && event !== 'failed')
|
|
147
|
+
return;
|
|
148
|
+
const name = String(worker['name'] ?? 'queue');
|
|
149
|
+
const job = args[0];
|
|
150
|
+
// Lifecycle events join the worker LEG of the originating flow when the
|
|
151
|
+
// job carries the stamp; un-stamped jobs stay flow-less (canvas only).
|
|
152
|
+
const stamp = jobFlow(job);
|
|
153
|
+
const fctx = stamp?.flowId ? { flowId: stamp.flowId, requestId: jobLegId(name, job) } : undefined;
|
|
154
|
+
if (event === 'active') {
|
|
155
|
+
(0, emit_1.lensEmit)('queue', 'info', `processing ${job?.name}#${job?.id}`, {
|
|
156
|
+
queue: name, kind: 'queue-active', jobId: job?.id,
|
|
157
|
+
}, `queue:${name}`, fctx);
|
|
158
|
+
}
|
|
159
|
+
else if (event === 'completed') {
|
|
160
|
+
let returned;
|
|
161
|
+
try {
|
|
162
|
+
const rv = job?.returnvalue ?? args[1];
|
|
163
|
+
const s = JSON.stringify(rv);
|
|
164
|
+
returned = s && s !== 'undefined' ? (s.length > 160 ? s.slice(0, 159) + '…' : s) : undefined;
|
|
165
|
+
}
|
|
166
|
+
catch { /* unserializable */ }
|
|
167
|
+
(0, emit_1.lensEmit)('queue', 'info', `completed ${job?.name}#${job?.id}`, {
|
|
168
|
+
queue: name, kind: 'queue-completed', jobId: job?.id,
|
|
169
|
+
...(returned ? { returned } : {}),
|
|
170
|
+
}, `queue:${name}`, fctx);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const err = args[1];
|
|
174
|
+
(0, emit_1.lensEmit)('queue', 'error', `job ${job?.id} failed: ${err?.message}`, {
|
|
175
|
+
queue: name, kind: 'queue-failed', jobId: job?.id, error: String(err?.message ?? err),
|
|
176
|
+
}, `queue:${name}`, fctx);
|
|
177
|
+
}
|
|
178
|
+
if (!publishers.has(name)) {
|
|
179
|
+
// Build a snapshot Queue from the worker's own connection opts.
|
|
180
|
+
try {
|
|
181
|
+
const opts = worker['opts'];
|
|
182
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
183
|
+
const mod = require('bullmq');
|
|
184
|
+
const Queue = mod['Queue'];
|
|
185
|
+
ensureStatePublisher(name, new Queue(name, { connection: opts?.['connection'] }));
|
|
186
|
+
}
|
|
187
|
+
catch { /* snapshots unavailable — lifecycle events still flow */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function ensureStatePublisher(name, queue) {
|
|
191
|
+
if (publishers.has(name))
|
|
192
|
+
return;
|
|
193
|
+
let lastSig = '';
|
|
194
|
+
const timer = setInterval(() => {
|
|
195
|
+
void (async () => {
|
|
196
|
+
try {
|
|
197
|
+
const counts = await queue.getJobCounts();
|
|
198
|
+
const recent = [];
|
|
199
|
+
for (const state of ['active', 'waiting', 'failed', 'completed', 'delayed']) {
|
|
200
|
+
for (const j of await queue.getJobs([state], 0, 7)) {
|
|
201
|
+
const to = j?.data?.['to'];
|
|
202
|
+
recent.push({
|
|
203
|
+
id: String(j.id), name: j.name, state, timestamp: j.timestamp ?? 0,
|
|
204
|
+
...(typeof to === 'string' ? { to } : {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
recent.sort((a, b) => Number(b['timestamp']) - Number(a['timestamp']));
|
|
209
|
+
const top = recent.slice(0, 8);
|
|
210
|
+
const sig = JSON.stringify([counts, top.map((r) => `${r['id']}${r['state']}`)]);
|
|
211
|
+
if (sig === lastSig)
|
|
212
|
+
return;
|
|
213
|
+
lastSig = sig;
|
|
214
|
+
(0, emit_1.lensEmit)('queue', 'info', `queue state: ${counts['active'] ?? 0} active · ${counts['waiting'] ?? 0} waiting · ${counts['failed'] ?? 0} failed`, { queue: name, kind: 'queue-state', counts, recent: top }, `queue:${name}`);
|
|
215
|
+
}
|
|
216
|
+
catch { /* redis hiccup — next tick */ }
|
|
217
|
+
})();
|
|
218
|
+
}, 2000);
|
|
219
|
+
timer.unref?.();
|
|
220
|
+
publishers.set(name, timer);
|
|
221
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tap-egress.d.ts","sourceRoot":"","sources":["../../src/lib/tap-egress.ts"],"names":[],"mappings":"AA0BA,wBAAgB,SAAS,IAAI,IAAI,CAIhC"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tapEgress = tapEgress;
|
|
4
|
+
/**
|
|
5
|
+
* Egress watch, zero-touch: wraps global `fetch` AND `http`/`https.request`
|
|
6
|
+
* so ANY outbound call leaving the system (openai, stripe, a BSP…) is
|
|
7
|
+
* published to the lens (source `external`) and appears on the cluster
|
|
8
|
+
* canvas attributed to this service — observed traffic, not config.
|
|
9
|
+
*
|
|
10
|
+
* Local/cluster-internal destinations (*.local, localhost, loopback, RFC1918,
|
|
11
|
+
* unix sockets) are passed through untraced: service-to-service calls are
|
|
12
|
+
* already attributed by the gateway (`x-api-key-id`), and double edges lie.
|
|
13
|
+
*/
|
|
14
|
+
const emit_1 = require("./emit");
|
|
15
|
+
const flow_context_1 = require("./flow-context");
|
|
16
|
+
const INTERNAL = [
|
|
17
|
+
/\.local$/i,
|
|
18
|
+
/^localhost$/i,
|
|
19
|
+
/^127\./,
|
|
20
|
+
/^\[?::1\]?$/,
|
|
21
|
+
/^0\.0\.0\.0$/,
|
|
22
|
+
/^10\./,
|
|
23
|
+
/^192\.168\./,
|
|
24
|
+
];
|
|
25
|
+
const isInternal = (host) => host === '' || INTERNAL.some((p) => p.test(host));
|
|
26
|
+
function tapEgress() {
|
|
27
|
+
tapFetch();
|
|
28
|
+
tapHttpModule('http');
|
|
29
|
+
tapHttpModule('https');
|
|
30
|
+
}
|
|
31
|
+
function tapFetch() {
|
|
32
|
+
const g = globalThis;
|
|
33
|
+
if (typeof g.fetch !== 'function' || g['__lensmcpFetchTap'])
|
|
34
|
+
return;
|
|
35
|
+
g['__lensmcpFetchTap'] = true;
|
|
36
|
+
const original = g.fetch.bind(globalThis);
|
|
37
|
+
g.fetch = (async (input, init) => {
|
|
38
|
+
let host = '';
|
|
39
|
+
try {
|
|
40
|
+
host = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).hostname;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* relative URL — internal by definition */
|
|
44
|
+
}
|
|
45
|
+
if (isInternal(host))
|
|
46
|
+
return original(input, init);
|
|
47
|
+
const t0 = Date.now();
|
|
48
|
+
const ctx = (0, flow_context_1.flowContext)(); // snapshot at the call site, before any await
|
|
49
|
+
const liveF = (0, flow_context_1.liveFlowContext)();
|
|
50
|
+
if (liveF)
|
|
51
|
+
liveF.externalCallCount = (liveF.externalCallCount ?? 0) + 1; // fan-out detector
|
|
52
|
+
const method = String(init?.method ?? 'GET').toUpperCase();
|
|
53
|
+
try {
|
|
54
|
+
const res = await original(input, init);
|
|
55
|
+
(0, emit_1.lensEmit)('external', 'info', `egress ${method} ${host} → ${res.status}`, {
|
|
56
|
+
name: host, kind: 'egress', method, host, status: res.status,
|
|
57
|
+
startedAt: t0, durationMs: Date.now() - t0,
|
|
58
|
+
}, undefined, ctx);
|
|
59
|
+
return res;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
(0, emit_1.lensEmit)('external', 'error', `egress ${method} ${host} failed`, {
|
|
63
|
+
name: host, kind: 'egress', method, host,
|
|
64
|
+
error: String(err?.message ?? err),
|
|
65
|
+
startedAt: t0, durationMs: Date.now() - t0,
|
|
66
|
+
}, undefined, ctx);
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function tapHttpModule(proto) {
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
73
|
+
const mod = require(`node:${proto}`);
|
|
74
|
+
const origRequest = mod['request'];
|
|
75
|
+
if (typeof origRequest !== 'function' || origRequest.__lensmcp)
|
|
76
|
+
return;
|
|
77
|
+
const wrapped = function (...args) {
|
|
78
|
+
const req = origRequest.apply(this, args);
|
|
79
|
+
try {
|
|
80
|
+
observe(args, req);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* never break the call */
|
|
84
|
+
}
|
|
85
|
+
return req;
|
|
86
|
+
};
|
|
87
|
+
wrapped.__lensmcp = true;
|
|
88
|
+
mod['request'] = wrapped;
|
|
89
|
+
const origGet = mod['get'];
|
|
90
|
+
if (typeof origGet === 'function') {
|
|
91
|
+
mod['get'] = function (...args) {
|
|
92
|
+
const req = wrapped.apply(this, args);
|
|
93
|
+
req.end();
|
|
94
|
+
return req;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function observe(args, req) {
|
|
99
|
+
const { host, method } = parseTarget(args);
|
|
100
|
+
if (isInternal(host))
|
|
101
|
+
return;
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
const ctx = (0, flow_context_1.flowContext)(); // call-site scope, not response-callback scope
|
|
104
|
+
const liveH = (0, flow_context_1.liveFlowContext)();
|
|
105
|
+
if (liveH)
|
|
106
|
+
liveH.externalCallCount = (liveH.externalCallCount ?? 0) + 1; // fan-out detector
|
|
107
|
+
let done = false;
|
|
108
|
+
req.once('response', (...resArgs) => {
|
|
109
|
+
if (done)
|
|
110
|
+
return;
|
|
111
|
+
done = true;
|
|
112
|
+
const res = resArgs[0];
|
|
113
|
+
(0, emit_1.lensEmit)('external', 'info', `egress ${method} ${host} → ${res?.statusCode ?? '?'}`, {
|
|
114
|
+
name: host, kind: 'egress', method, host,
|
|
115
|
+
...(res?.statusCode != null ? { status: res.statusCode } : {}),
|
|
116
|
+
startedAt: t0, durationMs: Date.now() - t0,
|
|
117
|
+
}, undefined, ctx);
|
|
118
|
+
});
|
|
119
|
+
req.once('error', (...errArgs) => {
|
|
120
|
+
if (done)
|
|
121
|
+
return;
|
|
122
|
+
done = true;
|
|
123
|
+
const err = errArgs[0];
|
|
124
|
+
(0, emit_1.lensEmit)('external', 'error', `egress ${method} ${host} failed`, {
|
|
125
|
+
name: host, kind: 'egress', method, host,
|
|
126
|
+
error: String(err?.message ?? err), startedAt: t0, durationMs: Date.now() - t0,
|
|
127
|
+
}, undefined, ctx);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function parseTarget(args) {
|
|
131
|
+
let host = '';
|
|
132
|
+
let method = 'GET';
|
|
133
|
+
for (const a of args) {
|
|
134
|
+
if (typeof a === 'string') {
|
|
135
|
+
try {
|
|
136
|
+
host = new URL(a).hostname;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* not a URL */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else if (a instanceof URL) {
|
|
143
|
+
host = a.hostname;
|
|
144
|
+
}
|
|
145
|
+
else if (a && typeof a === 'object') {
|
|
146
|
+
const o = a;
|
|
147
|
+
if (typeof o['method'] === 'string')
|
|
148
|
+
method = o['method'].toUpperCase();
|
|
149
|
+
if (typeof o['socketPath'] === 'string')
|
|
150
|
+
return { host: '', method }; // unix sock → internal
|
|
151
|
+
if (typeof o['hostname'] === 'string' && o['hostname'])
|
|
152
|
+
host = o['hostname'];
|
|
153
|
+
else if (typeof o['host'] === 'string' && o['host'])
|
|
154
|
+
host = o['host'].split(':')[0] ?? '';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { host, method };
|
|
158
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tap-exec.d.ts","sourceRoot":"","sources":["../../src/lib/tap-exec.ts"],"names":[],"mappings":"AA4DA,wBAAgB,OAAO,IAAI,IAAI,CA4D9B"}
|
package/lib/tap-exec.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tapExec = tapExec;
|
|
4
|
+
/**
|
|
5
|
+
* child_process tap (source `exec`): every spawn/exec/fork a pod performs is
|
|
6
|
+
* a fact worth seeing — shell-outs are the least visible side effects a
|
|
7
|
+
* service has. Async forms emit ONE event at exit (cmd, code, duration);
|
|
8
|
+
* sync forms time the call. Internal Node refs aren't double-counted:
|
|
9
|
+
* `exec` → internal `execFile` → internal `spawn` bypasses the patched
|
|
10
|
+
* exports, so each public call emits exactly once.
|
|
11
|
+
*/
|
|
12
|
+
const emit_1 = require("./emit");
|
|
13
|
+
const fmtCmd = (file, args) => {
|
|
14
|
+
const a = Array.isArray(args) ? args.map(String).slice(0, 8).join(' ') : '';
|
|
15
|
+
return `${String(file)}${a ? ' ' + a : ''}`.slice(0, 200);
|
|
16
|
+
};
|
|
17
|
+
function wrap(obj, key, make) {
|
|
18
|
+
const orig = obj[key];
|
|
19
|
+
if (typeof orig !== 'function' || orig.__lensmcp)
|
|
20
|
+
return;
|
|
21
|
+
const wrapped = make(orig);
|
|
22
|
+
wrapped.__lensmcp = true;
|
|
23
|
+
obj[key] = wrapped;
|
|
24
|
+
}
|
|
25
|
+
function observeChild(via, cmd, child) {
|
|
26
|
+
if (!child || typeof child.once !== 'function')
|
|
27
|
+
return;
|
|
28
|
+
// On current Node `exec()` dispatches to the module-export `execFile`, which
|
|
29
|
+
// dispatches to `spawn` — all three are wrapped, so the SAME child passes
|
|
30
|
+
// through multiple wrappers. Tag it once: the OUTERMOST public call wins
|
|
31
|
+
// (richest `via`/cmd), inner re-observations bail → exactly one event.
|
|
32
|
+
const c = child;
|
|
33
|
+
if (c.__lensmcpObserved)
|
|
34
|
+
return;
|
|
35
|
+
c.__lensmcpObserved = true;
|
|
36
|
+
const t0 = Date.now();
|
|
37
|
+
child.once('exit', (...args) => {
|
|
38
|
+
const code = args[0];
|
|
39
|
+
const signal = args[1];
|
|
40
|
+
(0, emit_1.lensEmit)('exec', code === 0 ? 'info' : 'error', `exec ${cmd.split(' ')[0]} → ${code ?? signal}`, {
|
|
41
|
+
name: 'exec', kind: 'exec', cmd, via,
|
|
42
|
+
...(code != null ? { code } : {}),
|
|
43
|
+
...(signal ? { signal } : {}),
|
|
44
|
+
durationMs: Date.now() - t0,
|
|
45
|
+
...(child.pid ? { pid: child.pid } : {}),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
child.once('error', (...args) => {
|
|
49
|
+
const err = args[0];
|
|
50
|
+
(0, emit_1.lensEmit)('exec', 'error', `exec ${cmd.split(' ')[0]} failed to start`, {
|
|
51
|
+
name: 'exec', kind: 'exec', cmd, via,
|
|
52
|
+
error: String(err?.message ?? err), durationMs: Date.now() - t0,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function tapExec() {
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
58
|
+
const cp = require('node:child_process');
|
|
59
|
+
for (const key of ['spawn', 'fork']) {
|
|
60
|
+
wrap(cp, key, (orig) => function (file, ...rest) {
|
|
61
|
+
const args = Array.isArray(rest[0]) ? rest[0] : [];
|
|
62
|
+
const child = orig.call(this, file, ...rest);
|
|
63
|
+
try {
|
|
64
|
+
observeChild(key, fmtCmd(file, args), child);
|
|
65
|
+
}
|
|
66
|
+
catch { /* never break the spawn */ }
|
|
67
|
+
return child;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
wrap(cp, 'exec', (orig) => function (cmd, ...rest) {
|
|
71
|
+
const child = orig.call(this, cmd, ...rest);
|
|
72
|
+
try {
|
|
73
|
+
observeChild('exec', String(cmd).slice(0, 200), child);
|
|
74
|
+
}
|
|
75
|
+
catch { /* ignore */ }
|
|
76
|
+
return child;
|
|
77
|
+
});
|
|
78
|
+
wrap(cp, 'execFile', (orig) => function (file, ...rest) {
|
|
79
|
+
const args = Array.isArray(rest[0]) ? rest[0] : [];
|
|
80
|
+
const child = orig.call(this, file, ...rest);
|
|
81
|
+
try {
|
|
82
|
+
observeChild('execFile', fmtCmd(file, args), child);
|
|
83
|
+
}
|
|
84
|
+
catch { /* ignore */ }
|
|
85
|
+
return child;
|
|
86
|
+
});
|
|
87
|
+
for (const key of ['execSync', 'execFileSync', 'spawnSync']) {
|
|
88
|
+
wrap(cp, key, (orig) => function (file, ...rest) {
|
|
89
|
+
const args = Array.isArray(rest[0]) ? rest[0] : [];
|
|
90
|
+
const cmd = key === 'execSync' ? String(file).slice(0, 200) : fmtCmd(file, args);
|
|
91
|
+
const t0 = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
const r = orig.call(this, file, ...rest);
|
|
94
|
+
const status = r && typeof r === 'object' && 'status' in r
|
|
95
|
+
? (r.status ?? 0)
|
|
96
|
+
: 0;
|
|
97
|
+
(0, emit_1.lensEmit)('exec', status === 0 ? 'info' : 'error', `exec ${cmd.split(' ')[0]} → ${status}`, {
|
|
98
|
+
name: 'exec', kind: 'exec', cmd, via: key, code: status, durationMs: Date.now() - t0, sync: true,
|
|
99
|
+
});
|
|
100
|
+
return r;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
(0, emit_1.lensEmit)('exec', 'error', `exec ${cmd.split(' ')[0]} failed`, {
|
|
104
|
+
name: 'exec', kind: 'exec', cmd, via: key,
|
|
105
|
+
error: String(err?.message ?? err), durationMs: Date.now() - t0, sync: true,
|
|
106
|
+
});
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
package/lib/tap-fs.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tap-fs.d.ts","sourceRoot":"","sources":["../../src/lib/tap-fs.ts"],"names":[],"mappings":"AA6FA,wBAAgB,KAAK,IAAI,IAAI,CA6E5B"}
|