Skip to content

Commit 4b98349

Browse files
authored
fix(replay): Adjust slow/multi click handling (#8380)
Implements the changes outlined in #8379
1 parent 02e5035 commit 4b98349

File tree

7 files changed

+150
-148
lines changed

7 files changed

+150
-148
lines changed

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

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { expect } from '@playwright/test';
22

33
import { sentryTest } from '../../../../utils/fixtures';
4-
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
4+
import {
5+
getCustomRecordingEvents,
6+
shouldSkipReplayTest,
7+
waitForReplayRequest,
8+
waitForReplayRequests,
9+
} from '../../../../utils/replayHelpers';
510

611
sentryTest('captures multi click when not detecting slow click', async ({ getLocalTestUrl, page }) => {
712
if (shouldSkipReplayTest()) {
@@ -58,3 +63,97 @@ sentryTest('captures multi click when not detecting slow click', async ({ getLoc
5863
},
5964
]);
6065
});
66+
67+
sentryTest('captures multiple multi clicks', async ({ getLocalTestUrl, page, forceFlushReplay }) => {
68+
if (shouldSkipReplayTest()) {
69+
sentryTest.skip();
70+
}
71+
72+
const reqPromise0 = waitForReplayRequest(page, 0);
73+
74+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
75+
return route.fulfill({
76+
status: 200,
77+
contentType: 'application/json',
78+
body: JSON.stringify({ id: 'test-id' }),
79+
});
80+
});
81+
82+
const url = await getLocalTestUrl({ testDir: __dirname });
83+
84+
await page.goto(url);
85+
await reqPromise0;
86+
87+
let multiClickBreadcrumbCount = 0;
88+
89+
const reqsPromise = waitForReplayRequests(page, (_event, res) => {
90+
const { breadcrumbs } = getCustomRecordingEvents(res);
91+
const count = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick').length;
92+
93+
multiClickBreadcrumbCount += count;
94+
95+
if (multiClickBreadcrumbCount === 2) {
96+
return true;
97+
}
98+
99+
return false;
100+
});
101+
102+
await page.click('#mutationButtonImmediately', { clickCount: 4 });
103+
await forceFlushReplay();
104+
105+
// Ensure we waited at least 1s, which is the threshold to create a new ui.click breadcrumb
106+
await new Promise(resolve => setTimeout(resolve, 1001));
107+
108+
await page.click('#mutationButtonImmediately', { clickCount: 2 });
109+
await forceFlushReplay();
110+
111+
const responses = await reqsPromise;
112+
113+
const slowClickBreadcrumbs = responses
114+
.flatMap(res => getCustomRecordingEvents(res).breadcrumbs)
115+
.filter(breadcrumb => breadcrumb.category === 'ui.multiClick');
116+
117+
expect(slowClickBreadcrumbs).toEqual([
118+
{
119+
category: 'ui.multiClick',
120+
type: 'default',
121+
data: {
122+
clickCount: 6,
123+
metric: true,
124+
node: {
125+
attributes: {
126+
id: 'mutationButtonImmediately',
127+
},
128+
id: expect.any(Number),
129+
tagName: 'button',
130+
textContent: '******* ******** ***********',
131+
},
132+
nodeId: expect.any(Number),
133+
url: 'http://sentry-test.io/index.html',
134+
},
135+
message: 'body > button#mutationButtonImmediately',
136+
timestamp: expect.any(Number),
137+
},
138+
{
139+
category: 'ui.multiClick',
140+
type: 'default',
141+
data: {
142+
clickCount: 2,
143+
metric: true,
144+
node: {
145+
attributes: {
146+
id: 'mutationButtonImmediately',
147+
},
148+
id: expect.any(Number),
149+
tagName: 'button',
150+
textContent: '******* ******** ***********',
151+
},
152+
nodeId: expect.any(Number),
153+
url: 'http://sentry-test.io/index.html',
154+
},
155+
message: 'body > button#mutationButtonImmediately',
156+
timestamp: expect.any(Number),
157+
},
158+
]);
159+
});

packages/browser-integration-tests/utils/replayHelpers.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,46 @@ export function waitForReplayRequest(
101101
);
102102
}
103103

104+
/**
105+
* Wait until a callback returns true, collecting all replay responses along the way.
106+
* This can be useful when you don't know if stuff will be in one or multiple replay requests.
107+
*/
108+
export function waitForReplayRequests(
109+
page: Page,
110+
callback: (event: ReplayEvent, res: Response) => boolean,
111+
timeout?: number,
112+
): Promise<Response[]> {
113+
const responses: Response[] = [];
114+
115+
return new Promise<Response[]>(resolve => {
116+
void page.waitForResponse(
117+
res => {
118+
const req = res.request();
119+
120+
const event = getReplayEventFromRequest(req);
121+
122+
if (!event) {
123+
return false;
124+
}
125+
126+
responses.push(res);
127+
128+
try {
129+
if (callback(event, res)) {
130+
resolve(responses);
131+
return true;
132+
}
133+
134+
return false;
135+
} catch {
136+
return false;
137+
}
138+
},
139+
timeout ? { timeout } : undefined,
140+
);
141+
});
142+
}
143+
104144
export function isReplayEvent(event: Event): event is ReplayEvent {
105145
return event.type === 'replay_event';
106146
}

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
@@ -485,5 +485,4 @@ export interface SlowClickConfig {
485485
timeout: number;
486486
scrollTimeout: number;
487487
ignoreSelector: string;
488-
multiClickTimeout: number;
489488
}

0 commit comments

Comments
 (0)