browser-commander 0.28.0 → 0.29.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 (43) hide show
  1. package/.jscpd-baseline.json +5 -19
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +20 -10
  4. package/package.json +1 -1
  5. package/src/browser/browser-profile-files.js +5 -2
  6. package/src/browser/connector.js +140 -65
  7. package/src/browser/persistent-session.js +110 -38
  8. package/src/browser/session-adoption.js +96 -0
  9. package/src/browser/session-worker.js +12 -0
  10. package/src/cli/args.js +3 -0
  11. package/src/cli/commands.js +3 -0
  12. package/src/cli/high-level-methods.js +7 -0
  13. package/src/cli/sessions.js +3 -0
  14. package/src/core/commander-features.js +6 -1
  15. package/src/core/navigation-safety.js +1 -0
  16. package/src/core/page-trigger-manager.js +173 -134
  17. package/src/index.js +2 -0
  18. package/src/interactions/overlays.js +59 -14
  19. package/src/traces/bundle.js +41 -3
  20. package/src/traces/checkpoints.js +7 -3
  21. package/src/traces/links.js +44 -5
  22. package/src/traces/mutation-stream.js +8 -2
  23. package/src/traces/network.js +65 -13
  24. package/src/traces/page-capture.js +65 -21
  25. package/src/traces/recorder.js +33 -3
  26. package/src/traces/redaction.js +89 -6
  27. package/src/traces/rolling-bundle.js +240 -0
  28. package/src/traces/rolling.js +10 -113
  29. package/src/traces/stop.js +1 -0
  30. package/tests/e2e/issue-160.e2e.test.js +291 -0
  31. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  32. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  33. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
  34. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  35. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  36. package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
  37. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  38. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  39. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  40. package/tests/unit/interactions/overlays.test.js +58 -0
  41. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  42. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  43. package/tests/unit/traces/network.test.js +55 -3
@@ -20,7 +20,7 @@ import {
20
20
  RECORDER_GLOBAL,
21
21
  stopMutationRecorderInPage,
22
22
  } from './page-capture.js';
23
- import { REDACTED } from './redaction.js';
23
+ import { redactValue, REDACTED } from './redaction.js';
24
24
  import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
25
25
 
26
26
  /** In-page records kept before the recorder starts counting drops instead. */
@@ -62,6 +62,8 @@ export function createMutationStream(options) {
62
62
  const recorderOptions = {
63
63
  globalName: RECORDER_GLOBAL,
64
64
  redactSelectors: privacyOptions.redactSelectors,
65
+ redactAttributes: privacyOptions.redactAttributes,
66
+ useDefaults: privacyOptions.useDefaults,
65
67
  redacted: REDACTED,
66
68
  ignoreSelectors: domOptions.ignoreSelectors ?? [],
67
69
  maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
@@ -195,13 +197,17 @@ export function createMutationStream(options) {
195
197
  // frame; one ordered file is what a replay reads.
196
198
  batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
197
199
 
198
- const member = await bundle.writeMutations(index, batches);
200
+ const member = await bundle.writeMutations(
201
+ index,
202
+ redactValue(batches, privacyOptions)
203
+ );
199
204
  if (member) {
200
205
  await record(TRACE_EVENT.MUTATIONS, {
201
206
  member,
202
207
  batches: batches.length,
203
208
  checkpoint: index,
204
209
  frames: drained.length,
210
+ truncated: bundle.mutationTruncated,
205
211
  });
206
212
  }
207
213
  return member;
@@ -1,6 +1,11 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { withDeadline } from './deadline.js';
4
+ import {
5
+ normalizePrivacyOptions,
6
+ redactText,
7
+ redactHeaders as privateHeaders,
8
+ } from './redaction.js';
4
9
 
5
10
  const PRIVATE_HEADERS = new Set([
6
11
  'cookie',
@@ -9,11 +14,20 @@ const PRIVATE_HEADERS = new Set([
9
14
  'set-cookie',
10
15
  ]);
11
16
  export function redactHeaders(headers = {}) {
12
- return Object.fromEntries(
13
- Object.entries(headers).map(([key, value]) => [
14
- key.toLowerCase(),
15
- PRIVATE_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value,
16
- ])
17
+ return privateHeaders(
18
+ Object.fromEntries(
19
+ Object.entries(headers).map(([key, value]) => [
20
+ key.toLowerCase(),
21
+ PRIVATE_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value,
22
+ ])
23
+ ),
24
+ normalizePrivacyOptions()
25
+ );
26
+ }
27
+
28
+ export function isTextBody(contentType = '') {
29
+ return /^(?:text\/|application\/(?:[\w.+-]*json|[\w.+-]*xml|javascript|x-www-form-urlencoded))|multipart\/form-data/i.test(
30
+ contentType
17
31
  );
18
32
  }
19
33
 
@@ -81,10 +95,12 @@ export function networkHar(events) {
81
95
  content: {
82
96
  size: response.body?.size ?? 0,
83
97
  mimeType: response.contentType ?? '',
84
- ...(response.body
98
+ ...(response.body?.data !== undefined
85
99
  ? {
86
100
  text: response.body.data,
87
- encoding: 'base64',
101
+ ...(response.body.encoding === 'utf8'
102
+ ? {}
103
+ : { encoding: 'base64' }),
88
104
  _truncated: response.body.truncated,
89
105
  }
90
106
  : {}),
@@ -131,7 +147,13 @@ export function validateNetworkOptions(network) {
131
147
  );
132
148
  }
133
149
  }
134
- export function attachNetwork({ page, network, record, note }) {
150
+ export function attachNetwork({
151
+ page,
152
+ network,
153
+ record,
154
+ note,
155
+ privacy = normalizePrivacyOptions(),
156
+ }) {
135
157
  validateNetworkOptions(network);
136
158
  if (!network) {
137
159
  return async () => {};
@@ -172,7 +194,12 @@ export function attachNetwork({ page, network, record, note }) {
172
194
  ['document', 'xhr', 'fetch'].includes(request.resourceType?.()) &&
173
195
  request.postData?.()
174
196
  ? {
175
- postData: Buffer.from(request.postData())
197
+ postData: Buffer.from(
198
+ redactText(request.postData(), privacy, {
199
+ kind: 'network',
200
+ name: 'postData',
201
+ })
202
+ )
176
203
  .subarray(0, maxBodyBytes)
177
204
  .toString('utf8'),
178
205
  postDataTruncated:
@@ -210,9 +237,26 @@ export function attachNetwork({ page, network, record, note }) {
210
237
  options.bodies &&
211
238
  ['document', 'xhr', 'fetch'].includes(resourceType)
212
239
  ) {
213
- const length = Number(headers['content-length']);
214
- if (Number.isFinite(length) && length > maxBodyBytes) {
215
- payload.body = { size: length, truncated: true, data: '' };
240
+ let length = Number(headers['content-length']);
241
+ if (!Number.isFinite(length) && request.sizes) {
242
+ const sizes = await withDeadline(
243
+ request.sizes(),
244
+ 5000,
245
+ 'network sizes'
246
+ ).catch(() => ({}));
247
+ length = sizes.responseBodySize ?? NaN;
248
+ }
249
+ if (!isTextBody(payload.contentType ?? '')) {
250
+ payload.body = {
251
+ size: Number.isFinite(length) ? length : null,
252
+ omitted: 'binary',
253
+ };
254
+ } else if (Number.isFinite(length) && length > maxBodyBytes) {
255
+ payload.body = {
256
+ size: length,
257
+ truncated: true,
258
+ omitted: 'size limit',
259
+ };
216
260
  } else {
217
261
  try {
218
262
  const data = Buffer.from(
@@ -225,7 +269,15 @@ export function attachNetwork({ page, network, record, note }) {
225
269
  payload.body = {
226
270
  size: data.length,
227
271
  truncated: data.length > maxBodyBytes,
228
- data: data.subarray(0, maxBodyBytes).toString('base64'),
272
+ encoding: 'utf8',
273
+ data: Buffer.from(
274
+ redactText(data.toString('utf8'), privacy, {
275
+ kind: 'network',
276
+ name: 'responseBody',
277
+ })
278
+ )
279
+ .subarray(0, maxBodyBytes)
280
+ .toString('utf8'),
229
281
  };
230
282
  } catch (error) {
231
283
  payload.bodyError = error.message;
@@ -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
  *
@@ -166,13 +167,13 @@ export async function startTrace(options = {}) {
166
167
  // readable export of everything that had happened. The bundle stays
167
168
  // authoritative: nothing is written here that is not written there first.
168
169
  let linksSink = null;
169
- const bundle = await openTraceBundle({
170
+ const bundle = await openRecorderBundle(options, {
170
171
  output,
171
172
  limits,
172
173
  strict,
173
174
  now,
174
175
  monotonic,
175
- onEvent: (event) => linksSink?.event(event),
176
+ onEvent: (event, source) => linksSink?.event(event, source),
176
177
  });
177
178
  const startedAt = new Date(now()).toISOString();
178
179
 
@@ -297,7 +298,13 @@ export async function startTrace(options = {}) {
297
298
  identity,
298
299
  now,
299
300
  });
300
- detachNetwork = attachNetwork({ page, network, record, note });
301
+ detachNetwork = attachNetwork({
302
+ page,
303
+ network,
304
+ record,
305
+ note,
306
+ privacy: privacyOptions,
307
+ });
301
308
  video = options.video
302
309
  ? await startRecording({
303
310
  page,
@@ -405,6 +412,12 @@ export async function startTrace(options = {}) {
405
412
 
406
413
  return {
407
414
  path: bundle.root,
415
+ get currentPath() {
416
+ return bundle.currentPath ?? bundle.root;
417
+ },
418
+ get segments() {
419
+ return bundle.segments;
420
+ },
408
421
  links: linksSink?.path ?? null,
409
422
  mode: recorderMode,
410
423
  get bytesWritten() {
@@ -449,3 +462,20 @@ function requireTraceSupport(options) {
449
462
  validateNetworkOptions(options.network);
450
463
  (options.page ?? options.commander?.page)?.requireFeature?.('tracing');
451
464
  }
465
+
466
+ function openRecorderBundle(options, settings) {
467
+ const writer =
468
+ options.openBundle ??
469
+ (options.mode === TRACE_MODE.CONTINUOUS
470
+ ? (config) =>
471
+ openRollingBundle(
472
+ config,
473
+ {
474
+ lazy: true,
475
+ maxBytes: settings.limits.maxBundleBytes ?? 256 * 1024 * 1024,
476
+ },
477
+ options
478
+ )
479
+ : openTraceBundle);
480
+ return writer(settings);
481
+ }
@@ -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;