Skip to content

Commit 2921c5e

Browse files
committed
fix(replay): Adjust slow/multi click handling
1 parent 54e091e commit 2921c5e

File tree

6 files changed

+112
-147
lines changed

6 files changed

+112
-147
lines changed

packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,105 @@ sentryTest('captures multi click when not detecting slow click', async ({ getLoc
5858
},
5959
]);
6060
});
61+
62+
sentryTest('captures multiple multi clicks', async ({ getLocalTestUrl, page }) => {
63+
if (shouldSkipReplayTest()) {
64+
sentryTest.skip();
65+
}
66+
67+
const reqPromise0 = waitForReplayRequest(page, 0);
68+
69+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
70+
return route.fulfill({
71+
status: 200,
72+
contentType: 'application/json',
73+
body: JSON.stringify({ id: 'test-id' }),
74+
});
75+
});
76+
77+
const url = await getLocalTestUrl({ testDir: __dirname });
78+
79+
await page.goto(url);
80+
await reqPromise0;
81+
82+
let lastMultiClickSegmentId: number | undefined;
83+
84+
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
85+
const { breadcrumbs } = getCustomRecordingEvents(res);
86+
87+
const check = !lastMultiClickSegmentId && breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.multiClick');
88+
if (check) {
89+
lastMultiClickSegmentId = event.segment_id;
90+
}
91+
return check;
92+
});
93+
94+
const reqPromise2 = waitForReplayRequest(page, (event, res) => {
95+
const { breadcrumbs } = getCustomRecordingEvents(res);
96+
97+
const check =
98+
!!lastMultiClickSegmentId &&
99+
event.segment_id > lastMultiClickSegmentId &&
100+
breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.multiClick');
101+
if (check) {
102+
lastMultiClickSegmentId = event.segment_id;
103+
}
104+
return check;
105+
});
106+
107+
await page.click('#mutationButtonImmediately', { clickCount: 4 });
108+
109+
await new Promise(resolve => setTimeout(resolve, 1001));
110+
111+
await page.click('#mutationButtonImmediately', { clickCount: 2 });
112+
113+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
114+
const { breadcrumbs: breadcrumb2 } = getCustomRecordingEvents(await reqPromise2);
115+
116+
const slowClickBreadcrumbs = breadcrumbs
117+
.concat(breadcrumb2)
118+
.filter(breadcrumb => breadcrumb.category === 'ui.multiClick');
119+
120+
expect(slowClickBreadcrumbs).toEqual([
121+
{
122+
category: 'ui.multiClick',
123+
type: 'default',
124+
data: {
125+
clickCount: 6,
126+
metric: true,
127+
node: {
128+
attributes: {
129+
id: 'mutationButtonImmediately',
130+
},
131+
id: expect.any(Number),
132+
tagName: 'button',
133+
textContent: '******* ******** ***********',
134+
},
135+
nodeId: expect.any(Number),
136+
url: 'http://sentry-test.io/index.html',
137+
},
138+
message: 'body > button#mutationButtonImmediately',
139+
timestamp: expect.any(Number),
140+
},
141+
{
142+
category: 'ui.multiClick',
143+
type: 'default',
144+
data: {
145+
clickCount: 2,
146+
metric: true,
147+
node: {
148+
attributes: {
149+
id: 'mutationButtonImmediately',
150+
},
151+
id: expect.any(Number),
152+
tagName: 'button',
153+
textContent: '******* ******** ***********',
154+
},
155+
nodeId: expect.any(Number),
156+
url: 'http://sentry-test.io/index.html',
157+
},
158+
message: 'body > button#mutationButtonImmediately',
159+
timestamp: expect.any(Number),
160+
},
161+
]);
162+
});

packages/replay/src/constants.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ export const CONSOLE_ARG_MAX_SIZE = 5_000;
4242
export const SLOW_CLICK_THRESHOLD = 3_000;
4343
/* For scroll actions after a click, we only look for a very short time period to detect programmatic scrolling. */
4444
export const SLOW_CLICK_SCROLL_TIMEOUT = 300;
45-
/* Clicks in this time period are considered e.g. double/triple clicks. */
46-
export const MULTI_CLICK_TIMEOUT = 1_000;
4745

4846
/** When encountering a total segment size exceeding this size, stop the replay (as we cannot properly ingest it). */
4947
export const REPLAY_MAX_EVENT_BUFFER_SIZE = 20_000_000; // ~20MB

packages/replay/src/coreHandlers/handleClick.ts

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export class ClickDetector implements ReplayClickDetector {
3333
private _clicks: Click[] = [];
3434
private _teardown: undefined | (() => void);
3535

36-
private _multiClickTimeout: number;
3736
private _threshold: number;
3837
private _scollTimeout: number;
3938
private _timeout: number;
@@ -51,7 +50,6 @@ export class ClickDetector implements ReplayClickDetector {
5150
) {
5251
// We want everything in s, but options are in ms
5352
this._timeout = slowClickConfig.timeout / 1000;
54-
this._multiClickTimeout = slowClickConfig.multiClickTimeout / 1000;
5553
this._threshold = slowClickConfig.threshold / 1000;
5654
this._scollTimeout = slowClickConfig.scrollTimeout / 1000;
5755
this._replay = replay;
@@ -126,13 +124,6 @@ export class ClickDetector implements ReplayClickDetector {
126124
return;
127125
}
128126

129-
const click = this._getClick(node);
130-
131-
if (click) {
132-
// this means a click on the same element was captured in the last 1s, so we consider this a multi click
133-
return;
134-
}
135-
136127
const newClick: Click = {
137128
timestamp: breadcrumb.timestamp,
138129
clickBreadcrumb: breadcrumb,
@@ -150,22 +141,14 @@ export class ClickDetector implements ReplayClickDetector {
150141

151142
/** Count multiple clicks on elements. */
152143
private _handleMultiClick(node: HTMLElement): void {
153-
const click = this._getClick(node);
154-
155-
if (!click) {
156-
return;
157-
}
158-
159-
click.clickCount++;
144+
this._getClicks(node).forEach(click => {
145+
click.clickCount++;
146+
});
160147
}
161148

162-
/** Try to get an existing click on the given element. */
163-
private _getClick(node: HTMLElement): Click | undefined {
164-
const now = nowInSeconds();
165-
166-
// Find any click on the same element in the last second
167-
// If one exists, we consider this click as a double/triple/etc click
168-
return this._clicks.find(click => click.node === node && now - click.timestamp < this._multiClickTimeout);
149+
/** Get all pending clicks for a given node. */
150+
private _getClicks(node: HTMLElement): Click[] {
151+
return this._clicks.filter(click => click.node === node);
169152
}
170153

171154
/** Check the clicks that happened. */
@@ -182,13 +165,6 @@ export class ClickDetector implements ReplayClickDetector {
182165
click.scrollAfter = click.timestamp <= this._lastScroll ? this._lastScroll - click.timestamp : undefined;
183166
}
184167

185-
// If an action happens after the multi click threshold, we can skip waiting and handle the click right away
186-
const actionTime = click.scrollAfter || click.mutationAfter || 0;
187-
if (actionTime && actionTime >= this._multiClickTimeout) {
188-
timedOutClicks.push(click);
189-
return;
190-
}
191-
192168
if (click.timestamp + this._timeout <= now) {
193169
timedOutClicks.push(click);
194170
}
@@ -269,6 +245,10 @@ export class ClickDetector implements ReplayClickDetector {
269245

270246
/** Schedule to check current clicks. */
271247
private _scheduleCheckClicks(): void {
248+
if (this._checkClickTimeout) {
249+
clearTimeout(this._checkClickTimeout);
250+
}
251+
272252
this._checkClickTimeout = setTimeout(() => this._checkClicks(), 1000);
273253
}
274254
}

packages/replay/src/replay.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { logger } from '@sentry/utils';
77
import {
88
BUFFER_CHECKOUT_TIME,
99
MAX_SESSION_LIFE,
10-
MULTI_CLICK_TIMEOUT,
1110
SESSION_IDLE_EXPIRE_DURATION,
1211
SESSION_IDLE_PAUSE_DURATION,
1312
SLOW_CLICK_SCROLL_TIMEOUT,
@@ -175,7 +174,6 @@ export class ReplayContainer implements ReplayContainerInterface {
175174
timeout: slowClickTimeout,
176175
scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT,
177176
ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '',
178-
multiClickTimeout: MULTI_CLICK_TIMEOUT,
179177
}
180178
: undefined;
181179

packages/replay/src/types/replay.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,5 +476,4 @@ export interface SlowClickConfig {
476476
timeout: number;
477477
scrollTimeout: number;
478478
ignoreSelector: string;
479-
multiClickTimeout: number;
480479
}

packages/replay/test/unit/coreHandlers/handleClick.test.ts

Lines changed: 0 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ describe('Unit | coreHandlers | handleClick', () => {
2626
timeout: 3_000,
2727
scrollTimeout: 200,
2828
ignoreSelector: '',
29-
multiClickTimeout: 1_000,
3029
},
3130
mockAddBreadcrumbEvent,
3231
);
@@ -72,113 +71,6 @@ describe('Unit | coreHandlers | handleClick', () => {
7271
expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
7372
});
7473

75-
test('it groups multiple clicks together', async () => {
76-
const replay = {
77-
getCurrentRoute: () => 'test-route',
78-
} as ReplayContainer;
79-
80-
const mockAddBreadcrumbEvent = jest.fn();
81-
82-
const detector = new ClickDetector(
83-
replay,
84-
{
85-
threshold: 1_000,
86-
timeout: 3_000,
87-
scrollTimeout: 200,
88-
ignoreSelector: '',
89-
multiClickTimeout: 1_000,
90-
},
91-
mockAddBreadcrumbEvent,
92-
);
93-
94-
const breadcrumb1: Breadcrumb = {
95-
timestamp: BASE_TIMESTAMP / 1000,
96-
data: {
97-
nodeId: 1,
98-
},
99-
};
100-
const breadcrumb2: Breadcrumb = {
101-
timestamp: BASE_TIMESTAMP / 1000 + 0.2,
102-
data: {
103-
nodeId: 1,
104-
},
105-
};
106-
const breadcrumb3: Breadcrumb = {
107-
timestamp: BASE_TIMESTAMP / 1000 + 0.6,
108-
data: {
109-
nodeId: 1,
110-
},
111-
};
112-
const breadcrumb4: Breadcrumb = {
113-
timestamp: BASE_TIMESTAMP / 1000 + 2,
114-
data: {
115-
nodeId: 1,
116-
},
117-
};
118-
const breadcrumb5: Breadcrumb = {
119-
timestamp: BASE_TIMESTAMP / 1000 + 2.9,
120-
data: {
121-
nodeId: 1,
122-
},
123-
};
124-
const node = document.createElement('button');
125-
detector.handleClick(breadcrumb1, node);
126-
127-
detector.handleClick(breadcrumb2, node);
128-
129-
detector.handleClick(breadcrumb3, node);
130-
131-
expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
132-
133-
jest.advanceTimersByTime(2_000);
134-
135-
expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
136-
137-
detector.handleClick(breadcrumb4, node);
138-
detector.handleClick(breadcrumb5, node);
139-
140-
jest.advanceTimersByTime(1_000);
141-
142-
expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
143-
expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
144-
category: 'ui.slowClickDetected',
145-
type: 'default',
146-
data: {
147-
// count is not actually correct, because this is identified by a different click handler
148-
clickCount: 1,
149-
endReason: 'timeout',
150-
nodeId: 1,
151-
route: 'test-route',
152-
timeAfterClickMs: 3000,
153-
url: 'http://localhost/',
154-
},
155-
message: undefined,
156-
timestamp: expect.any(Number),
157-
});
158-
159-
jest.advanceTimersByTime(2_000);
160-
161-
expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2);
162-
expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
163-
category: 'ui.slowClickDetected',
164-
type: 'default',
165-
data: {
166-
// count is not actually correct, because this is identified by a different click handler
167-
clickCount: 1,
168-
endReason: 'timeout',
169-
nodeId: 1,
170-
route: 'test-route',
171-
timeAfterClickMs: 3000,
172-
url: 'http://localhost/',
173-
},
174-
message: undefined,
175-
timestamp: expect.any(Number),
176-
});
177-
178-
jest.advanceTimersByTime(5_000);
179-
expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2);
180-
});
181-
18274
test('it captures clicks on different elements', async () => {
18375
const replay = {
18476
getCurrentRoute: () => 'test-route',
@@ -193,7 +85,6 @@ describe('Unit | coreHandlers | handleClick', () => {
19385
timeout: 3_000,
19486
scrollTimeout: 200,
19587
ignoreSelector: '',
196-
multiClickTimeout: 1_000,
19788
},
19889
mockAddBreadcrumbEvent,
19990
);
@@ -247,7 +138,6 @@ describe('Unit | coreHandlers | handleClick', () => {
247138
timeout: 3_000,
248139
scrollTimeout: 200,
249140
ignoreSelector: '',
250-
multiClickTimeout: 1_000,
251141
},
252142
mockAddBreadcrumbEvent,
253143
);
@@ -304,7 +194,6 @@ describe('Unit | coreHandlers | handleClick', () => {
304194
timeout: 3_000,
305195
scrollTimeout: 200,
306196
ignoreSelector: '',
307-
multiClickTimeout: 1_000,
308197
},
309198
mockAddBreadcrumbEvent,
310199
);
@@ -437,7 +326,6 @@ describe('Unit | coreHandlers | handleClick', () => {
437326
timeout: 3_000,
438327
scrollTimeout: 200,
439328
ignoreSelector: '',
440-
multiClickTimeout: 1_000,
441329
},
442330
mockAddBreadcrumbEvent,
443331
);

0 commit comments

Comments
 (0)