From e7747b39af9d7d7d6623f6563c023a8622c1e981 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Apr 2026 13:32:15 -0400 Subject: [PATCH 1/6] fix(replay): use live click attributes in breadcrumbs Co-Authored-By: GPT-5 --- .../src/coreHandlers/handleDom.ts | 43 ++++++++++------ .../test/unit/coreHandlers/handleDom.test.ts | 51 ++++++++++++++++++- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/packages/replay-internal/src/coreHandlers/handleDom.ts b/packages/replay-internal/src/coreHandlers/handleDom.ts index ffe5dbc9096f..4df2e73f410a 100644 --- a/packages/replay-internal/src/coreHandlers/handleDom.ts +++ b/packages/replay-internal/src/coreHandlers/handleDom.ts @@ -53,24 +53,30 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea const node = nodeId && record.mirror.getNode(nodeId); const meta = node && record.mirror.getMeta(node); const element = meta && isElement(meta) ? meta : null; + const liveElement = target instanceof Element && nodeId > -1 ? target : null; return { message, - data: element - ? { - nodeId, - node: { - id: nodeId, - tagName: element.tagName, - textContent: Array.from(element.childNodes) - .map((node: serializedNodeWithId) => node.type === NodeType.Text && node.textContent) - .filter(Boolean) // filter out empty values - .map(text => (text as string).trim()) - .join(''), - attributes: getAttributesToRecord(element.attributes), - }, - } - : {}, + data: + element || liveElement + ? { + nodeId, + node: { + id: nodeId, + tagName: element?.tagName || liveElement?.tagName.toLowerCase() || '', + textContent: element + ? Array.from(element.childNodes) + .map((node: serializedNodeWithId) => node.type === NodeType.Text && node.textContent) + .filter(Boolean) // filter out empty values + .map(text => (text as string).trim()) + .join('') + : '', + attributes: getAttributesToRecord( + liveElement ? getElementAttributes(liveElement) : element?.attributes || {}, + ), + }, + } + : {}, }; } @@ -107,3 +113,10 @@ function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; messa function isElement(node: serializedNodeWithId): node is serializedElementNodeWithId { return node.type === NodeType.Element; } + +function getElementAttributes(element: Element): Record { + return Array.from(element.attributes).reduce>((attributes, attribute) => { + attributes[attribute.name] = attribute.value; + return attributes; + }, {}); +} diff --git a/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts index 1512f3481de9..59c177c0755a 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts @@ -3,10 +3,15 @@ */ import type { HandlerDataDom } from '@sentry/core'; -import { describe, expect, test } from 'vitest'; +import { record } from '@sentry-internal/rrweb'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { handleDom } from '../../../src/coreHandlers/handleDom'; describe('Unit | coreHandlers | handleDom', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test('it works with a basic click event on a div', () => { const parent = document.createElement('body'); const target = document.createElement('div'); @@ -132,4 +137,48 @@ describe('Unit | coreHandlers | handleDom', () => { type: 'default', }); }); + + test('it prefers live element attributes over stale rrweb mirror metadata', () => { + const target = document.createElement('button'); + target.setAttribute('id', 'save-note-button'); + target.setAttribute('data-testid', 'save-note-button'); + target.textContent = 'Save Note'; + + vi.spyOn(record.mirror, 'getId').mockReturnValue(42); + vi.spyOn(record.mirror, 'getNode').mockReturnValue(target); + vi.spyOn(record.mirror, 'getMeta').mockReturnValue({ + id: 42, + type: 2, + tagName: 'button', + childNodes: [{ id: 43, type: 3, textContent: 'Save Note' }], + attributes: { + id: 'next-question-button', + 'data-testid': 'next-question-button', + }, + }); + + const actual = handleDom({ + name: 'click', + event: { target }, + }); + + expect(actual).toEqual({ + category: 'ui.click', + data: { + nodeId: 42, + node: { + id: 42, + tagName: 'button', + textContent: 'Save Note', + attributes: { + id: 'save-note-button', + testId: 'save-note-button', + }, + }, + }, + message: 'button#save-note-button', + timestamp: expect.any(Number), + type: 'default', + }); + }); }); From 46af456c1c331f695596f73b53d8e432dcd4bc05 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Apr 2026 13:34:35 -0400 Subject: [PATCH 2/6] test(replay): rename stale attribute regression test Co-Authored-By: GPT-5 --- .../replay-internal/test/unit/coreHandlers/handleDom.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts index 59c177c0755a..ceb4c147f808 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts @@ -138,7 +138,7 @@ describe('Unit | coreHandlers | handleDom', () => { }); }); - test('it prefers live element attributes over stale rrweb mirror metadata', () => { + test('prefers live element attributes over stale rrweb mirror metadata', () => { const target = document.createElement('button'); target.setAttribute('id', 'save-note-button'); target.setAttribute('data-testid', 'save-note-button'); From 18f89cc5becbcd7e1aa68cae19ef1da242ff82cf Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Apr 2026 13:58:05 -0400 Subject: [PATCH 3/6] fix(replay): sync mirror attributes for click breadcrumbs Co-Authored-By: GPT-5 --- .../src/coreHandlers/handleDom.ts | 43 +++---- .../src/util/handleRecordingEmit.ts | 44 ++++++- .../test/unit/coreHandlers/handleDom.test.ts | 51 +------- .../unit/util/handleRecordingEmit.test.ts | 113 +++++++++++++++++- 4 files changed, 170 insertions(+), 81 deletions(-) diff --git a/packages/replay-internal/src/coreHandlers/handleDom.ts b/packages/replay-internal/src/coreHandlers/handleDom.ts index 4df2e73f410a..ffe5dbc9096f 100644 --- a/packages/replay-internal/src/coreHandlers/handleDom.ts +++ b/packages/replay-internal/src/coreHandlers/handleDom.ts @@ -53,30 +53,24 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea const node = nodeId && record.mirror.getNode(nodeId); const meta = node && record.mirror.getMeta(node); const element = meta && isElement(meta) ? meta : null; - const liveElement = target instanceof Element && nodeId > -1 ? target : null; return { message, - data: - element || liveElement - ? { - nodeId, - node: { - id: nodeId, - tagName: element?.tagName || liveElement?.tagName.toLowerCase() || '', - textContent: element - ? Array.from(element.childNodes) - .map((node: serializedNodeWithId) => node.type === NodeType.Text && node.textContent) - .filter(Boolean) // filter out empty values - .map(text => (text as string).trim()) - .join('') - : '', - attributes: getAttributesToRecord( - liveElement ? getElementAttributes(liveElement) : element?.attributes || {}, - ), - }, - } - : {}, + data: element + ? { + nodeId, + node: { + id: nodeId, + tagName: element.tagName, + textContent: Array.from(element.childNodes) + .map((node: serializedNodeWithId) => node.type === NodeType.Text && node.textContent) + .filter(Boolean) // filter out empty values + .map(text => (text as string).trim()) + .join(''), + attributes: getAttributesToRecord(element.attributes), + }, + } + : {}, }; } @@ -113,10 +107,3 @@ function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; messa function isElement(node: serializedNodeWithId): node is serializedElementNodeWithId { return node.type === NodeType.Element; } - -function getElementAttributes(element: Element): Record { - return Array.from(element.attributes).reduce>((attributes, attribute) => { - attributes[attribute.name] = attribute.value; - return attributes; - }, {}); -} diff --git a/packages/replay-internal/src/util/handleRecordingEmit.ts b/packages/replay-internal/src/util/handleRecordingEmit.ts index 215f94daa1db..82b9b4747898 100644 --- a/packages/replay-internal/src/util/handleRecordingEmit.ts +++ b/packages/replay-internal/src/util/handleRecordingEmit.ts @@ -1,4 +1,5 @@ -import { EventType } from '@sentry-internal/rrweb'; +import { EventType, IncrementalSource, record } from '@sentry-internal/rrweb'; +import { NodeType } from '@sentry-internal/rrweb-snapshot'; import { updateClickDetectorForRecordingEvent } from '../coreHandlers/handleClick'; import { DEBUG_BUILD } from '../debug-build'; import { saveSession } from '../session/saveSession'; @@ -6,6 +7,11 @@ import type { RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '.. import { addEventSync } from './addEvent'; import { debug } from './logger'; +type MutationAttributeData = { + id: number; + attributes: Record; +}; + type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void; /** @@ -29,6 +35,8 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa const isCheckout = _isCheckout || !hadFirstEvent; hadFirstEvent = true; + syncMirrorAttributesFromMutationEvent(event); + if (replay.clickDetector) { updateClickDetectorForRecordingEvent(replay.clickDetector, event); } @@ -112,6 +120,40 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa }; } +export function syncMirrorAttributesFromMutationEvent(event: RecordingEvent): void { + const data = event.data; + + if ( + event.type !== EventType.IncrementalSnapshot || + !data || + typeof data !== 'object' || + !('source' in data) || + data.source !== IncrementalSource.Mutation || + !('attributes' in data) || + !Array.isArray(data.attributes) + ) { + return; + } + + for (const mutation of data.attributes as MutationAttributeData[]) { + const node = record.mirror.getNode(mutation.id); + const meta = node && record.mirror.getMeta(node); + + if (!meta || meta.type !== NodeType.Element) { + continue; + } + + for (const [attributeName, value] of Object.entries(mutation.attributes)) { + if (value === null) { + // oxlint-disable-next-line typescript/no-dynamic-delete + delete meta.attributes[attributeName]; + } else { + meta.attributes[attributeName] = value; + } + } + } +} + /** * Exported for tests */ diff --git a/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts index ceb4c147f808..1512f3481de9 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts @@ -3,15 +3,10 @@ */ import type { HandlerDataDom } from '@sentry/core'; -import { record } from '@sentry-internal/rrweb'; -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { describe, expect, test } from 'vitest'; import { handleDom } from '../../../src/coreHandlers/handleDom'; describe('Unit | coreHandlers | handleDom', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - test('it works with a basic click event on a div', () => { const parent = document.createElement('body'); const target = document.createElement('div'); @@ -137,48 +132,4 @@ describe('Unit | coreHandlers | handleDom', () => { type: 'default', }); }); - - test('prefers live element attributes over stale rrweb mirror metadata', () => { - const target = document.createElement('button'); - target.setAttribute('id', 'save-note-button'); - target.setAttribute('data-testid', 'save-note-button'); - target.textContent = 'Save Note'; - - vi.spyOn(record.mirror, 'getId').mockReturnValue(42); - vi.spyOn(record.mirror, 'getNode').mockReturnValue(target); - vi.spyOn(record.mirror, 'getMeta').mockReturnValue({ - id: 42, - type: 2, - tagName: 'button', - childNodes: [{ id: 43, type: 3, textContent: 'Save Note' }], - attributes: { - id: 'next-question-button', - 'data-testid': 'next-question-button', - }, - }); - - const actual = handleDom({ - name: 'click', - event: { target }, - }); - - expect(actual).toEqual({ - category: 'ui.click', - data: { - nodeId: 42, - node: { - id: 42, - tagName: 'button', - textContent: 'Save Note', - attributes: { - id: 'save-note-button', - testId: 'save-note-button', - }, - }, - }, - message: 'button#save-note-button', - timestamp: expect.any(Number), - type: 'default', - }); - }); }); diff --git a/packages/replay-internal/test/unit/util/handleRecordingEmit.test.ts b/packages/replay-internal/test/unit/util/handleRecordingEmit.test.ts index 41188cd5f1f1..a0e6a31fd490 100644 --- a/packages/replay-internal/test/unit/util/handleRecordingEmit.test.ts +++ b/packages/replay-internal/test/unit/util/handleRecordingEmit.test.ts @@ -3,12 +3,18 @@ */ import '../../utils/mock-internal-setTimeout'; -import { EventType } from '@sentry-internal/rrweb'; +import { EventType, IncrementalSource, record } from '@sentry-internal/rrweb'; +import { NodeType, type serializedElementNodeWithId } from '@sentry-internal/rrweb-snapshot'; import type { MockInstance } from 'vitest'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleDom } from '../../../src/coreHandlers/handleDom'; import type { ReplayOptionFrameEvent } from '../../../src/types'; import * as SentryAddEvent from '../../../src/util/addEvent'; -import { createOptionsEvent, getHandleRecordingEmit } from '../../../src/util/handleRecordingEmit'; +import { + createOptionsEvent, + getHandleRecordingEmit, + syncMirrorAttributesFromMutationEvent, +} from '../../../src/util/handleRecordingEmit'; import { BASE_TIMESTAMP } from '../..'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; @@ -30,6 +36,7 @@ describe('Unit | util | handleRecordingEmit', () => { afterEach(function () { addEventMock.mockReset(); + vi.restoreAllMocks(); }); it('interprets first event as checkout event', async function () { @@ -95,4 +102,106 @@ describe('Unit | util | handleRecordingEmit', () => { expect(addEventMock).toHaveBeenNthCalledWith(3, replay, event, true); expect(addEventMock).toHaveBeenLastCalledWith(replay, { ...optionsEvent, timestamp: BASE_TIMESTAMP }, false); }); + + it('syncs mirror attributes from mutation events', function () { + const target = document.createElement('button'); + target.textContent = 'Save Note'; + + const meta = { + id: 42, + type: NodeType.Element, + tagName: 'button', + childNodes: [{ id: 43, type: NodeType.Text, textContent: 'Save Note' }], + attributes: { + id: 'next-question-button', + 'data-testid': 'next-question-button', + }, + }; + + vi.spyOn(record.mirror, 'getNode').mockReturnValue(target); + vi.spyOn(record.mirror, 'getMeta').mockReturnValue(meta as serializedElementNodeWithId); + vi.spyOn(record.mirror, 'getId').mockReturnValue(42); + + syncMirrorAttributesFromMutationEvent({ + type: EventType.IncrementalSnapshot, + timestamp: BASE_TIMESTAMP + 10, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [ + { + id: 42, + attributes: { + id: 'save-note-button', + 'data-testid': 'save-note-button', + }, + }, + ], + removes: [], + adds: [], + }, + }); + + expect( + handleDom({ + name: 'click', + event: { target }, + }), + ).toEqual({ + category: 'ui.click', + data: { + nodeId: 42, + node: { + id: 42, + tagName: 'button', + textContent: 'Save Note', + attributes: { + id: 'save-note-button', + testId: 'save-note-button', + }, + }, + }, + message: 'button', + timestamp: expect.any(Number), + type: 'default', + }); + }); + + it('preserves masked mutation attribute values', function () { + const target = document.createElement('button'); + + const meta = { + id: 42, + type: NodeType.Element, + tagName: 'button', + childNodes: [], + attributes: { + 'aria-label': 'Save Note', + }, + }; + + vi.spyOn(record.mirror, 'getNode').mockReturnValue(target); + vi.spyOn(record.mirror, 'getMeta').mockReturnValue(meta as serializedElementNodeWithId); + + syncMirrorAttributesFromMutationEvent({ + type: EventType.IncrementalSnapshot, + timestamp: BASE_TIMESTAMP + 10, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [ + { + id: 42, + attributes: { + 'aria-label': '*********', + }, + }, + ], + removes: [], + adds: [], + }, + }); + + expect(meta.attributes['aria-label']).toBe('*********'); + }); }); From e9ec66db4e8840b9e7e312750d38dbb6c67dda73 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Apr 2026 14:28:19 -0400 Subject: [PATCH 4/6] build: bump replay CDN size limit Co-Authored-By: GPT-5 --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 4100751f2c40..812e549d96b4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -269,7 +269,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '247 KB', + limit: '248 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', From 87e886b54af29e8dc0dc92bc7430745c975a748c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Apr 2026 16:22:18 -0400 Subject: [PATCH 5/6] test(replay): cover mutated click breadcrumb attributes Co-Authored-By: GPT-5 --- .../suites/replay/slowClick/mutation/test.ts | 60 +++++++++++++++++++ .../suites/replay/slowClick/template.html | 1 + 2 files changed, 61 insertions(+) diff --git a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index 08aad51de3ff..1373f78b3a5c 100644 --- a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -56,6 +56,66 @@ sentryTest('mutation after threshold results in slow click', async ({ forceFlush expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3501); }); +sentryTest( + 'uses updated attributes for click breadcrumbs after mutation', + async ({ forceFlushReplay, getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const replayRequestPromise = waitForReplayRequest(page, 0); + const segmentReqWithClickBreadcrumbPromise = waitForReplayRequest(page, (_event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); + + await page.goto(url); + await replayRequestPromise; + + await forceFlushReplay(); + + await page.evaluate(() => { + const target = document.getElementById('next-question-button'); + if (!target) { + throw new Error('Could not find target button'); + } + + target.id = 'save-note-button'; + target.setAttribute('data-testid', 'save-note-button'); + }); + + await page.getByRole('button', { name: 'Next question' }).click(); + await forceFlushReplay(); + + const segmentReqWithClickBreadcrumb = await segmentReqWithClickBreadcrumbPromise; + + const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithClickBreadcrumb); + const updatedClickBreadcrumb = breadcrumbs.find(breadcrumb => breadcrumb.category === 'ui.click'); + + expect(updatedClickBreadcrumb).toEqual({ + category: 'ui.click', + data: { + node: { + attributes: { + id: 'save-note-button', + testId: 'save-note-button', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '**** ********', + }, + nodeId: expect.any(Number), + }, + message: 'body > button#save-note-button', + timestamp: expect.any(Number), + type: 'default', + }); + }, +); + sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); diff --git a/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html b/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html index 030401479a6b..2e0558870e1e 100644 --- a/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html +++ b/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html @@ -6,6 +6,7 @@
Trigger mutation
+