repro-nest 0.0.214 → 0.0.216

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
@@ -42,23 +42,35 @@ type TracerApi = {
42
42
  setFunctionLogsEnabled?: (enabled: boolean) => void;
43
43
  };
44
44
  type TraceEventPhase = 'enter' | 'exit';
45
- export type TraceRulePattern = string | RegExp | Array<string | RegExp>;
45
+ export type TraceRulePattern = string | number | RegExp | Array<string | number | RegExp>;
46
46
  export type TraceEventForFilter = {
47
47
  type: TraceEventPhase;
48
48
  eventType: TraceEventPhase;
49
49
  functionType?: string | null;
50
50
  fn?: string;
51
+ wrapperClass?: string | null;
51
52
  file?: string | null;
53
+ line?: number | null;
52
54
  depth?: number;
53
55
  library?: string | null;
54
56
  };
55
57
  export type HeaderRule = string | RegExp;
56
58
  export type HeaderCaptureOptions = {
57
- /** When true, sensitive headers such as Authorization are kept; default redacts them. */
59
+ /** When true, sensitive headers such as Authorization are kept unmasked; default masks them. */
58
60
  allowSensitiveHeaders?: boolean;
59
- /** Additional header names (string or RegExp) to drop from captures. */
61
+ /**
62
+ * Header names (string or RegExp) to mask.
63
+ * `dropHeaders` is kept for backward-compatibility and treated as an alias for `maskHeaders`.
64
+ */
65
+ maskHeaders?: HeaderRule | HeaderRule[];
66
+ /** @deprecated Alias for {@link maskHeaders}. */
60
67
  dropHeaders?: HeaderRule | HeaderRule[];
61
- /** Explicit allowlist that overrides defaults and drop rules. */
68
+ /**
69
+ * Header names (string or RegExp) to keep unmasked, overriding defaults and `maskHeaders`.
70
+ * `keepHeaders` is kept for backward-compatibility and treated as an alias for `unmaskHeaders`.
71
+ */
72
+ unmaskHeaders?: HeaderRule | HeaderRule[];
73
+ /** @deprecated Alias for {@link unmaskHeaders}. */
62
74
  keepHeaders?: HeaderRule | HeaderRule[];
63
75
  };
64
76
  /** Lightweight helper to disable every trace emitted from specific files. */
@@ -78,8 +90,21 @@ export type DisableFunctionTraceRule = {
78
90
  fn?: TraceRulePattern;
79
91
  /** Function name (e.g. `"findOne"`, `/^UserService\./`). */
80
92
  functionName?: TraceRulePattern;
81
- /** Absolute file path where the function was defined. */
93
+ /** Shortcut for {@link wrapperClass}. */
94
+ wrapper?: TraceRulePattern;
95
+ /**
96
+ * Wrapper/owner name derived from {@link functionName} (e.g. `"UserService"` in `"UserService.create"`).
97
+ * Useful when multiple functions share the same method name.
98
+ */
99
+ wrapperClass?: TraceRulePattern;
100
+ /** Alias for {@link wrapperClass}. */
101
+ className?: TraceRulePattern;
102
+ /** Alias for {@link wrapperClass}. */
103
+ owner?: TraceRulePattern;
104
+ /** Source filename reported by the trace event. */
82
105
  file?: TraceRulePattern;
106
+ /** Line number reported by the trace event. */
107
+ line?: TraceRulePattern;
83
108
  /** Shortcut for {@link library}. */
84
109
  lib?: TraceRulePattern;
85
110
  /** Library/package name inferred from the file path (e.g. `"mongoose"`). */
@@ -95,6 +120,33 @@ export type DisableFunctionTraceRule = {
95
120
  };
96
121
  export type DisableFunctionTracePredicate = (event: TraceEventForFilter) => boolean;
97
122
  export type DisableFunctionTraceConfig = DisableFunctionTraceRule | DisableFunctionTracePredicate;
123
+ export type ReproMaskTarget = 'request.headers' | 'request.body' | 'request.params' | 'request.query' | 'response.body' | 'trace.args' | 'trace.returnValue' | 'trace.error';
124
+ export type ReproMaskWhen = DisableFunctionTraceRule & {
125
+ /** Match HTTP method (e.g. `"GET"`, `/^post$/i`). */
126
+ method?: TraceRulePattern;
127
+ /** Match request path without query string (e.g. `"/api/auth/login"`). */
128
+ path?: TraceRulePattern;
129
+ /** Match normalized endpoint key (e.g. `"POST /api/auth/login"`). */
130
+ key?: TraceRulePattern;
131
+ };
132
+ export type ReproMaskRule = {
133
+ when?: ReproMaskWhen;
134
+ target: ReproMaskTarget | ReproMaskTarget[];
135
+ /**
136
+ * Dot/bracket paths to mask (supports `*`, `[0]`, `[*]`).
137
+ * Examples: `"password"`, `"user.token"`, `"items[*].serial"`, `"0.password"` (for trace args arrays).
138
+ */
139
+ paths?: string | string[];
140
+ /** Key name patterns to mask anywhere in the payload. */
141
+ keys?: TraceRulePattern;
142
+ /** Override replacement value for this rule (defaults to config replacement / `"[REDACTED]"`). */
143
+ replacement?: any;
144
+ };
145
+ export type ReproMaskingConfig = {
146
+ /** Default replacement value (defaults to `"[REDACTED]"`). */
147
+ replacement?: any;
148
+ rules?: ReproMaskRule[] | null;
149
+ };
98
150
  export declare function setDisabledFunctionTraces(rules?: DisableFunctionTraceConfig[] | null): void;
99
151
  export declare function setDisabledFunctionTypes(patterns?: TraceRulePattern | null): void;
100
152
  export declare function setDisabledTraceFiles(config?: DisableTraceFileConfig | DisableTraceFileConfig[] | null): void;
@@ -146,8 +198,10 @@ export type ReproMiddlewareConfig = {
146
198
  tenantId: string;
147
199
  appSecret: string;
148
200
  apiBase: string;
149
- /** Configure header capture/redaction. Defaults to capturing with sensitive headers removed. */
201
+ /** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
150
202
  captureHeaders?: boolean | HeaderCaptureOptions;
203
+ /** Optional masking rules for request/response payloads and function traces. */
204
+ masking?: ReproMaskingConfig;
151
205
  };
152
206
  export declare function reproMiddleware(cfg: ReproMiddlewareConfig): (req: Request, res: Response, next: NextFunction) => void;
153
207
  export declare function reproMongoosePlugin(cfg: {
package/dist/index.js CHANGED
@@ -298,12 +298,35 @@ 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;
328
+ if (!matchesPattern(event.line == null ? null : String(event.line), rule.line))
329
+ return false;
307
330
  const libPattern = rule.lib ?? rule.library;
308
331
  if (!matchesPattern(event.library, libPattern))
309
332
  return false;
@@ -364,7 +387,7 @@ function flattenTraceFilePatterns(config) {
364
387
  if (Array.isArray(config)) {
365
388
  return config.flatMap(entry => flattenTraceFilePatterns(entry));
366
389
  }
367
- if (config instanceof RegExp || typeof config === 'string') {
390
+ if (config instanceof RegExp || typeof config === 'string' || typeof config === 'number') {
368
391
  return [config];
369
392
  }
370
393
  if (typeof config === 'object' && 'file' in config) {
@@ -1197,6 +1220,7 @@ const DEFAULT_SENSITIVE_HEADERS = [
1197
1220
  'cookie',
1198
1221
  'set-cookie',
1199
1222
  ];
1223
+ const DEFAULT_MASK_REPLACEMENT = '[REDACTED]';
1200
1224
  function normalizeHeaderRules(rules) {
1201
1225
  return normalizePatternArray(rules || []);
1202
1226
  }
@@ -1218,14 +1242,14 @@ function matchesHeaderRule(name, rules) {
1218
1242
  }
1219
1243
  function normalizeHeaderCaptureConfig(raw) {
1220
1244
  if (raw === false) {
1221
- return { enabled: false, allowSensitive: false, drop: [], keep: [] };
1245
+ return { enabled: false, allowSensitive: false, mask: [], unmask: [] };
1222
1246
  }
1223
1247
  const opts = raw && raw !== true ? raw : {};
1224
1248
  return {
1225
1249
  enabled: true,
1226
1250
  allowSensitive: opts.allowSensitiveHeaders === true,
1227
- drop: normalizeHeaderRules(opts.dropHeaders),
1228
- keep: normalizeHeaderRules(opts.keepHeaders),
1251
+ mask: [...normalizeHeaderRules(opts.maskHeaders), ...normalizeHeaderRules(opts.dropHeaders)],
1252
+ unmask: [...normalizeHeaderRules(opts.unmaskHeaders), ...normalizeHeaderRules(opts.keepHeaders)],
1229
1253
  };
1230
1254
  }
1231
1255
  function sanitizeHeaderValue(value) {
@@ -1248,24 +1272,209 @@ function sanitizeHeaders(headers, rawCfg) {
1248
1272
  const cfg = normalizeHeaderCaptureConfig(rawCfg);
1249
1273
  if (!cfg.enabled)
1250
1274
  return {};
1251
- const dropList = cfg.allowSensitive ? cfg.drop : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.drop];
1252
1275
  const out = {};
1253
1276
  for (const [rawKey, rawVal] of Object.entries(headers || {})) {
1254
1277
  const key = String(rawKey || '').toLowerCase();
1255
1278
  if (!key)
1256
1279
  continue;
1257
- const shouldDrop = matchesHeaderRule(key, dropList);
1258
- const keep = matchesHeaderRule(key, cfg.keep);
1259
- if (shouldDrop && !keep)
1260
- continue;
1261
1280
  const sanitizedValue = sanitizeHeaderValue(rawVal);
1262
1281
  if (sanitizedValue !== undefined) {
1263
1282
  out[key] = sanitizedValue;
1264
1283
  }
1265
1284
  }
1285
+ const maskList = cfg.allowSensitive ? cfg.mask : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.mask];
1286
+ Object.keys(out).forEach((key) => {
1287
+ if (!matchesHeaderRule(key, maskList))
1288
+ return;
1289
+ if (matchesHeaderRule(key, cfg.unmask))
1290
+ return;
1291
+ const current = out[key];
1292
+ if (Array.isArray(current)) {
1293
+ out[key] = current.map(() => DEFAULT_MASK_REPLACEMENT);
1294
+ return;
1295
+ }
1296
+ out[key] = DEFAULT_MASK_REPLACEMENT;
1297
+ });
1266
1298
  return out;
1267
1299
  }
1300
+ function normalizeMaskTargets(target) {
1301
+ const out = Array.isArray(target) ? target : [target];
1302
+ return out.filter((t) => typeof t === 'string' && t.length > 0);
1303
+ }
1304
+ function parseMaskPath(raw) {
1305
+ if (!raw)
1306
+ return [];
1307
+ let path = String(raw).trim();
1308
+ if (!path)
1309
+ return [];
1310
+ if (path.startsWith('$.'))
1311
+ path = path.slice(2);
1312
+ if (path.startsWith('.'))
1313
+ path = path.slice(1);
1314
+ path = path.replace(/\[(\d+|\*)\]/g, '.$1');
1315
+ return path.split('.').map(s => s.trim()).filter(Boolean);
1316
+ }
1317
+ function normalizeMaskPaths(paths) {
1318
+ if (!paths)
1319
+ return [];
1320
+ const list = Array.isArray(paths) ? paths : [paths];
1321
+ return list
1322
+ .map(p => parseMaskPath(p))
1323
+ .filter(parts => parts.length > 0);
1324
+ }
1325
+ function normalizeMaskingConfig(raw) {
1326
+ const rules = raw?.rules;
1327
+ if (!Array.isArray(rules) || rules.length === 0)
1328
+ return null;
1329
+ const replacement = raw?.replacement ?? DEFAULT_MASK_REPLACEMENT;
1330
+ const normalized = [];
1331
+ for (const rule of rules) {
1332
+ if (!rule)
1333
+ continue;
1334
+ const targets = normalizeMaskTargets(rule.target);
1335
+ if (!targets.length)
1336
+ continue;
1337
+ const paths = normalizeMaskPaths(rule.paths);
1338
+ const keys = rule.keys ?? undefined;
1339
+ if (!paths.length && !keys)
1340
+ continue;
1341
+ normalized.push({
1342
+ when: rule.when,
1343
+ targets,
1344
+ paths,
1345
+ keys,
1346
+ replacement: rule.replacement ?? replacement,
1347
+ });
1348
+ }
1349
+ return normalized.length ? { replacement, rules: normalized } : null;
1350
+ }
1351
+ function maskWhenRequiresTrace(when) {
1352
+ return Boolean(when.fn ||
1353
+ when.functionName ||
1354
+ when.wrapper ||
1355
+ when.wrapperClass ||
1356
+ when.className ||
1357
+ when.owner ||
1358
+ when.file ||
1359
+ when.lib ||
1360
+ when.library ||
1361
+ when.type ||
1362
+ when.functionType ||
1363
+ when.event ||
1364
+ when.eventType);
1365
+ }
1366
+ function matchesMaskWhen(when, req, trace) {
1367
+ if (!when)
1368
+ return true;
1369
+ if (!matchesPattern(req.method, when.method))
1370
+ return false;
1371
+ if (!matchesPattern(req.path, when.path))
1372
+ return false;
1373
+ if (!matchesPattern(req.key, when.key))
1374
+ return false;
1375
+ if (maskWhenRequiresTrace(when)) {
1376
+ if (!trace)
1377
+ return false;
1378
+ if (!matchesRule(when, trace))
1379
+ return false;
1380
+ }
1381
+ return true;
1382
+ }
1383
+ function maskKeysInPlace(node, keys, replacement) {
1384
+ if (!node)
1385
+ return;
1386
+ if (Array.isArray(node)) {
1387
+ node.forEach(item => maskKeysInPlace(item, keys, replacement));
1388
+ return;
1389
+ }
1390
+ if (typeof node !== 'object')
1391
+ return;
1392
+ Object.keys(node).forEach((key) => {
1393
+ if (matchesPattern(key, keys, false)) {
1394
+ try {
1395
+ node[key] = replacement;
1396
+ }
1397
+ catch { }
1398
+ return;
1399
+ }
1400
+ maskKeysInPlace(node[key], keys, replacement);
1401
+ });
1402
+ }
1403
+ function maskPathInPlace(node, pathParts, replacement, depth = 0) {
1404
+ if (!node)
1405
+ return;
1406
+ if (depth >= pathParts.length)
1407
+ return;
1408
+ const part = pathParts[depth];
1409
+ const isLast = depth === pathParts.length - 1;
1410
+ const applyAt = (container, key) => {
1411
+ if (!container)
1412
+ return;
1413
+ try {
1414
+ container[key] = replacement;
1415
+ }
1416
+ catch { }
1417
+ };
1418
+ if (part === '*') {
1419
+ if (Array.isArray(node)) {
1420
+ for (let i = 0; i < node.length; i++) {
1421
+ if (isLast)
1422
+ applyAt(node, i);
1423
+ else
1424
+ maskPathInPlace(node[i], pathParts, replacement, depth + 1);
1425
+ }
1426
+ }
1427
+ else if (typeof node === 'object') {
1428
+ for (const key of Object.keys(node)) {
1429
+ if (isLast)
1430
+ applyAt(node, key);
1431
+ else
1432
+ maskPathInPlace(node[key], pathParts, replacement, depth + 1);
1433
+ }
1434
+ }
1435
+ return;
1436
+ }
1437
+ const index = Number(part);
1438
+ const isIndex = Number.isInteger(index) && String(index) === part;
1439
+ if (Array.isArray(node) && isIndex) {
1440
+ if (index < 0 || index >= node.length)
1441
+ return;
1442
+ if (isLast)
1443
+ applyAt(node, index);
1444
+ else
1445
+ maskPathInPlace(node[index], pathParts, replacement, depth + 1);
1446
+ return;
1447
+ }
1448
+ if (typeof node !== 'object')
1449
+ return;
1450
+ if (!Object.prototype.hasOwnProperty.call(node, part))
1451
+ return;
1452
+ if (isLast)
1453
+ applyAt(node, part);
1454
+ else
1455
+ maskPathInPlace(node[part], pathParts, replacement, depth + 1);
1456
+ }
1457
+ function applyMasking(target, value, req, trace, masking) {
1458
+ if (!masking || !masking.rules.length)
1459
+ return value;
1460
+ if (value === undefined)
1461
+ return value;
1462
+ for (const rule of masking.rules) {
1463
+ if (!rule.targets.includes(target))
1464
+ continue;
1465
+ if (!matchesMaskWhen(rule.when, req, trace))
1466
+ continue;
1467
+ const replacement = rule.replacement ?? masking.replacement ?? DEFAULT_MASK_REPLACEMENT;
1468
+ if (rule.keys)
1469
+ maskKeysInPlace(value, rule.keys, replacement);
1470
+ if (rule.paths.length) {
1471
+ rule.paths.forEach(parts => maskPathInPlace(value, parts, replacement, 0));
1472
+ }
1473
+ }
1474
+ return value;
1475
+ }
1268
1476
  function reproMiddleware(cfg) {
1477
+ const masking = normalizeMaskingConfig(cfg.masking);
1269
1478
  return function (req, res, next) {
1270
1479
  const sid = req.headers['x-bug-session-id'] || '';
1271
1480
  const aid = req.headers['x-bug-action-id'] || '';
@@ -1278,8 +1487,10 @@ function reproMiddleware(cfg) {
1278
1487
  const rid = nextRequestId(requestEpochMs);
1279
1488
  const t0 = requestStartRaw;
1280
1489
  const url = req.originalUrl || req.url || '/';
1490
+ const urlPathOnly = (url || '/').split('?')[0] || '/';
1281
1491
  const path = url; // back-compat
1282
1492
  const key = normalizeRouteKey(req.method, url);
1493
+ const maskReq = { method: String(req.method || 'GET').toUpperCase(), path: urlPathOnly, key };
1283
1494
  const requestHeaders = sanitizeHeaders(req.headers, cfg.captureHeaders);
1284
1495
  beginSessionRequest(sid);
1285
1496
  // ---- response body capture (unchanged) ----
@@ -1426,6 +1637,9 @@ function reproMiddleware(cfg) {
1426
1637
  unsubscribe = __TRACER__.tracer.on((ev) => {
1427
1638
  if (!ev || ev.traceId !== tidNow)
1428
1639
  return;
1640
+ const sourceFileForLibrary = typeof ev.sourceFile === 'string' && ev.sourceFile
1641
+ ? String(ev.sourceFile)
1642
+ : null;
1429
1643
  const evt = {
1430
1644
  t: alignTimestamp(ev.t),
1431
1645
  type: ev.type,
@@ -1439,14 +1653,25 @@ function reproMiddleware(cfg) {
1439
1653
  if (ev.functionType !== undefined) {
1440
1654
  evt.functionType = ev.functionType;
1441
1655
  }
1656
+ const candidate = {
1657
+ type: evt.type,
1658
+ eventType: evt.type,
1659
+ functionType: evt.functionType ?? null,
1660
+ fn: evt.fn,
1661
+ wrapperClass: inferWrapperClassFromFn(evt.fn),
1662
+ file: evt.file ?? null,
1663
+ line: evt.line ?? null,
1664
+ depth: evt.depth,
1665
+ library: inferLibraryNameFromFile(sourceFileForLibrary ?? evt.file),
1666
+ };
1442
1667
  if (ev.args !== undefined) {
1443
- evt.args = sanitizeTraceArgs(ev.args);
1668
+ evt.args = applyMasking('trace.args', sanitizeTraceArgs(ev.args), maskReq, candidate, masking);
1444
1669
  }
1445
1670
  if (ev.returnValue !== undefined) {
1446
- evt.returnValue = sanitizeTraceValue(ev.returnValue);
1671
+ evt.returnValue = applyMasking('trace.returnValue', sanitizeTraceValue(ev.returnValue), maskReq, candidate, masking);
1447
1672
  }
1448
1673
  if (ev.error !== undefined) {
1449
- evt.error = sanitizeTraceValue(ev.error);
1674
+ evt.error = applyMasking('trace.error', sanitizeTraceValue(ev.error), maskReq, candidate, masking);
1450
1675
  }
1451
1676
  if (ev.threw !== undefined) {
1452
1677
  evt.threw = Boolean(ev.threw);
@@ -1454,15 +1679,6 @@ function reproMiddleware(cfg) {
1454
1679
  if (ev.unawaited !== undefined) {
1455
1680
  evt.unawaited = ev.unawaited === true;
1456
1681
  }
1457
- const candidate = {
1458
- type: evt.type,
1459
- eventType: evt.type,
1460
- functionType: evt.functionType ?? null,
1461
- fn: evt.fn,
1462
- file: evt.file ?? null,
1463
- depth: evt.depth,
1464
- library: inferLibraryNameFromFile(evt.file),
1465
- };
1466
1682
  const dropEvent = shouldDropTraceEvent(candidate);
1467
1683
  const spanKey = normalizeSpanId(evt.spanId);
1468
1684
  if (evt.type === 'enter') {
@@ -1546,9 +1762,25 @@ function reproMiddleware(cfg) {
1546
1762
  ?? firstAppTrace
1547
1763
  ?? { fn: null, file: null, line: null, functionType: null };
1548
1764
  const traceBatches = chunkArray(orderedEvents, TRACE_BATCH_SIZE);
1549
- const requestBody = sanitizeRequestSnapshot(req.body);
1550
- const requestParams = sanitizeRequestSnapshot(req.params);
1551
- const requestQuery = sanitizeRequestSnapshot(req.query);
1765
+ const endpointTraceCtx = (() => {
1766
+ if (!chosenEndpoint?.fn && !chosenEndpoint?.file)
1767
+ return null;
1768
+ return {
1769
+ type: 'enter',
1770
+ eventType: 'enter',
1771
+ fn: chosenEndpoint.fn ?? undefined,
1772
+ wrapperClass: inferWrapperClassFromFn(chosenEndpoint.fn),
1773
+ file: chosenEndpoint.file ?? null,
1774
+ line: chosenEndpoint.line ?? null,
1775
+ functionType: chosenEndpoint.functionType ?? null,
1776
+ library: inferLibraryNameFromFile(chosenEndpoint.file),
1777
+ };
1778
+ })();
1779
+ const requestBody = applyMasking('request.body', sanitizeRequestSnapshot(req.body), maskReq, endpointTraceCtx, masking);
1780
+ const requestParams = applyMasking('request.params', sanitizeRequestSnapshot(req.params), maskReq, endpointTraceCtx, masking);
1781
+ const requestQuery = applyMasking('request.query', sanitizeRequestSnapshot(req.query), maskReq, endpointTraceCtx, masking);
1782
+ const maskedHeaders = applyMasking('request.headers', requestHeaders, maskReq, endpointTraceCtx, masking);
1783
+ const responseBody = applyMasking('response.body', capturedBody === undefined ? undefined : sanitizeRequestSnapshot(capturedBody), maskReq, endpointTraceCtx, masking);
1552
1784
  const requestPayload = {
1553
1785
  rid,
1554
1786
  method: req.method,
@@ -1556,9 +1788,9 @@ function reproMiddleware(cfg) {
1556
1788
  path,
1557
1789
  status: res.statusCode,
1558
1790
  durMs: Date.now() - t0,
1559
- headers: requestHeaders,
1791
+ headers: maskedHeaders,
1560
1792
  key,
1561
- respBody: capturedBody,
1793
+ respBody: responseBody,
1562
1794
  trace: traceBatches.length ? undefined : '[]',
1563
1795
  };
1564
1796
  if (requestBody !== undefined)
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,7 +50,9 @@ 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
- | `file` | Match the absolute source filename, useful for filtering entire modules. |
53
+ | `wrapper`/`wrapperClass`/`className`/`owner` | Match the wrapper/owner inferred from the function name (e.g. `"UserService"` in `"UserService.create"`). |
54
+ | `file` | Match the filename reported by the trace event (callsite for wrapped calls; definition file for body-traced app functions). |
55
+ | `line` | Match the line number reported by the trace event. |
54
56
  | `lib`/`library` | Match the npm package inferred from the file path (e.g. `"mongoose"`). |
55
57
  | `type`/`functionType` | Match the detected function kind (e.g. `"constructor"`, `"method"`, `"arrow"`). |
56
58
  | `event`/`eventType` | Match the trace phase (`"enter"` or `"exit"`) to suppress only specific edges of a call. |
@@ -64,7 +66,7 @@ Provide a function that receives the raw trace event and returns `true` when it
64
66
  should be discarded. Use this form for complex, stateful, or cross-field logic.
65
67
 
66
68
  ```ts
67
- import { setDisabledFunctionTraces } from '@repro/sdk';
69
+ import { setDisabledFunctionTraces } from 'repro-nest';
68
70
 
69
71
  setDisabledFunctionTraces([
70
72
  (event) => event.library === 'mongoose' && event.functionType === 'constructor',
@@ -74,6 +76,71 @@ setDisabledFunctionTraces([
74
76
  Pass `null` or an empty array to `initReproTracing` or `setDisabledFunctionTraces`
75
77
  to remove previously configured rules.
76
78
 
79
+ ## Payload masking (`reproMiddleware`)
80
+
81
+ `reproMiddleware` can mask request/response payloads, request headers, and traced
82
+ function inputs/outputs before they are persisted.
83
+
84
+ ### Targets
85
+
86
+ - `request.headers`
87
+ - `request.body`
88
+ - `request.params`
89
+ - `request.query`
90
+ - `response.body`
91
+ - `trace.args`
92
+ - `trace.returnValue`
93
+ - `trace.error`
94
+
95
+ ### Rules
96
+
97
+ - `when.method` / `when.path` / `when.key` scope rules by endpoint (`key` is `"METHOD /path"` without query string).
98
+ - For function-specific rules, `when` supports the same fields as `disableFunctionTraces` (`fn`, `wrapperClass`, `file`, `line`, etc). This is useful when multiple functions share the same name.
99
+ - `paths` uses dot/bracket syntax and supports `*`, `[0]`, `[*]` (example: `"items[*].token"` or `"0.password"` for trace args arrays).
100
+ - `keys` masks matching key names anywhere in the payload (string/RegExp/array).
101
+ - `replacement` overrides the default replacement value (defaults to `"[REDACTED]"`).
102
+
103
+ ```ts
104
+ import { reproMiddleware } from 'repro-nest';
105
+
106
+ app.use(reproMiddleware({
107
+ appId,
108
+ tenantId,
109
+ appSecret,
110
+ apiBase,
111
+ masking: {
112
+ rules: [
113
+ {
114
+ when: { key: 'POST /api/auth/login' },
115
+ target: 'request.body',
116
+ paths: ['password'],
117
+ },
118
+ {
119
+ when: { wrapperClass: 'AuthService', functionName: 'login', file: '/app/src/auth/auth.service.ts' },
120
+ target: ['trace.args', 'trace.returnValue', 'trace.error'],
121
+ keys: [/token/i],
122
+ },
123
+ {
124
+ target: 'request.headers',
125
+ keys: [/authorization/i, /cookie/i],
126
+ },
127
+ ],
128
+ },
129
+ }));
130
+ ```
131
+
132
+ ### Header capture/masking (`captureHeaders`)
133
+
134
+ Headers are captured by default with sensitive values masked. Configure `captureHeaders`
135
+ to change the behavior:
136
+
137
+ - `captureHeaders: false` disables header capture entirely.
138
+ - `captureHeaders: true` (or omitted) captures headers and masks sensitive ones.
139
+ - `captureHeaders.allowSensitiveHeaders: true` keeps default sensitive headers unmasked (use with care).
140
+ - `captureHeaders.maskHeaders` adds additional header names to mask.
141
+ - `captureHeaders.unmaskHeaders` keeps specific header names unmasked (overrides defaults and `maskHeaders`).
142
+ - `captureHeaders.dropHeaders` / `captureHeaders.keepHeaders` are legacy aliases for `maskHeaders` / `unmaskHeaders`.
143
+
77
144
  ## `logFunctionCalls`
78
145
 
79
146
  Set to `true` to enable verbose console logging of function entry/exit events at
@@ -81,7 +148,7 @@ runtime, or to `false` to silence them. The value is forwarded to
81
148
  `setReproTraceLogsEnabled`, so you can also toggle logging later in the process:
82
149
 
83
150
  ```ts
84
- import { enableReproTraceLogs, disableReproTraceLogs } from '@repro/sdk';
151
+ import { enableReproTraceLogs, disableReproTraceLogs } from 'repro-nest';
85
152
 
86
153
  enableReproTraceLogs();
87
154
  // ...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repro-nest",
3
- "version": "0.0.214",
3
+ "version": "0.0.216",
4
4
  "description": "Repro Nest SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -229,14 +229,16 @@ let __TRACER__: TracerApi | null = null;
229
229
  let __TRACER_READY = false;
230
230
 
231
231
  type TraceEventPhase = 'enter' | 'exit';
232
- export type TraceRulePattern = string | RegExp | Array<string | RegExp>;
232
+ export type TraceRulePattern = string | number | RegExp | Array<string | number | RegExp>;
233
233
 
234
234
  export type TraceEventForFilter = {
235
235
  type: TraceEventPhase; // legacy alias for eventType
236
236
  eventType: TraceEventPhase;
237
237
  functionType?: string | null;
238
238
  fn?: string;
239
+ wrapperClass?: string | null;
239
240
  file?: string | null;
241
+ line?: number | null;
240
242
  depth?: number;
241
243
  library?: string | null;
242
244
  };
@@ -267,19 +269,29 @@ type EndpointTraceInfo = {
267
269
 
268
270
  export type HeaderRule = string | RegExp;
269
271
  export type HeaderCaptureOptions = {
270
- /** When true, sensitive headers such as Authorization are kept; default redacts them. */
272
+ /** When true, sensitive headers such as Authorization are kept unmasked; default masks them. */
271
273
  allowSensitiveHeaders?: boolean;
272
- /** Additional header names (string or RegExp) to drop from captures. */
274
+ /**
275
+ * Header names (string or RegExp) to mask.
276
+ * `dropHeaders` is kept for backward-compatibility and treated as an alias for `maskHeaders`.
277
+ */
278
+ maskHeaders?: HeaderRule | HeaderRule[];
279
+ /** @deprecated Alias for {@link maskHeaders}. */
273
280
  dropHeaders?: HeaderRule | HeaderRule[];
274
- /** Explicit allowlist that overrides defaults and drop rules. */
281
+ /**
282
+ * Header names (string or RegExp) to keep unmasked, overriding defaults and `maskHeaders`.
283
+ * `keepHeaders` is kept for backward-compatibility and treated as an alias for `unmaskHeaders`.
284
+ */
285
+ unmaskHeaders?: HeaderRule | HeaderRule[];
286
+ /** @deprecated Alias for {@link unmaskHeaders}. */
275
287
  keepHeaders?: HeaderRule | HeaderRule[];
276
288
  };
277
289
 
278
290
  type NormalizedHeaderCapture = {
279
291
  enabled: boolean;
280
292
  allowSensitive: boolean;
281
- drop: HeaderRule[];
282
- keep: HeaderRule[];
293
+ mask: HeaderRule[];
294
+ unmask: HeaderRule[];
283
295
  };
284
296
 
285
297
  /** Lightweight helper to disable every trace emitted from specific files. */
@@ -301,8 +313,21 @@ export type DisableFunctionTraceRule = {
301
313
  fn?: TraceRulePattern;
302
314
  /** Function name (e.g. `"findOne"`, `/^UserService\./`). */
303
315
  functionName?: TraceRulePattern;
304
- /** Absolute file path where the function was defined. */
316
+ /** Shortcut for {@link wrapperClass}. */
317
+ wrapper?: TraceRulePattern;
318
+ /**
319
+ * Wrapper/owner name derived from {@link functionName} (e.g. `"UserService"` in `"UserService.create"`).
320
+ * Useful when multiple functions share the same method name.
321
+ */
322
+ wrapperClass?: TraceRulePattern;
323
+ /** Alias for {@link wrapperClass}. */
324
+ className?: TraceRulePattern;
325
+ /** Alias for {@link wrapperClass}. */
326
+ owner?: TraceRulePattern;
327
+ /** Source filename reported by the trace event. */
305
328
  file?: TraceRulePattern;
329
+ /** Line number reported by the trace event. */
330
+ line?: TraceRulePattern;
306
331
  /** Shortcut for {@link library}. */
307
332
  lib?: TraceRulePattern;
308
333
  /** Library/package name inferred from the file path (e.g. `"mongoose"`). */
@@ -323,6 +348,45 @@ export type DisableFunctionTraceConfig =
323
348
  | DisableFunctionTraceRule
324
349
  | DisableFunctionTracePredicate;
325
350
 
351
+ export type ReproMaskTarget =
352
+ | 'request.headers'
353
+ | 'request.body'
354
+ | 'request.params'
355
+ | 'request.query'
356
+ | 'response.body'
357
+ | 'trace.args'
358
+ | 'trace.returnValue'
359
+ | 'trace.error';
360
+
361
+ export type ReproMaskWhen = DisableFunctionTraceRule & {
362
+ /** Match HTTP method (e.g. `"GET"`, `/^post$/i`). */
363
+ method?: TraceRulePattern;
364
+ /** Match request path without query string (e.g. `"/api/auth/login"`). */
365
+ path?: TraceRulePattern;
366
+ /** Match normalized endpoint key (e.g. `"POST /api/auth/login"`). */
367
+ key?: TraceRulePattern;
368
+ };
369
+
370
+ export type ReproMaskRule = {
371
+ when?: ReproMaskWhen;
372
+ target: ReproMaskTarget | ReproMaskTarget[];
373
+ /**
374
+ * Dot/bracket paths to mask (supports `*`, `[0]`, `[*]`).
375
+ * Examples: `"password"`, `"user.token"`, `"items[*].serial"`, `"0.password"` (for trace args arrays).
376
+ */
377
+ paths?: string | string[];
378
+ /** Key name patterns to mask anywhere in the payload. */
379
+ keys?: TraceRulePattern;
380
+ /** Override replacement value for this rule (defaults to config replacement / `"[REDACTED]"`). */
381
+ replacement?: any;
382
+ };
383
+
384
+ export type ReproMaskingConfig = {
385
+ /** Default replacement value (defaults to `"[REDACTED]"`). */
386
+ replacement?: any;
387
+ rules?: ReproMaskRule[] | null;
388
+ };
389
+
326
390
  const DEFAULT_INTERCEPTOR_TRACE_RULES: DisableFunctionTraceConfig[] = [
327
391
  { fn: /switchToHttp$/i },
328
392
  { fn: /intercept$/i },
@@ -348,8 +412,8 @@ function refreshDisabledFunctionTraceRules() {
348
412
  }
349
413
 
350
414
  let disabledFunctionTraceRules: DisableFunctionTraceConfig[] = computeDisabledFunctionTraceRules();
351
- let disabledFunctionTypePatterns: Array<string | RegExp> = [];
352
- let disabledTraceFilePatterns: Array<string | RegExp> = [];
415
+ let disabledFunctionTypePatterns: Array<string | number | RegExp> = [];
416
+ let disabledTraceFilePatterns: Array<string | number | RegExp> = [];
353
417
  let __TRACE_LOG_PREF: boolean | null = null;
354
418
 
355
419
  function setInterceptorTracingEnabled(enabled: boolean) {
@@ -403,11 +467,32 @@ function inferLibraryNameFromFile(file?: string | null): string | null {
403
467
  return segments[0] || null;
404
468
  }
405
469
 
470
+ function inferWrapperClassFromFn(fn?: string | null): string | null {
471
+ if (!fn) return null;
472
+ const raw = String(fn);
473
+ if (!raw) return null;
474
+ const hashIdx = raw.indexOf('#');
475
+ if (hashIdx > 0) {
476
+ const left = raw.slice(0, hashIdx).trim();
477
+ return left || null;
478
+ }
479
+ const dotIdx = raw.lastIndexOf('.');
480
+ if (dotIdx > 0) {
481
+ const left = raw.slice(0, dotIdx).trim();
482
+ return left || null;
483
+ }
484
+ return null;
485
+ }
486
+
406
487
  function matchesRule(rule: DisableFunctionTraceRule, event: TraceEventForFilter): boolean {
407
488
  const namePattern = rule.fn ?? rule.functionName;
408
489
  if (!matchesPattern(event.fn, namePattern)) return false;
409
490
 
491
+ const wrapperPattern = rule.wrapper ?? rule.wrapperClass ?? rule.className ?? rule.owner;
492
+ if (!matchesPattern(event.wrapperClass, wrapperPattern)) return false;
493
+
410
494
  if (!matchesPattern(event.file, rule.file)) return false;
495
+ if (!matchesPattern(event.line == null ? null : String(event.line), rule.line)) return false;
411
496
 
412
497
  const libPattern = rule.lib ?? rule.library;
413
498
  if (!matchesPattern(event.library, libPattern)) return false;
@@ -460,12 +545,12 @@ export function setDisabledFunctionTypes(patterns?: TraceRulePattern | null) {
460
545
  disabledFunctionTypePatterns = normalizePatternArray(patterns);
461
546
  }
462
547
 
463
- function flattenTraceFilePatterns(config: DisableTraceFileConfig): Array<string | RegExp> {
548
+ function flattenTraceFilePatterns(config: DisableTraceFileConfig): Array<string | number | RegExp> {
464
549
  if (config === null || config === undefined) return [];
465
550
  if (Array.isArray(config)) {
466
551
  return config.flatMap(entry => flattenTraceFilePatterns(entry));
467
552
  }
468
- if (config instanceof RegExp || typeof config === 'string') {
553
+ if (config instanceof RegExp || typeof config === 'string' || typeof config === 'number') {
469
554
  return [config];
470
555
  }
471
556
  if (typeof config === 'object' && 'file' in config) {
@@ -1421,6 +1506,8 @@ const DEFAULT_SENSITIVE_HEADERS: Array<string | RegExp> = [
1421
1506
  'set-cookie',
1422
1507
  ];
1423
1508
 
1509
+ const DEFAULT_MASK_REPLACEMENT = '[REDACTED]';
1510
+
1424
1511
  function normalizeHeaderRules(rules?: HeaderRule | HeaderRule[] | null): HeaderRule[] {
1425
1512
  return normalizePatternArray<HeaderRule>(rules || []);
1426
1513
  }
@@ -1438,14 +1525,14 @@ function matchesHeaderRule(name: string, rules: HeaderRule[]): boolean {
1438
1525
 
1439
1526
  function normalizeHeaderCaptureConfig(raw?: boolean | HeaderCaptureOptions): NormalizedHeaderCapture {
1440
1527
  if (raw === false) {
1441
- return { enabled: false, allowSensitive: false, drop: [], keep: [] };
1528
+ return { enabled: false, allowSensitive: false, mask: [], unmask: [] };
1442
1529
  }
1443
1530
  const opts: HeaderCaptureOptions = raw && raw !== true ? raw : {};
1444
1531
  return {
1445
1532
  enabled: true,
1446
1533
  allowSensitive: opts.allowSensitiveHeaders === true,
1447
- drop: normalizeHeaderRules(opts.dropHeaders),
1448
- keep: normalizeHeaderRules(opts.keepHeaders),
1534
+ mask: [...normalizeHeaderRules(opts.maskHeaders), ...normalizeHeaderRules(opts.dropHeaders)],
1535
+ unmask: [...normalizeHeaderRules(opts.unmaskHeaders), ...normalizeHeaderRules(opts.keepHeaders)],
1449
1536
  };
1450
1537
  }
1451
1538
 
@@ -1466,24 +1553,210 @@ function sanitizeHeaders(headers: any, rawCfg?: boolean | HeaderCaptureOptions)
1466
1553
  const cfg = normalizeHeaderCaptureConfig(rawCfg);
1467
1554
  if (!cfg.enabled) return {};
1468
1555
 
1469
- const dropList = cfg.allowSensitive ? cfg.drop : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.drop];
1470
1556
  const out: Record<string, any> = {};
1471
1557
 
1472
1558
  for (const [rawKey, rawVal] of Object.entries(headers || {})) {
1473
1559
  const key = String(rawKey || '').toLowerCase();
1474
1560
  if (!key) continue;
1475
- const shouldDrop = matchesHeaderRule(key, dropList);
1476
- const keep = matchesHeaderRule(key, cfg.keep);
1477
- if (shouldDrop && !keep) continue;
1478
-
1479
1561
  const sanitizedValue = sanitizeHeaderValue(rawVal);
1480
1562
  if (sanitizedValue !== undefined) {
1481
1563
  out[key] = sanitizedValue;
1482
1564
  }
1483
1565
  }
1566
+
1567
+ const maskList = cfg.allowSensitive ? cfg.mask : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.mask];
1568
+ Object.keys(out).forEach((key) => {
1569
+ if (!matchesHeaderRule(key, maskList)) return;
1570
+ if (matchesHeaderRule(key, cfg.unmask)) return;
1571
+ const current = out[key];
1572
+ if (Array.isArray(current)) {
1573
+ out[key] = current.map(() => DEFAULT_MASK_REPLACEMENT);
1574
+ return;
1575
+ }
1576
+ out[key] = DEFAULT_MASK_REPLACEMENT;
1577
+ });
1578
+
1484
1579
  return out;
1485
1580
  }
1486
1581
 
1582
+ // ===================================================================
1583
+ // Masking (request/response payloads + trace args/returns/errors)
1584
+ // ===================================================================
1585
+ type NormalizedMaskRule = {
1586
+ when?: ReproMaskWhen;
1587
+ targets: ReproMaskTarget[];
1588
+ paths: string[][];
1589
+ keys?: TraceRulePattern;
1590
+ replacement: any;
1591
+ };
1592
+
1593
+ type NormalizedMaskingConfig = {
1594
+ replacement: any;
1595
+ rules: NormalizedMaskRule[];
1596
+ };
1597
+
1598
+ type MaskRequestContext = {
1599
+ method: string;
1600
+ path: string;
1601
+ key: string;
1602
+ };
1603
+
1604
+ function normalizeMaskTargets(target: ReproMaskTarget | ReproMaskTarget[]): ReproMaskTarget[] {
1605
+ const out = Array.isArray(target) ? target : [target];
1606
+ return out.filter((t): t is ReproMaskTarget => typeof t === 'string' && t.length > 0);
1607
+ }
1608
+
1609
+ function parseMaskPath(raw: string): string[] {
1610
+ if (!raw) return [];
1611
+ let path = String(raw).trim();
1612
+ if (!path) return [];
1613
+ if (path.startsWith('$.')) path = path.slice(2);
1614
+ if (path.startsWith('.')) path = path.slice(1);
1615
+ path = path.replace(/\[(\d+|\*)\]/g, '.$1');
1616
+ return path.split('.').map(s => s.trim()).filter(Boolean);
1617
+ }
1618
+
1619
+ function normalizeMaskPaths(paths?: string | string[]): string[][] {
1620
+ if (!paths) return [];
1621
+ const list = Array.isArray(paths) ? paths : [paths];
1622
+ return list
1623
+ .map(p => parseMaskPath(p))
1624
+ .filter(parts => parts.length > 0);
1625
+ }
1626
+
1627
+ function normalizeMaskingConfig(raw?: ReproMaskingConfig): NormalizedMaskingConfig | null {
1628
+ const rules = raw?.rules;
1629
+ if (!Array.isArray(rules) || rules.length === 0) return null;
1630
+ const replacement = raw?.replacement ?? DEFAULT_MASK_REPLACEMENT;
1631
+ const normalized: NormalizedMaskRule[] = [];
1632
+ for (const rule of rules) {
1633
+ if (!rule) continue;
1634
+ const targets = normalizeMaskTargets(rule.target);
1635
+ if (!targets.length) continue;
1636
+ const paths = normalizeMaskPaths(rule.paths);
1637
+ const keys = rule.keys ?? undefined;
1638
+ if (!paths.length && !keys) continue;
1639
+ normalized.push({
1640
+ when: rule.when,
1641
+ targets,
1642
+ paths,
1643
+ keys,
1644
+ replacement: rule.replacement ?? replacement,
1645
+ });
1646
+ }
1647
+ return normalized.length ? { replacement, rules: normalized } : null;
1648
+ }
1649
+
1650
+ function maskWhenRequiresTrace(when: ReproMaskWhen): boolean {
1651
+ return Boolean(
1652
+ when.fn ||
1653
+ when.functionName ||
1654
+ when.wrapper ||
1655
+ when.wrapperClass ||
1656
+ when.className ||
1657
+ when.owner ||
1658
+ when.file ||
1659
+ when.lib ||
1660
+ when.library ||
1661
+ when.type ||
1662
+ when.functionType ||
1663
+ when.event ||
1664
+ when.eventType
1665
+ );
1666
+ }
1667
+
1668
+ function matchesMaskWhen(when: ReproMaskWhen | undefined, req: MaskRequestContext, trace: TraceEventForFilter | null): boolean {
1669
+ if (!when) return true;
1670
+ if (!matchesPattern(req.method, when.method)) return false;
1671
+ if (!matchesPattern(req.path, when.path)) return false;
1672
+ if (!matchesPattern(req.key, when.key)) return false;
1673
+
1674
+ if (maskWhenRequiresTrace(when)) {
1675
+ if (!trace) return false;
1676
+ if (!matchesRule(when, trace)) return false;
1677
+ }
1678
+
1679
+ return true;
1680
+ }
1681
+
1682
+ function maskKeysInPlace(node: any, keys: TraceRulePattern, replacement: any) {
1683
+ if (!node) return;
1684
+ if (Array.isArray(node)) {
1685
+ node.forEach(item => maskKeysInPlace(item, keys, replacement));
1686
+ return;
1687
+ }
1688
+ if (typeof node !== 'object') return;
1689
+ Object.keys(node).forEach((key) => {
1690
+ if (matchesPattern(key, keys, false)) {
1691
+ try { node[key] = replacement; } catch {}
1692
+ return;
1693
+ }
1694
+ maskKeysInPlace(node[key], keys, replacement);
1695
+ });
1696
+ }
1697
+
1698
+ function maskPathInPlace(node: any, pathParts: string[], replacement: any, depth: number = 0) {
1699
+ if (!node) return;
1700
+ if (depth >= pathParts.length) return;
1701
+ const part = pathParts[depth];
1702
+ const isLast = depth === pathParts.length - 1;
1703
+
1704
+ const applyAt = (container: any, key: string | number) => {
1705
+ if (!container) return;
1706
+ try { container[key] = replacement; } catch {}
1707
+ };
1708
+
1709
+ if (part === '*') {
1710
+ if (Array.isArray(node)) {
1711
+ for (let i = 0; i < node.length; i++) {
1712
+ if (isLast) applyAt(node, i);
1713
+ else maskPathInPlace(node[i], pathParts, replacement, depth + 1);
1714
+ }
1715
+ } else if (typeof node === 'object') {
1716
+ for (const key of Object.keys(node)) {
1717
+ if (isLast) applyAt(node, key);
1718
+ else maskPathInPlace(node[key], pathParts, replacement, depth + 1);
1719
+ }
1720
+ }
1721
+ return;
1722
+ }
1723
+
1724
+ const index = Number(part);
1725
+ const isIndex = Number.isInteger(index) && String(index) === part;
1726
+ if (Array.isArray(node) && isIndex) {
1727
+ if (index < 0 || index >= node.length) return;
1728
+ if (isLast) applyAt(node, index);
1729
+ else maskPathInPlace(node[index], pathParts, replacement, depth + 1);
1730
+ return;
1731
+ }
1732
+
1733
+ if (typeof node !== 'object') return;
1734
+ if (!Object.prototype.hasOwnProperty.call(node, part)) return;
1735
+ if (isLast) applyAt(node, part);
1736
+ else maskPathInPlace(node[part], pathParts, replacement, depth + 1);
1737
+ }
1738
+
1739
+ function applyMasking(
1740
+ target: ReproMaskTarget,
1741
+ value: any,
1742
+ req: MaskRequestContext,
1743
+ trace: TraceEventForFilter | null,
1744
+ masking: NormalizedMaskingConfig | null
1745
+ ) {
1746
+ if (!masking || !masking.rules.length) return value;
1747
+ if (value === undefined) return value;
1748
+ for (const rule of masking.rules) {
1749
+ if (!rule.targets.includes(target)) continue;
1750
+ if (!matchesMaskWhen(rule.when, req, trace)) continue;
1751
+ const replacement = rule.replacement ?? masking.replacement ?? DEFAULT_MASK_REPLACEMENT;
1752
+ if (rule.keys) maskKeysInPlace(value, rule.keys, replacement);
1753
+ if (rule.paths.length) {
1754
+ rule.paths.forEach(parts => maskPathInPlace(value, parts, replacement, 0));
1755
+ }
1756
+ }
1757
+ return value;
1758
+ }
1759
+
1487
1760
  // ===================================================================
1488
1761
  // reproMiddleware — unchanged behavior + passive per-request trace
1489
1762
  // ===================================================================
@@ -1492,11 +1765,14 @@ export type ReproMiddlewareConfig = {
1492
1765
  tenantId: string;
1493
1766
  appSecret: string;
1494
1767
  apiBase: string;
1495
- /** Configure header capture/redaction. Defaults to capturing with sensitive headers removed. */
1768
+ /** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
1496
1769
  captureHeaders?: boolean | HeaderCaptureOptions;
1770
+ /** Optional masking rules for request/response payloads and function traces. */
1771
+ masking?: ReproMaskingConfig;
1497
1772
  };
1498
1773
 
1499
1774
  export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1775
+ const masking = normalizeMaskingConfig(cfg.masking);
1500
1776
  return function (req: Request, res: Response, next: NextFunction) {
1501
1777
  const sid = (req.headers['x-bug-session-id'] as string) || '';
1502
1778
  const aid = (req.headers['x-bug-action-id'] as string) || '';
@@ -1509,8 +1785,10 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1509
1785
  const rid = nextRequestId(requestEpochMs);
1510
1786
  const t0 = requestStartRaw;
1511
1787
  const url = (req as any).originalUrl || req.url || '/';
1788
+ const urlPathOnly = (url || '/').split('?')[0] || '/';
1512
1789
  const path = url; // back-compat
1513
1790
  const key = normalizeRouteKey(req.method, url);
1791
+ const maskReq: MaskRequestContext = { method: String(req.method || 'GET').toUpperCase(), path: urlPathOnly, key };
1514
1792
  const requestHeaders = sanitizeHeaders(req.headers, cfg.captureHeaders);
1515
1793
  beginSessionRequest(sid);
1516
1794
 
@@ -1636,6 +1914,10 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1636
1914
  unsubscribe = __TRACER__.tracer.on((ev: any) => {
1637
1915
  if (!ev || ev.traceId !== tidNow) return;
1638
1916
 
1917
+ const sourceFileForLibrary = typeof ev.sourceFile === 'string' && ev.sourceFile
1918
+ ? String(ev.sourceFile)
1919
+ : null;
1920
+
1639
1921
  const evt: TraceEventRecord = {
1640
1922
  t: alignTimestamp(ev.t),
1641
1923
  type: ev.type,
@@ -1651,14 +1933,44 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1651
1933
  evt.functionType = ev.functionType;
1652
1934
  }
1653
1935
 
1936
+ const candidate: TraceEventForFilter = {
1937
+ type: evt.type,
1938
+ eventType: evt.type,
1939
+ functionType: evt.functionType ?? null,
1940
+ fn: evt.fn,
1941
+ wrapperClass: inferWrapperClassFromFn(evt.fn),
1942
+ file: evt.file ?? null,
1943
+ line: evt.line ?? null,
1944
+ depth: evt.depth,
1945
+ library: inferLibraryNameFromFile(sourceFileForLibrary ?? evt.file),
1946
+ };
1947
+
1654
1948
  if (ev.args !== undefined) {
1655
- evt.args = sanitizeTraceArgs(ev.args);
1949
+ evt.args = applyMasking(
1950
+ 'trace.args',
1951
+ sanitizeTraceArgs(ev.args),
1952
+ maskReq,
1953
+ candidate,
1954
+ masking
1955
+ );
1656
1956
  }
1657
1957
  if (ev.returnValue !== undefined) {
1658
- evt.returnValue = sanitizeTraceValue(ev.returnValue);
1958
+ evt.returnValue = applyMasking(
1959
+ 'trace.returnValue',
1960
+ sanitizeTraceValue(ev.returnValue),
1961
+ maskReq,
1962
+ candidate,
1963
+ masking
1964
+ );
1659
1965
  }
1660
1966
  if (ev.error !== undefined) {
1661
- evt.error = sanitizeTraceValue(ev.error);
1967
+ evt.error = applyMasking(
1968
+ 'trace.error',
1969
+ sanitizeTraceValue(ev.error),
1970
+ maskReq,
1971
+ candidate,
1972
+ masking
1973
+ );
1662
1974
  }
1663
1975
  if (ev.threw !== undefined) {
1664
1976
  evt.threw = Boolean(ev.threw);
@@ -1667,16 +1979,6 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1667
1979
  evt.unawaited = ev.unawaited === true;
1668
1980
  }
1669
1981
 
1670
- const candidate: TraceEventForFilter = {
1671
- type: evt.type,
1672
- eventType: evt.type,
1673
- functionType: evt.functionType ?? null,
1674
- fn: evt.fn,
1675
- file: evt.file ?? null,
1676
- depth: evt.depth,
1677
- library: inferLibraryNameFromFile(evt.file),
1678
- };
1679
-
1680
1982
  const dropEvent = shouldDropTraceEvent(candidate);
1681
1983
  const spanKey = normalizeSpanId(evt.spanId);
1682
1984
  if (evt.type === 'enter') {
@@ -1759,9 +2061,55 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1759
2061
  ?? firstAppTrace
1760
2062
  ?? { fn: null, file: null, line: null, functionType: null };
1761
2063
  const traceBatches = chunkArray(orderedEvents, TRACE_BATCH_SIZE);
1762
- const requestBody = sanitizeRequestSnapshot((req as any).body);
1763
- const requestParams = sanitizeRequestSnapshot((req as any).params);
1764
- const requestQuery = sanitizeRequestSnapshot((req as any).query);
2064
+ const endpointTraceCtx: TraceEventForFilter | null = (() => {
2065
+ if (!chosenEndpoint?.fn && !chosenEndpoint?.file) return null;
2066
+ return {
2067
+ type: 'enter',
2068
+ eventType: 'enter',
2069
+ fn: chosenEndpoint.fn ?? undefined,
2070
+ wrapperClass: inferWrapperClassFromFn(chosenEndpoint.fn),
2071
+ file: chosenEndpoint.file ?? null,
2072
+ line: chosenEndpoint.line ?? null,
2073
+ functionType: chosenEndpoint.functionType ?? null,
2074
+ library: inferLibraryNameFromFile(chosenEndpoint.file),
2075
+ };
2076
+ })();
2077
+
2078
+ const requestBody = applyMasking(
2079
+ 'request.body',
2080
+ sanitizeRequestSnapshot((req as any).body),
2081
+ maskReq,
2082
+ endpointTraceCtx,
2083
+ masking
2084
+ );
2085
+ const requestParams = applyMasking(
2086
+ 'request.params',
2087
+ sanitizeRequestSnapshot((req as any).params),
2088
+ maskReq,
2089
+ endpointTraceCtx,
2090
+ masking
2091
+ );
2092
+ const requestQuery = applyMasking(
2093
+ 'request.query',
2094
+ sanitizeRequestSnapshot((req as any).query),
2095
+ maskReq,
2096
+ endpointTraceCtx,
2097
+ masking
2098
+ );
2099
+ const maskedHeaders = applyMasking(
2100
+ 'request.headers',
2101
+ requestHeaders,
2102
+ maskReq,
2103
+ endpointTraceCtx,
2104
+ masking
2105
+ );
2106
+ const responseBody = applyMasking(
2107
+ 'response.body',
2108
+ capturedBody === undefined ? undefined : sanitizeRequestSnapshot(capturedBody),
2109
+ maskReq,
2110
+ endpointTraceCtx,
2111
+ masking
2112
+ );
1765
2113
 
1766
2114
  const requestPayload: Record<string, any> = {
1767
2115
  rid,
@@ -1770,9 +2118,9 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1770
2118
  path,
1771
2119
  status: res.statusCode,
1772
2120
  durMs: Date.now() - t0,
1773
- headers: requestHeaders,
2121
+ headers: maskedHeaders,
1774
2122
  key,
1775
- respBody: capturedBody,
2123
+ respBody: responseBody,
1776
2124
  trace: traceBatches.length ? undefined : '[]',
1777
2125
  };
1778
2126
  if (requestBody !== undefined) requestPayload.body = requestBody;
package/tracer/runtime.js CHANGED
@@ -128,6 +128,7 @@ const trace = {
128
128
  fn,
129
129
  file: meta?.file,
130
130
  line: meta?.line,
131
+ sourceFile: meta?.sourceFile || null,
131
132
  functionType: meta?.functionType || null,
132
133
  traceId: ctx.traceId,
133
134
  depth: ctx.depth,
@@ -144,6 +145,7 @@ const trace = {
144
145
  fn: meta?.fn,
145
146
  file: meta?.file,
146
147
  line: meta?.line,
148
+ sourceFile: meta?.sourceFile || null,
147
149
  functionType: meta?.functionType || null
148
150
  };
149
151
  const frameStack = ctx.__repro_frame_unawaited;
@@ -190,6 +192,7 @@ const trace = {
190
192
  fn: baseMeta.fn,
191
193
  file: baseMeta.file,
192
194
  line: baseMeta.line,
195
+ sourceFile: baseMeta.sourceFile,
193
196
  functionType: baseMeta.functionType || null,
194
197
  traceId: traceIdAtExit,
195
198
  depth: spanInfo.depth ?? depthAtExit,
@@ -601,10 +604,14 @@ if (!global.__repro_call) {
601
604
  const name = (label && label.length) || fn.name
602
605
  ? (label && label.length ? label : fn.name)
603
606
  : '(anonymous)';
604
- const sourceFile = fn[SYM_SRC_FILE];
607
+ const definitionFile = fn[SYM_SRC_FILE];
608
+ const normalizedCallFile = callFile ? String(callFile).trim() : '';
609
+ const callsiteFile = normalizedCallFile ? normalizedCallFile : null;
610
+ const callsiteLine = Number.isFinite(Number(callLine)) && Number(callLine) > 0 ? Number(callLine) : null;
605
611
  const meta = {
606
- file: sourceFile || callFile || null,
607
- line: sourceFile ? null : (callLine || null),
612
+ file: callsiteFile || definitionFile || null,
613
+ line: callsiteLine,
614
+ sourceFile: definitionFile || null,
608
615
  parentSpanId: forcedParentSpanId
609
616
  };
610
617
 
@@ -638,7 +645,7 @@ if (!global.__repro_call) {
638
645
  })();
639
646
 
640
647
  const runExit = (detail) => {
641
- const runner = () => trace.exit({ fn: name, file: meta.file, line: meta.line }, detail);
648
+ const runner = () => trace.exit({ fn: name, file: meta.file, line: meta.line, sourceFile: meta.sourceFile }, detail);
642
649
  if (exitStore) return als.run(exitStore, runner);
643
650
  return runner();
644
651
  };
@@ -685,7 +692,7 @@ if (!global.__repro_call) {
685
692
  runExit(exitDetailBase);
686
693
  return out;
687
694
  } catch (e) {
688
- trace.exit({ fn: name, file: meta.file, line: meta.line }, { threw: true, error: e, args });
695
+ trace.exit({ fn: name, file: meta.file, line: meta.line, sourceFile: meta.sourceFile }, { threw: true, error: e, args });
689
696
  throw e;
690
697
  } finally {
691
698
  if (pendingMarker) {