trickle-observe 0.1.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/auto-env.js +13 -0
- package/auto-esm.mjs +128 -0
- package/auto.js +3 -0
- package/dist/auto-codegen.d.ts +29 -0
- package/dist/auto-codegen.js +999 -0
- package/dist/auto-register.d.ts +16 -0
- package/dist/auto-register.js +99 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.js +52 -0
- package/dist/env-detect.d.ts +5 -0
- package/dist/env-detect.js +35 -0
- package/dist/express.d.ts +44 -0
- package/dist/express.js +342 -0
- package/dist/fetch-observer.d.ts +24 -0
- package/dist/fetch-observer.js +217 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +172 -0
- package/dist/observe-register.d.ts +29 -0
- package/dist/observe-register.js +455 -0
- package/dist/observe.d.ts +44 -0
- package/dist/observe.js +109 -0
- package/dist/proxy-tracker.d.ts +15 -0
- package/dist/proxy-tracker.js +172 -0
- package/dist/register.d.ts +21 -0
- package/dist/register.js +105 -0
- package/dist/transport.d.ts +22 -0
- package/dist/transport.js +228 -0
- package/dist/type-hash.d.ts +5 -0
- package/dist/type-hash.js +60 -0
- package/dist/type-inference.d.ts +14 -0
- package/dist/type-inference.js +259 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +2 -0
- package/dist/wrap.d.ts +10 -0
- package/dist/wrap.js +247 -0
- package/observe-esm-hooks.mjs +367 -0
- package/observe-esm.mjs +40 -0
- package/observe.js +2 -0
- package/package.json +26 -0
- package/register.js +2 -0
- package/src/auto-codegen.ts +1058 -0
- package/src/auto-register.ts +102 -0
- package/src/cache.ts +53 -0
- package/src/env-detect.ts +22 -0
- package/src/express.ts +386 -0
- package/src/fetch-observer.ts +226 -0
- package/src/index.ts +199 -0
- package/src/observe-register.ts +453 -0
- package/src/observe.ts +127 -0
- package/src/proxy-tracker.ts +208 -0
- package/src/register.ts +110 -0
- package/src/transport.ts +207 -0
- package/src/type-hash.ts +71 -0
- package/src/type-inference.ts +285 -0
- package/src/types.ts +61 -0
- package/src/wrap.ts +289 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-observation register — patches Node's module loader to automatically
|
|
3
|
+
* wrap ALL functions in user code (not just exports).
|
|
4
|
+
*
|
|
5
|
+
* Uses two complementary hooks:
|
|
6
|
+
* 1. Module._compile: Transforms source code to wrap function declarations
|
|
7
|
+
* IMMEDIATELY after each function body. This catches:
|
|
8
|
+
* - Entry file functions (previously invisible)
|
|
9
|
+
* - Non-exported helper functions inside modules
|
|
10
|
+
* - All function declarations in user code
|
|
11
|
+
* 2. Module._load: Wraps exported functions on the exports object
|
|
12
|
+
* (covers module.exports patterns like { foo, bar })
|
|
13
|
+
*
|
|
14
|
+
* A double-wrap guard in wrapFunction prevents the same function being
|
|
15
|
+
* wrapped by both hooks.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
*
|
|
19
|
+
* node -r trickle/observe app.js
|
|
20
|
+
*
|
|
21
|
+
* Environment variables:
|
|
22
|
+
* TRICKLE_BACKEND_URL — Backend URL (default: http://localhost:4888)
|
|
23
|
+
* TRICKLE_ENABLED — Set to "0" or "false" to disable
|
|
24
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
25
|
+
* TRICKLE_ENV — Override detected environment
|
|
26
|
+
* TRICKLE_OBSERVE_INCLUDE — Comma-separated substrings to include (default: all user code)
|
|
27
|
+
* TRICKLE_OBSERVE_EXCLUDE — Comma-separated substrings to exclude (default: none)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import Module from 'module';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
import { configure } from './transport';
|
|
33
|
+
import { detectEnvironment } from './env-detect';
|
|
34
|
+
import { wrapFunction } from './wrap';
|
|
35
|
+
import { WrapOptions } from './types';
|
|
36
|
+
import { patchFetch } from './fetch-observer';
|
|
37
|
+
|
|
38
|
+
const M = Module as any;
|
|
39
|
+
const originalLoad = M._load;
|
|
40
|
+
const originalCompile = M.prototype._compile;
|
|
41
|
+
|
|
42
|
+
// Read config from environment
|
|
43
|
+
const backendUrl = process.env.TRICKLE_BACKEND_URL || 'http://localhost:4888';
|
|
44
|
+
const enabled = process.env.TRICKLE_ENABLED !== '0' && process.env.TRICKLE_ENABLED !== 'false';
|
|
45
|
+
const debug = process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true';
|
|
46
|
+
const envOverride = process.env.TRICKLE_ENV || undefined;
|
|
47
|
+
|
|
48
|
+
const includePatterns = process.env.TRICKLE_OBSERVE_INCLUDE
|
|
49
|
+
? process.env.TRICKLE_OBSERVE_INCLUDE.split(',').map(s => s.trim())
|
|
50
|
+
: [];
|
|
51
|
+
const excludePatterns = process.env.TRICKLE_OBSERVE_EXCLUDE
|
|
52
|
+
? process.env.TRICKLE_OBSERVE_EXCLUDE.split(',').map(s => s.trim())
|
|
53
|
+
: [];
|
|
54
|
+
|
|
55
|
+
const wrapped = new Set<string>();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a file path should be observed (user code, not node_modules/trickle internals).
|
|
59
|
+
*/
|
|
60
|
+
function shouldObserve(filename: string): boolean {
|
|
61
|
+
if (!filename || !filename.startsWith('/')) return false;
|
|
62
|
+
if (filename.includes('node_modules')) return false;
|
|
63
|
+
// Don't transform trickle's own code
|
|
64
|
+
if (filename.includes('client-js/') || filename.includes('trickle-client/') || filename.includes('trickle/dist/')) return false;
|
|
65
|
+
|
|
66
|
+
// Apply include/exclude filters
|
|
67
|
+
if (includePatterns.length > 0) {
|
|
68
|
+
if (!includePatterns.some(p => filename.includes(p))) return false;
|
|
69
|
+
}
|
|
70
|
+
if (excludePatterns.length > 0) {
|
|
71
|
+
if (excludePatterns.some(p => filename.includes(p))) return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Only transform JS/TS files
|
|
75
|
+
const ext = path.extname(filename).toLowerCase();
|
|
76
|
+
if (!['.js', '.jsx', '.ts', '.tsx', '.cjs', '.mjs'].includes(ext)) return false;
|
|
77
|
+
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Find the closing brace position for a function body starting at `openBrace`.
|
|
83
|
+
* Handles nested braces, strings, template literals, and comments.
|
|
84
|
+
*/
|
|
85
|
+
function findClosingBrace(source: string, openBrace: number): number {
|
|
86
|
+
let depth = 1;
|
|
87
|
+
let pos = openBrace + 1;
|
|
88
|
+
while (pos < source.length && depth > 0) {
|
|
89
|
+
const ch = source[pos];
|
|
90
|
+
if (ch === '{') {
|
|
91
|
+
depth++;
|
|
92
|
+
} else if (ch === '}') {
|
|
93
|
+
depth--;
|
|
94
|
+
if (depth === 0) return pos;
|
|
95
|
+
} else if (ch === '"' || ch === "'" || ch === '`') {
|
|
96
|
+
// Skip string/template literal
|
|
97
|
+
const quote = ch;
|
|
98
|
+
pos++;
|
|
99
|
+
while (pos < source.length) {
|
|
100
|
+
if (source[pos] === '\\') { pos++; } // skip escaped char
|
|
101
|
+
else if (source[pos] === quote) break;
|
|
102
|
+
else if (quote === '`' && source[pos] === '$' && pos + 1 < source.length && source[pos + 1] === '{') {
|
|
103
|
+
// Template literal expression — skip nested content
|
|
104
|
+
pos += 2;
|
|
105
|
+
let tDepth = 1;
|
|
106
|
+
while (pos < source.length && tDepth > 0) {
|
|
107
|
+
if (source[pos] === '{') tDepth++;
|
|
108
|
+
else if (source[pos] === '}') tDepth--;
|
|
109
|
+
else if (source[pos] === '"' || source[pos] === "'" || source[pos] === '`') {
|
|
110
|
+
const q = source[pos]; pos++;
|
|
111
|
+
while (pos < source.length && source[pos] !== q) {
|
|
112
|
+
if (source[pos] === '\\') pos++;
|
|
113
|
+
pos++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
pos++;
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
pos++;
|
|
121
|
+
}
|
|
122
|
+
} else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
|
|
123
|
+
// Line comment — skip to end of line
|
|
124
|
+
while (pos < source.length && source[pos] !== '\n') pos++;
|
|
125
|
+
} else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
|
|
126
|
+
// Block comment — skip to */
|
|
127
|
+
pos += 2;
|
|
128
|
+
while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/')) pos++;
|
|
129
|
+
pos++; // skip past /
|
|
130
|
+
}
|
|
131
|
+
pos++;
|
|
132
|
+
}
|
|
133
|
+
return -1; // not found
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Transform CJS source code to wrap function declarations with trickle observation.
|
|
138
|
+
*
|
|
139
|
+
* For each `function foo(...) { ... }` found, inserts a wrapper call
|
|
140
|
+
* IMMEDIATELY AFTER the function body closes:
|
|
141
|
+
*
|
|
142
|
+
* function foo(a) { return a; }
|
|
143
|
+
* foo = __trickle_wrap(foo, 'foo');
|
|
144
|
+
*
|
|
145
|
+
* This ensures functions are wrapped before subsequent code calls them,
|
|
146
|
+
* which is critical for entry files where functions are defined and used
|
|
147
|
+
* in the same top-level scope.
|
|
148
|
+
*/
|
|
149
|
+
function transformCjsSource(source: string, filename: string, moduleName: string, env: string): string {
|
|
150
|
+
const funcRegex = /^[ \t]*(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
151
|
+
const insertions: Array<{ position: number; name: string; paramNames: string[] }> = [];
|
|
152
|
+
let match;
|
|
153
|
+
|
|
154
|
+
while ((match = funcRegex.exec(source)) !== null) {
|
|
155
|
+
const name = match[1];
|
|
156
|
+
// Skip common false positives
|
|
157
|
+
if (name === 'require' || name === 'exports' || name === 'module') continue;
|
|
158
|
+
|
|
159
|
+
// Find the opening brace of the function body
|
|
160
|
+
const afterMatch = match.index + match[0].length;
|
|
161
|
+
const openBrace = source.indexOf('{', afterMatch);
|
|
162
|
+
if (openBrace === -1) continue;
|
|
163
|
+
|
|
164
|
+
// Extract parameter names from the source between ( and {
|
|
165
|
+
const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
|
|
166
|
+
const paramNames = paramStr
|
|
167
|
+
? paramStr.split(',').map(p => {
|
|
168
|
+
// Handle default values: "x = 5" → "x", destructuring: "{a, b}" → skip
|
|
169
|
+
const trimmed = p.trim().split('=')[0].trim().split(':')[0].trim();
|
|
170
|
+
// Skip destructuring patterns and rest params
|
|
171
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('...')) return '';
|
|
172
|
+
return trimmed;
|
|
173
|
+
}).filter(Boolean)
|
|
174
|
+
: [];
|
|
175
|
+
|
|
176
|
+
// Find the matching closing brace
|
|
177
|
+
const closeBrace = findClosingBrace(source, openBrace);
|
|
178
|
+
if (closeBrace === -1) continue;
|
|
179
|
+
|
|
180
|
+
insertions.push({ position: closeBrace + 1, name, paramNames });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (insertions.length === 0) return source;
|
|
184
|
+
|
|
185
|
+
// Resolve the path to the wrap helper (compiled JS)
|
|
186
|
+
const wrapHelperPath = path.join(__dirname, 'wrap.js');
|
|
187
|
+
|
|
188
|
+
// Prepend: load the wrapper and create the wrap helper
|
|
189
|
+
const prefix = [
|
|
190
|
+
`var __trickle_mod = require(${JSON.stringify(wrapHelperPath)});`,
|
|
191
|
+
`var __trickle_wrap = function(fn, name, paramNames) {`,
|
|
192
|
+
` var opts = {`,
|
|
193
|
+
` functionName: name,`,
|
|
194
|
+
` module: ${JSON.stringify(moduleName)},`,
|
|
195
|
+
` trackArgs: true,`,
|
|
196
|
+
` trackReturn: true,`,
|
|
197
|
+
` sampleRate: 1,`,
|
|
198
|
+
` maxDepth: 5,`,
|
|
199
|
+
` environment: ${JSON.stringify(env)},`,
|
|
200
|
+
` enabled: true,`,
|
|
201
|
+
` };`,
|
|
202
|
+
` if (paramNames && paramNames.length) opts.paramNames = paramNames;`,
|
|
203
|
+
` return __trickle_mod.wrapFunction(fn, opts);`,
|
|
204
|
+
`};`,
|
|
205
|
+
'',
|
|
206
|
+
].join('\n');
|
|
207
|
+
|
|
208
|
+
// Insert wrapper calls immediately after each function body (reverse order to preserve positions)
|
|
209
|
+
let result = source;
|
|
210
|
+
for (let i = insertions.length - 1; i >= 0; i--) {
|
|
211
|
+
const { position, name, paramNames } = insertions[i];
|
|
212
|
+
const paramNamesArg = paramNames.length > 0 ? JSON.stringify(paramNames) : 'null';
|
|
213
|
+
const wrapperCall = `\ntry{${name}=__trickle_wrap(${name},'${name}',${paramNamesArg})}catch(__e){}\n`;
|
|
214
|
+
result = result.slice(0, position) + wrapperCall + result.slice(position);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return prefix + result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extract parameter names from a function using fn.toString().
|
|
222
|
+
*/
|
|
223
|
+
function extractParamNames(fn: Function): string[] {
|
|
224
|
+
try {
|
|
225
|
+
const src = fn.toString();
|
|
226
|
+
const parenMatch = src.match(/^(?:async\s+)?(?:function\s*\w*|\w+)\s*\(([^)]*)\)/);
|
|
227
|
+
if (!parenMatch) return [];
|
|
228
|
+
const paramStr = parenMatch[1].trim();
|
|
229
|
+
if (!paramStr) return [];
|
|
230
|
+
return paramStr.split(',').map(p => {
|
|
231
|
+
const trimmed = p.trim().split('=')[0].trim().split(':')[0].trim();
|
|
232
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('...')) return '';
|
|
233
|
+
return trimmed;
|
|
234
|
+
}).filter(Boolean);
|
|
235
|
+
} catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (enabled) {
|
|
241
|
+
const environment = envOverride || detectEnvironment();
|
|
242
|
+
|
|
243
|
+
configure({
|
|
244
|
+
backendUrl,
|
|
245
|
+
batchIntervalMs: 2000,
|
|
246
|
+
debug,
|
|
247
|
+
enabled: true,
|
|
248
|
+
environment,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (debug) {
|
|
252
|
+
console.log(`[trickle/observe] Auto-observation enabled (backend: ${backendUrl})`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Hook 0: Patch global.fetch to capture HTTP response types ──
|
|
256
|
+
patchFetch(environment, debug);
|
|
257
|
+
|
|
258
|
+
// ── Hook 1: Module._compile — transform source to wrap function declarations ──
|
|
259
|
+
// This catches ALL functions including entry file and non-exported helpers.
|
|
260
|
+
|
|
261
|
+
M.prototype._compile = function hookedCompile(content: string, filename: string): any {
|
|
262
|
+
if (shouldObserve(filename)) {
|
|
263
|
+
const moduleName = path.basename(filename).replace(/\.[jt]sx?$/, '');
|
|
264
|
+
try {
|
|
265
|
+
const transformed = transformCjsSource(content, filename, moduleName, environment);
|
|
266
|
+
if (transformed !== content) {
|
|
267
|
+
// Count how many functions were wrapped (from insertions)
|
|
268
|
+
const funcRegex = /^[ \t]*(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
269
|
+
let count = 0;
|
|
270
|
+
let m;
|
|
271
|
+
while ((m = funcRegex.exec(content)) !== null) {
|
|
272
|
+
if (m[1] !== 'require' && m[1] !== 'exports' && m[1] !== 'module') count++;
|
|
273
|
+
}
|
|
274
|
+
if (debug && count > 0) {
|
|
275
|
+
console.log(`[trickle/observe] Deep-wrapped ${count} functions in ${moduleName} (${filename})`);
|
|
276
|
+
}
|
|
277
|
+
return originalCompile.call(this, transformed, filename);
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// If transform fails, fall through to original compilation
|
|
281
|
+
if (debug) {
|
|
282
|
+
console.log(`[trickle/observe] Transform failed for ${filename}: ${err}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return originalCompile.call(this, content, filename);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// ── Hook 2: Module._load — wrap exports object (catches module.exports patterns) ──
|
|
290
|
+
// Still useful for wrapping exports that use inline function expressions
|
|
291
|
+
// (e.g. module.exports = { foo: function() {} }).
|
|
292
|
+
// The double-wrap guard in wrapFunction prevents redundant wrapping.
|
|
293
|
+
|
|
294
|
+
M._load = function hookedLoad(request: string, parent: any, isMain: boolean): any {
|
|
295
|
+
const exports = originalLoad.apply(this, arguments);
|
|
296
|
+
|
|
297
|
+
// Only process user modules (relative paths)
|
|
298
|
+
if (!request.startsWith('.') && !request.startsWith('/')) {
|
|
299
|
+
return exports;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Resolve to absolute path for dedup
|
|
303
|
+
let resolvedPath: string;
|
|
304
|
+
try {
|
|
305
|
+
resolvedPath = M._resolveFilename(request, parent);
|
|
306
|
+
} catch {
|
|
307
|
+
return exports;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Skip node_modules and trickle internals
|
|
311
|
+
if (resolvedPath.includes('node_modules')) return exports;
|
|
312
|
+
if (resolvedPath.includes('client-js/') || resolvedPath.includes('trickle-client/') || resolvedPath.includes('trickle/dist/')) return exports;
|
|
313
|
+
|
|
314
|
+
// Skip already-wrapped modules
|
|
315
|
+
if (wrapped.has(resolvedPath)) return exports;
|
|
316
|
+
|
|
317
|
+
// Apply include/exclude filters
|
|
318
|
+
if (includePatterns.length > 0) {
|
|
319
|
+
const matches = includePatterns.some(p => resolvedPath.includes(p));
|
|
320
|
+
if (!matches) return exports;
|
|
321
|
+
}
|
|
322
|
+
if (excludePatterns.length > 0) {
|
|
323
|
+
const excluded = excludePatterns.some(p => resolvedPath.includes(p));
|
|
324
|
+
if (excluded) return exports;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
wrapped.add(resolvedPath);
|
|
328
|
+
|
|
329
|
+
// Derive module name from file path
|
|
330
|
+
const moduleName = path.basename(resolvedPath).replace(/\.[jt]sx?$/, '');
|
|
331
|
+
|
|
332
|
+
// Wrap exported functions
|
|
333
|
+
if (exports && typeof exports === 'object') {
|
|
334
|
+
let count = 0;
|
|
335
|
+
for (const key of Object.keys(exports)) {
|
|
336
|
+
if (typeof exports[key] === 'function' && key !== 'default') {
|
|
337
|
+
const fn = exports[key];
|
|
338
|
+
// Skip classes — their prototype methods are wrapped separately below
|
|
339
|
+
const isClass = fn.prototype && fn.prototype.constructor === fn &&
|
|
340
|
+
Object.getOwnPropertyNames(fn.prototype).some(m => m !== 'constructor' && typeof fn.prototype[m] === 'function');
|
|
341
|
+
if (isClass) continue;
|
|
342
|
+
const paramNames = extractParamNames(fn);
|
|
343
|
+
const wrapOpts: WrapOptions = {
|
|
344
|
+
functionName: key,
|
|
345
|
+
module: moduleName,
|
|
346
|
+
trackArgs: true,
|
|
347
|
+
trackReturn: true,
|
|
348
|
+
sampleRate: 1,
|
|
349
|
+
maxDepth: 5,
|
|
350
|
+
environment,
|
|
351
|
+
enabled: true,
|
|
352
|
+
paramNames: paramNames.length > 0 ? paramNames : undefined,
|
|
353
|
+
};
|
|
354
|
+
exports[key] = wrapFunction(fn, wrapOpts);
|
|
355
|
+
count++;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Handle default export if it's a function
|
|
360
|
+
if (typeof exports.default === 'function') {
|
|
361
|
+
const fn = exports.default;
|
|
362
|
+
const paramNames = extractParamNames(fn);
|
|
363
|
+
const wrapOpts: WrapOptions = {
|
|
364
|
+
functionName: fn.name || 'default',
|
|
365
|
+
module: moduleName,
|
|
366
|
+
trackArgs: true,
|
|
367
|
+
trackReturn: true,
|
|
368
|
+
sampleRate: 1,
|
|
369
|
+
maxDepth: 5,
|
|
370
|
+
environment,
|
|
371
|
+
enabled: true,
|
|
372
|
+
paramNames: paramNames.length > 0 ? paramNames : undefined,
|
|
373
|
+
};
|
|
374
|
+
exports.default = wrapFunction(fn, wrapOpts);
|
|
375
|
+
count++;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Wrap class prototype methods for exported classes
|
|
379
|
+
for (const key of Object.keys(exports)) {
|
|
380
|
+
const val = exports[key];
|
|
381
|
+
if (typeof val === 'function' && val.prototype && val.prototype.constructor === val) {
|
|
382
|
+
const protoNames = Object.getOwnPropertyNames(val.prototype)
|
|
383
|
+
.filter(m => m !== 'constructor' && typeof val.prototype[m] === 'function');
|
|
384
|
+
for (const method of protoNames) {
|
|
385
|
+
if (method.startsWith('_')) continue;
|
|
386
|
+
try {
|
|
387
|
+
const origMethod = val.prototype[method];
|
|
388
|
+
if ((origMethod as any)[Symbol.for('__trickle_wrapped')]) continue;
|
|
389
|
+
const methodParamNames = extractParamNames(origMethod);
|
|
390
|
+
const methodOpts: WrapOptions = {
|
|
391
|
+
functionName: `${key}.${method}`,
|
|
392
|
+
module: moduleName,
|
|
393
|
+
trackArgs: true,
|
|
394
|
+
trackReturn: true,
|
|
395
|
+
sampleRate: 1,
|
|
396
|
+
maxDepth: 5,
|
|
397
|
+
environment,
|
|
398
|
+
enabled: true,
|
|
399
|
+
paramNames: methodParamNames.length > 0 ? methodParamNames : undefined,
|
|
400
|
+
};
|
|
401
|
+
val.prototype[method] = wrapFunction(origMethod, methodOpts);
|
|
402
|
+
count++;
|
|
403
|
+
} catch { /* skip methods that can't be wrapped */ }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (debug && count > 0) {
|
|
409
|
+
console.log(`[trickle/observe] Wrapped ${count} exports from ${moduleName} (${resolvedPath})`);
|
|
410
|
+
}
|
|
411
|
+
} else if (typeof exports === 'function') {
|
|
412
|
+
// Module exports a single function (e.g. module.exports = fn)
|
|
413
|
+
const fn = exports;
|
|
414
|
+
const fnParamNames = extractParamNames(fn);
|
|
415
|
+
const wrapOpts: WrapOptions = {
|
|
416
|
+
functionName: fn.name || moduleName,
|
|
417
|
+
module: moduleName,
|
|
418
|
+
trackArgs: true,
|
|
419
|
+
trackReturn: true,
|
|
420
|
+
sampleRate: 1,
|
|
421
|
+
maxDepth: 5,
|
|
422
|
+
environment,
|
|
423
|
+
enabled: true,
|
|
424
|
+
paramNames: fnParamNames.length > 0 ? fnParamNames : undefined,
|
|
425
|
+
};
|
|
426
|
+
const wrappedFn = wrapFunction(fn, wrapOpts);
|
|
427
|
+
|
|
428
|
+
// Copy static properties
|
|
429
|
+
for (const key of Object.keys(fn)) {
|
|
430
|
+
(wrappedFn as any)[key] = fn[key];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Update require cache
|
|
434
|
+
try {
|
|
435
|
+
if (require.cache[resolvedPath]) {
|
|
436
|
+
require.cache[resolvedPath]!.exports = wrappedFn;
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
// Cache update failed — non-critical
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (debug) {
|
|
443
|
+
console.log(`[trickle/observe] Wrapped default export from ${moduleName}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return wrappedFn;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return exports;
|
|
450
|
+
};
|
|
451
|
+
} else if (debug) {
|
|
452
|
+
console.log('[trickle/observe] Auto-observation disabled (TRICKLE_ENABLED=false)');
|
|
453
|
+
}
|
package/src/observe.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal function observation — wrap any module's exports to capture
|
|
3
|
+
* runtime types and sample data for every function call.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
*
|
|
7
|
+
* import { observe } from 'trickle';
|
|
8
|
+
* import * as helpers from './myHelpers';
|
|
9
|
+
*
|
|
10
|
+
* const { fetchUser, createOrder } = observe(helpers, { module: 'my-helpers' });
|
|
11
|
+
* // Every call to fetchUser / createOrder now captures types + samples
|
|
12
|
+
*
|
|
13
|
+
* Works with any object whose values are functions — module exports, plain
|
|
14
|
+
* objects, class instances, test helpers, SDK clients, etc.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { wrapFunction } from './wrap';
|
|
18
|
+
import { WrapOptions } from './types';
|
|
19
|
+
import { detectEnvironment } from './env-detect';
|
|
20
|
+
|
|
21
|
+
export interface ObserveOpts {
|
|
22
|
+
/** Module name shown in `trickle functions` output. Defaults to 'observed'. */
|
|
23
|
+
module?: string;
|
|
24
|
+
/** Environment label. Auto-detected if omitted. */
|
|
25
|
+
environment?: string;
|
|
26
|
+
/** Fraction of calls to capture (0–1). Defaults to 1 (all calls). */
|
|
27
|
+
sampleRate?: number;
|
|
28
|
+
/** Max depth for type inference. Defaults to 5. */
|
|
29
|
+
maxDepth?: number;
|
|
30
|
+
/** Set to false to disable observation (passthrough). Defaults to true. */
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap every function property on `obj` so calls are observed by trickle.
|
|
36
|
+
* Non-function properties are copied through unchanged.
|
|
37
|
+
*
|
|
38
|
+
* Returns a new object with the same shape — the original is never mutated.
|
|
39
|
+
*/
|
|
40
|
+
export function observe<T extends Record<string, any>>(obj: T, opts?: ObserveOpts): T {
|
|
41
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
42
|
+
|
|
43
|
+
const moduleName = opts?.module || inferModuleName();
|
|
44
|
+
const environment = opts?.environment || detectEnvironment();
|
|
45
|
+
const enabled = opts?.enabled !== false;
|
|
46
|
+
const sampleRate = opts?.sampleRate ?? 1;
|
|
47
|
+
const maxDepth = opts?.maxDepth ?? 5;
|
|
48
|
+
|
|
49
|
+
const result: Record<string, any> = {};
|
|
50
|
+
|
|
51
|
+
for (const key of Object.keys(obj)) {
|
|
52
|
+
const val = obj[key];
|
|
53
|
+
|
|
54
|
+
if (typeof val === 'function') {
|
|
55
|
+
const wrapOpts: WrapOptions = {
|
|
56
|
+
functionName: key,
|
|
57
|
+
module: moduleName,
|
|
58
|
+
trackArgs: true,
|
|
59
|
+
trackReturn: true,
|
|
60
|
+
sampleRate,
|
|
61
|
+
maxDepth,
|
|
62
|
+
environment,
|
|
63
|
+
enabled,
|
|
64
|
+
};
|
|
65
|
+
result[key] = wrapFunction(val, wrapOpts);
|
|
66
|
+
} else {
|
|
67
|
+
result[key] = val;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result as T;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Wrap a single function for observation.
|
|
76
|
+
* Convenience for when you don't have a module object to pass to observe().
|
|
77
|
+
*
|
|
78
|
+
* import { observeFn } from 'trickle';
|
|
79
|
+
* const tracedFetch = observeFn(fetchUser, { module: 'api', name: 'fetchUser' });
|
|
80
|
+
*/
|
|
81
|
+
export function observeFn<T extends (...args: any[]) => any>(
|
|
82
|
+
fn: T,
|
|
83
|
+
opts?: ObserveOpts & { name?: string },
|
|
84
|
+
): T {
|
|
85
|
+
const wrapOpts: WrapOptions = {
|
|
86
|
+
functionName: opts?.name || fn.name || 'anonymous',
|
|
87
|
+
module: opts?.module || inferModuleName(),
|
|
88
|
+
trackArgs: true,
|
|
89
|
+
trackReturn: true,
|
|
90
|
+
sampleRate: opts?.sampleRate ?? 1,
|
|
91
|
+
maxDepth: opts?.maxDepth ?? 5,
|
|
92
|
+
environment: opts?.environment || detectEnvironment(),
|
|
93
|
+
enabled: opts?.enabled !== false,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return wrapFunction(fn, wrapOpts);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Attempt to infer a module name from the call stack.
|
|
101
|
+
*/
|
|
102
|
+
function inferModuleName(): string {
|
|
103
|
+
try {
|
|
104
|
+
const stack = new Error().stack;
|
|
105
|
+
if (!stack) return 'observed';
|
|
106
|
+
|
|
107
|
+
const lines = stack.split('\n');
|
|
108
|
+
// Skip internal frames (Error, observe internals)
|
|
109
|
+
for (let i = 3; i < lines.length; i++) {
|
|
110
|
+
const line = lines[i].trim();
|
|
111
|
+
const match = line.match(/(?:at\s+)?(?:.*?\s+\()?(.+?)(?::\d+:\d+)?\)?$/);
|
|
112
|
+
if (match) {
|
|
113
|
+
const filePath = match[1];
|
|
114
|
+
if (filePath.includes('node_modules')) continue;
|
|
115
|
+
if (filePath.includes('trickle')) continue;
|
|
116
|
+
const parts = filePath.split('/');
|
|
117
|
+
const filename = parts[parts.length - 1];
|
|
118
|
+
if (filename && !filename.startsWith('<')) {
|
|
119
|
+
return filename.replace(/\.[jt]sx?$/, '');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Don't crash on stack inspection failure
|
|
125
|
+
}
|
|
126
|
+
return 'observed';
|
|
127
|
+
}
|