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: {}, };