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/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);
@@ -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
- if (insertions.length === 0) return source;
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 prefix = [
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: 5,`,
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
- ].join('\n');
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
- // 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];
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
- const wrapperCall = `\ntry{${name}=__trickle_wrap(${name},'${name}',${paramNamesArg})}catch(__e){}\n`;
214
- result = result.slice(0, position) + wrapperCall + result.slice(position);
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 0: Patch global.fetch to capture HTTP response types ──
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
- // Only process user modules (relative paths)
298
- if (!request.startsWith('.') && !request.startsWith('/')) {
299
- return exports;
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 — 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');
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: 5,
682
+ maxDepth: 3,
350
683
  environment,
351
684
  enabled: true,
352
685
  paramNames: paramNames.length > 0 ? paramNames : undefined,
353
686
  };
354
- exports[key] = wrapFunction(fn, wrapOpts);
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
- 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++;
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
- const protoNames = Object.getOwnPropertyNames(val.prototype)
383
- .filter(m => m !== 'constructor' && typeof val.prototype[m] === 'function');
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: `${key}.${method}`,
764
+ functionName: `${className}.${method}`,
392
765
  module: moduleName,
393
766
  trackArgs: true,
394
767
  trackReturn: true,
395
768
  sampleRate: 1,
396
- maxDepth: 5,
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: 5,
800
+ maxDepth: 3,
422
801
  environment,
423
802
  enabled: true,
424
803
  paramNames: fnParamNames.length > 0 ? fnParamNames : undefined,