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.
Files changed (57) hide show
  1. package/auto-env.js +13 -0
  2. package/auto-esm.mjs +128 -0
  3. package/auto.js +3 -0
  4. package/dist/auto-codegen.d.ts +29 -0
  5. package/dist/auto-codegen.js +999 -0
  6. package/dist/auto-register.d.ts +16 -0
  7. package/dist/auto-register.js +99 -0
  8. package/dist/cache.d.ts +27 -0
  9. package/dist/cache.js +52 -0
  10. package/dist/env-detect.d.ts +5 -0
  11. package/dist/env-detect.js +35 -0
  12. package/dist/express.d.ts +44 -0
  13. package/dist/express.js +342 -0
  14. package/dist/fetch-observer.d.ts +24 -0
  15. package/dist/fetch-observer.js +217 -0
  16. package/dist/index.d.ts +64 -0
  17. package/dist/index.js +172 -0
  18. package/dist/observe-register.d.ts +29 -0
  19. package/dist/observe-register.js +455 -0
  20. package/dist/observe.d.ts +44 -0
  21. package/dist/observe.js +109 -0
  22. package/dist/proxy-tracker.d.ts +15 -0
  23. package/dist/proxy-tracker.js +172 -0
  24. package/dist/register.d.ts +21 -0
  25. package/dist/register.js +105 -0
  26. package/dist/transport.d.ts +22 -0
  27. package/dist/transport.js +228 -0
  28. package/dist/type-hash.d.ts +5 -0
  29. package/dist/type-hash.js +60 -0
  30. package/dist/type-inference.d.ts +14 -0
  31. package/dist/type-inference.js +259 -0
  32. package/dist/types.d.ts +78 -0
  33. package/dist/types.js +2 -0
  34. package/dist/wrap.d.ts +10 -0
  35. package/dist/wrap.js +247 -0
  36. package/observe-esm-hooks.mjs +367 -0
  37. package/observe-esm.mjs +40 -0
  38. package/observe.js +2 -0
  39. package/package.json +26 -0
  40. package/register.js +2 -0
  41. package/src/auto-codegen.ts +1058 -0
  42. package/src/auto-register.ts +102 -0
  43. package/src/cache.ts +53 -0
  44. package/src/env-detect.ts +22 -0
  45. package/src/express.ts +386 -0
  46. package/src/fetch-observer.ts +226 -0
  47. package/src/index.ts +199 -0
  48. package/src/observe-register.ts +453 -0
  49. package/src/observe.ts +127 -0
  50. package/src/proxy-tracker.ts +208 -0
  51. package/src/register.ts +110 -0
  52. package/src/transport.ts +207 -0
  53. package/src/type-hash.ts +71 -0
  54. package/src/type-inference.ts +285 -0
  55. package/src/types.ts +61 -0
  56. package/src/wrap.ts +289 -0
  57. 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
+ }