Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<body>
<button id="mutationButton">Trigger mutation</button>
<div id="mutationDiv">Trigger mutation</div>
<button id="next-question-button" data-testid="next-question-button">Next question</button>
<button id="mutationButtonImmediately">Trigger mutation immediately</button>
<button
id="mutationButtonInline"
Expand Down
44 changes: 43 additions & 1 deletion packages/replay-internal/src/util/handleRecordingEmit.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
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';
import type { RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '../types';
import { addEventSync } from './addEvent';
import { debug } from './logger';

type MutationAttributeData = {
id: number;
attributes: Record<string, string | number | true | null>;
};

type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void;

/**
Expand All @@ -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);
}
Expand Down Expand Up @@ -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?.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
*/
Expand Down
113 changes: 111 additions & 2 deletions packages/replay-internal/test/unit/util/handleRecordingEmit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

afterEach(function () {
addEventMock.mockReset();
vi.restoreAllMocks();
});

it('interprets first event as checkout event', async function () {
Expand Down Expand Up @@ -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('*********');
});
});
Loading