trickle-observe 0.2.2 → 0.2.4
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/dist/auto-codegen.d.ts +1 -1
- package/dist/auto-codegen.js +234 -17
- package/dist/auto-register.js +1 -1
- package/dist/express.js +13 -0
- package/dist/observe-register.js +450 -41
- package/dist/trace-var.d.ts +44 -0
- package/dist/trace-var.js +219 -0
- package/dist/type-inference.js +9 -1
- package/dist/vite-plugin.d.ts +5 -2
- package/dist/vite-plugin.js +385 -25
- package/dist/wrap.js +4 -12
- package/package.json +10 -3
- package/src/auto-codegen.ts +226 -18
- package/src/auto-register.ts +1 -1
- package/src/express.d.ts +387 -0
- package/src/express.ts +14 -0
- package/src/observe-register.ts +420 -41
- package/src/trace-var.ts +202 -0
- package/src/type-inference.ts +11 -1
- package/src/vite-plugin.ts +444 -24
- package/src/wrap.ts +4 -12
package/src/express.ts
CHANGED
|
@@ -320,6 +320,8 @@ export function trickleMiddleware(
|
|
|
320
320
|
maxDepth: userOpts?.maxDepth ?? 5,
|
|
321
321
|
};
|
|
322
322
|
|
|
323
|
+
const debug = process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true';
|
|
324
|
+
|
|
323
325
|
return function trickleMiddlewareHandler(req: any, res: any, next: (...args: any[]) => void): void {
|
|
324
326
|
if (!opts.enabled) {
|
|
325
327
|
next();
|
|
@@ -332,6 +334,10 @@ export function trickleMiddleware(
|
|
|
332
334
|
return;
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
if (debug) {
|
|
338
|
+
console.log(`[trickle/middleware] Intercepting ${req.method} ${req.originalUrl || req.url}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
335
341
|
let captured = false;
|
|
336
342
|
|
|
337
343
|
// We derive the route name lazily once the response is being sent,
|
|
@@ -356,6 +362,9 @@ export function trickleMiddleware(
|
|
|
356
362
|
if (!captured) {
|
|
357
363
|
captured = true;
|
|
358
364
|
const routeName = getRouteName();
|
|
365
|
+
if (debug) {
|
|
366
|
+
console.log(`[trickle/middleware] Captured res.json for ${routeName}`);
|
|
367
|
+
}
|
|
359
368
|
// Re-extract input here because body parsers may have run since middleware was entered
|
|
360
369
|
const latestInput = extractRequestInput(req);
|
|
361
370
|
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data);
|
|
@@ -363,6 +372,8 @@ export function trickleMiddleware(
|
|
|
363
372
|
res.json = originalJson;
|
|
364
373
|
return originalJson.call(res, data);
|
|
365
374
|
};
|
|
375
|
+
} else if (debug) {
|
|
376
|
+
console.log(`[trickle/middleware] res.json is not a function: ${typeof originalJson}`);
|
|
366
377
|
}
|
|
367
378
|
|
|
368
379
|
// Intercept res.send()
|
|
@@ -372,6 +383,9 @@ export function trickleMiddleware(
|
|
|
372
383
|
if (!captured) {
|
|
373
384
|
captured = true;
|
|
374
385
|
const routeName = getRouteName();
|
|
386
|
+
if (debug) {
|
|
387
|
+
console.log(`[trickle/middleware] Captured res.send for ${routeName}`);
|
|
388
|
+
}
|
|
375
389
|
const latestInput = extractRequestInput(req);
|
|
376
390
|
const output = typeof data === 'string' ? { __html: true } : data;
|
|
377
391
|
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output);
|
package/src/observe-register.ts
CHANGED
|
@@ -34,6 +34,8 @@ import { detectEnvironment } from './env-detect';
|
|
|
34
34
|
import { wrapFunction } from './wrap';
|
|
35
35
|
import { WrapOptions } from './types';
|
|
36
36
|
import { patchFetch } from './fetch-observer';
|
|
37
|
+
import { instrumentExpress, trickleMiddleware } from './express';
|
|
38
|
+
import { initVarTracer, traceVar } from './trace-var';
|
|
37
39
|
|
|
38
40
|
const M = Module as any;
|
|
39
41
|
const originalLoad = M._load;
|
|
@@ -146,6 +148,210 @@ function findClosingBrace(source: string, openBrace: number): number {
|
|
|
146
148
|
* which is critical for entry files where functions are defined and used
|
|
147
149
|
* in the same top-level scope.
|
|
148
150
|
*/
|
|
151
|
+
/**
|
|
152
|
+
* Find variable declarations in source and return insertions for tracing.
|
|
153
|
+
* Handles: const x = ...; let x = ...; var x = ...;
|
|
154
|
+
* Skips: destructuring, for-loop vars, require() calls, imports.
|
|
155
|
+
*/
|
|
156
|
+
function findVarDeclarations(source: string): Array<{ lineEnd: number; varName: string; lineNo: number }> {
|
|
157
|
+
const varInsertions: Array<{ lineEnd: number; varName: string; lineNo: number }> = [];
|
|
158
|
+
|
|
159
|
+
// Match: const/let/var <identifier> = <something>
|
|
160
|
+
// We look for lines containing a simple variable declaration with an assignment
|
|
161
|
+
const varRegex = /^([ \t]*)(const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=[^=]/gm;
|
|
162
|
+
let vmatch;
|
|
163
|
+
|
|
164
|
+
while ((vmatch = varRegex.exec(source)) !== null) {
|
|
165
|
+
const varName = vmatch[3];
|
|
166
|
+
|
|
167
|
+
// Skip common noise
|
|
168
|
+
if (varName === '__trickle_mod' || varName === '__trickle_wrap' || varName === '__trickle_tv') continue;
|
|
169
|
+
if (varName.startsWith('__trickle')) continue;
|
|
170
|
+
if (varName === '_a' || varName === '_b' || varName === '_c') continue; // TS compiled vars
|
|
171
|
+
|
|
172
|
+
// Check if this is a require() call — skip those (they're imports, not interesting values)
|
|
173
|
+
const restOfLine = source.slice(vmatch.index + vmatch[0].length - 1, vmatch.index + vmatch[0].length + 200);
|
|
174
|
+
if (/^\s*require\s*\(/.test(restOfLine)) continue;
|
|
175
|
+
|
|
176
|
+
// Calculate line number (count newlines before this position)
|
|
177
|
+
let lineNo = 1;
|
|
178
|
+
for (let i = 0; i < vmatch.index; i++) {
|
|
179
|
+
if (source[i] === '\n') lineNo++;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Find the end of this statement — look for the semicolon at depth 0
|
|
183
|
+
// or the end of the line for semicolon-free code
|
|
184
|
+
const startPos = vmatch.index + vmatch[0].length - 1; // position of the '='
|
|
185
|
+
let pos = startPos;
|
|
186
|
+
let depth = 0;
|
|
187
|
+
let foundEnd = -1;
|
|
188
|
+
|
|
189
|
+
while (pos < source.length) {
|
|
190
|
+
const ch = source[pos];
|
|
191
|
+
if (ch === '(' || ch === '[' || ch === '{') {
|
|
192
|
+
depth++;
|
|
193
|
+
} else if (ch === ')' || ch === ']' || ch === '}') {
|
|
194
|
+
depth--;
|
|
195
|
+
if (depth < 0) break; // we've gone past our scope
|
|
196
|
+
} else if (ch === ';' && depth === 0) {
|
|
197
|
+
foundEnd = pos;
|
|
198
|
+
break;
|
|
199
|
+
} else if (ch === '\n' && depth === 0) {
|
|
200
|
+
// For semicolon-free code, the newline is the end
|
|
201
|
+
// But only if the next non-whitespace isn't a continuation (., +, etc.)
|
|
202
|
+
const nextNonWs = source.slice(pos + 1).match(/^\s*(\S)/);
|
|
203
|
+
if (nextNonWs && !'.+=-|&?:,'.includes(nextNonWs[1])) {
|
|
204
|
+
foundEnd = pos;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
} else if (ch === '"' || ch === "'" || ch === '`') {
|
|
208
|
+
// Skip strings
|
|
209
|
+
const quote = ch;
|
|
210
|
+
pos++;
|
|
211
|
+
while (pos < source.length) {
|
|
212
|
+
if (source[pos] === '\\') { pos++; }
|
|
213
|
+
else if (source[pos] === quote) break;
|
|
214
|
+
pos++;
|
|
215
|
+
}
|
|
216
|
+
} else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
|
|
217
|
+
// Skip line comment
|
|
218
|
+
while (pos < source.length && source[pos] !== '\n') pos++;
|
|
219
|
+
continue;
|
|
220
|
+
} else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
|
|
221
|
+
// Skip block comment
|
|
222
|
+
pos += 2;
|
|
223
|
+
while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/')) pos++;
|
|
224
|
+
pos++;
|
|
225
|
+
}
|
|
226
|
+
pos++;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (foundEnd === -1) continue; // couldn't find statement end
|
|
230
|
+
|
|
231
|
+
varInsertions.push({ lineEnd: foundEnd + 1, varName, lineNo });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return varInsertions;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Find destructured variable declarations: const { a, b } = ... and const [a, b] = ...
|
|
239
|
+
*/
|
|
240
|
+
function findDestructuredDeclarations(source: string): Array<{ lineEnd: number; varNames: string[]; lineNo: number }> {
|
|
241
|
+
const results: Array<{ lineEnd: number; varNames: string[]; lineNo: number }> = [];
|
|
242
|
+
|
|
243
|
+
const destructRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\])\s*(?::\s*[^=]+?)?\s*=[^=]/gm;
|
|
244
|
+
let match;
|
|
245
|
+
|
|
246
|
+
while ((match = destructRegex.exec(source)) !== null) {
|
|
247
|
+
const pattern = match[1];
|
|
248
|
+
const varNames = extractDestructuredNames(pattern);
|
|
249
|
+
if (varNames.length === 0) continue;
|
|
250
|
+
|
|
251
|
+
const restOfLine = source.slice(match.index + match[0].length - 1, match.index + match[0].length + 200);
|
|
252
|
+
if (/^\s*require\s*\(/.test(restOfLine)) continue;
|
|
253
|
+
|
|
254
|
+
let lineNo = 1;
|
|
255
|
+
for (let i = 0; i < match.index; i++) {
|
|
256
|
+
if (source[i] === '\n') lineNo++;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const startPos = match.index + match[0].length - 1;
|
|
260
|
+
let pos = startPos;
|
|
261
|
+
let depth = 0;
|
|
262
|
+
let foundEnd = -1;
|
|
263
|
+
|
|
264
|
+
while (pos < source.length) {
|
|
265
|
+
const ch = source[pos];
|
|
266
|
+
if (ch === '(' || ch === '[' || ch === '{') {
|
|
267
|
+
depth++;
|
|
268
|
+
} else if (ch === ')' || ch === ']' || ch === '}') {
|
|
269
|
+
depth--;
|
|
270
|
+
if (depth < 0) break;
|
|
271
|
+
} else if (ch === ';' && depth === 0) {
|
|
272
|
+
foundEnd = pos;
|
|
273
|
+
break;
|
|
274
|
+
} else if (ch === '\n' && depth === 0) {
|
|
275
|
+
const nextNonWs = source.slice(pos + 1).match(/^\s*(\S)/);
|
|
276
|
+
if (nextNonWs && !'.+=-|&?:,'.includes(nextNonWs[1])) {
|
|
277
|
+
foundEnd = pos;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
} else if (ch === '"' || ch === "'" || ch === '`') {
|
|
281
|
+
const quote = ch;
|
|
282
|
+
pos++;
|
|
283
|
+
while (pos < source.length) {
|
|
284
|
+
if (source[pos] === '\\') { pos++; }
|
|
285
|
+
else if (source[pos] === quote) break;
|
|
286
|
+
pos++;
|
|
287
|
+
}
|
|
288
|
+
} else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
|
|
289
|
+
while (pos < source.length && source[pos] !== '\n') pos++;
|
|
290
|
+
continue;
|
|
291
|
+
} else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
|
|
292
|
+
pos += 2;
|
|
293
|
+
while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/')) pos++;
|
|
294
|
+
pos++;
|
|
295
|
+
}
|
|
296
|
+
pos++;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (foundEnd === -1) continue;
|
|
300
|
+
results.push({ lineEnd: foundEnd + 1, varNames, lineNo });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return results;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Extract variable names from a destructuring pattern.
|
|
308
|
+
* { a, b, c: d } → ['a', 'b', 'd'], [a, b, ...rest] → ['a', 'b', 'rest']
|
|
309
|
+
*/
|
|
310
|
+
function extractDestructuredNames(pattern: string): string[] {
|
|
311
|
+
const names: string[] = [];
|
|
312
|
+
const inner = pattern.slice(1, -1).trim();
|
|
313
|
+
if (!inner) return names;
|
|
314
|
+
|
|
315
|
+
const parts: string[] = [];
|
|
316
|
+
let depth = 0;
|
|
317
|
+
let current = '';
|
|
318
|
+
for (const ch of inner) {
|
|
319
|
+
if (ch === '{' || ch === '[') depth++;
|
|
320
|
+
else if (ch === '}' || ch === ']') depth--;
|
|
321
|
+
else if (ch === ',' && depth === 0) {
|
|
322
|
+
parts.push(current.trim());
|
|
323
|
+
current = '';
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
current += ch;
|
|
327
|
+
}
|
|
328
|
+
if (current.trim()) parts.push(current.trim());
|
|
329
|
+
|
|
330
|
+
for (const part of parts) {
|
|
331
|
+
if (part.startsWith('...')) {
|
|
332
|
+
const restName = part.slice(3).trim().split(/[\s:]/)[0];
|
|
333
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) names.push(restName);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const colonIdx = part.indexOf(':');
|
|
338
|
+
if (colonIdx !== -1) {
|
|
339
|
+
const afterColon = part.slice(colonIdx + 1).trim();
|
|
340
|
+
if (afterColon.startsWith('{') || afterColon.startsWith('[')) {
|
|
341
|
+
names.push(...extractDestructuredNames(afterColon));
|
|
342
|
+
} else {
|
|
343
|
+
const localName = afterColon.split(/[\s=]/)[0].trim();
|
|
344
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(localName)) names.push(localName);
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
const name = part.split(/[\s=]/)[0].trim();
|
|
348
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) names.push(name);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return names;
|
|
353
|
+
}
|
|
354
|
+
|
|
149
355
|
function transformCjsSource(source: string, filename: string, moduleName: string, env: string): string {
|
|
150
356
|
const funcRegex = /^[ \t]*(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
151
357
|
const insertions: Array<{ position: number; name: string; paramNames: string[] }> = [];
|
|
@@ -180,13 +386,18 @@ function transformCjsSource(source: string, filename: string, moduleName: string
|
|
|
180
386
|
insertions.push({ position: closeBrace + 1, name, paramNames });
|
|
181
387
|
}
|
|
182
388
|
|
|
183
|
-
|
|
389
|
+
// Also find variable declarations for tracing
|
|
390
|
+
const varTraceEnabled = process.env.TRICKLE_TRACE_VARS !== '0';
|
|
391
|
+
const varInsertions = varTraceEnabled ? findVarDeclarations(source) : [];
|
|
392
|
+
const destructInsertions = varTraceEnabled ? findDestructuredDeclarations(source) : [];
|
|
393
|
+
|
|
394
|
+
if (insertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0) return source;
|
|
184
395
|
|
|
185
396
|
// Resolve the path to the wrap helper (compiled JS)
|
|
186
397
|
const wrapHelperPath = path.join(__dirname, 'wrap.js');
|
|
187
398
|
|
|
188
399
|
// Prepend: load the wrapper and create the wrap helper
|
|
189
|
-
const
|
|
400
|
+
const prefixLines = [
|
|
190
401
|
`var __trickle_mod = require(${JSON.stringify(wrapHelperPath)});`,
|
|
191
402
|
`var __trickle_wrap = function(fn, name, paramNames) {`,
|
|
192
403
|
` var opts = {`,
|
|
@@ -195,23 +406,60 @@ function transformCjsSource(source: string, filename: string, moduleName: string
|
|
|
195
406
|
` trackArgs: true,`,
|
|
196
407
|
` trackReturn: true,`,
|
|
197
408
|
` sampleRate: 1,`,
|
|
198
|
-
` maxDepth:
|
|
409
|
+
` maxDepth: 3,`,
|
|
199
410
|
` environment: ${JSON.stringify(env)},`,
|
|
200
411
|
` enabled: true,`,
|
|
201
412
|
` };`,
|
|
202
413
|
` if (paramNames && paramNames.length) opts.paramNames = paramNames;`,
|
|
203
414
|
` return __trickle_mod.wrapFunction(fn, opts);`,
|
|
204
415
|
`};`,
|
|
205
|
-
|
|
206
|
-
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
// Add variable tracing helper if we have var insertions
|
|
419
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0) {
|
|
420
|
+
const traceVarPath = path.join(__dirname, 'trace-var.js');
|
|
421
|
+
prefixLines.push(
|
|
422
|
+
`var __trickle_tv_mod = require(${JSON.stringify(traceVarPath)});`,
|
|
423
|
+
`var __trickle_tv = function(v, n, l) { try { __trickle_tv_mod.traceVar(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e){} };`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
207
426
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
427
|
+
prefixLines.push('');
|
|
428
|
+
const prefix = prefixLines.join('\n');
|
|
429
|
+
|
|
430
|
+
// Merge all insertions (function wraps + variable traces) and sort by position descending
|
|
431
|
+
type Insertion = { position: number; code: string };
|
|
432
|
+
const allInsertions: Insertion[] = [];
|
|
433
|
+
|
|
434
|
+
for (const { position, name, paramNames } of insertions) {
|
|
212
435
|
const paramNamesArg = paramNames.length > 0 ? JSON.stringify(paramNames) : 'null';
|
|
213
|
-
|
|
214
|
-
|
|
436
|
+
allInsertions.push({
|
|
437
|
+
position,
|
|
438
|
+
code: `\ntry{${name}=__trickle_wrap(${name},'${name}',${paramNamesArg})}catch(__e){}\n`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const { lineEnd, varName, lineNo } of varInsertions) {
|
|
443
|
+
allInsertions.push({
|
|
444
|
+
position: lineEnd,
|
|
445
|
+
code: `\ntry{__trickle_tv(${varName},${JSON.stringify(varName)},${lineNo})}catch(__e){}\n`,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const { lineEnd, varNames, lineNo } of destructInsertions) {
|
|
450
|
+
const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
|
|
451
|
+
allInsertions.push({
|
|
452
|
+
position: lineEnd,
|
|
453
|
+
code: `\n;try{${calls}}catch(__e){}\n`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Sort by position descending (insert from end to preserve earlier positions)
|
|
458
|
+
allInsertions.sort((a, b) => b.position - a.position);
|
|
459
|
+
|
|
460
|
+
let result = source;
|
|
461
|
+
for (const { position, code } of allInsertions) {
|
|
462
|
+
result = result.slice(0, position) + code + result.slice(position);
|
|
215
463
|
}
|
|
216
464
|
|
|
217
465
|
return prefix + result;
|
|
@@ -252,9 +500,14 @@ if (enabled) {
|
|
|
252
500
|
console.log(`[trickle/observe] Auto-observation enabled (backend: ${backendUrl})`);
|
|
253
501
|
}
|
|
254
502
|
|
|
255
|
-
// ── Hook
|
|
503
|
+
// ── Hook 0a: Patch global.fetch to capture HTTP response types ──
|
|
256
504
|
patchFetch(environment, debug);
|
|
257
505
|
|
|
506
|
+
// ── Hook 0b: Initialize variable tracer ──
|
|
507
|
+
if (process.env.TRICKLE_TRACE_VARS !== '0') {
|
|
508
|
+
initVarTracer({ debug });
|
|
509
|
+
}
|
|
510
|
+
|
|
258
511
|
// ── Hook 1: Module._compile — transform source to wrap function declarations ──
|
|
259
512
|
// This catches ALL functions including entry file and non-exported helpers.
|
|
260
513
|
|
|
@@ -291,15 +544,80 @@ if (enabled) {
|
|
|
291
544
|
// (e.g. module.exports = { foo: function() {} }).
|
|
292
545
|
// The double-wrap guard in wrapFunction prevents redundant wrapping.
|
|
293
546
|
|
|
547
|
+
// Track whether we've already patched Express to avoid double-patching
|
|
548
|
+
const expressPatched = new Set<string>();
|
|
549
|
+
|
|
294
550
|
M._load = function hookedLoad(request: string, parent: any, isMain: boolean): any {
|
|
295
551
|
const exports = originalLoad.apply(this, arguments);
|
|
296
552
|
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
553
|
+
// ── Express auto-detection: wrap Express factory to capture route types ──
|
|
554
|
+
// When someone requires 'express', wrap the factory so every app.get/post/etc
|
|
555
|
+
// automatically captures { body, params, query } → res.json() type data.
|
|
556
|
+
if (request === 'express' && !expressPatched.has('express')) {
|
|
557
|
+
expressPatched.add('express');
|
|
558
|
+
try {
|
|
559
|
+
const origExpress = exports;
|
|
560
|
+
const wrappedExpress = function (this: any, ...args: any[]): any {
|
|
561
|
+
const app = origExpress.apply(this, args);
|
|
562
|
+
// Tag the app so we can verify the middleware was injected
|
|
563
|
+
(app as any).__trickle_instrumented = true;
|
|
564
|
+
try {
|
|
565
|
+
// Wrap route methods for future route definitions
|
|
566
|
+
instrumentExpress(app, { environment });
|
|
567
|
+
// Also inject middleware to capture routes defined BEFORE instrumentation
|
|
568
|
+
// (common in DI/class-based architectures where routes are defined in constructors)
|
|
569
|
+
if (typeof app.use === 'function') {
|
|
570
|
+
app.use(trickleMiddleware({ environment }));
|
|
571
|
+
if (debug) {
|
|
572
|
+
console.log('[trickle/observe] Injected trickleMiddleware into Express app');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (debug) {
|
|
576
|
+
console.log('[trickle/observe] Auto-instrumented Express app (route types will be captured)');
|
|
577
|
+
}
|
|
578
|
+
} catch (e: unknown) {
|
|
579
|
+
if (debug) {
|
|
580
|
+
console.log('[trickle/observe] Express instrumentation error:', (e as Error).message);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return app;
|
|
584
|
+
};
|
|
585
|
+
// Copy all static properties (express.json, express.static, express.Router, etc.)
|
|
586
|
+
for (const key of Object.keys(origExpress)) {
|
|
587
|
+
(wrappedExpress as any)[key] = origExpress[key];
|
|
588
|
+
}
|
|
589
|
+
Object.setPrototypeOf(wrappedExpress, Object.getPrototypeOf(origExpress));
|
|
590
|
+
|
|
591
|
+
// Also wrap express.Router() to instrument route handlers on Router instances.
|
|
592
|
+
// Most real Express apps define routes on Routers, not directly on the app.
|
|
593
|
+
if (typeof origExpress.Router === 'function') {
|
|
594
|
+
const origRouter = origExpress.Router;
|
|
595
|
+
(wrappedExpress as any).Router = function (this: any, ...rArgs: any[]): any {
|
|
596
|
+
const router = origRouter.apply(this, rArgs);
|
|
597
|
+
try {
|
|
598
|
+
instrumentExpress(router, { environment });
|
|
599
|
+
if (debug) {
|
|
600
|
+
console.log('[trickle/observe] Auto-instrumented Express Router');
|
|
601
|
+
}
|
|
602
|
+
} catch { /* don't crash */ }
|
|
603
|
+
return router;
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Update require cache
|
|
608
|
+
try {
|
|
609
|
+
const resolvedPath = M._resolveFilename(request, parent);
|
|
610
|
+
if (require.cache[resolvedPath]) {
|
|
611
|
+
require.cache[resolvedPath]!.exports = wrappedExpress;
|
|
612
|
+
}
|
|
613
|
+
} catch { /* non-critical */ }
|
|
614
|
+
return wrappedExpress;
|
|
615
|
+
} catch { /* fall through to normal processing */ }
|
|
300
616
|
}
|
|
301
617
|
|
|
302
|
-
// Resolve to absolute path for dedup
|
|
618
|
+
// Resolve to absolute path for dedup — do this FIRST since bundlers like
|
|
619
|
+
// tsx/esbuild may use path aliases (e.g., @config/env) that don't start
|
|
620
|
+
// with './' or '/'. We need the resolved path to decide if it's user code.
|
|
303
621
|
let resolvedPath: string;
|
|
304
622
|
try {
|
|
305
623
|
resolvedPath = M._resolveFilename(request, parent);
|
|
@@ -307,6 +625,9 @@ if (enabled) {
|
|
|
307
625
|
return exports;
|
|
308
626
|
}
|
|
309
627
|
|
|
628
|
+
// Skip built-in modules (they resolve to names like 'fs', 'path', not absolute paths)
|
|
629
|
+
if (!resolvedPath.startsWith('/')) return exports;
|
|
630
|
+
|
|
310
631
|
// Skip node_modules and trickle internals
|
|
311
632
|
if (resolvedPath.includes('node_modules')) return exports;
|
|
312
633
|
if (resolvedPath.includes('client-js/') || resolvedPath.includes('trickle-client/') || resolvedPath.includes('trickle/dist/')) return exports;
|
|
@@ -335,9 +656,21 @@ if (enabled) {
|
|
|
335
656
|
for (const key of Object.keys(exports)) {
|
|
336
657
|
if (typeof exports[key] === 'function' && key !== 'default') {
|
|
337
658
|
const fn = exports[key];
|
|
338
|
-
// Skip classes —
|
|
339
|
-
|
|
340
|
-
|
|
659
|
+
// Skip classes — wrapping them breaks DI containers (tsyringe, inversify, etc.)
|
|
660
|
+
// and decorator metadata. Detect via toString() for ES2015+ classes,
|
|
661
|
+
// and via prototype check for classes with prototype methods.
|
|
662
|
+
// Wrapped in try-catch because some prototype property access can throw
|
|
663
|
+
// (e.g., Node.js stream classes with getter-only properties).
|
|
664
|
+
let isClass = false;
|
|
665
|
+
try {
|
|
666
|
+
const fnStr = Function.prototype.toString.call(fn);
|
|
667
|
+
isClass = fnStr.startsWith('class ') ||
|
|
668
|
+
(fn.prototype && fn.prototype.constructor === fn &&
|
|
669
|
+
Object.getOwnPropertyNames(fn.prototype).some(m => {
|
|
670
|
+
try { return m !== 'constructor' && typeof fn.prototype[m] === 'function'; }
|
|
671
|
+
catch { return false; }
|
|
672
|
+
}));
|
|
673
|
+
} catch { /* assume not a class */ }
|
|
341
674
|
if (isClass) continue;
|
|
342
675
|
const paramNames = extractParamNames(fn);
|
|
343
676
|
const wrapOpts: WrapOptions = {
|
|
@@ -346,41 +679,81 @@ if (enabled) {
|
|
|
346
679
|
trackArgs: true,
|
|
347
680
|
trackReturn: true,
|
|
348
681
|
sampleRate: 1,
|
|
349
|
-
maxDepth:
|
|
682
|
+
maxDepth: 3,
|
|
350
683
|
environment,
|
|
351
684
|
enabled: true,
|
|
352
685
|
paramNames: paramNames.length > 0 ? paramNames : undefined,
|
|
353
686
|
};
|
|
354
|
-
|
|
687
|
+
const wrapped = wrapFunction(fn, wrapOpts);
|
|
688
|
+
try {
|
|
689
|
+
exports[key] = wrapped;
|
|
690
|
+
} catch {
|
|
691
|
+
// Property might be getter-only (tsx/esbuild uses getter exports).
|
|
692
|
+
// Redefine with Object.defineProperty.
|
|
693
|
+
try {
|
|
694
|
+
Object.defineProperty(exports, key, {
|
|
695
|
+
value: wrapped,
|
|
696
|
+
enumerable: true,
|
|
697
|
+
configurable: true,
|
|
698
|
+
writable: true,
|
|
699
|
+
});
|
|
700
|
+
} catch { continue; /* truly read-only, skip */ }
|
|
701
|
+
}
|
|
355
702
|
count++;
|
|
356
703
|
}
|
|
357
704
|
}
|
|
358
705
|
|
|
359
|
-
// Handle default export if it's a function
|
|
706
|
+
// Handle default export if it's a function (but not a class)
|
|
360
707
|
if (typeof exports.default === 'function') {
|
|
361
708
|
const fn = exports.default;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
709
|
+
let defaultIsClass = false;
|
|
710
|
+
try {
|
|
711
|
+
const defaultFnStr = Function.prototype.toString.call(fn);
|
|
712
|
+
defaultIsClass = defaultFnStr.startsWith('class ') ||
|
|
713
|
+
(fn.prototype && fn.prototype.constructor === fn &&
|
|
714
|
+
Object.getOwnPropertyNames(fn.prototype).some(m => {
|
|
715
|
+
try { return m !== 'constructor' && typeof fn.prototype[m] === 'function'; }
|
|
716
|
+
catch { return false; }
|
|
717
|
+
}));
|
|
718
|
+
} catch { /* assume not a class */ }
|
|
719
|
+
if (!defaultIsClass) {
|
|
720
|
+
const paramNames = extractParamNames(fn);
|
|
721
|
+
const wrapOpts: WrapOptions = {
|
|
722
|
+
functionName: fn.name || 'default',
|
|
723
|
+
module: moduleName,
|
|
724
|
+
trackArgs: true,
|
|
725
|
+
trackReturn: true,
|
|
726
|
+
sampleRate: 1,
|
|
727
|
+
maxDepth: 3,
|
|
728
|
+
environment,
|
|
729
|
+
enabled: true,
|
|
730
|
+
paramNames: paramNames.length > 0 ? paramNames : undefined,
|
|
731
|
+
};
|
|
732
|
+
const wrapped = wrapFunction(fn, wrapOpts);
|
|
733
|
+
try {
|
|
734
|
+
exports.default = wrapped;
|
|
735
|
+
} catch {
|
|
736
|
+
try {
|
|
737
|
+
Object.defineProperty(exports, 'default', {
|
|
738
|
+
value: wrapped, enumerable: true, configurable: true, writable: true,
|
|
739
|
+
});
|
|
740
|
+
} catch { /* skip */ }
|
|
741
|
+
}
|
|
742
|
+
count++;
|
|
743
|
+
}
|
|
376
744
|
}
|
|
377
745
|
|
|
378
746
|
// Wrap class prototype methods for exported classes
|
|
379
747
|
for (const key of Object.keys(exports)) {
|
|
380
748
|
const val = exports[key];
|
|
381
749
|
if (typeof val === 'function' && val.prototype && val.prototype.constructor === val) {
|
|
382
|
-
|
|
383
|
-
|
|
750
|
+
let protoNames: string[];
|
|
751
|
+
try {
|
|
752
|
+
protoNames = Object.getOwnPropertyNames(val.prototype)
|
|
753
|
+
.filter(m => { try { return m !== 'constructor' && typeof val.prototype[m] === 'function'; } catch { return false; } });
|
|
754
|
+
} catch { continue; }
|
|
755
|
+
// Use the class's actual name, not the export key (avoids "default.method")
|
|
756
|
+
const className = val.name || key;
|
|
384
757
|
for (const method of protoNames) {
|
|
385
758
|
if (method.startsWith('_')) continue;
|
|
386
759
|
try {
|
|
@@ -388,12 +761,12 @@ if (enabled) {
|
|
|
388
761
|
if ((origMethod as any)[Symbol.for('__trickle_wrapped')]) continue;
|
|
389
762
|
const methodParamNames = extractParamNames(origMethod);
|
|
390
763
|
const methodOpts: WrapOptions = {
|
|
391
|
-
functionName: `${
|
|
764
|
+
functionName: `${className}.${method}`,
|
|
392
765
|
module: moduleName,
|
|
393
766
|
trackArgs: true,
|
|
394
767
|
trackReturn: true,
|
|
395
768
|
sampleRate: 1,
|
|
396
|
-
maxDepth:
|
|
769
|
+
maxDepth: 3,
|
|
397
770
|
environment,
|
|
398
771
|
enabled: true,
|
|
399
772
|
paramNames: methodParamNames.length > 0 ? methodParamNames : undefined,
|
|
@@ -410,7 +783,13 @@ if (enabled) {
|
|
|
410
783
|
}
|
|
411
784
|
} else if (typeof exports === 'function') {
|
|
412
785
|
// Module exports a single function (e.g. module.exports = fn)
|
|
786
|
+
// But skip classes — wrapping them breaks DI, decorators, and instanceof
|
|
413
787
|
const fn = exports;
|
|
788
|
+
const singleFnStr = Function.prototype.toString.call(fn);
|
|
789
|
+
const singleIsClass = singleFnStr.startsWith('class ') ||
|
|
790
|
+
(fn.prototype && fn.prototype.constructor === fn &&
|
|
791
|
+
Object.getOwnPropertyNames(fn.prototype).some(m => m !== 'constructor' && typeof fn.prototype[m] === 'function'));
|
|
792
|
+
if (singleIsClass) return exports;
|
|
414
793
|
const fnParamNames = extractParamNames(fn);
|
|
415
794
|
const wrapOpts: WrapOptions = {
|
|
416
795
|
functionName: fn.name || moduleName,
|
|
@@ -418,7 +797,7 @@ if (enabled) {
|
|
|
418
797
|
trackArgs: true,
|
|
419
798
|
trackReturn: true,
|
|
420
799
|
sampleRate: 1,
|
|
421
|
-
maxDepth:
|
|
800
|
+
maxDepth: 3,
|
|
422
801
|
environment,
|
|
423
802
|
enabled: true,
|
|
424
803
|
paramNames: fnParamNames.length > 0 ? fnParamNames : undefined,
|