Skip to content

Commit 285d82b

Browse files
committed
feat(replay): Rework slow click & rage click detection
1 parent 2868626 commit 285d82b

File tree

16 files changed

+1107
-234
lines changed

16 files changed

+1107
-234
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest }
6767
category: 'ui.slowClickDetected',
6868
data: {
6969
endReason: 'timeout',
70+
clickCount: 1,
7071
node: {
7172
attributes: expect.objectContaining({
7273
id,

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

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,81 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
2929
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
3030
});
3131

32-
// Trigger this twice, sometimes this was flaky otherwise...
32+
await page.click('#mutationButton');
33+
34+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
35+
36+
const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
37+
38+
expect(slowClickBreadcrumbs).toEqual([
39+
{
40+
category: 'ui.slowClickDetected',
41+
data: {
42+
endReason: 'mutation',
43+
clickCount: 1,
44+
node: {
45+
attributes: {
46+
id: 'mutationButton',
47+
},
48+
id: expect.any(Number),
49+
tagName: 'button',
50+
textContent: '******* ********',
51+
},
52+
nodeId: expect.any(Number),
53+
timeAfterClickMs: expect.any(Number),
54+
url: 'http://sentry-test.io/index.html',
55+
},
56+
message: 'body > button#mutationButton',
57+
timestamp: expect.any(Number),
58+
},
59+
]);
60+
61+
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
62+
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
63+
});
64+
65+
sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => {
66+
if (shouldSkipReplayTest()) {
67+
sentryTest.skip();
68+
}
69+
70+
const reqPromise0 = waitForReplayRequest(page, 0);
71+
72+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
73+
return route.fulfill({
74+
status: 200,
75+
contentType: 'application/json',
76+
body: JSON.stringify({ id: 'test-id' }),
77+
});
78+
});
79+
80+
const url = await getLocalTestUrl({ testDir: __dirname });
81+
82+
await page.goto(url);
83+
await reqPromise0;
84+
85+
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
86+
const { breadcrumbs } = getCustomRecordingEvents(res);
87+
88+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
89+
});
90+
91+
await page.click('#mutationButton');
92+
await page.click('#mutationButton');
3393
await page.click('#mutationButton');
3494
await page.click('#mutationButton');
3595

3696
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
3797

3898
const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
99+
const rageClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.rageClickDetected');
39100

40101
expect(slowClickBreadcrumbs).toEqual([
41102
{
42103
category: 'ui.slowClickDetected',
43104
data: {
44105
endReason: 'mutation',
106+
clickCount: 4,
45107
node: {
46108
attributes: {
47109
id: 'mutationButton',
@@ -58,6 +120,7 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
58120
timestamp: expect.any(Number),
59121
},
60122
]);
123+
expect(rageClickBreadcrumbs.length).toEqual(0);
61124

62125
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
63126
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
@@ -165,3 +228,55 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal
165228
},
166229
]);
167230
});
231+
232+
sentryTest('mouseDown events are considered', async ({ browserName, getLocalTestUrl, page }) => {
233+
// This test seems to only be flakey on firefox
234+
if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) {
235+
sentryTest.skip();
236+
}
237+
238+
const reqPromise0 = waitForReplayRequest(page, 0);
239+
240+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
241+
return route.fulfill({
242+
status: 200,
243+
contentType: 'application/json',
244+
body: JSON.stringify({ id: 'test-id' }),
245+
});
246+
});
247+
248+
const url = await getLocalTestUrl({ testDir: __dirname });
249+
250+
await page.goto(url);
251+
await reqPromise0;
252+
253+
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
254+
const { breadcrumbs } = getCustomRecordingEvents(res);
255+
256+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
257+
});
258+
259+
await page.click('#mouseDownButton');
260+
261+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
262+
263+
expect(breadcrumbs).toEqual([
264+
{
265+
category: 'ui.click',
266+
data: {
267+
node: {
268+
attributes: {
269+
id: 'mouseDownButton',
270+
},
271+
id: expect.any(Number),
272+
tagName: 'button',
273+
textContent: '******* ******** ** ***** ****',
274+
},
275+
nodeId: expect.any(Number),
276+
},
277+
message: 'body > button#mouseDownButton',
278+
timestamp: expect.any(Number),
279+
type: 'default',
280+
},
281+
]);
282+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('captures rage click when not detecting slow click', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const reqPromise0 = waitForReplayRequest(page, 0);
12+
13+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
14+
return route.fulfill({
15+
status: 200,
16+
contentType: 'application/json',
17+
body: JSON.stringify({ id: 'test-id' }),
18+
});
19+
});
20+
21+
const url = await getLocalTestUrl({ testDir: __dirname });
22+
23+
await page.goto(url);
24+
await reqPromise0;
25+
26+
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
27+
const { breadcrumbs } = getCustomRecordingEvents(res);
28+
29+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.rageClickDetected');
30+
});
31+
32+
await page.click('#mutationButtonImmediately');
33+
await page.click('#mutationButtonImmediately');
34+
await page.click('#mutationButtonImmediately');
35+
await page.click('#mutationButtonImmediately');
36+
37+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
38+
39+
const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.rageClickDetected');
40+
41+
expect(slowClickBreadcrumbs).toEqual([
42+
{
43+
category: 'ui.rageClickDetected',
44+
data: {
45+
clickCount: 4,
46+
metric: true,
47+
node: {
48+
attributes: {
49+
id: 'mutationButtonImmediately',
50+
},
51+
id: expect.any(Number),
52+
tagName: 'button',
53+
textContent: '******* ******** ***********',
54+
},
55+
nodeId: expect.any(Number),
56+
url: 'http://sentry-test.io/index.html',
57+
},
58+
message: 'body > button#mutationButtonImmediately',
59+
timestamp: expect.any(Number),
60+
},
61+
]);
62+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ sentryTest('late scroll triggers slow click', async ({ getLocalTestUrl, page })
9191
category: 'ui.slowClickDetected',
9292
data: {
9393
endReason: 'timeout',
94+
clickCount: 1,
9495
node: {
9596
attributes: {
9697
id: 'scrollLateButton',

packages/browser-integration-tests/suites/replay/slowClick/template.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<button id="scrollButton">Trigger scroll</button>
1919
<button id="scrollLateButton">Trigger scroll late</button>
2020
<button id="mutationIgnoreButton" class="ignore-class">Trigger scroll late</button>
21+
<button id="mouseDownButton">Trigger mutation on mouse down</button>
2122

2223
<a href="#" id="link">Link</a>
2324
<a href="#" target="_blank" id="linkExternal">Link external</a>
@@ -69,6 +70,9 @@ <h1 id="h2">Bottom</h1>
6970
console.log('DONE');
7071
}, 3001);
7172
});
73+
document.getElementById('mouseDownButton').addEventListener('mousedown', () => {
74+
document.getElementById('out').innerHTML += 'mutationButton clicked<br>';
75+
});
7276

7377
// Do nothing on these elements
7478
document

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest
4040
category: 'ui.slowClickDetected',
4141
data: {
4242
endReason: 'timeout',
43+
clickCount: 1,
4344
node: {
4445
attributes: {
4546
id: 'mutationButtonLate',
@@ -95,6 +96,7 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page }
9596
category: 'ui.slowClickDetected',
9697
data: {
9798
endReason: 'timeout',
99+
clickCount: 1,
98100
node: {
99101
attributes: {
100102
id: 'consoleLogButton',

packages/replay/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ 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;

0 commit comments

Comments
 (0)