browser-commander 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.jscpd-baseline.json +7 -20
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +27 -10
  4. package/package.json +1 -1
  5. package/scripts/run-tests.mjs +3 -1
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +140 -65
  8. package/src/browser/launcher.js +19 -8
  9. package/src/browser/persistent-session.js +110 -38
  10. package/src/browser/session-adoption.js +96 -0
  11. package/src/browser/session-worker.js +12 -0
  12. package/src/cli/args.js +3 -0
  13. package/src/cli/commands.js +3 -0
  14. package/src/cli/high-level-methods.js +7 -0
  15. package/src/cli/sessions.js +3 -0
  16. package/src/core/commander-features.js +33 -25
  17. package/src/core/navigation-safety.js +1 -0
  18. package/src/core/page-trigger-manager.js +173 -134
  19. package/src/index.js +2 -0
  20. package/src/interactions/overlays.js +59 -14
  21. package/src/traces/bundle.js +41 -3
  22. package/src/traces/checkpoints.js +7 -3
  23. package/src/traces/commander-actions.js +146 -0
  24. package/src/traces/debug-session.js +119 -0
  25. package/src/traces/drain-timer.js +25 -0
  26. package/src/traces/links.js +44 -5
  27. package/src/traces/mutation-stream.js +12 -5
  28. package/src/traces/network.js +65 -13
  29. package/src/traces/observers.js +50 -46
  30. package/src/traces/page-capture.js +65 -21
  31. package/src/traces/recorder.js +79 -28
  32. package/src/traces/redaction.js +89 -6
  33. package/src/traces/rolling-bundle.js +240 -0
  34. package/src/traces/rolling.js +10 -113
  35. package/src/traces/stop.js +1 -0
  36. package/src/traces/user-actions.js +405 -0
  37. package/tests/e2e/issue-160.e2e.test.js +291 -0
  38. package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
  39. package/tests/e2e/traces.e2e.test.js +4 -1
  40. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  41. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  42. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +4 -4
  43. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  44. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  45. package/tests/fixtures/traces/conformance/expected/trace.lino +4 -4
  46. package/tests/fixtures/traces/conformance/scenario.json +1 -0
  47. package/tests/helpers/e2e-browser.js +2 -1
  48. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  49. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  50. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  51. package/tests/unit/interactions/overlays.test.js +58 -0
  52. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  53. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  54. package/tests/unit/traces/issue-162-defaults.test.js +223 -0
  55. package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
  56. package/tests/unit/traces/network.test.js +55 -3
  57. package/tests/unit/traces/recorder.test.js +4 -1
@@ -28,6 +28,7 @@ export function captureSnapshotInPage(options) {
28
28
  const {
29
29
  redactSelectors = [],
30
30
  redactAttributes = [],
31
+ useDefaults = true,
31
32
  redacted = '[redacted]',
32
33
  html: wantHtml = true,
33
34
  liveControlState = true,
@@ -121,6 +122,13 @@ export function captureSnapshotInPage(options) {
121
122
  // `<input>` otherwise shows the value the server sent, not the one the user
122
123
  // typed.
123
124
  const carryLiveState = (original, copy, secret) => {
125
+ if (
126
+ useDefaults &&
127
+ original.localName === 'meta' &&
128
+ /token|csrf|xsrf|secret/i.test(original.getAttribute('name') || '')
129
+ ) {
130
+ copy.setAttribute('content', redacted);
131
+ }
124
132
  for (const attribute of redactAttributes) {
125
133
  if (copy.hasAttribute && copy.hasAttribute(attribute)) {
126
134
  copy.setAttribute(attribute, redacted);
@@ -197,8 +205,13 @@ export function captureSnapshotInPage(options) {
197
205
  const clone = cloneWithState(document.documentElement);
198
206
 
199
207
  html = `<!DOCTYPE html>\n${clone?.outerHTML ?? ''}`;
200
- if (maxHtmlBytes > 0 && html.length > maxHtmlBytes) {
201
- html = html.slice(0, maxHtmlBytes);
208
+ const encoded = new globalThis.TextEncoder().encode(html);
209
+ if (maxHtmlBytes > 0 && encoded.length > maxHtmlBytes) {
210
+ const marker = '<!-- trace HTML truncated -->';
211
+ const prefix = new globalThis.TextDecoder().decode(
212
+ encoded.slice(0, Math.max(0, maxHtmlBytes - marker.length - 3))
213
+ );
214
+ html = `${prefix}${marker}`;
202
215
  truncated = true;
203
216
  }
204
217
  }
@@ -281,6 +294,8 @@ export function installMutationRecorderInPage(options) {
281
294
  const {
282
295
  globalName = '__browserCommanderTrace__',
283
296
  redactSelectors = [],
297
+ redactAttributes = [],
298
+ useDefaults = true,
284
299
  redacted = '[redacted]',
285
300
  maxQueued = 5000,
286
301
  ignoreSelectors = [],
@@ -296,8 +311,6 @@ export function installMutationRecorderInPage(options) {
296
311
  queue: [],
297
312
  dropped: 0,
298
313
  sequence: 0,
299
- // Every document gets its own name, so a batch drained from an iframe is
300
- // never mistaken for one drained from the page that holds it.
301
314
  frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
302
315
  frameName: window.name || null,
303
316
  mainFrame: window.top === window,
@@ -317,7 +330,11 @@ export function installMutationRecorderInPage(options) {
317
330
  }
318
331
  });
319
332
  };
320
- const isSecret = (node) => matchesAny(node, redactSelectors);
333
+ const isSecret = (node) =>
334
+ matchesAny(node, redactSelectors) ||
335
+ (useDefaults &&
336
+ node?.localName === 'meta' &&
337
+ /token|csrf|xsrf|secret|password/i.test(node.getAttribute('name') ?? ''));
321
338
  const isIgnored = (node) => matchesAny(node, ignoreSelectors);
322
339
  const sanitized = (node) => {
323
340
  const clone = node.cloneNode(true);
@@ -334,6 +351,20 @@ export function installMutationRecorderInPage(options) {
334
351
  }
335
352
  }
336
353
  }
354
+ for (const element of [clone, ...clone.querySelectorAll('*')]) {
355
+ for (const attribute of redactAttributes) {
356
+ if (element.hasAttribute(attribute)) {
357
+ element.setAttribute(attribute, redacted);
358
+ }
359
+ }
360
+ if (
361
+ useDefaults &&
362
+ element.localName === 'meta' &&
363
+ /token|csrf|xsrf|secret/i.test(element.getAttribute('name') || '')
364
+ ) {
365
+ element.setAttribute('content', redacted);
366
+ }
367
+ }
337
368
  return clone;
338
369
  };
339
370
 
@@ -346,11 +377,9 @@ export function installMutationRecorderInPage(options) {
346
377
  steps.unshift(`#${at.id}`);
347
378
  break;
348
379
  }
349
- const siblings = at.parentElement
350
- ? [...at.parentElement.children].filter(
351
- (c) => c.localName === at.localName
352
- )
353
- : [];
380
+ const siblings = [...(at.parentElement?.children ?? [])].filter(
381
+ (c) => c.localName === at.localName
382
+ );
354
383
  const nth =
355
384
  siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : '';
356
385
  steps.unshift(at.localName + nth);
@@ -399,6 +428,23 @@ export function installMutationRecorderInPage(options) {
399
428
  };
400
429
  };
401
430
 
431
+ // Mutation targets identify an existing node. Only additions need markup.
432
+ const identify = (node) => {
433
+ if (!node || isIgnored(node)) {
434
+ return null;
435
+ }
436
+ const element = node.nodeType === 1 ? node : node.parentElement;
437
+ return {
438
+ type: node.nodeType === 3 ? 'text' : 'element',
439
+ path: pathOf(element),
440
+ tag: element?.localName,
441
+ id: element?.id || null,
442
+ visible:
443
+ Boolean(element?.getClientRects().length) &&
444
+ window.getComputedStyle(element).visibility !== 'hidden',
445
+ };
446
+ };
447
+
402
448
  /**
403
449
  * Describe a node that is no longer where it was.
404
450
  *
@@ -458,13 +504,15 @@ export function installMutationRecorderInPage(options) {
458
504
  .map((record) => {
459
505
  const entry = {
460
506
  kind: record.type,
461
- target: describe(record.target),
507
+ target: identify(record.target),
462
508
  };
463
509
  if (record.type === 'attributes') {
464
510
  entry.attribute = record.attributeName;
465
511
  const secret =
466
512
  isSecret(record.target) ||
467
- redactSelectors.includes(record.attributeName);
513
+ redactAttributes.includes(record.attributeName) ||
514
+ (useDefaults &&
515
+ /token|csrf|xsrf|secret|password/i.test(record.attributeName));
468
516
  entry.before = secret ? redacted : record.oldValue;
469
517
  entry.after =
470
518
  secret || !record.target.getAttribute
@@ -485,8 +533,8 @@ export function installMutationRecorderInPage(options) {
485
533
  entry.removed = [...record.removedNodes]
486
534
  .filter((node) => !isIgnored(node))
487
535
  .map((node) => describeAt(node, record));
488
- entry.previous = describe(record.previousSibling);
489
- entry.next = describe(record.nextSibling);
536
+ entry.previous = identify(record.previousSibling);
537
+ entry.next = identify(record.nextSibling);
490
538
  }
491
539
  return entry;
492
540
  }),
@@ -511,7 +559,6 @@ export function installMutationRecorderInPage(options) {
511
559
  // into a field and then leaving it fires `input` for each keystroke and
512
560
  // `change` for the value it ended at, and a checkbox fires both for the
513
561
  // one click. Replaying a state that is already on screen shows nothing, so
514
- // a repeat of what this element last reported is not recorded.
515
562
  const repeats = (element, property, after) => {
516
563
  const seen = lastLive.get(element) || {};
517
564
  const asText = JSON.stringify(after === undefined ? null : after);
@@ -526,16 +573,13 @@ export function installMutationRecorderInPage(options) {
526
573
  const liveEntry = (target, property, after, before) => ({
527
574
  kind: 'live-state',
528
575
  property,
529
- target: describe(target),
576
+ target: identify(target),
530
577
  before: before === undefined ? null : before,
531
578
  after,
532
579
  });
533
580
 
534
581
  const pushLive = (entry) => {
535
- // Scrolling fires far more often than it changes anything worth
536
- // replaying, so consecutive scrolls of one element collapse into the
537
- // position it ended at. Every other property keeps each step, because
538
- // "stepwise replay must show each state" (issue #93).
582
+ // Consecutive scrolls collapse; other state changes retain each step.
539
583
  const last = state.queue[state.queue.length - 1];
540
584
  const only = last && last.records.length === 1 ? last.records[0] : null;
541
585
  if (
@@ -608,7 +652,7 @@ export function installMutationRecorderInPage(options) {
608
652
  pushLive({
609
653
  kind: 'live-state',
610
654
  property: 'scroll',
611
- target: scrolling ? describe(scrolling) : null,
655
+ target: scrolling ? identify(scrolling) : null,
612
656
  before: null,
613
657
  after: scrolling
614
658
  ? { top: scrolling.scrollTop, left: scrolling.scrollLeft }
@@ -1,5 +1,6 @@
1
1
  import { abortTraceStart } from './start-cleanup.js';
2
2
  import { createTraceStop } from './stop.js';
3
+ import { openRollingBundle } from './rolling-bundle.js';
3
4
  /**
4
5
  * The trace recorder (issue #87).
5
6
  *
@@ -15,10 +16,13 @@ import { openTraceLinks } from './links.js';
15
16
  import { createTraceIdentity } from './identity.js';
16
17
  import { withDeadline } from './deadline.js';
17
18
  import { createMutationStream } from './mutation-stream.js';
18
- import { attachTimelineObservers } from './observers.js';
19
+ import { createUserActionStream } from './user-actions.js';
20
+ import { startTraceDrain } from './drain-timer.js';
21
+ import { attachRecorderObservers } from './observers.js';
19
22
  import { attachNetwork, validateNetworkOptions } from './network.js';
20
23
  import { startRecording } from '../capture/recording.js';
21
24
  import { normalizePrivacyOptions, redactValue } from './redaction.js';
25
+ import path from 'node:path';
22
26
  import {
23
27
  DEFAULT_CAPTURE_TIMEOUT,
24
28
  TRACE_CHECKPOINT_REASON,
@@ -93,13 +97,19 @@ function normalizeTraceOptions(options) {
93
97
  return {
94
98
  ...options,
95
99
  page: options.page ?? options.commander?.page,
96
- mode: options.mode ?? TRACE_MODE.CHECKPOINTS,
100
+ mode: options.mode ?? TRACE_MODE.CONTINUOUS,
97
101
  screenshots: options.screenshots ?? 'checkpoints',
98
102
  dom: options.dom ?? {},
99
103
  privacy: options.privacy ?? {},
100
104
  limits: options.limits ?? {},
101
- links: options.links ?? null,
102
- network: options.network ?? false,
105
+ links:
106
+ options.links === false || options.links === null
107
+ ? null
108
+ : (options.links ?? {
109
+ output: path.join(options.output ?? '', 'trace.lino'),
110
+ dom: 'full',
111
+ }),
112
+ network: options.network ?? { har: true },
103
113
  strict: options.strict ?? false,
104
114
  captureTimeoutMs: options.captureTimeoutMs ?? DEFAULT_CAPTURE_TIMEOUT,
105
115
  commanderVersion: options.commanderVersion ?? null,
@@ -109,6 +119,7 @@ function normalizeTraceOptions(options) {
109
119
  }
110
120
 
111
121
  export async function startTrace(options = {}) {
122
+ options = normalizeTraceOptions(options);
112
123
  if (options.limits?.rotate) {
113
124
  const { startRollingTrace } = await import('./rolling.js');
114
125
  return startRollingTrace(options, startTrace);
@@ -150,8 +161,8 @@ export async function startTrace(options = {}) {
150
161
  // Otherwise a rejected start (for example `links: {}`) strands the bundle's
151
162
  // events handle because no running trace is returned for the caller to stop.
152
163
  validateLinksOptions(links);
153
- const engine = commander?.engine ?? null;
154
- const identity = createTraceIdentity({ page });
164
+ const engine = commander?.engine ?? null,
165
+ identity = createTraceIdentity({ page });
155
166
 
156
167
  // A continuous trace records changes, and a change is only meaningful against
157
168
  // something. Taking the base snapshot automatically is the safe default,
@@ -166,13 +177,13 @@ export async function startTrace(options = {}) {
166
177
  // readable export of everything that had happened. The bundle stays
167
178
  // authoritative: nothing is written here that is not written there first.
168
179
  let linksSink = null;
169
- const bundle = await openTraceBundle({
180
+ const bundle = await openRecorderBundle(options, {
170
181
  output,
171
182
  limits,
172
183
  strict,
173
184
  now,
174
185
  monotonic,
175
- onEvent: (event) => linksSink?.event(event),
186
+ onEvent: (event, source) => linksSink?.event(event, source),
176
187
  });
177
188
  const startedAt = new Date(now()).toISOString();
178
189
 
@@ -242,6 +253,18 @@ export async function startTrace(options = {}) {
242
253
  captureTimeoutMs,
243
254
  evaluateInPage,
244
255
  });
256
+ const userActions = createUserActionStream({
257
+ page,
258
+ record,
259
+ identity,
260
+ note,
261
+ privacyOptions,
262
+ limits,
263
+ enabled: eventSources.includes('interaction'),
264
+ ignoreSelectors: domOptions.ignoreSelectors,
265
+ evaluateInPage,
266
+ captureTimeoutMs,
267
+ });
245
268
 
246
269
  async function screenshot(reason) {
247
270
  const wanted =
@@ -288,7 +311,7 @@ export async function startTrace(options = {}) {
288
311
  detachNetwork = async () => {},
289
312
  video = null;
290
313
  try {
291
- detachers = attachTimelineObservers({
314
+ const observers = attachRecorderObservers({
292
315
  commander,
293
316
  page,
294
317
  eventSources,
@@ -297,7 +320,14 @@ export async function startTrace(options = {}) {
297
320
  identity,
298
321
  now,
299
322
  });
300
- detachNetwork = attachNetwork({ page, network, record, note });
323
+ detachers = observers.detachers;
324
+ detachNetwork = attachNetwork({
325
+ page,
326
+ network,
327
+ record,
328
+ note,
329
+ privacy: privacyOptions,
330
+ });
301
331
  video = options.video
302
332
  ? await startRecording({
303
333
  page,
@@ -337,7 +367,11 @@ export async function startTrace(options = {}) {
337
367
  events: eventSources,
338
368
  initialCheckpoint: Boolean(baseCheckpoint),
339
369
  });
340
- await mutations.install();
370
+ await Promise.all([mutations.install(), userActions.install()]);
371
+ detachers.push(async () => {
372
+ await userActions.drain();
373
+ await userActions.stop();
374
+ });
341
375
 
342
376
  if (baseCheckpoint) {
343
377
  await checkpoint(
@@ -349,23 +383,10 @@ export async function startTrace(options = {}) {
349
383
  );
350
384
  }
351
385
 
352
- let drainTask;
353
- if (domOptions.mutations) {
354
- const timer = setInterval(() => {
355
- if (!drainTask) {
356
- drainTask = mutations
357
- .drain(captureState.index)
358
- .catch((error) => note(error.message))
359
- .finally(() => {
360
- drainTask = null;
361
- });
362
- }
363
- }, 500);
364
- timer.unref?.();
365
- detachers.push(async () => {
366
- clearInterval(timer);
367
- await drainTask;
368
- });
386
+ if (domOptions.mutations || eventSources.includes('interaction')) {
387
+ detachers.push(
388
+ startTraceDrain({ mutations, userActions, captureState, note })
389
+ );
369
390
  }
370
391
 
371
392
  /**
@@ -405,12 +426,19 @@ export async function startTrace(options = {}) {
405
426
 
406
427
  return {
407
428
  path: bundle.root,
429
+ get currentPath() {
430
+ return bundle.currentPath ?? bundle.root;
431
+ },
432
+ get segments() {
433
+ return bundle.segments;
434
+ },
408
435
  links: linksSink?.path ?? null,
409
436
  mode: recorderMode,
410
437
  get bytesWritten() {
411
438
  return bundle.bytesWritten;
412
439
  },
413
440
  checkpoint,
441
+ attachCommander: observers.attachCommander,
414
442
  /**
415
443
  * Record an event a caller cares about on the same timeline.
416
444
  *
@@ -447,5 +475,28 @@ export async function startTrace(options = {}) {
447
475
 
448
476
  function requireTraceSupport(options) {
449
477
  validateNetworkOptions(options.network);
478
+ const maxQueued = options.limits.maxQueuedUserActions ?? 5000;
479
+ if (!Number.isSafeInteger(maxQueued) || maxQueued < 1) {
480
+ throw new RangeError(
481
+ 'maxQueuedUserActions must be a positive safe integer'
482
+ );
483
+ }
450
484
  (options.page ?? options.commander?.page)?.requireFeature?.('tracing');
451
485
  }
486
+
487
+ function openRecorderBundle(options, settings) {
488
+ const writer =
489
+ options.openBundle ??
490
+ (options.mode === TRACE_MODE.CONTINUOUS
491
+ ? (config) =>
492
+ openRollingBundle(
493
+ config,
494
+ {
495
+ lazy: true,
496
+ maxBytes: settings.limits.maxBundleBytes ?? 256 * 1024 * 1024,
497
+ },
498
+ options
499
+ )
500
+ : openTraceBundle);
501
+ return writer(settings);
502
+ }
@@ -13,6 +13,7 @@ export const REDACTED = '[redacted]';
13
13
  /** Controls whose value is a secret unless the caller says otherwise. */
14
14
  export const DEFAULT_REDACT_SELECTORS = Object.freeze([
15
15
  'input[type=password]',
16
+ 'input[type=hidden]',
16
17
  '[data-private]',
17
18
  '[data-bc-redact]',
18
19
  ]);
@@ -42,8 +43,74 @@ export const DEFAULT_REDACT_QUERY_PARAMS = Object.freeze([
42
43
  'session',
43
44
  'signature',
44
45
  'token',
46
+ 'csrf',
47
+ 'xsrf',
48
+ '_xsrf',
45
49
  ]);
46
50
 
51
+ /** Match names rather than values, including framework-specific token names. */
52
+ export function sensitiveName(name, privacy) {
53
+ return (
54
+ Boolean(privacy.useDefaults) &&
55
+ /password|passwd|token|csrf|xsrf|otp|secret|api[_-]?key|authorization/i.test(
56
+ name
57
+ )
58
+ );
59
+ }
60
+
61
+ function redactFields(value, privacy) {
62
+ if (Array.isArray(value)) {
63
+ return value.map((entry) => redactFields(entry, privacy));
64
+ }
65
+ if (value && typeof value === 'object') {
66
+ return Object.fromEntries(
67
+ Object.entries(value).map(([key, entry]) => [
68
+ key,
69
+ sensitiveName(key, privacy) ? REDACTED : redactFields(entry, privacy),
70
+ ])
71
+ );
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function redactStructuredText(value, privacy) {
77
+ if (!privacy.useDefaults) {
78
+ return value;
79
+ }
80
+ try {
81
+ const parsed = JSON.parse(value);
82
+ if (parsed && typeof parsed === 'object') {
83
+ return JSON.stringify(redactFields(parsed, privacy));
84
+ }
85
+ } catch {
86
+ /* HTML, forms and script assignments are handled below. */
87
+ }
88
+ return value
89
+ .replace(
90
+ /(\b(?:["']?[\w-]+["']?)\s*[:=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\d+(?:\.\d+)?)/g,
91
+ (match, prefix) =>
92
+ sensitiveName(prefix, privacy)
93
+ ? `${prefix}${JSON.stringify(REDACTED)}`
94
+ : match
95
+ )
96
+ .replace(/(^|[&?])([^=&\s]+)=([^&\s]*)/g, (match, start, key) => {
97
+ let name = key;
98
+ try {
99
+ name = decodeURIComponent(key.replaceAll('+', ' '));
100
+ } catch {
101
+ /* Keep malformed names. */
102
+ }
103
+ return sensitiveName(name, privacy)
104
+ ? `${start}${key}=${REDACTED}`
105
+ : match;
106
+ })
107
+ .replace(
108
+ /(Content-Disposition:[^\r\n]*?;\s*name="([^"]+)"[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n)([\s\S]*?)(?=\r?\n--)/gi,
109
+ (match, header, name) =>
110
+ sensitiveName(name, privacy) ? `${header}${REDACTED}` : match
111
+ );
112
+ }
113
+
47
114
  function lowerSet(values) {
48
115
  return [...new Set(values.map((value) => String(value).toLowerCase()))];
49
116
  }
@@ -75,6 +142,7 @@ export function normalizePrivacyOptions(privacy = {}) {
75
142
  }
76
143
 
77
144
  return {
145
+ useDefaults,
78
146
  redactSelectors: lowerSet([
79
147
  ...(useDefaults ? DEFAULT_REDACT_SELECTORS : []),
80
148
  ...redactSelectors,
@@ -107,7 +175,8 @@ export function redactText(value, privacy, context = {}) {
107
175
  return value;
108
176
  }
109
177
 
110
- let text = value;
178
+ let text =
179
+ context.kind === 'url' ? value : redactStructuredText(value, privacy);
111
180
  for (const pattern of privacy.redactPatterns || []) {
112
181
  // A caller's regex may be stateful; resetting keeps one trace's output
113
182
  // from depending on the previous record's match position.
@@ -148,7 +217,10 @@ export function redactUrl(url, privacy) {
148
217
  parsed.password = parsed.password ? REDACTED : '';
149
218
  }
150
219
  for (const key of [...parsed.searchParams.keys()]) {
151
- if (privacy.redactQueryParams.includes(key.toLowerCase())) {
220
+ if (
221
+ privacy.redactQueryParams.includes(key.toLowerCase()) ||
222
+ sensitiveName(key, privacy)
223
+ ) {
152
224
  parsed.searchParams.set(key, REDACTED);
153
225
  }
154
226
  }
@@ -157,7 +229,10 @@ export function redactUrl(url, privacy) {
157
229
  const fragment = new URLSearchParams(parsed.hash.slice(1));
158
230
  let changed = false;
159
231
  for (const key of [...fragment.keys()]) {
160
- if (privacy.redactQueryParams.includes(key.toLowerCase())) {
232
+ if (
233
+ privacy.redactQueryParams.includes(key.toLowerCase()) ||
234
+ sensitiveName(key, privacy)
235
+ ) {
161
236
  fragment.set(key, REDACTED);
162
237
  changed = true;
163
238
  }
@@ -190,9 +265,11 @@ export function redactHeaders(headers, privacy) {
190
265
 
191
266
  const result = {};
192
267
  for (const [name, value] of Object.entries(headers)) {
193
- result[name] = privacy.redactAttributes.includes(name.toLowerCase())
194
- ? REDACTED
195
- : redactText(String(value), privacy, { kind: 'header', name });
268
+ result[name] =
269
+ privacy.redactAttributes.includes(name.toLowerCase()) ||
270
+ sensitiveName(name, privacy)
271
+ ? REDACTED
272
+ : redactText(String(value), privacy, { kind: 'header', name });
196
273
  }
197
274
  return result;
198
275
  }
@@ -209,6 +286,12 @@ export function redactHeaders(headers, privacy) {
209
286
  * @returns {*} The value with strings redacted
210
287
  */
211
288
  export function redactValue(value, privacy, key = '') {
289
+ if (
290
+ sensitiveName(key, privacy) ||
291
+ privacy.redactAttributes.includes(key.toLowerCase())
292
+ ) {
293
+ return REDACTED;
294
+ }
212
295
  if (typeof value === 'string') {
213
296
  if (privacy.redactAttributes.includes(key.toLowerCase())) {
214
297
  return REDACTED;