Skip to content

Commit da2fb29

Browse files
committed
feat(replay): Capture slow clicks (GA)
This moves the slow click detection out of GA and makes it generally available. You can opt-out of this by setting `slowClickTimeout: 0`.
1 parent 8ffde2a commit da2fb29

File tree

16 files changed

+213
-90
lines changed

16 files changed

+213
-90
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 500,
6+
flushMaxDelay: 500,
7+
slowClickTimeout: 0,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 0,
13+
replaysSessionSampleRate: 1.0,
14+
replaysOnErrorSampleRate: 0.0,
15+
16+
integrations: [window.Replay],
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('does not capture slow click when slowClickTimeout === 0', 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.click');
30+
});
31+
32+
await page.click('#mutationButton');
33+
34+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
35+
36+
expect(breadcrumbs).toEqual([
37+
{
38+
category: 'ui.click',
39+
data: {
40+
node: {
41+
attributes: {
42+
id: 'mutationButton',
43+
},
44+
id: expect.any(Number),
45+
tagName: 'button',
46+
textContent: '******* ********',
47+
},
48+
nodeId: expect.any(Number),
49+
},
50+
message: 'body > button#mutationButton',
51+
timestamp: expect.any(Number),
52+
type: 'default',
53+
},
54+
]);
55+
});

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

+51
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,54 @@ sentryTest('click is ignored on ignoreSelectors', async ({ getLocalTestUrl, page
5454
},
5555
]);
5656
});
57+
58+
sentryTest('click is ignored on div', async ({ getLocalTestUrl, page }) => {
59+
if (shouldSkipReplayTest()) {
60+
sentryTest.skip();
61+
}
62+
63+
const reqPromise0 = waitForReplayRequest(page, 0);
64+
65+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
66+
return route.fulfill({
67+
status: 200,
68+
contentType: 'application/json',
69+
body: JSON.stringify({ id: 'test-id' }),
70+
});
71+
});
72+
73+
const url = await getLocalTestUrl({ testDir: __dirname });
74+
75+
await page.goto(url);
76+
await reqPromise0;
77+
78+
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
79+
const { breadcrumbs } = getCustomRecordingEvents(res);
80+
81+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
82+
});
83+
84+
await page.click('#mutationDiv');
85+
86+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
87+
88+
expect(breadcrumbs).toEqual([
89+
{
90+
category: 'ui.click',
91+
data: {
92+
node: {
93+
attributes: {
94+
id: 'mutationDiv',
95+
},
96+
id: expect.any(Number),
97+
tagName: 'div',
98+
textContent: '******* ********',
99+
},
100+
nodeId: expect.any(Number),
101+
},
102+
message: 'body > div#mutationDiv',
103+
timestamp: expect.any(Number),
104+
type: 'default',
105+
},
106+
]);
107+
});

packages/browser-integration-tests/suites/replay/slowClick/init.js

+2-8
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,8 @@ window.Sentry = Sentry;
44
window.Replay = new Sentry.Replay({
55
flushMinDelay: 500,
66
flushMaxDelay: 500,
7-
_experiments: {
8-
slowClicks: {
9-
threshold: 300,
10-
scrollThreshold: 300,
11-
timeout: 2000,
12-
ignoreSelectors: ['.ignore-class', '[ignore-attribute]'],
13-
},
14-
},
7+
slowClickTimeout: 3100,
8+
slowClickIgnoreSelectors: ['.ignore-class', '[ignore-attribute]'],
159
});
1610

1711
Sentry.init({

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

+2-55
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
5959
},
6060
]);
6161

62-
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(300);
63-
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(2000);
62+
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
63+
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
6464
});
6565

6666
sentryTest('immediate mutation does not trigger slow click', async ({ browserName, getLocalTestUrl, page }) => {
@@ -165,56 +165,3 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal
165165
},
166166
]);
167167
});
168-
169-
sentryTest('click is not ignored on div', async ({ getLocalTestUrl, page }) => {
170-
if (shouldSkipReplayTest()) {
171-
sentryTest.skip();
172-
}
173-
174-
const reqPromise0 = waitForReplayRequest(page, 0);
175-
176-
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
177-
return route.fulfill({
178-
status: 200,
179-
contentType: 'application/json',
180-
body: JSON.stringify({ id: 'test-id' }),
181-
});
182-
});
183-
184-
const url = await getLocalTestUrl({ testDir: __dirname });
185-
186-
await page.goto(url);
187-
await reqPromise0;
188-
189-
const reqPromise1 = waitForReplayRequest(page, (event, res) => {
190-
const { breadcrumbs } = getCustomRecordingEvents(res);
191-
192-
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
193-
});
194-
195-
await page.click('#mutationDiv');
196-
197-
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
198-
199-
expect(breadcrumbs.filter(({ category }) => category === 'ui.slowClickDetected')).toEqual([
200-
{
201-
category: 'ui.slowClickDetected',
202-
data: {
203-
endReason: 'mutation',
204-
node: {
205-
attributes: {
206-
id: 'mutationDiv',
207-
},
208-
id: expect.any(Number),
209-
tagName: 'div',
210-
textContent: '******* ********',
211-
},
212-
nodeId: expect.any(Number),
213-
timeAfterClickMs: expect.any(Number),
214-
url: 'http://sentry-test.io/index.html',
215-
},
216-
message: 'body > div#mutationDiv',
217-
timestamp: expect.any(Number),
218-
},
219-
]);
220-
});

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

+6-6
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,22 @@ <h1 id="h2">Bottom</h1>
3636
document.getElementById('mutationButton').addEventListener('click', () => {
3737
setTimeout(() => {
3838
document.getElementById('out').innerHTML += 'mutationButton clicked<br>';
39-
}, 400);
39+
}, 3001);
4040
});
4141
document.getElementById('mutationIgnoreButton').addEventListener('click', () => {
4242
setTimeout(() => {
4343
document.getElementById('out').innerHTML += 'mutationIgnoreButton clicked<br>';
44-
}, 400);
44+
}, 3001);
4545
});
4646
document.getElementById('mutationDiv').addEventListener('click', () => {
4747
setTimeout(() => {
4848
document.getElementById('out').innerHTML += 'mutationDiv clicked<br>';
49-
}, 400);
49+
}, 3001);
5050
});
5151
document.getElementById('mutationButtonLate').addEventListener('click', () => {
5252
setTimeout(() => {
5353
document.getElementById('out').innerHTML += 'mutationButtonLate clicked<br>';
54-
}, 3000);
54+
}, 3101);
5555
});
5656
document.getElementById('mutationButtonImmediately').addEventListener('click', () => {
5757
document.getElementById('out').innerHTML += 'mutationButtonImmediately clicked<br>';
@@ -62,12 +62,12 @@ <h1 id="h2">Bottom</h1>
6262
document.getElementById('scrollLateButton').addEventListener('click', () => {
6363
setTimeout(() => {
6464
document.getElementById('h2').scrollIntoView({ behavior: 'smooth' });
65-
}, 400);
65+
}, 3001);
6666
});
6767
document.getElementById('consoleLogButton').addEventListener('click', () => {
6868
setTimeout(() => {
6969
console.log('DONE');
70-
}, 400);
70+
}, 3001);
7171
});
7272

7373
// Do nothing on these elements

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest
4949
textContent: '******* ******** ****',
5050
},
5151
nodeId: expect.any(Number),
52-
timeAfterClickMs: 2000,
52+
timeAfterClickMs: 3100,
5353
url: 'http://sentry-test.io/index.html',
5454
},
5555
message: 'body > button#mutationButtonLate',
@@ -104,7 +104,7 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page }
104104
textContent: '******* ******* ***',
105105
},
106106
nodeId: expect.any(Number),
107-
timeAfterClickMs: 2000,
107+
timeAfterClickMs: 3100,
108108
url: 'http://sentry-test.io/index.html',
109109
},
110110
message: 'body > button#consoleLogButton',

packages/replay/src/constants.ts

+5
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ export const NETWORK_BODY_MAX_SIZE = 150_000;
3737

3838
/* The max size of a single console arg that is captured. Any arg larger than this will be truncated. */
3939
export const CONSOLE_ARG_MAX_SIZE = 5_000;
40+
41+
/* Min. time to wait before we consider something a slow click. */
42+
export const SLOW_CLICK_THRESHOLD = 3_000;
43+
/* For scroll actions after a click, we only look for a very short time period to detect programmatic scrolling. */
44+
export const SLOW_CLICK_SCROLL_TIMEOUT = 300;

packages/replay/src/coreHandlers/handleDom.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NodeType } from '@sentry-internal/rrweb-snapshot';
33
import type { Breadcrumb } from '@sentry/types';
44
import { htmlTreeAsString } from '@sentry/utils';
55

6+
import { SLOW_CLICK_SCROLL_TIMEOUT, SLOW_CLICK_THRESHOLD } from '../constants';
67
import type { ReplayContainer, SlowClickConfig } from '../types';
78
import { createBreadcrumb } from '../util/createBreadcrumb';
89
import { detectSlowClick } from './handleSlowClick';
@@ -17,14 +18,14 @@ export interface DomHandlerData {
1718
export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void = (
1819
replay: ReplayContainer,
1920
) => {
20-
const slowClickExperiment = replay.getOptions()._experiments.slowClicks;
21+
const { slowClickTimeout, slowClickIgnoreSelectors } = replay.getOptions();
2122

22-
const slowClickConfig: SlowClickConfig | undefined = slowClickExperiment
23+
const slowClickConfig: SlowClickConfig | undefined = slowClickTimeout
2324
? {
24-
threshold: slowClickExperiment.threshold,
25-
timeout: slowClickExperiment.timeout,
26-
scrollTimeout: slowClickExperiment.scrollTimeout,
27-
ignoreSelector: slowClickExperiment.ignoreSelectors ? slowClickExperiment.ignoreSelectors.join(',') : '',
25+
threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout),
26+
timeout: slowClickTimeout,
27+
scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT,
28+
ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '',
2829
}
2930
: undefined;
3031

packages/replay/src/coreHandlers/handleSlowClick.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,16 @@ function handleSlowClick(
114114
addBreadcrumbEvent(replay, breadcrumb);
115115
}
116116

117-
const SLOW_CLICK_IGNORE_TAGS = ['SELECT', 'OPTION'];
117+
const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT'];
118118

119-
function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean {
120-
// If <input> tag, we only want to consider input[type='submit'] & input[type='button']
121-
if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) {
119+
/** exported for tests only */
120+
export function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean {
121+
if (!SLOW_CLICK_TAGS.includes(node.tagName)) {
122122
return true;
123123
}
124124

125-
if (SLOW_CLICK_IGNORE_TAGS.includes(node.tagName)) {
125+
// If <input> tag, we only want to consider input[type='submit'] & input[type='button']
126+
if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) {
126127
return true;
127128
}
128129

packages/replay/src/coreHandlers/util/getAttributesToRecord.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const ATTRIBUTES_TO_RECORD = new Set([
1010
'title',
1111
'data-test-id',
1212
'data-testid',
13+
'disabled',
14+
'aria-disabled',
1315
]);
1416

1517
/**

packages/replay/src/integration.ts

+5
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export class Replay implements Integration {
6363
mutationBreadcrumbLimit = 750,
6464
mutationLimit = 10_000,
6565

66+
slowClickTimeout = 7_000,
67+
slowClickIgnoreSelectors = [],
68+
6669
networkDetailAllowUrls = [],
6770
networkCaptureBodies = true,
6871
networkRequestHeaders = [],
@@ -132,6 +135,8 @@ export class Replay implements Integration {
132135
maskAllText,
133136
mutationBreadcrumbLimit,
134137
mutationLimit,
138+
slowClickTimeout,
139+
slowClickIgnoreSelectors,
135140
networkDetailAllowUrls,
136141
networkCaptureBodies,
137142
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),

packages/replay/src/types/replay.ts

+14-6
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,20 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
155155
*/
156156
mutationLimit: number;
157157

158+
/**
159+
* The max. time in ms to wait for a slow click to finish.
160+
* After this amount of time we stop waiting for actions after a click happened.
161+
* Set this to 0 to disable slow click capture.
162+
*
163+
* Default: 7000ms
164+
*/
165+
slowClickTimeout: number;
166+
167+
/**
168+
* Ignore clicks on elements matching the given selectors for slow click detection.
169+
*/
170+
slowClickIgnoreSelectors: string[];
171+
158172
/**
159173
* Callback before adding a custom recording event
160174
*
@@ -178,12 +192,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
178192
_experiments: Partial<{
179193
captureExceptions: boolean;
180194
traceInternals: boolean;
181-
slowClicks: {
182-
threshold: number;
183-
timeout: number;
184-
scrollTimeout: number;
185-
ignoreSelectors: string[];
186-
};
187195
delayFlushOnCheckout: number;
188196
}>;
189197
}

0 commit comments

Comments
 (0)