From ec3cd6f76e515f4d213469c3fb4426cf3afc9238 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 8 Oct 2026 14:27:42 +0200 Subject: [PATCH 1/5] fix(replay): Apply attribute masking to breadcrumb messages Co-Authored-By: GPT-6 --- .../browser-utils/src/htmlTreeAsString.ts | 23 ++-- .../test/htmlTreeAsString.test.ts | 40 +++++++ .../src/coreHandlers/handleDom.ts | 18 ++- .../src/coreHandlers/handleKeyboardEvent.ts | 11 +- packages/replay-internal/src/replay.ts | 4 + packages/replay-internal/src/types/replay.ts | 1 + packages/replay-internal/src/types/rrweb.ts | 1 + .../breadcrumbAttributeMasking.test.ts | 109 ++++++++++++++++++ 8 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts diff --git a/packages/browser-utils/src/htmlTreeAsString.ts b/packages/browser-utils/src/htmlTreeAsString.ts index 1fd7f75590bf..80f685e6f39c 100644 --- a/packages/browser-utils/src/htmlTreeAsString.ts +++ b/packages/browser-utils/src/htmlTreeAsString.ts @@ -2,6 +2,8 @@ import { isString } from '@sentry/core'; const DEFAULT_MAX_STRING_LENGTH = 80; +type MaskAttributeFn = (key: string, value: string, el: HTMLElement) => string; + type SimpleNode = { parentNode: SimpleNode; } | null; @@ -54,7 +56,7 @@ function _safeRead(el: unknown, prop: AccessorKey, arg?: string): T { */ export function htmlTreeAsString( elem: unknown, - options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {}, + options: string[] | { keyAttrs?: string[]; maxStringLength?: number; maskAttributeFn?: MaskAttributeFn } = {}, ): string { if (!elem) { return ''; @@ -74,10 +76,11 @@ export function htmlTreeAsString( const sepLength = separator.length; let nextStr; const keyAttrs = Array.isArray(options) ? options : options.keyAttrs; + const maskAttributeFn = Array.isArray(options) ? undefined : options.maskAttributeFn; const maxStringLength = (!Array.isArray(options) && options.maxStringLength) || DEFAULT_MAX_STRING_LENGTH; while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) { - nextStr = _htmlElementAsString(currentElem, keyAttrs); + nextStr = _htmlElementAsString(currentElem, keyAttrs, maskAttributeFn); // bail out if // - nextStr is the 'html' element // - the length of the string that would be created exceeds maxStringLength @@ -103,7 +106,7 @@ export function htmlTreeAsString( * e.g. [HTMLElement] => input#foo.btn[name=baz] * @returns generated DOM path */ -function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { +function _htmlElementAsString(el: unknown, keyAttrs?: string[], maskAttributeFn?: MaskAttributeFn): string { const out = []; const tagName = _safeRead(el, 'tagName'); @@ -132,22 +135,24 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { const keyAttrPairs = keyAttrs?.length ? keyAttrs .filter(keyAttr => _safeRead(el, 'getAttribute', keyAttr)) - .map(keyAttr => [keyAttr, _safeRead(el, 'getAttribute', keyAttr)]) + .map(keyAttr => [keyAttr, _safeRead(el, 'getAttribute', keyAttr)] as const) : null; if (keyAttrPairs?.length) { - keyAttrPairs.forEach(keyAttrPair => { - out.push(`[${keyAttrPair[0]}="${keyAttrPair[1]}"]`); + keyAttrPairs.forEach(([key, value]) => { + out.push(`[${key}="${maskAttributeFn && value ? maskAttributeFn(key, value, el as HTMLElement) : value}"]`); }); } else { const id = _safeRead(el, 'id'); if (id) { - out.push(`#${id}`); + out.push(`#${maskAttributeFn ? maskAttributeFn('id', id, el as HTMLElement) : id}`); } const className = _safeRead(el, 'className'); if (className && isString(className)) { - const classes = className.split(/\s+/); + const classes = (maskAttributeFn ? maskAttributeFn('class', className, el as HTMLElement) : className).split( + /\s+/, + ); for (const c of classes) { out.push(`.${c}`); } @@ -156,7 +161,7 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) { const attr = _safeRead(el, 'getAttribute', k); if (attr) { - out.push(`[${k}="${attr}"]`); + out.push(`[${k}="${maskAttributeFn ? maskAttributeFn(k, attr, el as HTMLElement) : attr}"]`); } } diff --git a/packages/browser-utils/test/htmlTreeAsString.test.ts b/packages/browser-utils/test/htmlTreeAsString.test.ts index 86db19c4143a..02b2cc9090dc 100644 --- a/packages/browser-utils/test/htmlTreeAsString.test.ts +++ b/packages/browser-utils/test/htmlTreeAsString.test.ts @@ -6,6 +6,46 @@ import { describe, expect, it } from 'vitest'; import { htmlTreeAsString } from '../src/htmlTreeAsString'; describe('htmlTreeAsString', () => { + it('masks attributes on the target and its ancestors', () => { + const parent = document.createElement('div'); + parent.title = 'Account PIN'; + const target = document.createElement('button'); + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('type', 'button'); + parent.appendChild(target); + + const result = htmlTreeAsString(target, { + maskAttributeFn: (key, value) => (['title', 'aria-label'].includes(key) ? '[masked]' : value), + }); + + expect(result).toBe('div[title="[masked]"] > button[aria-label="[masked]"][type="button"]'); + }); + + it('masks custom key attributes', () => { + const target = document.createElement('button'); + target.setAttribute('data-account', 'alice@example.com'); + + const result = htmlTreeAsString(target, { + keyAttrs: ['data-account'], + maskAttributeFn: (_key, _value, el) => (el === target ? '[masked]' : '[unexpected element]'), + }); + + expect(result).toBe('button[data-account="[masked]"]'); + }); + + it('returns unknown when attribute masking throws', () => { + const target = document.createElement('button'); + target.title = 'Account PIN'; + + const result = htmlTreeAsString(target, { + maskAttributeFn: () => { + throw new Error('Invalid masking selector'); + }, + }); + + expect(result).toBe(''); + }); + it('returns a query-selector-like string for a single element', () => { const el = document.createElement('button'); el.id = 'submit'; diff --git a/packages/replay-internal/src/coreHandlers/handleDom.ts b/packages/replay-internal/src/coreHandlers/handleDom.ts index 4c3171aa3276..468080132715 100644 --- a/packages/replay-internal/src/coreHandlers/handleDom.ts +++ b/packages/replay-internal/src/coreHandlers/handleDom.ts @@ -4,7 +4,7 @@ import type { Breadcrumb } from '@sentry/core'; import { record } from '@sentry/rrweb'; import type { serializedElementNodeWithId, serializedNodeWithId } from '@sentry/rrweb-snapshot'; import { NodeType } from '@sentry/rrweb-snapshot'; -import type { ReplayContainer } from '../types'; +import type { RecordingOptions, ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { handleClick } from './handleClick'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; @@ -19,7 +19,7 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: Handl return; } - const result = handleDom(handlerData); + const result = handleDom(handlerData, replay.getMaskAttributeFn()); if (!result) { return; @@ -75,8 +75,11 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea * An event handler to react to DOM events. * Exported for tests. */ -export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { - const { target, message } = getDomTarget(handlerData); +export function handleDom( + handlerData: HandlerDataDom, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): Breadcrumb | null { + const { target, message } = getDomTarget(handlerData, maskAttributeFn); return createBreadcrumb({ category: `ui.${handlerData.name}`, @@ -84,7 +87,10 @@ export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { }); } -function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; message: string } { +function getDomTarget( + handlerData: HandlerDataDom, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): { target: Node | null; message: string } { const isClick = handlerData.name === 'click'; let message: string | undefined; @@ -93,7 +99,7 @@ function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; messa // Accessing event.target can throw (see getsentry/raven-js#838, #768) try { target = isClick ? getClickTargetNode(handlerData.event as Event) : getTargetNode(handlerData.event as Event); - message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; + message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; } catch { message = ''; } diff --git a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts index aba74d6c8abc..4fc1d3553089 100644 --- a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts @@ -1,6 +1,6 @@ import { htmlTreeAsString } from '@sentry/browser-utils'; import type { Breadcrumb } from '@sentry/core'; -import type { ReplayContainer } from '../types'; +import type { RecordingOptions, ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; import { getBaseDomBreadcrumb } from './handleDom'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; @@ -16,7 +16,7 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven // session with a single "keydown" breadcrumb is created) replay.updateUserActivity(); - const breadcrumb = getKeyboardBreadcrumb(event); + const breadcrumb = getKeyboardBreadcrumb(event, replay.getMaskAttributeFn()); if (!breadcrumb) { return; @@ -26,7 +26,10 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven } /** exported only for tests */ -export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null { +export function getKeyboardBreadcrumb( + event: KeyboardEvent, + maskAttributeFn?: RecordingOptions['maskAttributeFn'], +): Breadcrumb | null { const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event; // never capture for input fields @@ -44,7 +47,7 @@ export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null { return null; } - const message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; + const message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message); return createBreadcrumb({ diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 6d6ec213b653..b02133abdd0d 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -306,6 +306,10 @@ export class ReplayContainer implements ReplayContainerInterface { return this._options; } + public getMaskAttributeFn(): RecordingOptions['maskAttributeFn'] { + return this._recordingOptions.maskAttributeFn; + } + /** A wrapper to conditionally capture exceptions. */ public handleException(error: unknown): void { DEBUG_BUILD && debug.exception(error); diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 955393c2f225..f3a275ad95aa 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -538,6 +538,7 @@ export interface ReplayContainer { updateUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; + getMaskAttributeFn(): RecordingOptions['maskAttributeFn']; getSessionId(): string | undefined; checkAndHandleExpiredSession(): boolean | void; setInitialState(): void; diff --git a/packages/replay-internal/src/types/rrweb.ts b/packages/replay-internal/src/types/rrweb.ts index c6ca1333918d..caacbb515146 100644 --- a/packages/replay-internal/src/types/rrweb.ts +++ b/packages/replay-internal/src/types/rrweb.ts @@ -37,6 +37,7 @@ export type ReplayEventWithTime = { export type RrwebRecordOptions = { maskAllText?: boolean; maskAllInputs?: boolean; + maskAttributeFn?: (key: string, value: string, el: HTMLElement) => string; blockClass?: ClassOption; ignoreClass?: string; maskTextClass?: ClassOption; diff --git a/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts new file mode 100644 index 000000000000..c8f26171b949 --- /dev/null +++ b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment jsdom + */ + +import '../utils/mock-internal-setTimeout'; +import type { Breadcrumb } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Replay } from '../../src/integration'; +import type { ReplayConfiguration } from '../../src/types'; +import { resetSdkMock } from '../mocks/resetSdkMock'; + +describe('Integration | breadcrumb attribute masking', () => { + let integration: Replay; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + await integration?.stop(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it.each(['click', 'keypress', 'keydown'])('masks target and ancestor attributes in %s breadcrumbs', async name => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + const parent = document.createElement('div'); + parent.title = 'Account PIN'; + const target = document.createElement('button'); + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('type', 'button'); + parent.appendChild(target); + const event = new KeyboardEvent(name, { key: 'Escape' }); + target.dispatchEvent(event); + + if (name === 'keydown') { + sdk.replay['_handleKeyboardEvent'](event); + } else { + sdk.domHandler({ name, event }); + } + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe('div[title="******* ***"] > button[aria-label="*** *****"][type="button"]'); + }); + + it.each([ + { + label: 'custom attributes with text masking disabled', + options: { maskAttributes: ['alt', 'name', 'id', 'class'], maskAllText: false }, + expected: 'button#******.*******[aria-label="Pay Alice"][name="*******"][title="Account PIN"][alt="*****"]', + }, + { + label: 'explicit unmasking', + options: { unmask: ['.sentry-unmask'] }, + expected: + 'button#secret.private.sentry-unmask[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + { + label: 'an empty attribute mask list', + options: { maskAttributes: [] }, + expected: 'button#secret.private[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + ])('honors $label in click breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + } satisfies ReplayConfiguration, + }); + integration = sdk.integration; + const target = document.createElement('button'); + target.id = 'secret'; + target.className = 'private'; + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('name', 'account'); + target.title = 'Account PIN'; + target.setAttribute('alt', 'Alice'); + if ('unmask' in options) { + target.classList.add('sentry-unmask'); + } + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); +}); From 8d70654f38ea8d1caa1a7116c279a90e0c96f4cb Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 8 Oct 2026 15:05:53 +0200 Subject: [PATCH 2/5] test(replay): Expect masked attributes in breadcrumb messages Co-Authored-By: GPT-6 --- .../suites/replay/customEvents/test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index a83f6e9fa5ce..8a9c0046f5b4 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -97,7 +97,7 @@ sentryTest( expect.arrayContaining([ { ...expectedClickBreadcrumb, - message: 'body > div#error.btn.btn-error[aria-label="An Error in aria-label"]', + message: 'body > div#error.btn.btn-error[aria-label="** ***** ** **********"]', data: { nodeId: expect.any(Number), node: { @@ -124,7 +124,7 @@ sentryTest( expect.arrayContaining([ { ...expectedClickBreadcrumb, - message: 'body > button#img-button[title="Button title"]', + message: 'body > button#img-button[title="****** *****"]', data: { nodeId: expect.any(Number), node: { From b854e50168f510cda400b270a00cb0f2fa1420d9 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 8 Oct 2026 15:23:16 +0200 Subject: [PATCH 3/5] chore: Align Node size limits with CI bundle measurements Co-Authored-By: GPT-6 --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index fcb14bb45828..0651948ecc4d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -419,7 +419,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '150 KB', + limit: '151 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -465,7 +465,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '128 KB', + limit: '129 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { From 9830ba066cec7702376ed4a0c9d8aa2bdfc047c4 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 8 Oct 2026 15:53:01 +0200 Subject: [PATCH 4/5] revert: Restore Node bundle size limits Leave size-limit adjustments for a separate change. Co-Authored-By: GPT-6 --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 0651948ecc4d..fcb14bb45828 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -419,7 +419,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '151 KB', + limit: '150 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -465,7 +465,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '129 KB', + limit: '128 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { From f768c01f604ee901d96ef5c0f02a458762090f58 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 9 Oct 2026 13:36:23 +0200 Subject: [PATCH 5/5] fix(replay): Mask component annotations in breadcrumb messages Co-Authored-By: GPT-6 --- .../browser-utils/src/htmlTreeAsString.ts | 8 ++- .../breadcrumbAttributeMasking.test.ts | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/browser-utils/src/htmlTreeAsString.ts b/packages/browser-utils/src/htmlTreeAsString.ts index 80f685e6f39c..46dcc493a431 100644 --- a/packages/browser-utils/src/htmlTreeAsString.ts +++ b/packages/browser-utils/src/htmlTreeAsString.ts @@ -120,10 +120,14 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[], maskAttributeFn? const dataset = _safeRead(el, 'dataset'); if (dataset) { if (dataset['sentryComponent']) { - return dataset['sentryComponent']; + return maskAttributeFn + ? maskAttributeFn('data-sentry-component', dataset['sentryComponent'], el) + : dataset['sentryComponent']; } if (dataset['sentryElement']) { - return dataset['sentryElement']; + return maskAttributeFn + ? maskAttributeFn('data-sentry-element', dataset['sentryElement'], el) + : dataset['sentryElement']; } } } diff --git a/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts index c8f26171b949..c282e6f2ce7c 100644 --- a/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts +++ b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts @@ -23,6 +23,55 @@ describe('Integration | breadcrumb attribute masking', () => { vi.restoreAllMocks(); }); + describe.each(['data-sentry-component', 'data-sentry-element'])('%s', attribute => { + it.each([ + { + label: 'configured masking', + options: { maskAttributes: [attribute] }, + expected: '******* *** > *** *****', + }, + { + label: 'explicit unmasking', + options: { maskAttributes: [attribute], unmask: ['.sentry-unmask'] }, + expected: 'Account PIN > Pay Alice', + }, + { + label: 'default masking', + options: {}, + expected: 'Account PIN > Pay Alice', + }, + ])('honors $label for annotated target and ancestor breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + const parent = document.createElement('div'); + parent.className = 'sentry-unmask'; + parent.setAttribute(attribute, 'Account PIN'); + const target = document.createElement('button'); + target.className = 'sentry-unmask'; + target.setAttribute(attribute, 'Pay Alice'); + parent.appendChild(target); + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); + }); + it.each(['click', 'keypress', 'keydown'])('masks target and ancestor attributes in %s breadcrumbs', async name => { const breadcrumbs: Breadcrumb[] = []; const sdk = await resetSdkMock({