diff --git a/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts
index dfa1581d4704..59bfb2ea26e8 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts
@@ -65,8 +65,10 @@ import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest }
expect(slowClickBreadcrumbs).toEqual([
{
category: 'ui.slowClickDetected',
+ type: 'default',
data: {
endReason: 'timeout',
+ clickCount: 1,
node: {
attributes: expect.objectContaining({
id,
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts
new file mode 100644
index 000000000000..2d84eeb29ac3
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts
@@ -0,0 +1,60 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../../utils/fixtures';
+import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
+
+sentryTest('captures multi click when not detecting slow click', 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.multiClick');
+ });
+
+ await page.click('#mutationButtonImmediately', { clickCount: 4 });
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick');
+
+ expect(slowClickBreadcrumbs).toEqual([
+ {
+ category: 'ui.multiClick',
+ type: 'default',
+ data: {
+ clickCount: 4,
+ metric: true,
+ node: {
+ attributes: {
+ id: 'mutationButtonImmediately',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ******** ***********',
+ },
+ nodeId: expect.any(Number),
+ url: 'http://sentry-test.io/index.html',
+ },
+ message: 'body > button#mutationButtonImmediately',
+ timestamp: expect.any(Number),
+ },
+ ]);
+});
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 8a169a982aa8..deb394ebac2d 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
@@ -29,8 +29,6 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
});
- // Trigger this twice, sometimes this was flaky otherwise...
- await page.click('#mutationButton');
await page.click('#mutationButton');
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
@@ -40,8 +38,71 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
expect(slowClickBreadcrumbs).toEqual([
{
category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ endReason: 'mutation',
+ clickCount: 1,
+ node: {
+ attributes: {
+ id: 'mutationButton',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ********',
+ },
+ nodeId: expect.any(Number),
+ timeAfterClickMs: expect.any(Number),
+ url: 'http://sentry-test.io/index.html',
+ },
+ message: 'body > button#mutationButton',
+ timestamp: expect.any(Number),
+ },
+ ]);
+
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
+});
+
+sentryTest('multiple clicks are counted', 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');
+ });
+
+ void page.click('#mutationButton', { clickCount: 4 });
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
+ const multiClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick');
+
+ expect(slowClickBreadcrumbs).toEqual([
+ {
+ category: 'ui.slowClickDetected',
+ type: 'default',
data: {
endReason: 'mutation',
+ clickCount: 4,
node: {
attributes: {
id: 'mutationButton',
@@ -58,6 +119,7 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe
timestamp: expect.any(Number),
},
]);
+ expect(multiClickBreadcrumbs.length).toEqual(0);
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100);
@@ -165,3 +227,55 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal
},
]);
});
+
+sentryTest('mouseDown events are considered', async ({ browserName, getLocalTestUrl, page }) => {
+ // This test seems to only be flakey on firefox
+ if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) {
+ 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('#mouseDownButton');
+
+ const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
+
+ expect(breadcrumbs).toEqual([
+ {
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mouseDownButton',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ******** ** ***** ****',
+ },
+ nodeId: expect.any(Number),
+ },
+ message: 'body > button#mouseDownButton',
+ timestamp: expect.any(Number),
+ type: 'default',
+ },
+ ]);
+});
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts
index f7f705ce5670..a8e59752fc4a 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts
@@ -89,8 +89,10 @@ sentryTest('late scroll triggers slow click', async ({ getLocalTestUrl, page })
expect(slowClickBreadcrumbs).toEqual([
{
category: 'ui.slowClickDetected',
+ type: 'default',
data: {
endReason: 'timeout',
+ clickCount: 1,
node: {
attributes: {
id: 'scrollLateButton',
diff --git a/packages/browser-integration-tests/suites/replay/slowClick/template.html b/packages/browser-integration-tests/suites/replay/slowClick/template.html
index 1cf757f7b974..f49c8b1d410d 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/template.html
+++ b/packages/browser-integration-tests/suites/replay/slowClick/template.html
@@ -18,6 +18,7 @@
+
Link
Link external
@@ -69,6 +70,9 @@
Bottom
console.log('DONE');
}, 3001);
});
+ document.getElementById('mouseDownButton').addEventListener('mousedown', () => {
+ document.getElementById('out').innerHTML += 'mutationButton clicked
';
+ });
// Do nothing on these elements
document
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 fef742681614..7e94e0b68f15 100644
--- a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
+++ b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts
@@ -38,8 +38,10 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest
expect(slowClickBreadcrumbs).toEqual([
{
category: 'ui.slowClickDetected',
+ type: 'default',
data: {
endReason: 'timeout',
+ clickCount: 1,
node: {
attributes: {
id: 'mutationButtonLate',
@@ -93,8 +95,10 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page }
expect(slowClickBreadcrumbs).toEqual([
{
category: 'ui.slowClickDetected',
+ type: 'default',
data: {
endReason: 'timeout',
+ clickCount: 1,
node: {
attributes: {
id: 'consoleLogButton',
diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts
index 1801c34a4e8e..120079ebb857 100644
--- a/packages/replay/src/constants.ts
+++ b/packages/replay/src/constants.ts
@@ -42,3 +42,5 @@ export const CONSOLE_ARG_MAX_SIZE = 5_000;
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;
+/* Clicks in this time period are considered e.g. double/triple clicks. */
+export const MULTI_CLICK_TIMEOUT = 1_000;
diff --git a/packages/replay/src/coreHandlers/handleClick.ts b/packages/replay/src/coreHandlers/handleClick.ts
new file mode 100644
index 000000000000..5a75413b00d1
--- /dev/null
+++ b/packages/replay/src/coreHandlers/handleClick.ts
@@ -0,0 +1,306 @@
+import type { Breadcrumb } from '@sentry/types';
+
+import { WINDOW } from '../constants';
+import type { MultiClickFrame, ReplayClickDetector, ReplayContainer, SlowClickConfig, SlowClickFrame } from '../types';
+import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
+import { getClickTargetNode } from './util/domUtils';
+
+type ClickBreadcrumb = Breadcrumb & {
+ timestamp: number;
+};
+
+interface Click {
+ timestamp: number;
+ mutationAfter?: number;
+ scrollAfter?: number;
+ clickBreadcrumb: ClickBreadcrumb;
+ clickCount: number;
+ node: HTMLElement;
+}
+
+/** Handle a click. */
+export function handleClick(clickDetector: ReplayClickDetector, clickBreadcrumb: Breadcrumb, node: HTMLElement): void {
+ clickDetector.handleClick(clickBreadcrumb, node);
+}
+
+/** A click detector class that can be used to detect slow or rage clicks on elements. */
+export class ClickDetector implements ReplayClickDetector {
+ // protected for testing
+ protected _lastMutation = 0;
+ protected _lastScroll = 0;
+
+ private _clicks: Click[] = [];
+ private _teardown: undefined | (() => void);
+
+ private _multiClickTimeout: number;
+ private _threshold: number;
+ private _scollTimeout: number;
+ private _timeout: number;
+ private _ignoreSelector: string;
+
+ private _replay: ReplayContainer;
+ private _checkClickTimeout?: ReturnType;
+ private _addBreadcrumbEvent: typeof addBreadcrumbEvent;
+
+ public constructor(
+ replay: ReplayContainer,
+ slowClickConfig: SlowClickConfig,
+ // Just for easier testing
+ _addBreadcrumbEvent = addBreadcrumbEvent,
+ ) {
+ // We want everything in s, but options are in ms
+ this._timeout = slowClickConfig.timeout / 1000;
+ this._multiClickTimeout = slowClickConfig.multiClickTimeout / 1000;
+ this._threshold = slowClickConfig.threshold / 1000;
+ this._scollTimeout = slowClickConfig.scrollTimeout / 1000;
+ this._replay = replay;
+ this._ignoreSelector = slowClickConfig.ignoreSelector;
+ this._addBreadcrumbEvent = _addBreadcrumbEvent;
+ }
+
+ /** Register click detection handlers on mutation or scroll. */
+ public addListeners(): void {
+ const mutationHandler = (): void => {
+ this._lastMutation = nowInSeconds();
+ };
+
+ const scrollHandler = (): void => {
+ this._lastScroll = nowInSeconds();
+ };
+
+ const clickHandler = (event: MouseEvent): void => {
+ if (!event.target) {
+ return;
+ }
+
+ const node = getClickTargetNode(event);
+ if (node) {
+ this._handleMultiClick(node as HTMLElement);
+ }
+ };
+
+ const obs = new MutationObserver(mutationHandler);
+
+ obs.observe(WINDOW.document.documentElement, {
+ attributes: true,
+ characterData: true,
+ childList: true,
+ subtree: true,
+ });
+
+ WINDOW.addEventListener('scroll', scrollHandler, { passive: true });
+ WINDOW.addEventListener('click', clickHandler, { passive: true });
+
+ this._teardown = () => {
+ WINDOW.removeEventListener('scroll', scrollHandler);
+ WINDOW.removeEventListener('click', clickHandler);
+
+ obs.disconnect();
+ this._clicks = [];
+ this._lastMutation = 0;
+ this._lastScroll = 0;
+ };
+ }
+
+ /** Clean up listeners. */
+ public removeListeners(): void {
+ if (this._teardown) {
+ this._teardown();
+ }
+
+ if (this._checkClickTimeout) {
+ clearTimeout(this._checkClickTimeout);
+ }
+ }
+
+ /** Handle a click */
+ public handleClick(breadcrumb: Breadcrumb, node: HTMLElement): void {
+ if (ignoreElement(node, this._ignoreSelector) || !isClickBreadcrumb(breadcrumb)) {
+ return;
+ }
+
+ const click = this._getClick(node);
+
+ if (click) {
+ // this means a click on the same element was captured in the last 1s, so we consider this a multi click
+ return;
+ }
+
+ const newClick: Click = {
+ timestamp: breadcrumb.timestamp,
+ clickBreadcrumb: breadcrumb,
+ // Set this to 0 so we know it originates from the click breadcrumb
+ clickCount: 0,
+ node,
+ };
+ this._clicks.push(newClick);
+
+ // If this is the first new click, set a timeout to check for multi clicks
+ if (this._clicks.length === 1) {
+ this._scheduleCheckClicks();
+ }
+ }
+
+ /** Count multiple clicks on elements. */
+ private _handleMultiClick(node: HTMLElement): void {
+ const click = this._getClick(node);
+
+ if (!click) {
+ return;
+ }
+
+ click.clickCount++;
+ }
+
+ /** Try to get an existing click on the given element. */
+ private _getClick(node: HTMLElement): Click | undefined {
+ const now = nowInSeconds();
+
+ // Find any click on the same element in the last second
+ // If one exists, we consider this click as a double/triple/etc click
+ return this._clicks.find(click => click.node === node && now - click.timestamp < this._multiClickTimeout);
+ }
+
+ /** Check the clicks that happened. */
+ private _checkClicks(): void {
+ const timedOutClicks: Click[] = [];
+
+ const now = nowInSeconds();
+
+ this._clicks.forEach(click => {
+ if (!click.mutationAfter && this._lastMutation) {
+ click.mutationAfter = click.timestamp <= this._lastMutation ? this._lastMutation - click.timestamp : undefined;
+ }
+ if (!click.scrollAfter && this._lastScroll) {
+ click.scrollAfter = click.timestamp <= this._lastScroll ? this._lastScroll - click.timestamp : undefined;
+ }
+
+ // If an action happens after the multi click threshold, we can skip waiting and handle the click right away
+ const actionTime = click.scrollAfter || click.mutationAfter || 0;
+ if (actionTime && actionTime >= this._multiClickTimeout) {
+ timedOutClicks.push(click);
+ return;
+ }
+
+ if (click.timestamp + this._timeout <= now) {
+ timedOutClicks.push(click);
+ }
+ });
+
+ // Remove "old" clicks
+ for (const click of timedOutClicks) {
+ this._generateBreadcrumbs(click);
+
+ const pos = this._clicks.indexOf(click);
+ if (pos !== -1) {
+ this._clicks.splice(pos, 1);
+ }
+ }
+
+ // Trigger new check, unless no clicks left
+ if (this._clicks.length) {
+ this._scheduleCheckClicks();
+ }
+ }
+
+ /** Generate matching breadcrumb(s) for the click. */
+ private _generateBreadcrumbs(click: Click): void {
+ const replay = this._replay;
+ const hadScroll = click.scrollAfter && click.scrollAfter <= this._scollTimeout;
+ const hadMutation = click.mutationAfter && click.mutationAfter <= this._threshold;
+
+ const isSlowClick = !hadScroll && !hadMutation;
+ const { clickCount, clickBreadcrumb } = click;
+
+ // Slow click
+ if (isSlowClick) {
+ // If `mutationAfter` is set, it means a mutation happened after the threshold, but before the timeout
+ // If not, it means we just timed out without scroll & mutation
+ const timeAfterClickMs = Math.min(click.mutationAfter || this._timeout, this._timeout) * 1000;
+ const endReason = timeAfterClickMs < this._timeout * 1000 ? 'mutation' : 'timeout';
+
+ const breadcrumb: SlowClickFrame = {
+ type: 'default',
+ message: clickBreadcrumb.message,
+ timestamp: clickBreadcrumb.timestamp,
+ category: 'ui.slowClickDetected',
+ data: {
+ ...clickBreadcrumb.data,
+ url: WINDOW.location.href,
+ route: replay.getCurrentRoute(),
+ timeAfterClickMs,
+ endReason,
+ // If clickCount === 0, it means multiClick was not correctly captured here
+ // - we still want to send 1 in this case
+ clickCount: clickCount || 1,
+ },
+ };
+
+ this._addBreadcrumbEvent(replay, breadcrumb);
+ return;
+ }
+
+ // Multi click
+ if (clickCount > 1) {
+ const breadcrumb: MultiClickFrame = {
+ type: 'default',
+ message: clickBreadcrumb.message,
+ timestamp: clickBreadcrumb.timestamp,
+ category: 'ui.multiClick',
+ data: {
+ ...clickBreadcrumb.data,
+ url: WINDOW.location.href,
+ route: replay.getCurrentRoute(),
+ clickCount,
+ metric: true,
+ },
+ };
+
+ this._addBreadcrumbEvent(replay, breadcrumb);
+ }
+ }
+
+ /** Schedule to check current clicks. */
+ private _scheduleCheckClicks(): void {
+ this._checkClickTimeout = setTimeout(() => this._checkClicks(), 1000);
+ }
+}
+
+const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT'];
+
+/** exported for tests only */
+export function ignoreElement(node: HTMLElement, ignoreSelector: string): boolean {
+ if (!SLOW_CLICK_TAGS.includes(node.tagName)) {
+ return true;
+ }
+
+ // 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;
+ }
+
+ // If tag, detect special variants that may not lead to an action
+ // If target !== _self, we may open the link somewhere else, which would lead to no action
+ // Also, when downloading a file, we may not leave the page, but still not trigger an action
+ if (
+ node.tagName === 'A' &&
+ (node.hasAttribute('download') || (node.hasAttribute('target') && node.getAttribute('target') !== '_self'))
+ ) {
+ return true;
+ }
+
+ if (ignoreSelector && node.matches(ignoreSelector)) {
+ return true;
+ }
+
+ return false;
+}
+
+function isClickBreadcrumb(breadcrumb: Breadcrumb): breadcrumb is ClickBreadcrumb {
+ return !!(breadcrumb.data && typeof breadcrumb.data.nodeId === 'number' && breadcrumb.timestamp);
+}
+
+// This is good enough for us, and is easier to test/mock than `timestampInSeconds`
+function nowInSeconds(): number {
+ return Date.now() / 1000;
+}
diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts
index 54ab7ec8bb09..71fb211ee3fe 100644
--- a/packages/replay/src/coreHandlers/handleDom.ts
+++ b/packages/replay/src/coreHandlers/handleDom.ts
@@ -3,32 +3,17 @@ 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 type { ReplayContainer } from '../types';
import { createBreadcrumb } from '../util/createBreadcrumb';
-import { detectSlowClick } from './handleSlowClick';
+import { handleClick } from './handleClick';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
+import type { DomHandlerData } from './util/domUtils';
+import { getClickTargetNode, getTargetNode } from './util/domUtils';
import { getAttributesToRecord } from './util/getAttributesToRecord';
-export interface DomHandlerData {
- name: string;
- event: Node | { target: EventTarget };
-}
-
export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void = (
replay: ReplayContainer,
) => {
- const { slowClickTimeout, slowClickIgnoreSelectors } = replay.getOptions();
-
- const slowClickConfig: SlowClickConfig | undefined = slowClickTimeout
- ? {
- threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout),
- timeout: slowClickTimeout,
- scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT,
- ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '',
- }
- : undefined;
-
return (handlerData: DomHandlerData): void => {
if (!replay.isEnabled()) {
return;
@@ -43,11 +28,10 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHa
const isClick = handlerData.name === 'click';
const event = isClick && (handlerData.event as PointerEvent);
// Ignore clicks if ctrl/alt/meta keys are held down as they alter behavior of clicks (e.g. open in new tab)
- if (isClick && slowClickConfig && event && !event.altKey && !event.metaKey && !event.ctrlKey) {
- detectSlowClick(
- replay,
- slowClickConfig,
- result as Breadcrumb & { timestamp: number },
+ if (isClick && replay.clickDetector && event && !event.altKey && !event.metaKey && !event.ctrlKey) {
+ handleClick(
+ replay.clickDetector,
+ result as Breadcrumb & { timestamp: number; data: { nodeId: number } },
getClickTargetNode(handlerData.event) as HTMLElement,
);
}
@@ -118,32 +102,3 @@ function getDomTarget(handlerData: DomHandlerData): { target: Node | INode | nul
function isRrwebNode(node: EventTarget): node is INode {
return '__sn' in node;
}
-
-function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null {
- if (isEventWithTarget(event)) {
- return event.target as Node | null;
- }
-
- return event;
-}
-
-const INTERACTIVE_SELECTOR = 'button,a';
-
-// For clicks, we check if the target is inside of a button or link
-// If so, we use this as the target instead
-// This is useful because if you click on the image in ,
-// The target will be the image, not the button, which we don't want here
-function getClickTargetNode(event: DomHandlerData['event']): Node | INode | null {
- const target = getTargetNode(event);
-
- if (!target || !(target instanceof Element)) {
- return target;
- }
-
- const closestInteractive = target.closest(INTERACTIVE_SELECTOR);
- return closestInteractive || target;
-}
-
-function isEventWithTarget(event: unknown): event is { target: EventTarget | null } {
- return typeof event === 'object' && !!event && 'target' in event;
-}
diff --git a/packages/replay/src/coreHandlers/handleSlowClick.ts b/packages/replay/src/coreHandlers/handleSlowClick.ts
deleted file mode 100644
index c939a990f87a..000000000000
--- a/packages/replay/src/coreHandlers/handleSlowClick.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import type { Breadcrumb } from '@sentry/types';
-
-import { WINDOW } from '../constants';
-import type { ReplayContainer, SlowClickConfig } from '../types';
-import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
-
-type ClickBreadcrumb = Breadcrumb & {
- timestamp: number;
-};
-
-/**
- * Detect a slow click on a button/a tag,
- * and potentially create a corresponding breadcrumb.
- */
-export function detectSlowClick(
- replay: ReplayContainer,
- config: SlowClickConfig,
- clickBreadcrumb: ClickBreadcrumb,
- node: HTMLElement,
-): void {
- if (ignoreElement(node, config)) {
- return;
- }
-
- /*
- We consider a slow click a click on a button/a, which does not trigger one of:
- - DOM mutation
- - Scroll (within 100ms)
- Within the given threshold time.
- After time timeout time, we stop listening and mark it as a slow click anyhow.
- */
-
- let cleanup: () => void = () => {
- // replaced further down
- };
-
- // After timeout time, def. consider this a slow click, and stop watching for mutations
- const timeout = setTimeout(() => {
- handleSlowClick(replay, clickBreadcrumb, config.timeout, 'timeout');
- cleanup();
- }, config.timeout);
-
- const mutationHandler = (): void => {
- maybeHandleSlowClick(replay, clickBreadcrumb, config.threshold, config.timeout, 'mutation');
- cleanup();
- };
-
- const scrollHandler = (): void => {
- maybeHandleSlowClick(replay, clickBreadcrumb, config.scrollTimeout, config.timeout, 'scroll');
- cleanup();
- };
-
- const obs = new MutationObserver(mutationHandler);
-
- obs.observe(WINDOW.document.documentElement, {
- attributes: true,
- characterData: true,
- childList: true,
- subtree: true,
- });
-
- WINDOW.addEventListener('scroll', scrollHandler);
-
- // Stop listening to scroll timeouts early
- const scrollTimeout = setTimeout(() => {
- WINDOW.removeEventListener('scroll', scrollHandler);
- }, config.scrollTimeout);
-
- cleanup = (): void => {
- clearTimeout(timeout);
- clearTimeout(scrollTimeout);
- obs.disconnect();
- WINDOW.removeEventListener('scroll', scrollHandler);
- };
-}
-
-function maybeHandleSlowClick(
- replay: ReplayContainer,
- clickBreadcrumb: ClickBreadcrumb,
- threshold: number,
- timeout: number,
- endReason: string,
-): boolean {
- const now = Date.now();
- const timeAfterClickMs = now - clickBreadcrumb.timestamp * 1000;
-
- if (timeAfterClickMs > threshold) {
- handleSlowClick(replay, clickBreadcrumb, Math.min(timeAfterClickMs, timeout), endReason);
- return true;
- }
-
- return false;
-}
-
-function handleSlowClick(
- replay: ReplayContainer,
- clickBreadcrumb: ClickBreadcrumb,
- timeAfterClickMs: number,
- endReason: string,
-): void {
- const breadcrumb = {
- message: clickBreadcrumb.message,
- timestamp: clickBreadcrumb.timestamp,
- category: 'ui.slowClickDetected',
- data: {
- ...clickBreadcrumb.data,
- url: WINDOW.location.href,
- // TODO FN: add parametrized route, when possible
- timeAfterClickMs,
- endReason,
- },
- };
-
- addBreadcrumbEvent(replay, breadcrumb);
-}
-
-const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT'];
-
-/** exported for tests only */
-export function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean {
- if (!SLOW_CLICK_TAGS.includes(node.tagName)) {
- return true;
- }
-
- // 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;
- }
-
- // If tag, detect special variants that may not lead to an action
- // If target !== _self, we may open the link somewhere else, which would lead to no action
- // Also, when downloading a file, we may not leave the page, but still not trigger an action
- if (
- node.tagName === 'A' &&
- (node.hasAttribute('download') || (node.hasAttribute('target') && node.getAttribute('target') !== '_self'))
- ) {
- return true;
- }
-
- if (config.ignoreSelector && node.matches(config.ignoreSelector)) {
- return true;
- }
-
- return false;
-}
diff --git a/packages/replay/src/coreHandlers/util/domUtils.ts b/packages/replay/src/coreHandlers/util/domUtils.ts
new file mode 100644
index 000000000000..6091dc7fe837
--- /dev/null
+++ b/packages/replay/src/coreHandlers/util/domUtils.ts
@@ -0,0 +1,38 @@
+import type { INode } from '@sentry-internal/rrweb-snapshot';
+
+export interface DomHandlerData {
+ name: string;
+ event: Node | { target: EventTarget };
+}
+
+const INTERACTIVE_SELECTOR = 'button,a';
+
+/**
+ * For clicks, we check if the target is inside of a button or link
+ * If so, we use this as the target instead
+ * This is useful because if you click on the image in ,
+ * The target will be the image, not the button, which we don't want here
+ */
+export function getClickTargetNode(event: DomHandlerData['event'] | MouseEvent): Node | INode | null {
+ const target = getTargetNode(event);
+
+ if (!target || !(target instanceof Element)) {
+ return target;
+ }
+
+ const closestInteractive = target.closest(INTERACTIVE_SELECTOR);
+ return closestInteractive || target;
+}
+
+/** Get the event target node. */
+export function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null {
+ if (isEventWithTarget(event)) {
+ return event.target as Node | null;
+ }
+
+ return event;
+}
+
+function isEventWithTarget(event: unknown): event is { target: EventTarget | null } {
+ return typeof event === 'object' && !!event && 'target' in event;
+}
diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts
index a00b96f23c19..acb2980e608c 100644
--- a/packages/replay/src/replay.ts
+++ b/packages/replay/src/replay.ts
@@ -7,10 +7,14 @@ import { logger } from '@sentry/utils';
import {
BUFFER_CHECKOUT_TIME,
MAX_SESSION_LIFE,
+ MULTI_CLICK_TIMEOUT,
SESSION_IDLE_EXPIRE_DURATION,
SESSION_IDLE_PAUSE_DURATION,
+ SLOW_CLICK_SCROLL_TIMEOUT,
+ SLOW_CLICK_THRESHOLD,
WINDOW,
} from './constants';
+import { ClickDetector } from './coreHandlers/handleClick';
import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent';
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
import { createEventBuffer } from './eventBuffer';
@@ -31,6 +35,7 @@ import type {
ReplayPluginOptions,
SendBufferedReplayOptions,
Session,
+ SlowClickConfig,
Timeouts,
} from './types';
import { addEvent } from './util/addEvent';
@@ -60,6 +65,8 @@ export class ReplayContainer implements ReplayContainerInterface {
public session: Session | undefined;
+ public clickDetector: ClickDetector | undefined;
+
/**
* Recording can happen in one of three modes:
* - session: Record the whole session, sending it continuously
@@ -159,6 +166,22 @@ export class ReplayContainer implements ReplayContainerInterface {
// ... per 5s
5,
);
+
+ const { slowClickTimeout, slowClickIgnoreSelectors } = this.getOptions();
+
+ const slowClickConfig: SlowClickConfig | undefined = slowClickTimeout
+ ? {
+ threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout),
+ timeout: slowClickTimeout,
+ scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT,
+ ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '',
+ multiClickTimeout: MULTI_CLICK_TIMEOUT,
+ }
+ : undefined;
+
+ if (slowClickConfig) {
+ this.clickDetector = new ClickDetector(this, slowClickConfig);
+ }
}
/** Get the event context. */
@@ -737,6 +760,10 @@ export class ReplayContainer implements ReplayContainerInterface {
WINDOW.addEventListener('focus', this._handleWindowFocus);
WINDOW.addEventListener('keydown', this._handleKeyboardEvent);
+ if (this.clickDetector) {
+ this.clickDetector.addListeners();
+ }
+
// There is no way to remove these listeners, so ensure they are only added once
if (!this._hasInitializedCoreListeners) {
addGlobalListeners(this);
@@ -766,6 +793,10 @@ export class ReplayContainer implements ReplayContainerInterface {
WINDOW.removeEventListener('focus', this._handleWindowFocus);
WINDOW.removeEventListener('keydown', this._handleKeyboardEvent);
+ if (this.clickDetector) {
+ this.clickDetector.removeListeners();
+ }
+
if (this._performanceObserver) {
this._performanceObserver.disconnect();
this._performanceObserver = null;
diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts
index 2ec6e18346ee..f058b2c9011a 100644
--- a/packages/replay/src/types/replay.ts
+++ b/packages/replay/src/types/replay.ts
@@ -1,4 +1,5 @@
import type {
+ Breadcrumb,
FetchBreadcrumbHint,
HandlerDataFetch,
ReplayRecordingData,
@@ -407,8 +408,15 @@ export interface SendBufferedReplayOptions {
continueRecording?: boolean;
}
+export interface ReplayClickDetector {
+ addListeners(): void;
+ removeListeners(): void;
+ handleClick(breadcrumb: Breadcrumb, node: HTMLElement): void;
+}
+
export interface ReplayContainer {
eventBuffer: EventBuffer | null;
+ clickDetector: ReplayClickDetector | undefined;
performanceEvents: AllPerformanceEntry[];
session: Session | undefined;
recordingMode: ReplayRecordingMode;
@@ -468,4 +476,5 @@ export interface SlowClickConfig {
timeout: number;
scrollTimeout: number;
ignoreSelector: string;
+ multiClickTimeout: number;
}
diff --git a/packages/replay/src/types/replayFrame.ts b/packages/replay/src/types/replayFrame.ts
index f0107fdbf77a..379dbab91605 100644
--- a/packages/replay/src/types/replayFrame.ts
+++ b/packages/replay/src/types/replayFrame.ts
@@ -88,14 +88,28 @@ interface FocusFrame extends BaseBreadcrumbFrame {
interface SlowClickFrameData extends ClickFrameData {
url: string;
- timeAfterClickFs: number;
+ route?: string;
+ timeAfterClickMs: number;
endReason: string;
+ clickCount: number;
}
-interface SlowClickFrame extends BaseBreadcrumbFrame {
+export interface SlowClickFrame extends BaseBreadcrumbFrame {
category: 'ui.slowClickDetected';
data: SlowClickFrameData;
}
+interface MultiClickFrameData extends ClickFrameData {
+ url: string;
+ route?: string;
+ clickCount: number;
+ metric: true;
+}
+
+export interface MultiClickFrame extends BaseBreadcrumbFrame {
+ category: 'ui.multiClick';
+ data: MultiClickFrameData;
+}
+
interface OptionFrame {
blockAllMedia: boolean;
errorSampleRate: number;
@@ -118,6 +132,7 @@ export type BreadcrumbFrame =
| BlurFrame
| FocusFrame
| SlowClickFrame
+ | MultiClickFrame
| MutationFrame
| BaseBreadcrumbFrame;
diff --git a/packages/replay/test/unit/coreHandlers/handleClick.test.ts b/packages/replay/test/unit/coreHandlers/handleClick.test.ts
new file mode 100644
index 000000000000..8b06e4683656
--- /dev/null
+++ b/packages/replay/test/unit/coreHandlers/handleClick.test.ts
@@ -0,0 +1,542 @@
+import type { Breadcrumb } from '@sentry/types';
+
+import { BASE_TIMESTAMP } from '../..';
+import { ClickDetector, ignoreElement } from '../../../src/coreHandlers/handleClick';
+import type { ReplayContainer } from '../../../src/types';
+
+jest.useFakeTimers();
+
+describe('Unit | coreHandlers | handleClick', () => {
+ describe('ClickDetector', () => {
+ beforeEach(() => {
+ jest.setSystemTime(BASE_TIMESTAMP);
+ });
+
+ test('it captures a single click', async () => {
+ const replay = {
+ getCurrentRoute: () => 'test-route',
+ } as ReplayContainer;
+
+ const mockAddBreadcrumbEvent = jest.fn();
+
+ const detector = new ClickDetector(
+ replay,
+ {
+ threshold: 1_000,
+ timeout: 3_000,
+ scrollTimeout: 200,
+ ignoreSelector: '',
+ multiClickTimeout: 1_000,
+ },
+ mockAddBreadcrumbEvent,
+ );
+
+ const breadcrumb: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ clickCount: 1,
+ endReason: 'timeout',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 3000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: expect.any(Number),
+ });
+
+ jest.advanceTimersByTime(5_000);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ });
+
+ test('it groups multiple clicks together', async () => {
+ const replay = {
+ getCurrentRoute: () => 'test-route',
+ } as ReplayContainer;
+
+ const mockAddBreadcrumbEvent = jest.fn();
+
+ const detector = new ClickDetector(
+ replay,
+ {
+ threshold: 1_000,
+ timeout: 3_000,
+ scrollTimeout: 200,
+ ignoreSelector: '',
+ multiClickTimeout: 1_000,
+ },
+ mockAddBreadcrumbEvent,
+ );
+
+ const breadcrumb1: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb2: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000 + 0.2,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb3: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000 + 0.6,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb4: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000 + 2,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb5: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000 + 2.9,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb1, node);
+
+ detector.handleClick(breadcrumb2, node);
+
+ detector.handleClick(breadcrumb3, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(2_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ detector.handleClick(breadcrumb4, node);
+ detector.handleClick(breadcrumb5, node);
+
+ jest.advanceTimersByTime(1_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ // count is not actually correct, because this is identified by a different click handler
+ clickCount: 1,
+ endReason: 'timeout',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 3000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: expect.any(Number),
+ });
+
+ jest.advanceTimersByTime(2_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ // count is not actually correct, because this is identified by a different click handler
+ clickCount: 1,
+ endReason: 'timeout',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 3000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: expect.any(Number),
+ });
+
+ jest.advanceTimersByTime(5_000);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2);
+ });
+
+ test('it captures clicks on different elements', async () => {
+ const replay = {
+ getCurrentRoute: () => 'test-route',
+ } as ReplayContainer;
+
+ const mockAddBreadcrumbEvent = jest.fn();
+
+ const detector = new ClickDetector(
+ replay,
+ {
+ threshold: 1_000,
+ timeout: 3_000,
+ scrollTimeout: 200,
+ ignoreSelector: '',
+ multiClickTimeout: 1_000,
+ },
+ mockAddBreadcrumbEvent,
+ );
+
+ const breadcrumb1: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb2: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 2,
+ },
+ };
+ const breadcrumb3: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 3,
+ },
+ };
+ const node1 = document.createElement('button');
+ const node2 = document.createElement('button');
+ const node3 = document.createElement('button');
+ detector.handleClick(breadcrumb1, node1);
+ detector.handleClick(breadcrumb2, node2);
+ detector.handleClick(breadcrumb3, node3);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(3_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(3);
+
+ jest.advanceTimersByTime(5_000);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(3);
+ });
+
+ test('it ignores clicks on ignored elements', async () => {
+ const replay = {
+ getCurrentRoute: () => 'test-route',
+ } as ReplayContainer;
+
+ const mockAddBreadcrumbEvent = jest.fn();
+
+ const detector = new ClickDetector(
+ replay,
+ {
+ threshold: 1_000,
+ timeout: 3_000,
+ scrollTimeout: 200,
+ ignoreSelector: '',
+ multiClickTimeout: 1_000,
+ },
+ mockAddBreadcrumbEvent,
+ );
+
+ const breadcrumb1: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb2: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 2,
+ },
+ };
+ const breadcrumb3: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 3,
+ },
+ };
+ const node1 = document.createElement('div');
+ const node2 = document.createElement('div');
+ const node3 = document.createElement('div');
+ detector.handleClick(breadcrumb1, node1);
+ detector.handleClick(breadcrumb2, node2);
+ detector.handleClick(breadcrumb3, node3);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(3_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+ });
+
+ describe('mutations', () => {
+ let detector: ClickDetector;
+ let mockAddBreadcrumbEvent = jest.fn();
+
+ const replay = {
+ getCurrentRoute: () => 'test-route',
+ } as ReplayContainer;
+
+ beforeEach(() => {
+ jest.setSystemTime(BASE_TIMESTAMP);
+
+ mockAddBreadcrumbEvent = jest.fn();
+
+ detector = new ClickDetector(
+ replay,
+ {
+ threshold: 1_000,
+ timeout: 3_000,
+ scrollTimeout: 200,
+ ignoreSelector: '',
+ multiClickTimeout: 1_000,
+ },
+ mockAddBreadcrumbEvent,
+ );
+ });
+
+ test('it does not consider clicks with mutation before threshold as slow click', async () => {
+ const breadcrumb: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(500);
+
+ // Pretend a mutation happend
+ detector['_lastMutation'] = BASE_TIMESTAMP / 1000 + 0.5;
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(3_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+ });
+
+ test('it considers clicks with mutation after threshold as slow click', async () => {
+ const breadcrumb: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ // Pretend a mutation happend
+ detector['_lastMutation'] = BASE_TIMESTAMP / 1000 + 2;
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(3_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ clickCount: 1,
+ endReason: 'mutation',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 2000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: expect.any(Number),
+ });
+
+ jest.advanceTimersByTime(5_000);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ });
+
+ test('it caps timeout', async () => {
+ const breadcrumb: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ // Pretend a mutation happend
+ detector['_lastMutation'] = BASE_TIMESTAMP / 1000 + 5;
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(5_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ clickCount: 1,
+ endReason: 'timeout',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 3000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: expect.any(Number),
+ });
+
+ jest.advanceTimersByTime(5_000);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('scroll', () => {
+ let detector: ClickDetector;
+ let mockAddBreadcrumbEvent = jest.fn();
+
+ const replay = {
+ getCurrentRoute: () => 'test-route',
+ } as ReplayContainer;
+
+ beforeEach(() => {
+ jest.setSystemTime(BASE_TIMESTAMP);
+
+ mockAddBreadcrumbEvent = jest.fn();
+
+ detector = new ClickDetector(
+ replay,
+ {
+ threshold: 1_000,
+ timeout: 3_000,
+ scrollTimeout: 200,
+ ignoreSelector: '',
+ multiClickTimeout: 1_000,
+ },
+ mockAddBreadcrumbEvent,
+ );
+ });
+
+ test('it does not consider clicks with scroll before threshold as slow click', async () => {
+ const breadcrumb: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(100);
+
+ // Pretend a mutation happend
+ detector['_lastScroll'] = BASE_TIMESTAMP / 1000 + 0.15;
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(3_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+ });
+
+ test('it considers clicks with scroll after threshold as slow click', async () => {
+ const breadcrumb: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(300);
+
+ // Pretend a mutation happend
+ detector['_lastScroll'] = BASE_TIMESTAMP / 1000 + 0.3;
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(3_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ clickCount: 1,
+ endReason: 'timeout',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 3000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: expect.any(Number),
+ });
+
+ jest.advanceTimersByTime(5_000);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ 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, '')).toBe(expected);
+ });
+
+ test('it ignored selectors matching ignoreSelector', () => {
+ const button = document.createElement('button');
+ const a = document.createElement('a');
+
+ expect(ignoreElement(button, 'button')).toBe(true);
+ expect(ignoreElement(a, 'button')).toBe(false);
+ });
+ });
+});
diff --git a/packages/replay/test/unit/coreHandlers/handleDom.test.ts b/packages/replay/test/unit/coreHandlers/handleDom.test.ts
index dc1fff0b5ff2..99fa5dc1e367 100644
--- a/packages/replay/test/unit/coreHandlers/handleDom.test.ts
+++ b/packages/replay/test/unit/coreHandlers/handleDom.test.ts
@@ -1,5 +1,5 @@
-import type { DomHandlerData } from '../../../src/coreHandlers/handleDom';
import { handleDom } from '../../../src/coreHandlers/handleDom';
+import type { DomHandlerData } from '../../../src/coreHandlers/util/domUtils';
describe('Unit | coreHandlers | handleDom', () => {
test('it works with a basic click event on a div', () => {
diff --git a/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts b/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts
deleted file mode 100644
index 2d0922272115..000000000000
--- a/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-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);
- });
- });
-});