diff --git a/packages/browser-integration-tests/suites/replay/slowClick/disable/init.js b/packages/browser-integration-tests/suites/replay/slowClick/disable/init.js
new file mode 100644
index 000000000000..28bb6ed8778e
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/slowClick/disable/init.js
@@ -0,0 +1,17 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window.Replay = new Sentry.Replay({
+ flushMinDelay: 500,
+ flushMaxDelay: 500,
+ slowClickTimeout: 0,
+});
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ sampleRate: 0,
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+
+ integrations: [window.Replay],
+});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts
new file mode 100644
index 000000000000..1a88d992714e
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts
@@ -0,0 +1,55 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../../utils/fixtures';
+import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
+
+sentryTest('does not capture slow click when slowClickTimeout === 0', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const reqPromise0 = waitForReplayRequest(page, 0);
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+ await reqPromise0;
+
+ const reqPromise1 = waitForReplayRequest(page, (event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
+
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
+ });
+
+ await page.click('#mutationButton');
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ expect(breadcrumbs).toEqual([
+ {
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mutationButton',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ********',
+ },
+ nodeId: expect.any(Number),
+ },
+ message: 'body > button#mutationButton',
+ timestamp: expect.any(Number),
+ type: 'default',
+ },
+ ]);
+});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts
index c3cdb6e35c65..15e891b22e52 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts
@@ -54,3 +54,54 @@ sentryTest('click is ignored on ignoreSelectors', async ({ getLocalTestUrl, page
},
]);
});
+
+sentryTest('click is ignored on div', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const reqPromise0 = waitForReplayRequest(page, 0);
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+ await reqPromise0;
+
+ const reqPromise1 = waitForReplayRequest(page, (event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
+
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
+ });
+
+ await page.click('#mutationDiv');
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ expect(breadcrumbs).toEqual([
+ {
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mutationDiv',
+ },
+ id: expect.any(Number),
+ tagName: 'div',
+ textContent: '******* ********',
+ },
+ nodeId: expect.any(Number),
+ },
+ message: 'body > div#mutationDiv',
+ timestamp: expect.any(Number),
+ type: 'default',
+ },
+ ]);
+});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/init.js b/packages/browser-integration-tests/suites/replay/slowClick/init.js
index 2fc5dab81aea..1699d299530e 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/init.js
+++ b/packages/browser-integration-tests/suites/replay/slowClick/init.js
@@ -4,14 +4,8 @@ window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
- _experiments: {
- slowClicks: {
- threshold: 300,
- scrollThreshold: 300,
- timeout: 2000,
- ignoreSelectors: ['.ignore-class', '[ignore-attribute]'],
- },
- },
+ slowClickTimeout: 3100,
+ slowClickIgnoreSelectors: ['.ignore-class', '[ignore-attribute]'],
});
Sentry.init({
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
index d7b5800f9eea..8a169a982aa8 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
@@ -59,8 +59,8 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
},
]);
- expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(300);
- expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(2000);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
});
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
},
]);
});
-
-sentryTest('click is not ignored on div', async ({ getLocalTestUrl, page }) => {
- if (shouldSkipReplayTest()) {
- sentryTest.skip();
- }
-
- const reqPromise0 = waitForReplayRequest(page, 0);
-
- await page.route('https://dsn.ingest.sentry.io/**/*', route => {
- return route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ id: 'test-id' }),
- });
- });
-
- const url = await getLocalTestUrl({ testDir: __dirname });
-
- await page.goto(url);
- await reqPromise0;
-
- const reqPromise1 = waitForReplayRequest(page, (event, res) => {
- const { breadcrumbs } = getCustomRecordingEvents(res);
-
- return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
- });
-
- await page.click('#mutationDiv');
-
- const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
-
- expect(breadcrumbs.filter(({ category }) => category === 'ui.slowClickDetected')).toEqual([
- {
- category: 'ui.slowClickDetected',
- data: {
- endReason: 'mutation',
- node: {
- attributes: {
- id: 'mutationDiv',
- },
- id: expect.any(Number),
- tagName: 'div',
- textContent: '******* ********',
- },
- nodeId: expect.any(Number),
- timeAfterClickMs: expect.any(Number),
- url: 'http://sentry-test.io/index.html',
- },
- message: 'body > div#mutationDiv',
- timestamp: expect.any(Number),
- },
- ]);
-});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/template.html b/packages/browser-integration-tests/suites/replay/slowClick/template.html
index 07e12cc088f3..1cf757f7b974 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/template.html
+++ b/packages/browser-integration-tests/suites/replay/slowClick/template.html
@@ -36,22 +36,22 @@
Bottom
document.getElementById('mutationButton').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationButton clicked
';
- }, 400);
+ }, 3001);
});
document.getElementById('mutationIgnoreButton').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationIgnoreButton clicked
';
- }, 400);
+ }, 3001);
});
document.getElementById('mutationDiv').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationDiv clicked
';
- }, 400);
+ }, 3001);
});
document.getElementById('mutationButtonLate').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('out').innerHTML += 'mutationButtonLate clicked
';
- }, 3000);
+ }, 3101);
});
document.getElementById('mutationButtonImmediately').addEventListener('click', () => {
document.getElementById('out').innerHTML += 'mutationButtonImmediately clicked
';
@@ -62,12 +62,12 @@ Bottom
document.getElementById('scrollLateButton').addEventListener('click', () => {
setTimeout(() => {
document.getElementById('h2').scrollIntoView({ behavior: 'smooth' });
- }, 400);
+ }, 3001);
});
document.getElementById('consoleLogButton').addEventListener('click', () => {
setTimeout(() => {
console.log('DONE');
- }, 400);
+ }, 3001);
});
// Do nothing on these elements
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
index e3fba57cd2b9..fef742681614 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
@@ -49,7 +49,7 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest
textContent: '******* ******** ****',
},
nodeId: expect.any(Number),
- timeAfterClickMs: 2000,
+ timeAfterClickMs: 3100,
url: 'http://sentry-test.io/index.html',
},
message: 'body > button#mutationButtonLate',
@@ -104,7 +104,7 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page }
textContent: '******* ******* ***',
},
nodeId: expect.any(Number),
- timeAfterClickMs: 2000,
+ timeAfterClickMs: 3100,
url: 'http://sentry-test.io/index.html',
},
message: 'body > button#consoleLogButton',
diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts
index f9b452d3f04f..1801c34a4e8e 100644
--- a/packages/replay/src/constants.ts
+++ b/packages/replay/src/constants.ts
@@ -37,3 +37,8 @@ export const NETWORK_BODY_MAX_SIZE = 150_000;
/* The max size of a single console arg that is captured. Any arg larger than this will be truncated. */
export const CONSOLE_ARG_MAX_SIZE = 5_000;
+
+/* Min. time to wait before we consider something a slow click. */
+export const SLOW_CLICK_THRESHOLD = 3_000;
+/* For scroll actions after a click, we only look for a very short time period to detect programmatic scrolling. */
+export const SLOW_CLICK_SCROLL_TIMEOUT = 300;
diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts
index 1e46a18864d5..54ab7ec8bb09 100644
--- a/packages/replay/src/coreHandlers/handleDom.ts
+++ b/packages/replay/src/coreHandlers/handleDom.ts
@@ -3,6 +3,7 @@ import { NodeType } from '@sentry-internal/rrweb-snapshot';
import type { Breadcrumb } from '@sentry/types';
import { htmlTreeAsString } from '@sentry/utils';
+import { SLOW_CLICK_SCROLL_TIMEOUT, SLOW_CLICK_THRESHOLD } from '../constants';
import type { ReplayContainer, SlowClickConfig } from '../types';
import { createBreadcrumb } from '../util/createBreadcrumb';
import { detectSlowClick } from './handleSlowClick';
@@ -17,14 +18,14 @@ export interface DomHandlerData {
export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void = (
replay: ReplayContainer,
) => {
- const slowClickExperiment = replay.getOptions()._experiments.slowClicks;
+ const { slowClickTimeout, slowClickIgnoreSelectors } = replay.getOptions();
- const slowClickConfig: SlowClickConfig | undefined = slowClickExperiment
+ const slowClickConfig: SlowClickConfig | undefined = slowClickTimeout
? {
- threshold: slowClickExperiment.threshold,
- timeout: slowClickExperiment.timeout,
- scrollTimeout: slowClickExperiment.scrollTimeout,
- ignoreSelector: slowClickExperiment.ignoreSelectors ? slowClickExperiment.ignoreSelectors.join(',') : '',
+ threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout),
+ timeout: slowClickTimeout,
+ scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT,
+ ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '',
}
: undefined;
diff --git a/packages/replay/src/coreHandlers/handleSlowClick.ts b/packages/replay/src/coreHandlers/handleSlowClick.ts
index c8a867209ffd..c939a990f87a 100644
--- a/packages/replay/src/coreHandlers/handleSlowClick.ts
+++ b/packages/replay/src/coreHandlers/handleSlowClick.ts
@@ -114,15 +114,16 @@ function handleSlowClick(
addBreadcrumbEvent(replay, breadcrumb);
}
-const SLOW_CLICK_IGNORE_TAGS = ['SELECT', 'OPTION'];
+const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT'];
-function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean {
- // If tag, we only want to consider input[type='submit'] & input[type='button']
- if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) {
+/** exported for tests only */
+export function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean {
+ if (!SLOW_CLICK_TAGS.includes(node.tagName)) {
return true;
}
- if (SLOW_CLICK_IGNORE_TAGS.includes(node.tagName)) {
+ // If tag, we only want to consider input[type='submit'] & input[type='button']
+ if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) {
return true;
}
diff --git a/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts b/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts
index 7168a3243add..13c756901028 100644
--- a/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts
+++ b/packages/replay/src/coreHandlers/util/getAttributesToRecord.ts
@@ -10,6 +10,8 @@ const ATTRIBUTES_TO_RECORD = new Set([
'title',
'data-test-id',
'data-testid',
+ 'disabled',
+ 'aria-disabled',
]);
/**
diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts
index 0a8813c14d38..2baf117b5c38 100644
--- a/packages/replay/src/integration.ts
+++ b/packages/replay/src/integration.ts
@@ -63,6 +63,9 @@ export class Replay implements Integration {
mutationBreadcrumbLimit = 750,
mutationLimit = 10_000,
+ slowClickTimeout = 7_000,
+ slowClickIgnoreSelectors = [],
+
networkDetailAllowUrls = [],
networkCaptureBodies = true,
networkRequestHeaders = [],
@@ -132,6 +135,8 @@ export class Replay implements Integration {
maskAllText,
mutationBreadcrumbLimit,
mutationLimit,
+ slowClickTimeout,
+ slowClickIgnoreSelectors,
networkDetailAllowUrls,
networkCaptureBodies,
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts
index 262dcbf65a88..1fca83e06c17 100644
--- a/packages/replay/src/types/replay.ts
+++ b/packages/replay/src/types/replay.ts
@@ -155,6 +155,20 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
*/
mutationLimit: number;
+ /**
+ * The max. time in ms to wait for a slow click to finish.
+ * After this amount of time we stop waiting for actions after a click happened.
+ * Set this to 0 to disable slow click capture.
+ *
+ * Default: 7000ms
+ */
+ slowClickTimeout: number;
+
+ /**
+ * Ignore clicks on elements matching the given selectors for slow click detection.
+ */
+ slowClickIgnoreSelectors: string[];
+
/**
* Callback before adding a custom recording event
*
@@ -178,12 +192,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
_experiments: Partial<{
captureExceptions: boolean;
traceInternals: boolean;
- slowClicks: {
- threshold: number;
- timeout: number;
- scrollTimeout: number;
- ignoreSelectors: string[];
- };
delayFlushOnCheckout: number;
}>;
}
diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
index 0f9db6554ec9..b0f2211ed7e2 100644
--- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
+++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
@@ -5,6 +5,7 @@ import * as SentryUtils from '@sentry/utils';
import type { Replay } from '../../src';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
+import { EventType } from '../../src/types';
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
import { useFakeTimers } from '../utils/use-fake-timers';
@@ -53,8 +54,8 @@ describe('Integration | beforeAddRecordingEvent', () => {
}
// This should not do anything because callback should not be called
- // for `event.type != 5`
- if (event.type === 2) {
+ // for `event.type != 5` - but we guard anyhow to be safe
+ if ((event.type as EventType) === EventType.FullSnapshot) {
return null;
}
diff --git a/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts b/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts
new file mode 100644
index 000000000000..2d0922272115
--- /dev/null
+++ b/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts
@@ -0,0 +1,34 @@
+import { ignoreElement } from '../../../src/coreHandlers/handleSlowClick';
+import type { SlowClickConfig } from '../../../src/types';
+
+describe('Unit | coreHandlers | handleSlowClick', () => {
+ describe('ignoreElement', () => {
+ it.each([
+ ['div', {}, true],
+ ['button', {}, false],
+ ['a', {}, false],
+ ['input', {}, true],
+ ['input', { type: 'text' }, true],
+ ['input', { type: 'button' }, false],
+ ['input', { type: 'submit' }, false],
+ ['a', { target: '_self' }, false],
+ ['a', { target: '_blank' }, true],
+ ['a', { download: '' }, true],
+ ['a', { href: 'xx' }, false],
+ ])('it works with <%s> & %p', (tagName, attributes, expected) => {
+ const node = document.createElement(tagName);
+ Object.entries(attributes).forEach(([key, value]) => {
+ node.setAttribute(key, value);
+ });
+ expect(ignoreElement(node, {} as SlowClickConfig)).toBe(expected);
+ });
+
+ test('it ignored selectors matching ignoreSelector', () => {
+ const button = document.createElement('button');
+ const a = document.createElement('a');
+
+ expect(ignoreElement(button, { ignoreSelector: 'button' } as SlowClickConfig)).toBe(true);
+ expect(ignoreElement(a, { ignoreSelector: 'button' } as SlowClickConfig)).toBe(false);
+ });
+ });
+});
diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts
index c7c302cce72b..0237afddb538 100644
--- a/packages/replay/test/utils/setupReplayContainer.ts
+++ b/packages/replay/test/utils/setupReplayContainer.ts
@@ -17,6 +17,8 @@ const DEFAULT_OPTIONS = {
networkResponseHeaders: [],
mutationLimit: 1500,
mutationBreadcrumbLimit: 500,
+ slowClickTimeout: 7_000,
+ slowClickIgnoreSelectors: [],
_experiments: {},
};