repro-nest 0.0.213 → 0.0.215

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/index.d.ts CHANGED
@@ -48,17 +48,28 @@ export type TraceEventForFilter = {
48
48
  eventType: TraceEventPhase;
49
49
  functionType?: string | null;
50
50
  fn?: string;
51
+ wrapperClass?: string | null;
51
52
  file?: string | null;
52
53
  depth?: number;
53
54
  library?: string | null;
54
55
  };
55
56
  export type HeaderRule = string | RegExp;
56
57
  export type HeaderCaptureOptions = {
57
- /** When true, sensitive headers such as Authorization are kept; default redacts them. */
58
+ /** When true, sensitive headers such as Authorization are kept unmasked; default masks them. */
58
59
  allowSensitiveHeaders?: boolean;
59
- /** Additional header names (string or RegExp) to drop from captures. */
60
+ /**
61
+ * Header names (string or RegExp) to mask.
62
+ * `dropHeaders` is kept for backward-compatibility and treated as an alias for `maskHeaders`.
63
+ */
64
+ maskHeaders?: HeaderRule | HeaderRule[];
65
+ /** @deprecated Alias for {@link maskHeaders}. */
60
66
  dropHeaders?: HeaderRule | HeaderRule[];
61
- /** Explicit allowlist that overrides defaults and drop rules. */
67
+ /**
68
+ * Header names (string or RegExp) to keep unmasked, overriding defaults and `maskHeaders`.
69
+ * `keepHeaders` is kept for backward-compatibility and treated as an alias for `unmaskHeaders`.
70
+ */
71
+ unmaskHeaders?: HeaderRule | HeaderRule[];
72
+ /** @deprecated Alias for {@link unmaskHeaders}. */
62
73
  keepHeaders?: HeaderRule | HeaderRule[];
63
74
  };
64
75
  /** Lightweight helper to disable every trace emitted from specific files. */
@@ -78,6 +89,17 @@ export type DisableFunctionTraceRule = {
78
89
  fn?: TraceRulePattern;
79
90
  /** Function name (e.g. `"findOne"`, `/^UserService\./`). */
80
91
  functionName?: TraceRulePattern;
92
+ /** Shortcut for {@link wrapperClass}. */
93
+ wrapper?: TraceRulePattern;
94
+ /**
95
+ * Wrapper/owner name derived from {@link functionName} (e.g. `"UserService"` in `"UserService.create"`).
96
+ * Useful when multiple functions share the same method name.
97
+ */
98
+ wrapperClass?: TraceRulePattern;
99
+ /** Alias for {@link wrapperClass}. */
100
+ className?: TraceRulePattern;
101
+ /** Alias for {@link wrapperClass}. */
102
+ owner?: TraceRulePattern;
81
103
  /** Absolute file path where the function was defined. */
82
104
  file?: TraceRulePattern;
83
105
  /** Shortcut for {@link library}. */
@@ -95,6 +117,33 @@ export type DisableFunctionTraceRule = {
95
117
  };
96
118
  export type DisableFunctionTracePredicate = (event: TraceEventForFilter) => boolean;
97
119
  export type DisableFunctionTraceConfig = DisableFunctionTraceRule | DisableFunctionTracePredicate;
120
+ export type ReproMaskTarget = 'request.headers' | 'request.body' | 'request.params' | 'request.query' | 'response.body' | 'trace.args' | 'trace.returnValue' | 'trace.error';
121
+ export type ReproMaskWhen = DisableFunctionTraceRule & {
122
+ /** Match HTTP method (e.g. `"GET"`, `/^post$/i`). */
123
+ method?: TraceRulePattern;
124
+ /** Match request path without query string (e.g. `"/api/auth/login"`). */
125
+ path?: TraceRulePattern;
126
+ /** Match normalized endpoint key (e.g. `"POST /api/auth/login"`). */
127
+ key?: TraceRulePattern;
128
+ };
129
+ export type ReproMaskRule = {
130
+ when?: ReproMaskWhen;
131
+ target: ReproMaskTarget | ReproMaskTarget[];
132
+ /**
133
+ * Dot/bracket paths to mask (supports `*`, `[0]`, `[*]`).
134
+ * Examples: `"password"`, `"user.token"`, `"items[*].serial"`, `"0.password"` (for trace args arrays).
135
+ */
136
+ paths?: string | string[];
137
+ /** Key name patterns to mask anywhere in the payload. */
138
+ keys?: TraceRulePattern;
139
+ /** Override replacement value for this rule (defaults to config replacement / `"[REDACTED]"`). */
140
+ replacement?: any;
141
+ };
142
+ export type ReproMaskingConfig = {
143
+ /** Default replacement value (defaults to `"[REDACTED]"`). */
144
+ replacement?: any;
145
+ rules?: ReproMaskRule[] | null;
146
+ };
98
147
  export declare function setDisabledFunctionTraces(rules?: DisableFunctionTraceConfig[] | null): void;
99
148
  export declare function setDisabledFunctionTypes(patterns?: TraceRulePattern | null): void;
100
149
  export declare function setDisabledTraceFiles(config?: DisableTraceFileConfig | DisableTraceFileConfig[] | null): void;
@@ -146,8 +195,10 @@ export type ReproMiddlewareConfig = {
146
195
  tenantId: string;
147
196
  appSecret: string;
148
197
  apiBase: string;
149
- /** Configure header capture/redaction. Defaults to capturing with sensitive headers removed. */
198
+ /** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
150
199
  captureHeaders?: boolean | HeaderCaptureOptions;
200
+ /** Optional masking rules for request/response payloads and function traces. */
201
+ masking?: ReproMaskingConfig;
151
202
  };
152
203
  export declare function reproMiddleware(cfg: ReproMiddlewareConfig): (req: Request, res: Response, next: NextFunction) => void;
153
204
  export declare function reproMongoosePlugin(cfg: {
package/dist/index.js CHANGED
@@ -298,10 +298,31 @@ function inferLibraryNameFromFile(file) {
298
298
  }
299
299
  return segments[0] || null;
300
300
  }
301
+ function inferWrapperClassFromFn(fn) {
302
+ if (!fn)
303
+ return null;
304
+ const raw = String(fn);
305
+ if (!raw)
306
+ return null;
307
+ const hashIdx = raw.indexOf('#');
308
+ if (hashIdx > 0) {
309
+ const left = raw.slice(0, hashIdx).trim();
310
+ return left || null;
311
+ }
312
+ const dotIdx = raw.lastIndexOf('.');
313
+ if (dotIdx > 0) {
314
+ const left = raw.slice(0, dotIdx).trim();
315
+ return left || null;
316
+ }
317
+ return null;
318
+ }
301
319
  function matchesRule(rule, event) {
302
320
  const namePattern = rule.fn ?? rule.functionName;
303
321
  if (!matchesPattern(event.fn, namePattern))
304
322
  return false;
323
+ const wrapperPattern = rule.wrapper ?? rule.wrapperClass ?? rule.className ?? rule.owner;
324
+ if (!matchesPattern(event.wrapperClass, wrapperPattern))
325
+ return false;
305
326
  if (!matchesPattern(event.file, rule.file))
306
327
  return false;
307
328
  const libPattern = rule.lib ?? rule.library;
@@ -542,6 +563,26 @@ function captureSpanContextFromTracer(source) {
542
563
  catch { }
543
564
  return null;
544
565
  }
566
+ function isExcludedSpanId(spanId) {
567
+ if (spanId === null || spanId === undefined)
568
+ return false;
569
+ try {
570
+ const excluded = getCtx().excludedSpanIds;
571
+ if (!excluded || excluded.size === 0)
572
+ return false;
573
+ return excluded.has(String(spanId));
574
+ }
575
+ catch {
576
+ return false;
577
+ }
578
+ }
579
+ function shouldCaptureDbSpan(span) {
580
+ if (!span || span.spanId === null || span.spanId === undefined)
581
+ return false;
582
+ if (isExcludedSpanId(span.spanId))
583
+ return false;
584
+ return true;
585
+ }
545
586
  function attachSpanContext(target, span) {
546
587
  if (!target)
547
588
  return target;
@@ -1177,6 +1218,7 @@ const DEFAULT_SENSITIVE_HEADERS = [
1177
1218
  'cookie',
1178
1219
  'set-cookie',
1179
1220
  ];
1221
+ const DEFAULT_MASK_REPLACEMENT = '[REDACTED]';
1180
1222
  function normalizeHeaderRules(rules) {
1181
1223
  return normalizePatternArray(rules || []);
1182
1224
  }
@@ -1198,14 +1240,14 @@ function matchesHeaderRule(name, rules) {
1198
1240
  }
1199
1241
  function normalizeHeaderCaptureConfig(raw) {
1200
1242
  if (raw === false) {
1201
- return { enabled: false, allowSensitive: false, drop: [], keep: [] };
1243
+ return { enabled: false, allowSensitive: false, mask: [], unmask: [] };
1202
1244
  }
1203
1245
  const opts = raw && raw !== true ? raw : {};
1204
1246
  return {
1205
1247
  enabled: true,
1206
1248
  allowSensitive: opts.allowSensitiveHeaders === true,
1207
- drop: normalizeHeaderRules(opts.dropHeaders),
1208
- keep: normalizeHeaderRules(opts.keepHeaders),
1249
+ mask: [...normalizeHeaderRules(opts.maskHeaders), ...normalizeHeaderRules(opts.dropHeaders)],
1250
+ unmask: [...normalizeHeaderRules(opts.unmaskHeaders), ...normalizeHeaderRules(opts.keepHeaders)],
1209
1251
  };
1210
1252
  }
1211
1253
  function sanitizeHeaderValue(value) {
@@ -1228,24 +1270,209 @@ function sanitizeHeaders(headers, rawCfg) {
1228
1270
  const cfg = normalizeHeaderCaptureConfig(rawCfg);
1229
1271
  if (!cfg.enabled)
1230
1272
  return {};
1231
- const dropList = cfg.allowSensitive ? cfg.drop : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.drop];
1232
1273
  const out = {};
1233
1274
  for (const [rawKey, rawVal] of Object.entries(headers || {})) {
1234
1275
  const key = String(rawKey || '').toLowerCase();
1235
1276
  if (!key)
1236
1277
  continue;
1237
- const shouldDrop = matchesHeaderRule(key, dropList);
1238
- const keep = matchesHeaderRule(key, cfg.keep);
1239
- if (shouldDrop && !keep)
1240
- continue;
1241
1278
  const sanitizedValue = sanitizeHeaderValue(rawVal);
1242
1279
  if (sanitizedValue !== undefined) {
1243
1280
  out[key] = sanitizedValue;
1244
1281
  }
1245
1282
  }
1283
+ const maskList = cfg.allowSensitive ? cfg.mask : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.mask];
1284
+ Object.keys(out).forEach((key) => {
1285
+ if (!matchesHeaderRule(key, maskList))
1286
+ return;
1287
+ if (matchesHeaderRule(key, cfg.unmask))
1288
+ return;
1289
+ const current = out[key];
1290
+ if (Array.isArray(current)) {
1291
+ out[key] = current.map(() => DEFAULT_MASK_REPLACEMENT);
1292
+ return;
1293
+ }
1294
+ out[key] = DEFAULT_MASK_REPLACEMENT;
1295
+ });
1246
1296
  return out;
1247
1297
  }
1298
+ function normalizeMaskTargets(target) {
1299
+ const out = Array.isArray(target) ? target : [target];
1300
+ return out.filter((t) => typeof t === 'string' && t.length > 0);
1301
+ }
1302
+ function parseMaskPath(raw) {
1303
+ if (!raw)
1304
+ return [];
1305
+ let path = String(raw).trim();
1306
+ if (!path)
1307
+ return [];
1308
+ if (path.startsWith('$.'))
1309
+ path = path.slice(2);
1310
+ if (path.startsWith('.'))
1311
+ path = path.slice(1);
1312
+ path = path.replace(/\[(\d+|\*)\]/g, '.$1');
1313
+ return path.split('.').map(s => s.trim()).filter(Boolean);
1314
+ }
1315
+ function normalizeMaskPaths(paths) {
1316
+ if (!paths)
1317
+ return [];
1318
+ const list = Array.isArray(paths) ? paths : [paths];
1319
+ return list
1320
+ .map(p => parseMaskPath(p))
1321
+ .filter(parts => parts.length > 0);
1322
+ }
1323
+ function normalizeMaskingConfig(raw) {
1324
+ const rules = raw?.rules;
1325
+ if (!Array.isArray(rules) || rules.length === 0)
1326
+ return null;
1327
+ const replacement = raw?.replacement ?? DEFAULT_MASK_REPLACEMENT;
1328
+ const normalized = [];
1329
+ for (const rule of rules) {
1330
+ if (!rule)
1331
+ continue;
1332
+ const targets = normalizeMaskTargets(rule.target);
1333
+ if (!targets.length)
1334
+ continue;
1335
+ const paths = normalizeMaskPaths(rule.paths);
1336
+ const keys = rule.keys ?? undefined;
1337
+ if (!paths.length && !keys)
1338
+ continue;
1339
+ normalized.push({
1340
+ when: rule.when,
1341
+ targets,
1342
+ paths,
1343
+ keys,
1344
+ replacement: rule.replacement ?? replacement,
1345
+ });
1346
+ }
1347
+ return normalized.length ? { replacement, rules: normalized } : null;
1348
+ }
1349
+ function maskWhenRequiresTrace(when) {
1350
+ return Boolean(when.fn ||
1351
+ when.functionName ||
1352
+ when.wrapper ||
1353
+ when.wrapperClass ||
1354
+ when.className ||
1355
+ when.owner ||
1356
+ when.file ||
1357
+ when.lib ||
1358
+ when.library ||
1359
+ when.type ||
1360
+ when.functionType ||
1361
+ when.event ||
1362
+ when.eventType);
1363
+ }
1364
+ function matchesMaskWhen(when, req, trace) {
1365
+ if (!when)
1366
+ return true;
1367
+ if (!matchesPattern(req.method, when.method))
1368
+ return false;
1369
+ if (!matchesPattern(req.path, when.path))
1370
+ return false;
1371
+ if (!matchesPattern(req.key, when.key))
1372
+ return false;
1373
+ if (maskWhenRequiresTrace(when)) {
1374
+ if (!trace)
1375
+ return false;
1376
+ if (!matchesRule(when, trace))
1377
+ return false;
1378
+ }
1379
+ return true;
1380
+ }
1381
+ function maskKeysInPlace(node, keys, replacement) {
1382
+ if (!node)
1383
+ return;
1384
+ if (Array.isArray(node)) {
1385
+ node.forEach(item => maskKeysInPlace(item, keys, replacement));
1386
+ return;
1387
+ }
1388
+ if (typeof node !== 'object')
1389
+ return;
1390
+ Object.keys(node).forEach((key) => {
1391
+ if (matchesPattern(key, keys, false)) {
1392
+ try {
1393
+ node[key] = replacement;
1394
+ }
1395
+ catch { }
1396
+ return;
1397
+ }
1398
+ maskKeysInPlace(node[key], keys, replacement);
1399
+ });
1400
+ }
1401
+ function maskPathInPlace(node, pathParts, replacement, depth = 0) {
1402
+ if (!node)
1403
+ return;
1404
+ if (depth >= pathParts.length)
1405
+ return;
1406
+ const part = pathParts[depth];
1407
+ const isLast = depth === pathParts.length - 1;
1408
+ const applyAt = (container, key) => {
1409
+ if (!container)
1410
+ return;
1411
+ try {
1412
+ container[key] = replacement;
1413
+ }
1414
+ catch { }
1415
+ };
1416
+ if (part === '*') {
1417
+ if (Array.isArray(node)) {
1418
+ for (let i = 0; i < node.length; i++) {
1419
+ if (isLast)
1420
+ applyAt(node, i);
1421
+ else
1422
+ maskPathInPlace(node[i], pathParts, replacement, depth + 1);
1423
+ }
1424
+ }
1425
+ else if (typeof node === 'object') {
1426
+ for (const key of Object.keys(node)) {
1427
+ if (isLast)
1428
+ applyAt(node, key);
1429
+ else
1430
+ maskPathInPlace(node[key], pathParts, replacement, depth + 1);
1431
+ }
1432
+ }
1433
+ return;
1434
+ }
1435
+ const index = Number(part);
1436
+ const isIndex = Number.isInteger(index) && String(index) === part;
1437
+ if (Array.isArray(node) && isIndex) {
1438
+ if (index < 0 || index >= node.length)
1439
+ return;
1440
+ if (isLast)
1441
+ applyAt(node, index);
1442
+ else
1443
+ maskPathInPlace(node[index], pathParts, replacement, depth + 1);
1444
+ return;
1445
+ }
1446
+ if (typeof node !== 'object')
1447
+ return;
1448
+ if (!Object.prototype.hasOwnProperty.call(node, part))
1449
+ return;
1450
+ if (isLast)
1451
+ applyAt(node, part);
1452
+ else
1453
+ maskPathInPlace(node[part], pathParts, replacement, depth + 1);
1454
+ }
1455
+ function applyMasking(target, value, req, trace, masking) {
1456
+ if (!masking || !masking.rules.length)
1457
+ return value;
1458
+ if (value === undefined)
1459
+ return value;
1460
+ for (const rule of masking.rules) {
1461
+ if (!rule.targets.includes(target))
1462
+ continue;
1463
+ if (!matchesMaskWhen(rule.when, req, trace))
1464
+ continue;
1465
+ const replacement = rule.replacement ?? masking.replacement ?? DEFAULT_MASK_REPLACEMENT;
1466
+ if (rule.keys)
1467
+ maskKeysInPlace(value, rule.keys, replacement);
1468
+ if (rule.paths.length) {
1469
+ rule.paths.forEach(parts => maskPathInPlace(value, parts, replacement, 0));
1470
+ }
1471
+ }
1472
+ return value;
1473
+ }
1248
1474
  function reproMiddleware(cfg) {
1475
+ const masking = normalizeMaskingConfig(cfg.masking);
1249
1476
  return function (req, res, next) {
1250
1477
  const sid = req.headers['x-bug-session-id'] || '';
1251
1478
  const aid = req.headers['x-bug-action-id'] || '';
@@ -1258,8 +1485,10 @@ function reproMiddleware(cfg) {
1258
1485
  const rid = nextRequestId(requestEpochMs);
1259
1486
  const t0 = requestStartRaw;
1260
1487
  const url = req.originalUrl || req.url || '/';
1488
+ const urlPathOnly = (url || '/').split('?')[0] || '/';
1261
1489
  const path = url; // back-compat
1262
1490
  const key = normalizeRouteKey(req.method, url);
1491
+ const maskReq = { method: String(req.method || 'GET').toUpperCase(), path: urlPathOnly, key };
1263
1492
  const requestHeaders = sanitizeHeaders(req.headers, cfg.captureHeaders);
1264
1493
  beginSessionRequest(sid);
1265
1494
  // ---- response body capture (unchanged) ----
@@ -1294,7 +1523,7 @@ function reproMiddleware(cfg) {
1294
1523
  }
1295
1524
  return fn();
1296
1525
  };
1297
- runInTrace(() => als.run({ sid, aid, clockSkewMs }, () => {
1526
+ runInTrace(() => als.run({ sid, aid, clockSkewMs, excludedSpanIds: new Set() }, () => {
1298
1527
  const events = [];
1299
1528
  let endpointTrace = null;
1300
1529
  let preferredAppTrace = null;
@@ -1419,14 +1648,24 @@ function reproMiddleware(cfg) {
1419
1648
  if (ev.functionType !== undefined) {
1420
1649
  evt.functionType = ev.functionType;
1421
1650
  }
1651
+ const candidate = {
1652
+ type: evt.type,
1653
+ eventType: evt.type,
1654
+ functionType: evt.functionType ?? null,
1655
+ fn: evt.fn,
1656
+ wrapperClass: inferWrapperClassFromFn(evt.fn),
1657
+ file: evt.file ?? null,
1658
+ depth: evt.depth,
1659
+ library: inferLibraryNameFromFile(evt.file),
1660
+ };
1422
1661
  if (ev.args !== undefined) {
1423
- evt.args = sanitizeTraceArgs(ev.args);
1662
+ evt.args = applyMasking('trace.args', sanitizeTraceArgs(ev.args), maskReq, candidate, masking);
1424
1663
  }
1425
1664
  if (ev.returnValue !== undefined) {
1426
- evt.returnValue = sanitizeTraceValue(ev.returnValue);
1665
+ evt.returnValue = applyMasking('trace.returnValue', sanitizeTraceValue(ev.returnValue), maskReq, candidate, masking);
1427
1666
  }
1428
1667
  if (ev.error !== undefined) {
1429
- evt.error = sanitizeTraceValue(ev.error);
1668
+ evt.error = applyMasking('trace.error', sanitizeTraceValue(ev.error), maskReq, candidate, masking);
1430
1669
  }
1431
1670
  if (ev.threw !== undefined) {
1432
1671
  evt.threw = Boolean(ev.threw);
@@ -1434,15 +1673,7 @@ function reproMiddleware(cfg) {
1434
1673
  if (ev.unawaited !== undefined) {
1435
1674
  evt.unawaited = ev.unawaited === true;
1436
1675
  }
1437
- const candidate = {
1438
- type: evt.type,
1439
- eventType: evt.type,
1440
- functionType: evt.functionType ?? null,
1441
- fn: evt.fn,
1442
- file: evt.file ?? null,
1443
- depth: evt.depth,
1444
- library: inferLibraryNameFromFile(evt.file),
1445
- };
1676
+ const dropEvent = shouldDropTraceEvent(candidate);
1446
1677
  const spanKey = normalizeSpanId(evt.spanId);
1447
1678
  if (evt.type === 'enter') {
1448
1679
  lastEventAt = Date.now();
@@ -1462,7 +1693,13 @@ function reproMiddleware(cfg) {
1462
1693
  anonymousSpanDepth = Math.max(0, anonymousSpanDepth - 1);
1463
1694
  }
1464
1695
  }
1465
- if (shouldDropTraceEvent(candidate)) {
1696
+ if (dropEvent) {
1697
+ if (evt.type === 'enter' && spanKey) {
1698
+ try {
1699
+ getCtx().excludedSpanIds?.add(spanKey);
1700
+ }
1701
+ catch { }
1702
+ }
1466
1703
  if (finished) {
1467
1704
  scheduleIdleFlush();
1468
1705
  }
@@ -1519,9 +1756,24 @@ function reproMiddleware(cfg) {
1519
1756
  ?? firstAppTrace
1520
1757
  ?? { fn: null, file: null, line: null, functionType: null };
1521
1758
  const traceBatches = chunkArray(orderedEvents, TRACE_BATCH_SIZE);
1522
- const requestBody = sanitizeRequestSnapshot(req.body);
1523
- const requestParams = sanitizeRequestSnapshot(req.params);
1524
- const requestQuery = sanitizeRequestSnapshot(req.query);
1759
+ const endpointTraceCtx = (() => {
1760
+ if (!chosenEndpoint?.fn && !chosenEndpoint?.file)
1761
+ return null;
1762
+ return {
1763
+ type: 'enter',
1764
+ eventType: 'enter',
1765
+ fn: chosenEndpoint.fn ?? undefined,
1766
+ wrapperClass: inferWrapperClassFromFn(chosenEndpoint.fn),
1767
+ file: chosenEndpoint.file ?? null,
1768
+ functionType: chosenEndpoint.functionType ?? null,
1769
+ library: inferLibraryNameFromFile(chosenEndpoint.file),
1770
+ };
1771
+ })();
1772
+ const requestBody = applyMasking('request.body', sanitizeRequestSnapshot(req.body), maskReq, endpointTraceCtx, masking);
1773
+ const requestParams = applyMasking('request.params', sanitizeRequestSnapshot(req.params), maskReq, endpointTraceCtx, masking);
1774
+ const requestQuery = applyMasking('request.query', sanitizeRequestSnapshot(req.query), maskReq, endpointTraceCtx, masking);
1775
+ const maskedHeaders = applyMasking('request.headers', requestHeaders, maskReq, endpointTraceCtx, masking);
1776
+ const responseBody = applyMasking('response.body', capturedBody === undefined ? undefined : sanitizeRequestSnapshot(capturedBody), maskReq, endpointTraceCtx, masking);
1525
1777
  const requestPayload = {
1526
1778
  rid,
1527
1779
  method: req.method,
@@ -1529,9 +1781,9 @@ function reproMiddleware(cfg) {
1529
1781
  path,
1530
1782
  status: res.statusCode,
1531
1783
  durMs: Date.now() - t0,
1532
- headers: requestHeaders,
1784
+ headers: maskedHeaders,
1533
1785
  key,
1534
- respBody: capturedBody,
1786
+ respBody: responseBody,
1535
1787
  trace: traceBatches.length ? undefined : '[]',
1536
1788
  };
1537
1789
  if (requestBody !== undefined)
@@ -1646,6 +1898,8 @@ function reproMongoosePlugin(cfg) {
1646
1898
  const after = this.toObject({ depopulate: true });
1647
1899
  const collection = meta.collection || resolveCollectionOrWarn(this, 'doc');
1648
1900
  const spanContext = meta.spanContext || captureSpanContextFromTracer(this);
1901
+ if (!shouldCaptureDbSpan(spanContext))
1902
+ return;
1649
1903
  const query = meta.wasNew
1650
1904
  ? { op: 'insertOne', doc: after }
1651
1905
  : { filter: { _id: this._id }, update: buildMinimalUpdate(before, after), options: { upsert: false } };
@@ -1688,6 +1942,8 @@ function reproMongoosePlugin(cfg) {
1688
1942
  const after = res ?? null;
1689
1943
  const collection = this.__repro_collection || resolveCollectionOrWarn(this, 'query');
1690
1944
  const spanContext = this.__repro_spanContext || captureSpanContextFromTracer(this);
1945
+ if (!shouldCaptureDbSpan(spanContext))
1946
+ return;
1691
1947
  const pk = after?._id ?? before?._id;
1692
1948
  post(cfg.apiBase, cfg.tenantId, cfg.appId, cfg.appSecret, getCtx().sid, {
1693
1949
  entries: [{
@@ -1728,6 +1984,8 @@ function reproMongoosePlugin(cfg) {
1728
1984
  const collection = this.__repro_collection || resolveCollectionOrWarn(this, 'query');
1729
1985
  const filter = this.__repro_filter ?? { _id: before._id };
1730
1986
  const spanContext = this.__repro_spanContext || captureSpanContextFromTracer(this);
1987
+ if (!shouldCaptureDbSpan(spanContext))
1988
+ return;
1731
1989
  post(cfg.apiBase, cfg.tenantId, cfg.appId, cfg.appSecret, getCtx().sid, {
1732
1990
  entries: [{
1733
1991
  actionId: getCtx().aid,
@@ -2039,6 +2297,9 @@ function dehydrateComplexValue(value) {
2039
2297
  function emitDbQuery(cfg, sid, aid, payload) {
2040
2298
  if (!sid)
2041
2299
  return;
2300
+ const spanContext = payload?.spanContext ?? captureSpanContextFromTracer();
2301
+ if (!shouldCaptureDbSpan(spanContext))
2302
+ return;
2042
2303
  const dbEntry = attachSpanContext({
2043
2304
  collection: payload.collection,
2044
2305
  op: payload.op,
@@ -2047,7 +2308,7 @@ function emitDbQuery(cfg, sid, aid, payload) {
2047
2308
  durMs: payload.durMs ?? undefined,
2048
2309
  pk: null, before: null, after: null,
2049
2310
  error: payload.error ?? undefined,
2050
- }, payload?.spanContext);
2311
+ }, spanContext);
2051
2312
  post(cfg.apiBase, cfg.tenantId, cfg.appId, cfg.appSecret, sid, {
2052
2313
  entries: [{
2053
2314
  actionId: aid ?? null,
package/docs/tracing.md CHANGED
@@ -30,7 +30,7 @@ or getters. Provide a string, regular expression, or array of them and every
30
30
  matching trace event will be ignored, regardless of library or filename.
31
31
 
32
32
  ```ts
33
- import { setDisabledFunctionTypes } from '@repro/sdk';
33
+ import { setDisabledFunctionTypes } from 'repro-nest';
34
34
 
35
35
  setDisabledFunctionTypes(['constructor']);
36
36
  ```
@@ -50,6 +50,7 @@ captured in the session payload.
50
50
  | Property | Description |
51
51
  | --------------- | ------------------------------------------------------------------------------------------------- |
52
52
  | `fn`/`functionName` | Match against the instrumented function name (substring or RegExp). |
53
+ | `wrapper`/`wrapperClass`/`className`/`owner` | Match the wrapper/owner inferred from the function name (e.g. `"UserService"` in `"UserService.create"`). |
53
54
  | `file` | Match the absolute source filename, useful for filtering entire modules. |
54
55
  | `lib`/`library` | Match the npm package inferred from the file path (e.g. `"mongoose"`). |
55
56
  | `type`/`functionType` | Match the detected function kind (e.g. `"constructor"`, `"method"`, `"arrow"`). |
@@ -64,7 +65,7 @@ Provide a function that receives the raw trace event and returns `true` when it
64
65
  should be discarded. Use this form for complex, stateful, or cross-field logic.
65
66
 
66
67
  ```ts
67
- import { setDisabledFunctionTraces } from '@repro/sdk';
68
+ import { setDisabledFunctionTraces } from 'repro-nest';
68
69
 
69
70
  setDisabledFunctionTraces([
70
71
  (event) => event.library === 'mongoose' && event.functionType === 'constructor',
@@ -74,6 +75,71 @@ setDisabledFunctionTraces([
74
75
  Pass `null` or an empty array to `initReproTracing` or `setDisabledFunctionTraces`
75
76
  to remove previously configured rules.
76
77
 
78
+ ## Payload masking (`reproMiddleware`)
79
+
80
+ `reproMiddleware` can mask request/response payloads, request headers, and traced
81
+ function inputs/outputs before they are persisted.
82
+
83
+ ### Targets
84
+
85
+ - `request.headers`
86
+ - `request.body`
87
+ - `request.params`
88
+ - `request.query`
89
+ - `response.body`
90
+ - `trace.args`
91
+ - `trace.returnValue`
92
+ - `trace.error`
93
+
94
+ ### Rules
95
+
96
+ - `when.method` / `when.path` / `when.key` scope rules by endpoint (`key` is `"METHOD /path"` without query string).
97
+ - For function-specific rules, `when` supports the same fields as `disableFunctionTraces` (`fn`, `wrapperClass`, `file`, etc). This is useful when multiple functions share the same name.
98
+ - `paths` uses dot/bracket syntax and supports `*`, `[0]`, `[*]` (example: `"items[*].token"` or `"0.password"` for trace args arrays).
99
+ - `keys` masks matching key names anywhere in the payload (string/RegExp/array).
100
+ - `replacement` overrides the default replacement value (defaults to `"[REDACTED]"`).
101
+
102
+ ```ts
103
+ import { reproMiddleware } from 'repro-nest';
104
+
105
+ app.use(reproMiddleware({
106
+ appId,
107
+ tenantId,
108
+ appSecret,
109
+ apiBase,
110
+ masking: {
111
+ rules: [
112
+ {
113
+ when: { key: 'POST /api/auth/login' },
114
+ target: 'request.body',
115
+ paths: ['password'],
116
+ },
117
+ {
118
+ when: { wrapperClass: 'AuthService', functionName: 'login', file: '/app/src/auth/auth.service.ts' },
119
+ target: ['trace.args', 'trace.returnValue', 'trace.error'],
120
+ keys: [/token/i],
121
+ },
122
+ {
123
+ target: 'request.headers',
124
+ keys: [/authorization/i, /cookie/i],
125
+ },
126
+ ],
127
+ },
128
+ }));
129
+ ```
130
+
131
+ ### Header capture/masking (`captureHeaders`)
132
+
133
+ Headers are captured by default with sensitive values masked. Configure `captureHeaders`
134
+ to change the behavior:
135
+
136
+ - `captureHeaders: false` disables header capture entirely.
137
+ - `captureHeaders: true` (or omitted) captures headers and masks sensitive ones.
138
+ - `captureHeaders.allowSensitiveHeaders: true` keeps default sensitive headers unmasked (use with care).
139
+ - `captureHeaders.maskHeaders` adds additional header names to mask.
140
+ - `captureHeaders.unmaskHeaders` keeps specific header names unmasked (overrides defaults and `maskHeaders`).
141
+ - `captureHeaders.dropHeaders` / `captureHeaders.keepHeaders` are legacy aliases for `maskHeaders` / `unmaskHeaders`.
142
+
77
143
  ## `logFunctionCalls`
78
144
 
79
145
  Set to `true` to enable verbose console logging of function entry/exit events at
@@ -81,7 +147,7 @@ runtime, or to `false` to silence them. The value is forwarded to
81
147
  `setReproTraceLogsEnabled`, so you can also toggle logging later in the process:
82
148
 
83
149
  ```ts
84
- import { enableReproTraceLogs, disableReproTraceLogs } from '@repro/sdk';
150
+ import { enableReproTraceLogs, disableReproTraceLogs } from 'repro-nest';
85
151
 
86
152
  enableReproTraceLogs();
87
153
  // ...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repro-nest",
3
- "version": "0.0.213",
3
+ "version": "0.0.215",
4
4
  "description": "Repro Nest SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -236,6 +236,7 @@ export type TraceEventForFilter = {
236
236
  eventType: TraceEventPhase;
237
237
  functionType?: string | null;
238
238
  fn?: string;
239
+ wrapperClass?: string | null;
239
240
  file?: string | null;
240
241
  depth?: number;
241
242
  library?: string | null;
@@ -267,19 +268,29 @@ type EndpointTraceInfo = {
267
268
 
268
269
  export type HeaderRule = string | RegExp;
269
270
  export type HeaderCaptureOptions = {
270
- /** When true, sensitive headers such as Authorization are kept; default redacts them. */
271
+ /** When true, sensitive headers such as Authorization are kept unmasked; default masks them. */
271
272
  allowSensitiveHeaders?: boolean;
272
- /** Additional header names (string or RegExp) to drop from captures. */
273
+ /**
274
+ * Header names (string or RegExp) to mask.
275
+ * `dropHeaders` is kept for backward-compatibility and treated as an alias for `maskHeaders`.
276
+ */
277
+ maskHeaders?: HeaderRule | HeaderRule[];
278
+ /** @deprecated Alias for {@link maskHeaders}. */
273
279
  dropHeaders?: HeaderRule | HeaderRule[];
274
- /** Explicit allowlist that overrides defaults and drop rules. */
280
+ /**
281
+ * Header names (string or RegExp) to keep unmasked, overriding defaults and `maskHeaders`.
282
+ * `keepHeaders` is kept for backward-compatibility and treated as an alias for `unmaskHeaders`.
283
+ */
284
+ unmaskHeaders?: HeaderRule | HeaderRule[];
285
+ /** @deprecated Alias for {@link unmaskHeaders}. */
275
286
  keepHeaders?: HeaderRule | HeaderRule[];
276
287
  };
277
288
 
278
289
  type NormalizedHeaderCapture = {
279
290
  enabled: boolean;
280
291
  allowSensitive: boolean;
281
- drop: HeaderRule[];
282
- keep: HeaderRule[];
292
+ mask: HeaderRule[];
293
+ unmask: HeaderRule[];
283
294
  };
284
295
 
285
296
  /** Lightweight helper to disable every trace emitted from specific files. */
@@ -301,6 +312,17 @@ export type DisableFunctionTraceRule = {
301
312
  fn?: TraceRulePattern;
302
313
  /** Function name (e.g. `"findOne"`, `/^UserService\./`). */
303
314
  functionName?: TraceRulePattern;
315
+ /** Shortcut for {@link wrapperClass}. */
316
+ wrapper?: TraceRulePattern;
317
+ /**
318
+ * Wrapper/owner name derived from {@link functionName} (e.g. `"UserService"` in `"UserService.create"`).
319
+ * Useful when multiple functions share the same method name.
320
+ */
321
+ wrapperClass?: TraceRulePattern;
322
+ /** Alias for {@link wrapperClass}. */
323
+ className?: TraceRulePattern;
324
+ /** Alias for {@link wrapperClass}. */
325
+ owner?: TraceRulePattern;
304
326
  /** Absolute file path where the function was defined. */
305
327
  file?: TraceRulePattern;
306
328
  /** Shortcut for {@link library}. */
@@ -323,6 +345,45 @@ export type DisableFunctionTraceConfig =
323
345
  | DisableFunctionTraceRule
324
346
  | DisableFunctionTracePredicate;
325
347
 
348
+ export type ReproMaskTarget =
349
+ | 'request.headers'
350
+ | 'request.body'
351
+ | 'request.params'
352
+ | 'request.query'
353
+ | 'response.body'
354
+ | 'trace.args'
355
+ | 'trace.returnValue'
356
+ | 'trace.error';
357
+
358
+ export type ReproMaskWhen = DisableFunctionTraceRule & {
359
+ /** Match HTTP method (e.g. `"GET"`, `/^post$/i`). */
360
+ method?: TraceRulePattern;
361
+ /** Match request path without query string (e.g. `"/api/auth/login"`). */
362
+ path?: TraceRulePattern;
363
+ /** Match normalized endpoint key (e.g. `"POST /api/auth/login"`). */
364
+ key?: TraceRulePattern;
365
+ };
366
+
367
+ export type ReproMaskRule = {
368
+ when?: ReproMaskWhen;
369
+ target: ReproMaskTarget | ReproMaskTarget[];
370
+ /**
371
+ * Dot/bracket paths to mask (supports `*`, `[0]`, `[*]`).
372
+ * Examples: `"password"`, `"user.token"`, `"items[*].serial"`, `"0.password"` (for trace args arrays).
373
+ */
374
+ paths?: string | string[];
375
+ /** Key name patterns to mask anywhere in the payload. */
376
+ keys?: TraceRulePattern;
377
+ /** Override replacement value for this rule (defaults to config replacement / `"[REDACTED]"`). */
378
+ replacement?: any;
379
+ };
380
+
381
+ export type ReproMaskingConfig = {
382
+ /** Default replacement value (defaults to `"[REDACTED]"`). */
383
+ replacement?: any;
384
+ rules?: ReproMaskRule[] | null;
385
+ };
386
+
326
387
  const DEFAULT_INTERCEPTOR_TRACE_RULES: DisableFunctionTraceConfig[] = [
327
388
  { fn: /switchToHttp$/i },
328
389
  { fn: /intercept$/i },
@@ -403,10 +464,30 @@ function inferLibraryNameFromFile(file?: string | null): string | null {
403
464
  return segments[0] || null;
404
465
  }
405
466
 
467
+ function inferWrapperClassFromFn(fn?: string | null): string | null {
468
+ if (!fn) return null;
469
+ const raw = String(fn);
470
+ if (!raw) return null;
471
+ const hashIdx = raw.indexOf('#');
472
+ if (hashIdx > 0) {
473
+ const left = raw.slice(0, hashIdx).trim();
474
+ return left || null;
475
+ }
476
+ const dotIdx = raw.lastIndexOf('.');
477
+ if (dotIdx > 0) {
478
+ const left = raw.slice(0, dotIdx).trim();
479
+ return left || null;
480
+ }
481
+ return null;
482
+ }
483
+
406
484
  function matchesRule(rule: DisableFunctionTraceRule, event: TraceEventForFilter): boolean {
407
485
  const namePattern = rule.fn ?? rule.functionName;
408
486
  if (!matchesPattern(event.fn, namePattern)) return false;
409
487
 
488
+ const wrapperPattern = rule.wrapper ?? rule.wrapperClass ?? rule.className ?? rule.owner;
489
+ if (!matchesPattern(event.wrapperClass, wrapperPattern)) return false;
490
+
410
491
  if (!matchesPattern(event.file, rule.file)) return false;
411
492
 
412
493
  const libPattern = rule.lib ?? rule.library;
@@ -736,6 +817,23 @@ function captureSpanContextFromTracer(source?: any): SpanContext | null {
736
817
  return null;
737
818
  }
738
819
 
820
+ function isExcludedSpanId(spanId: string | number | null | undefined): boolean {
821
+ if (spanId === null || spanId === undefined) return false;
822
+ try {
823
+ const excluded = (getCtx() as Ctx).excludedSpanIds;
824
+ if (!excluded || excluded.size === 0) return false;
825
+ return excluded.has(String(spanId));
826
+ } catch {
827
+ return false;
828
+ }
829
+ }
830
+
831
+ function shouldCaptureDbSpan(span: SpanContext | null | undefined): span is SpanContext {
832
+ if (!span || span.spanId === null || span.spanId === undefined) return false;
833
+ if (isExcludedSpanId(span.spanId)) return false;
834
+ return true;
835
+ }
836
+
739
837
  function attachSpanContext<T extends Record<string, any>>(target: T, span?: SpanContext | null): T {
740
838
  if (!target) return target;
741
839
  const ctx = span ?? captureSpanContextFromTracer();
@@ -745,7 +843,7 @@ function attachSpanContext<T extends Record<string, any>>(target: T, span?: Span
745
843
  return target;
746
844
  }
747
845
 
748
- type Ctx = { sid?: string; aid?: string; clockSkewMs?: number };
846
+ type Ctx = { sid?: string; aid?: string; clockSkewMs?: number; excludedSpanIds?: Set<string> };
749
847
  const als = new AsyncLocalStorage<Ctx>();
750
848
  const getCtx = () => als.getStore() || {};
751
849
 
@@ -1404,6 +1502,8 @@ const DEFAULT_SENSITIVE_HEADERS: Array<string | RegExp> = [
1404
1502
  'set-cookie',
1405
1503
  ];
1406
1504
 
1505
+ const DEFAULT_MASK_REPLACEMENT = '[REDACTED]';
1506
+
1407
1507
  function normalizeHeaderRules(rules?: HeaderRule | HeaderRule[] | null): HeaderRule[] {
1408
1508
  return normalizePatternArray<HeaderRule>(rules || []);
1409
1509
  }
@@ -1421,14 +1521,14 @@ function matchesHeaderRule(name: string, rules: HeaderRule[]): boolean {
1421
1521
 
1422
1522
  function normalizeHeaderCaptureConfig(raw?: boolean | HeaderCaptureOptions): NormalizedHeaderCapture {
1423
1523
  if (raw === false) {
1424
- return { enabled: false, allowSensitive: false, drop: [], keep: [] };
1524
+ return { enabled: false, allowSensitive: false, mask: [], unmask: [] };
1425
1525
  }
1426
1526
  const opts: HeaderCaptureOptions = raw && raw !== true ? raw : {};
1427
1527
  return {
1428
1528
  enabled: true,
1429
1529
  allowSensitive: opts.allowSensitiveHeaders === true,
1430
- drop: normalizeHeaderRules(opts.dropHeaders),
1431
- keep: normalizeHeaderRules(opts.keepHeaders),
1530
+ mask: [...normalizeHeaderRules(opts.maskHeaders), ...normalizeHeaderRules(opts.dropHeaders)],
1531
+ unmask: [...normalizeHeaderRules(opts.unmaskHeaders), ...normalizeHeaderRules(opts.keepHeaders)],
1432
1532
  };
1433
1533
  }
1434
1534
 
@@ -1449,24 +1549,210 @@ function sanitizeHeaders(headers: any, rawCfg?: boolean | HeaderCaptureOptions)
1449
1549
  const cfg = normalizeHeaderCaptureConfig(rawCfg);
1450
1550
  if (!cfg.enabled) return {};
1451
1551
 
1452
- const dropList = cfg.allowSensitive ? cfg.drop : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.drop];
1453
1552
  const out: Record<string, any> = {};
1454
1553
 
1455
1554
  for (const [rawKey, rawVal] of Object.entries(headers || {})) {
1456
1555
  const key = String(rawKey || '').toLowerCase();
1457
1556
  if (!key) continue;
1458
- const shouldDrop = matchesHeaderRule(key, dropList);
1459
- const keep = matchesHeaderRule(key, cfg.keep);
1460
- if (shouldDrop && !keep) continue;
1461
-
1462
1557
  const sanitizedValue = sanitizeHeaderValue(rawVal);
1463
1558
  if (sanitizedValue !== undefined) {
1464
1559
  out[key] = sanitizedValue;
1465
1560
  }
1466
1561
  }
1562
+
1563
+ const maskList = cfg.allowSensitive ? cfg.mask : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.mask];
1564
+ Object.keys(out).forEach((key) => {
1565
+ if (!matchesHeaderRule(key, maskList)) return;
1566
+ if (matchesHeaderRule(key, cfg.unmask)) return;
1567
+ const current = out[key];
1568
+ if (Array.isArray(current)) {
1569
+ out[key] = current.map(() => DEFAULT_MASK_REPLACEMENT);
1570
+ return;
1571
+ }
1572
+ out[key] = DEFAULT_MASK_REPLACEMENT;
1573
+ });
1574
+
1467
1575
  return out;
1468
1576
  }
1469
1577
 
1578
+ // ===================================================================
1579
+ // Masking (request/response payloads + trace args/returns/errors)
1580
+ // ===================================================================
1581
+ type NormalizedMaskRule = {
1582
+ when?: ReproMaskWhen;
1583
+ targets: ReproMaskTarget[];
1584
+ paths: string[][];
1585
+ keys?: TraceRulePattern;
1586
+ replacement: any;
1587
+ };
1588
+
1589
+ type NormalizedMaskingConfig = {
1590
+ replacement: any;
1591
+ rules: NormalizedMaskRule[];
1592
+ };
1593
+
1594
+ type MaskRequestContext = {
1595
+ method: string;
1596
+ path: string;
1597
+ key: string;
1598
+ };
1599
+
1600
+ function normalizeMaskTargets(target: ReproMaskTarget | ReproMaskTarget[]): ReproMaskTarget[] {
1601
+ const out = Array.isArray(target) ? target : [target];
1602
+ return out.filter((t): t is ReproMaskTarget => typeof t === 'string' && t.length > 0);
1603
+ }
1604
+
1605
+ function parseMaskPath(raw: string): string[] {
1606
+ if (!raw) return [];
1607
+ let path = String(raw).trim();
1608
+ if (!path) return [];
1609
+ if (path.startsWith('$.')) path = path.slice(2);
1610
+ if (path.startsWith('.')) path = path.slice(1);
1611
+ path = path.replace(/\[(\d+|\*)\]/g, '.$1');
1612
+ return path.split('.').map(s => s.trim()).filter(Boolean);
1613
+ }
1614
+
1615
+ function normalizeMaskPaths(paths?: string | string[]): string[][] {
1616
+ if (!paths) return [];
1617
+ const list = Array.isArray(paths) ? paths : [paths];
1618
+ return list
1619
+ .map(p => parseMaskPath(p))
1620
+ .filter(parts => parts.length > 0);
1621
+ }
1622
+
1623
+ function normalizeMaskingConfig(raw?: ReproMaskingConfig): NormalizedMaskingConfig | null {
1624
+ const rules = raw?.rules;
1625
+ if (!Array.isArray(rules) || rules.length === 0) return null;
1626
+ const replacement = raw?.replacement ?? DEFAULT_MASK_REPLACEMENT;
1627
+ const normalized: NormalizedMaskRule[] = [];
1628
+ for (const rule of rules) {
1629
+ if (!rule) continue;
1630
+ const targets = normalizeMaskTargets(rule.target);
1631
+ if (!targets.length) continue;
1632
+ const paths = normalizeMaskPaths(rule.paths);
1633
+ const keys = rule.keys ?? undefined;
1634
+ if (!paths.length && !keys) continue;
1635
+ normalized.push({
1636
+ when: rule.when,
1637
+ targets,
1638
+ paths,
1639
+ keys,
1640
+ replacement: rule.replacement ?? replacement,
1641
+ });
1642
+ }
1643
+ return normalized.length ? { replacement, rules: normalized } : null;
1644
+ }
1645
+
1646
+ function maskWhenRequiresTrace(when: ReproMaskWhen): boolean {
1647
+ return Boolean(
1648
+ when.fn ||
1649
+ when.functionName ||
1650
+ when.wrapper ||
1651
+ when.wrapperClass ||
1652
+ when.className ||
1653
+ when.owner ||
1654
+ when.file ||
1655
+ when.lib ||
1656
+ when.library ||
1657
+ when.type ||
1658
+ when.functionType ||
1659
+ when.event ||
1660
+ when.eventType
1661
+ );
1662
+ }
1663
+
1664
+ function matchesMaskWhen(when: ReproMaskWhen | undefined, req: MaskRequestContext, trace: TraceEventForFilter | null): boolean {
1665
+ if (!when) return true;
1666
+ if (!matchesPattern(req.method, when.method)) return false;
1667
+ if (!matchesPattern(req.path, when.path)) return false;
1668
+ if (!matchesPattern(req.key, when.key)) return false;
1669
+
1670
+ if (maskWhenRequiresTrace(when)) {
1671
+ if (!trace) return false;
1672
+ if (!matchesRule(when, trace)) return false;
1673
+ }
1674
+
1675
+ return true;
1676
+ }
1677
+
1678
+ function maskKeysInPlace(node: any, keys: TraceRulePattern, replacement: any) {
1679
+ if (!node) return;
1680
+ if (Array.isArray(node)) {
1681
+ node.forEach(item => maskKeysInPlace(item, keys, replacement));
1682
+ return;
1683
+ }
1684
+ if (typeof node !== 'object') return;
1685
+ Object.keys(node).forEach((key) => {
1686
+ if (matchesPattern(key, keys, false)) {
1687
+ try { node[key] = replacement; } catch {}
1688
+ return;
1689
+ }
1690
+ maskKeysInPlace(node[key], keys, replacement);
1691
+ });
1692
+ }
1693
+
1694
+ function maskPathInPlace(node: any, pathParts: string[], replacement: any, depth: number = 0) {
1695
+ if (!node) return;
1696
+ if (depth >= pathParts.length) return;
1697
+ const part = pathParts[depth];
1698
+ const isLast = depth === pathParts.length - 1;
1699
+
1700
+ const applyAt = (container: any, key: string | number) => {
1701
+ if (!container) return;
1702
+ try { container[key] = replacement; } catch {}
1703
+ };
1704
+
1705
+ if (part === '*') {
1706
+ if (Array.isArray(node)) {
1707
+ for (let i = 0; i < node.length; i++) {
1708
+ if (isLast) applyAt(node, i);
1709
+ else maskPathInPlace(node[i], pathParts, replacement, depth + 1);
1710
+ }
1711
+ } else if (typeof node === 'object') {
1712
+ for (const key of Object.keys(node)) {
1713
+ if (isLast) applyAt(node, key);
1714
+ else maskPathInPlace(node[key], pathParts, replacement, depth + 1);
1715
+ }
1716
+ }
1717
+ return;
1718
+ }
1719
+
1720
+ const index = Number(part);
1721
+ const isIndex = Number.isInteger(index) && String(index) === part;
1722
+ if (Array.isArray(node) && isIndex) {
1723
+ if (index < 0 || index >= node.length) return;
1724
+ if (isLast) applyAt(node, index);
1725
+ else maskPathInPlace(node[index], pathParts, replacement, depth + 1);
1726
+ return;
1727
+ }
1728
+
1729
+ if (typeof node !== 'object') return;
1730
+ if (!Object.prototype.hasOwnProperty.call(node, part)) return;
1731
+ if (isLast) applyAt(node, part);
1732
+ else maskPathInPlace(node[part], pathParts, replacement, depth + 1);
1733
+ }
1734
+
1735
+ function applyMasking(
1736
+ target: ReproMaskTarget,
1737
+ value: any,
1738
+ req: MaskRequestContext,
1739
+ trace: TraceEventForFilter | null,
1740
+ masking: NormalizedMaskingConfig | null
1741
+ ) {
1742
+ if (!masking || !masking.rules.length) return value;
1743
+ if (value === undefined) return value;
1744
+ for (const rule of masking.rules) {
1745
+ if (!rule.targets.includes(target)) continue;
1746
+ if (!matchesMaskWhen(rule.when, req, trace)) continue;
1747
+ const replacement = rule.replacement ?? masking.replacement ?? DEFAULT_MASK_REPLACEMENT;
1748
+ if (rule.keys) maskKeysInPlace(value, rule.keys, replacement);
1749
+ if (rule.paths.length) {
1750
+ rule.paths.forEach(parts => maskPathInPlace(value, parts, replacement, 0));
1751
+ }
1752
+ }
1753
+ return value;
1754
+ }
1755
+
1470
1756
  // ===================================================================
1471
1757
  // reproMiddleware — unchanged behavior + passive per-request trace
1472
1758
  // ===================================================================
@@ -1475,11 +1761,14 @@ export type ReproMiddlewareConfig = {
1475
1761
  tenantId: string;
1476
1762
  appSecret: string;
1477
1763
  apiBase: string;
1478
- /** Configure header capture/redaction. Defaults to capturing with sensitive headers removed. */
1764
+ /** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
1479
1765
  captureHeaders?: boolean | HeaderCaptureOptions;
1766
+ /** Optional masking rules for request/response payloads and function traces. */
1767
+ masking?: ReproMaskingConfig;
1480
1768
  };
1481
1769
 
1482
1770
  export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1771
+ const masking = normalizeMaskingConfig(cfg.masking);
1483
1772
  return function (req: Request, res: Response, next: NextFunction) {
1484
1773
  const sid = (req.headers['x-bug-session-id'] as string) || '';
1485
1774
  const aid = (req.headers['x-bug-action-id'] as string) || '';
@@ -1492,8 +1781,10 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1492
1781
  const rid = nextRequestId(requestEpochMs);
1493
1782
  const t0 = requestStartRaw;
1494
1783
  const url = (req as any).originalUrl || req.url || '/';
1784
+ const urlPathOnly = (url || '/').split('?')[0] || '/';
1495
1785
  const path = url; // back-compat
1496
1786
  const key = normalizeRouteKey(req.method, url);
1787
+ const maskReq: MaskRequestContext = { method: String(req.method || 'GET').toUpperCase(), path: urlPathOnly, key };
1497
1788
  const requestHeaders = sanitizeHeaders(req.headers, cfg.captureHeaders);
1498
1789
  beginSessionRequest(sid);
1499
1790
 
@@ -1525,7 +1816,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1525
1816
  return fn();
1526
1817
  };
1527
1818
 
1528
- runInTrace(() => als.run({ sid, aid, clockSkewMs }, () => {
1819
+ runInTrace(() => als.run({ sid, aid, clockSkewMs, excludedSpanIds: new Set<string>() }, () => {
1529
1820
  const events: TraceEventRecord[] = [];
1530
1821
  let endpointTrace: EndpointTraceInfo | null = null;
1531
1822
  let preferredAppTrace: EndpointTraceInfo | null = null;
@@ -1634,14 +1925,43 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1634
1925
  evt.functionType = ev.functionType;
1635
1926
  }
1636
1927
 
1928
+ const candidate: TraceEventForFilter = {
1929
+ type: evt.type,
1930
+ eventType: evt.type,
1931
+ functionType: evt.functionType ?? null,
1932
+ fn: evt.fn,
1933
+ wrapperClass: inferWrapperClassFromFn(evt.fn),
1934
+ file: evt.file ?? null,
1935
+ depth: evt.depth,
1936
+ library: inferLibraryNameFromFile(evt.file),
1937
+ };
1938
+
1637
1939
  if (ev.args !== undefined) {
1638
- evt.args = sanitizeTraceArgs(ev.args);
1940
+ evt.args = applyMasking(
1941
+ 'trace.args',
1942
+ sanitizeTraceArgs(ev.args),
1943
+ maskReq,
1944
+ candidate,
1945
+ masking
1946
+ );
1639
1947
  }
1640
1948
  if (ev.returnValue !== undefined) {
1641
- evt.returnValue = sanitizeTraceValue(ev.returnValue);
1949
+ evt.returnValue = applyMasking(
1950
+ 'trace.returnValue',
1951
+ sanitizeTraceValue(ev.returnValue),
1952
+ maskReq,
1953
+ candidate,
1954
+ masking
1955
+ );
1642
1956
  }
1643
1957
  if (ev.error !== undefined) {
1644
- evt.error = sanitizeTraceValue(ev.error);
1958
+ evt.error = applyMasking(
1959
+ 'trace.error',
1960
+ sanitizeTraceValue(ev.error),
1961
+ maskReq,
1962
+ candidate,
1963
+ masking
1964
+ );
1645
1965
  }
1646
1966
  if (ev.threw !== undefined) {
1647
1967
  evt.threw = Boolean(ev.threw);
@@ -1650,16 +1970,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1650
1970
  evt.unawaited = ev.unawaited === true;
1651
1971
  }
1652
1972
 
1653
- const candidate: TraceEventForFilter = {
1654
- type: evt.type,
1655
- eventType: evt.type,
1656
- functionType: evt.functionType ?? null,
1657
- fn: evt.fn,
1658
- file: evt.file ?? null,
1659
- depth: evt.depth,
1660
- library: inferLibraryNameFromFile(evt.file),
1661
- };
1662
-
1973
+ const dropEvent = shouldDropTraceEvent(candidate);
1663
1974
  const spanKey = normalizeSpanId(evt.spanId);
1664
1975
  if (evt.type === 'enter') {
1665
1976
  lastEventAt = Date.now();
@@ -1677,7 +1988,10 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1677
1988
  }
1678
1989
  }
1679
1990
 
1680
- if (shouldDropTraceEvent(candidate)) {
1991
+ if (dropEvent) {
1992
+ if (evt.type === 'enter' && spanKey) {
1993
+ try { (getCtx() as Ctx).excludedSpanIds?.add(spanKey); } catch {}
1994
+ }
1681
1995
  if (finished) {
1682
1996
  scheduleIdleFlush();
1683
1997
  }
@@ -1738,9 +2052,54 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1738
2052
  ?? firstAppTrace
1739
2053
  ?? { fn: null, file: null, line: null, functionType: null };
1740
2054
  const traceBatches = chunkArray(orderedEvents, TRACE_BATCH_SIZE);
1741
- const requestBody = sanitizeRequestSnapshot((req as any).body);
1742
- const requestParams = sanitizeRequestSnapshot((req as any).params);
1743
- const requestQuery = sanitizeRequestSnapshot((req as any).query);
2055
+ const endpointTraceCtx: TraceEventForFilter | null = (() => {
2056
+ if (!chosenEndpoint?.fn && !chosenEndpoint?.file) return null;
2057
+ return {
2058
+ type: 'enter',
2059
+ eventType: 'enter',
2060
+ fn: chosenEndpoint.fn ?? undefined,
2061
+ wrapperClass: inferWrapperClassFromFn(chosenEndpoint.fn),
2062
+ file: chosenEndpoint.file ?? null,
2063
+ functionType: chosenEndpoint.functionType ?? null,
2064
+ library: inferLibraryNameFromFile(chosenEndpoint.file),
2065
+ };
2066
+ })();
2067
+
2068
+ const requestBody = applyMasking(
2069
+ 'request.body',
2070
+ sanitizeRequestSnapshot((req as any).body),
2071
+ maskReq,
2072
+ endpointTraceCtx,
2073
+ masking
2074
+ );
2075
+ const requestParams = applyMasking(
2076
+ 'request.params',
2077
+ sanitizeRequestSnapshot((req as any).params),
2078
+ maskReq,
2079
+ endpointTraceCtx,
2080
+ masking
2081
+ );
2082
+ const requestQuery = applyMasking(
2083
+ 'request.query',
2084
+ sanitizeRequestSnapshot((req as any).query),
2085
+ maskReq,
2086
+ endpointTraceCtx,
2087
+ masking
2088
+ );
2089
+ const maskedHeaders = applyMasking(
2090
+ 'request.headers',
2091
+ requestHeaders,
2092
+ maskReq,
2093
+ endpointTraceCtx,
2094
+ masking
2095
+ );
2096
+ const responseBody = applyMasking(
2097
+ 'response.body',
2098
+ capturedBody === undefined ? undefined : sanitizeRequestSnapshot(capturedBody),
2099
+ maskReq,
2100
+ endpointTraceCtx,
2101
+ masking
2102
+ );
1744
2103
 
1745
2104
  const requestPayload: Record<string, any> = {
1746
2105
  rid,
@@ -1749,9 +2108,9 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1749
2108
  path,
1750
2109
  status: res.statusCode,
1751
2110
  durMs: Date.now() - t0,
1752
- headers: requestHeaders,
2111
+ headers: maskedHeaders,
1753
2112
  key,
1754
- respBody: capturedBody,
2113
+ respBody: responseBody,
1755
2114
  trace: traceBatches.length ? undefined : '[]',
1756
2115
  };
1757
2116
  if (requestBody !== undefined) requestPayload.body = requestBody;
@@ -1865,6 +2224,7 @@ export function reproMongoosePlugin(cfg: { appId: string; tenantId: string; appS
1865
2224
  const after = this.toObject({ depopulate: true });
1866
2225
  const collection = meta.collection || resolveCollectionOrWarn(this, 'doc');
1867
2226
  const spanContext = meta.spanContext || captureSpanContextFromTracer(this);
2227
+ if (!shouldCaptureDbSpan(spanContext)) return;
1868
2228
 
1869
2229
  const query = meta.wasNew
1870
2230
  ? { op: 'insertOne', doc: after }
@@ -1909,6 +2269,7 @@ export function reproMongoosePlugin(cfg: { appId: string; tenantId: string; appS
1909
2269
  const after = res ?? null;
1910
2270
  const collection = (this as any).__repro_collection || resolveCollectionOrWarn(this, 'query');
1911
2271
  const spanContext = (this as any).__repro_spanContext || captureSpanContextFromTracer(this);
2272
+ if (!shouldCaptureDbSpan(spanContext)) return;
1912
2273
  const pk = after?._id ?? before?._id;
1913
2274
 
1914
2275
  post(cfg.apiBase, cfg.tenantId, cfg.appId, cfg.appSecret, (getCtx() as Ctx).sid!, {
@@ -1946,6 +2307,7 @@ export function reproMongoosePlugin(cfg: { appId: string; tenantId: string; appS
1946
2307
  const collection = (this as any).__repro_collection || resolveCollectionOrWarn(this, 'query');
1947
2308
  const filter = (this as any).__repro_filter ?? { _id: before._id };
1948
2309
  const spanContext = (this as any).__repro_spanContext || captureSpanContextFromTracer(this);
2310
+ if (!shouldCaptureDbSpan(spanContext)) return;
1949
2311
  post(cfg.apiBase, cfg.tenantId, cfg.appId, cfg.appSecret, (getCtx() as Ctx).sid!, {
1950
2312
  entries: [{
1951
2313
  actionId: (getCtx() as Ctx).aid!,
@@ -2261,6 +2623,8 @@ function dehydrateComplexValue(value: any) {
2261
2623
 
2262
2624
  function emitDbQuery(cfg: any, sid?: string, aid?: string, payload?: any) {
2263
2625
  if (!sid) return;
2626
+ const spanContext = payload?.spanContext ?? captureSpanContextFromTracer();
2627
+ if (!shouldCaptureDbSpan(spanContext)) return;
2264
2628
  const dbEntry = attachSpanContext({
2265
2629
  collection: payload.collection,
2266
2630
  op: payload.op,
@@ -2269,7 +2633,7 @@ function emitDbQuery(cfg: any, sid?: string, aid?: string, payload?: any) {
2269
2633
  durMs: payload.durMs ?? undefined,
2270
2634
  pk: null, before: null, after: null,
2271
2635
  error: payload.error ?? undefined,
2272
- }, payload?.spanContext);
2636
+ }, spanContext);
2273
2637
  post(cfg.apiBase, cfg.tenantId, cfg.appId, cfg.appSecret, sid, {
2274
2638
  entries: [{
2275
2639
  actionId: aid ?? null,
package/tracer/runtime.js CHANGED
@@ -545,7 +545,13 @@ if (!global.__repro_call) {
545
545
  const perCallStore = cloneStore(baseStoreSnapshot);
546
546
  let result;
547
547
  als.run(perCallStore, () => {
548
- result = arg.apply(this, arguments);
548
+ // Support both callbacks and constructors: some libraries (e.g., class-transformer)
549
+ // pass class constructors as args and invoke them with `new`.
550
+ if (new.target) {
551
+ result = Reflect.construct(arg, arguments, arg);
552
+ } else {
553
+ result = arg.apply(this, arguments);
554
+ }
549
555
  });
550
556
  return result;
551
557
  };