Skip to content

Commit 18f89cc

Browse files
logaretmclaude
andcommitted
fix(replay): sync mirror attributes for click breadcrumbs
Co-Authored-By: GPT-5 <noreply@anthropic.com>
1 parent 46af456 commit 18f89cc

File tree

4 files changed

+170
-81
lines changed

4 files changed

+170
-81
lines changed

packages/replay-internal/src/coreHandlers/handleDom.ts

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -53,30 +53,24 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea
5353
const node = nodeId && record.mirror.getNode(nodeId);
5454
const meta = node && record.mirror.getMeta(node);
5555
const element = meta && isElement(meta) ? meta : null;
56-
const liveElement = target instanceof Element && nodeId > -1 ? target : null;
5756

5857
return {
5958
message,
60-
data:
61-
element || liveElement
62-
? {
63-
nodeId,
64-
node: {
65-
id: nodeId,
66-
tagName: element?.tagName || liveElement?.tagName.toLowerCase() || '',
67-
textContent: element
68-
? Array.from(element.childNodes)
69-
.map((node: serializedNodeWithId) => node.type === NodeType.Text && node.textContent)
70-
.filter(Boolean) // filter out empty values
71-
.map(text => (text as string).trim())
72-
.join('')
73-
: '',
74-
attributes: getAttributesToRecord(
75-
liveElement ? getElementAttributes(liveElement) : element?.attributes || {},
76-
),
77-
},
78-
}
79-
: {},
59+
data: element
60+
? {
61+
nodeId,
62+
node: {
63+
id: nodeId,
64+
tagName: element.tagName,
65+
textContent: Array.from(element.childNodes)
66+
.map((node: serializedNodeWithId) => node.type === NodeType.Text && node.textContent)
67+
.filter(Boolean) // filter out empty values
68+
.map(text => (text as string).trim())
69+
.join(''),
70+
attributes: getAttributesToRecord(element.attributes),
71+
},
72+
}
73+
: {},
8074
};
8175
}
8276

@@ -113,10 +107,3 @@ function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; messa
113107
function isElement(node: serializedNodeWithId): node is serializedElementNodeWithId {
114108
return node.type === NodeType.Element;
115109
}
116-
117-
function getElementAttributes(element: Element): Record<string, string> {
118-
return Array.from(element.attributes).reduce<Record<string, string>>((attributes, attribute) => {
119-
attributes[attribute.name] = attribute.value;
120-
return attributes;
121-
}, {});
122-
}

packages/replay-internal/src/util/handleRecordingEmit.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { EventType } from '@sentry-internal/rrweb';
1+
import { EventType, IncrementalSource, record } from '@sentry-internal/rrweb';
2+
import { NodeType } from '@sentry-internal/rrweb-snapshot';
23
import { updateClickDetectorForRecordingEvent } from '../coreHandlers/handleClick';
34
import { DEBUG_BUILD } from '../debug-build';
45
import { saveSession } from '../session/saveSession';
56
import type { RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '../types';
67
import { addEventSync } from './addEvent';
78
import { debug } from './logger';
89

10+
type MutationAttributeData = {
11+
id: number;
12+
attributes: Record<string, string | number | true | null>;
13+
};
14+
915
type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void;
1016

1117
/**
@@ -29,6 +35,8 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
2935
const isCheckout = _isCheckout || !hadFirstEvent;
3036
hadFirstEvent = true;
3137

38+
syncMirrorAttributesFromMutationEvent(event);
39+
3240
if (replay.clickDetector) {
3341
updateClickDetectorForRecordingEvent(replay.clickDetector, event);
3442
}
@@ -112,6 +120,40 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
112120
};
113121
}
114122

123+
export function syncMirrorAttributesFromMutationEvent(event: RecordingEvent): void {
124+
const data = event.data;
125+
126+
if (
127+
event.type !== EventType.IncrementalSnapshot ||
128+
!data ||
129+
typeof data !== 'object' ||
130+
!('source' in data) ||
131+
data.source !== IncrementalSource.Mutation ||
132+
!('attributes' in data) ||
133+
!Array.isArray(data.attributes)
134+
) {
135+
return;
136+
}
137+
138+
for (const mutation of data.attributes as MutationAttributeData[]) {
139+
const node = record.mirror.getNode(mutation.id);
140+
const meta = node && record.mirror.getMeta(node);
141+
142+
if (!meta || meta.type !== NodeType.Element) {
143+
continue;
144+
}
145+
146+
for (const [attributeName, value] of Object.entries(mutation.attributes)) {
147+
if (value === null) {
148+
// oxlint-disable-next-line typescript/no-dynamic-delete
149+
delete meta.attributes[attributeName];
150+
} else {
151+
meta.attributes[attributeName] = value;
152+
}
153+
}
154+
}
155+
}
156+
115157
/**
116158
* Exported for tests
117159
*/

packages/replay-internal/test/unit/coreHandlers/handleDom.test.ts

Lines changed: 1 addition & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,10 @@
33
*/
44

55
import type { HandlerDataDom } from '@sentry/core';
6-
import { record } from '@sentry-internal/rrweb';
7-
import { afterEach, describe, expect, test, vi } from 'vitest';
6+
import { describe, expect, test } from 'vitest';
87
import { handleDom } from '../../../src/coreHandlers/handleDom';
98

109
describe('Unit | coreHandlers | handleDom', () => {
11-
afterEach(() => {
12-
vi.restoreAllMocks();
13-
});
14-
1510
test('it works with a basic click event on a div', () => {
1611
const parent = document.createElement('body');
1712
const target = document.createElement('div');
@@ -137,48 +132,4 @@ describe('Unit | coreHandlers | handleDom', () => {
137132
type: 'default',
138133
});
139134
});
140-
141-
test('prefers live element attributes over stale rrweb mirror metadata', () => {
142-
const target = document.createElement('button');
143-
target.setAttribute('id', 'save-note-button');
144-
target.setAttribute('data-testid', 'save-note-button');
145-
target.textContent = 'Save Note';
146-
147-
vi.spyOn(record.mirror, 'getId').mockReturnValue(42);
148-
vi.spyOn(record.mirror, 'getNode').mockReturnValue(target);
149-
vi.spyOn(record.mirror, 'getMeta').mockReturnValue({
150-
id: 42,
151-
type: 2,
152-
tagName: 'button',
153-
childNodes: [{ id: 43, type: 3, textContent: 'Save Note' }],
154-
attributes: {
155-
id: 'next-question-button',
156-
'data-testid': 'next-question-button',
157-
},
158-
});
159-
160-
const actual = handleDom({
161-
name: 'click',
162-
event: { target },
163-
});
164-
165-
expect(actual).toEqual({
166-
category: 'ui.click',
167-
data: {
168-
nodeId: 42,
169-
node: {
170-
id: 42,
171-
tagName: 'button',
172-
textContent: 'Save Note',
173-
attributes: {
174-
id: 'save-note-button',
175-
testId: 'save-note-button',
176-
},
177-
},
178-
},
179-
message: 'button#save-note-button',
180-
timestamp: expect.any(Number),
181-
type: 'default',
182-
});
183-
});
184135
});

packages/replay-internal/test/unit/util/handleRecordingEmit.test.ts

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
*/
44

55
import '../../utils/mock-internal-setTimeout';
6-
import { EventType } from '@sentry-internal/rrweb';
6+
import { EventType, IncrementalSource, record } from '@sentry-internal/rrweb';
7+
import { NodeType, type serializedElementNodeWithId } from '@sentry-internal/rrweb-snapshot';
78
import type { MockInstance } from 'vitest';
89
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
10+
import { handleDom } from '../../../src/coreHandlers/handleDom';
911
import type { ReplayOptionFrameEvent } from '../../../src/types';
1012
import * as SentryAddEvent from '../../../src/util/addEvent';
11-
import { createOptionsEvent, getHandleRecordingEmit } from '../../../src/util/handleRecordingEmit';
13+
import {
14+
createOptionsEvent,
15+
getHandleRecordingEmit,
16+
syncMirrorAttributesFromMutationEvent,
17+
} from '../../../src/util/handleRecordingEmit';
1218
import { BASE_TIMESTAMP } from '../..';
1319
import { setupReplayContainer } from '../../utils/setupReplayContainer';
1420

@@ -30,6 +36,7 @@ describe('Unit | util | handleRecordingEmit', () => {
3036

3137
afterEach(function () {
3238
addEventMock.mockReset();
39+
vi.restoreAllMocks();
3340
});
3441

3542
it('interprets first event as checkout event', async function () {
@@ -95,4 +102,106 @@ describe('Unit | util | handleRecordingEmit', () => {
95102
expect(addEventMock).toHaveBeenNthCalledWith(3, replay, event, true);
96103
expect(addEventMock).toHaveBeenLastCalledWith(replay, { ...optionsEvent, timestamp: BASE_TIMESTAMP }, false);
97104
});
105+
106+
it('syncs mirror attributes from mutation events', function () {
107+
const target = document.createElement('button');
108+
target.textContent = 'Save Note';
109+
110+
const meta = {
111+
id: 42,
112+
type: NodeType.Element,
113+
tagName: 'button',
114+
childNodes: [{ id: 43, type: NodeType.Text, textContent: 'Save Note' }],
115+
attributes: {
116+
id: 'next-question-button',
117+
'data-testid': 'next-question-button',
118+
},
119+
};
120+
121+
vi.spyOn(record.mirror, 'getNode').mockReturnValue(target);
122+
vi.spyOn(record.mirror, 'getMeta').mockReturnValue(meta as serializedElementNodeWithId);
123+
vi.spyOn(record.mirror, 'getId').mockReturnValue(42);
124+
125+
syncMirrorAttributesFromMutationEvent({
126+
type: EventType.IncrementalSnapshot,
127+
timestamp: BASE_TIMESTAMP + 10,
128+
data: {
129+
source: IncrementalSource.Mutation,
130+
texts: [],
131+
attributes: [
132+
{
133+
id: 42,
134+
attributes: {
135+
id: 'save-note-button',
136+
'data-testid': 'save-note-button',
137+
},
138+
},
139+
],
140+
removes: [],
141+
adds: [],
142+
},
143+
});
144+
145+
expect(
146+
handleDom({
147+
name: 'click',
148+
event: { target },
149+
}),
150+
).toEqual({
151+
category: 'ui.click',
152+
data: {
153+
nodeId: 42,
154+
node: {
155+
id: 42,
156+
tagName: 'button',
157+
textContent: 'Save Note',
158+
attributes: {
159+
id: 'save-note-button',
160+
testId: 'save-note-button',
161+
},
162+
},
163+
},
164+
message: 'button',
165+
timestamp: expect.any(Number),
166+
type: 'default',
167+
});
168+
});
169+
170+
it('preserves masked mutation attribute values', function () {
171+
const target = document.createElement('button');
172+
173+
const meta = {
174+
id: 42,
175+
type: NodeType.Element,
176+
tagName: 'button',
177+
childNodes: [],
178+
attributes: {
179+
'aria-label': 'Save Note',
180+
},
181+
};
182+
183+
vi.spyOn(record.mirror, 'getNode').mockReturnValue(target);
184+
vi.spyOn(record.mirror, 'getMeta').mockReturnValue(meta as serializedElementNodeWithId);
185+
186+
syncMirrorAttributesFromMutationEvent({
187+
type: EventType.IncrementalSnapshot,
188+
timestamp: BASE_TIMESTAMP + 10,
189+
data: {
190+
source: IncrementalSource.Mutation,
191+
texts: [],
192+
attributes: [
193+
{
194+
id: 42,
195+
attributes: {
196+
'aria-label': '*********',
197+
},
198+
},
199+
],
200+
removes: [],
201+
adds: [],
202+
},
203+
});
204+
205+
expect(meta.attributes['aria-label']).toBe('*********');
206+
});
98207
});

0 commit comments

Comments
 (0)