Skip to content

Commit 5dc453f

Browse files
committed
ref(replay): Use rrweb for slow click detection
1 parent f78c1d4 commit 5dc453f

File tree

4 files changed

+95
-38
lines changed

4 files changed

+95
-38
lines changed

packages/replay/src/coreHandlers/handleClick.ts

+75-36
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import { IncrementalSource, MouseInteractions, record } from '@sentry-internal/rrweb';
12
import type { Breadcrumb } from '@sentry/types';
23

34
import { WINDOW } from '../constants';
45
import type {
6+
RecordingEvent,
57
ReplayClickDetector,
68
ReplayContainer,
79
ReplayMultiClickFrame,
810
ReplaySlowClickFrame,
911
SlowClickConfig,
1012
} from '../types';
13+
import { ReplayEventTypeFullSnapshot, ReplayEventTypeIncrementalSnapshot } from '../types';
1114
import { timestampToS } from '../util/timestamp';
1215
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
13-
import { getClickTargetNode } from './util/domUtils';
16+
import { getClosestInteractive } from './util/domUtils';
1417
import { onWindowOpen } from './util/onWindowOpen';
1518

1619
type ClickBreadcrumb = Breadcrumb & {
@@ -26,6 +29,16 @@ interface Click {
2629
node: HTMLElement;
2730
}
2831

32+
type IncrementalRecordingEvent = RecordingEvent & {
33+
type: typeof ReplayEventTypeIncrementalSnapshot;
34+
data: { source: IncrementalSource };
35+
};
36+
37+
type IncrementalMouseInteractionRecordingEvent = IncrementalRecordingEvent & {
38+
type: typeof ReplayEventTypeIncrementalSnapshot;
39+
data: { type: MouseInteractions; id: number };
40+
};
41+
2942
/** Handle a click. */
3043
export function handleClick(clickDetector: ReplayClickDetector, clickBreadcrumb: Breadcrumb, node: HTMLElement): void {
3144
clickDetector.handleClick(clickBreadcrumb, node);
@@ -70,48 +83,14 @@ export class ClickDetector implements ReplayClickDetector {
7083

7184
/** Register click detection handlers on mutation or scroll. */
7285
public addListeners(): void {
73-
const mutationHandler = (): void => {
74-
this._lastMutation = nowInSeconds();
75-
};
76-
77-
const scrollHandler = (): void => {
78-
this._lastScroll = nowInSeconds();
79-
};
80-
8186
const cleanupWindowOpen = onWindowOpen(() => {
8287
// Treat window.open as mutation
8388
this._lastMutation = nowInSeconds();
8489
});
8590

86-
const clickHandler = (event: MouseEvent): void => {
87-
if (!event.target) {
88-
return;
89-
}
90-
91-
const node = getClickTargetNode(event);
92-
if (node) {
93-
this._handleMultiClick(node as HTMLElement);
94-
}
95-
};
96-
97-
const obs = new MutationObserver(mutationHandler);
98-
99-
obs.observe(WINDOW.document.documentElement, {
100-
attributes: true,
101-
characterData: true,
102-
childList: true,
103-
subtree: true,
104-
});
105-
106-
WINDOW.addEventListener('scroll', scrollHandler, { passive: true });
107-
WINDOW.addEventListener('click', clickHandler, { passive: true });
108-
10991
this._teardown = () => {
110-
WINDOW.removeEventListener('scroll', scrollHandler);
111-
WINDOW.removeEventListener('click', clickHandler);
11292
cleanupWindowOpen();
11393

114-
obs.disconnect();
11594
this._clicks = [];
11695
this._lastMutation = 0;
11796
this._lastScroll = 0;
@@ -129,7 +108,7 @@ export class ClickDetector implements ReplayClickDetector {
129108
}
130109
}
131110

132-
/** Handle a click */
111+
/** @inheritDoc */
133112
public handleClick(breadcrumb: Breadcrumb, node: HTMLElement): void {
134113
if (ignoreElement(node, this._ignoreSelector) || !isClickBreadcrumb(breadcrumb)) {
135114
return;
@@ -158,6 +137,22 @@ export class ClickDetector implements ReplayClickDetector {
158137
}
159138
}
160139

140+
/** @inheritDoc */
141+
public registerMutation(timestamp = Date.now()): void {
142+
this._lastMutation = timestampToS(timestamp);
143+
}
144+
145+
/** @inheritDoc */
146+
public registerScroll(timestamp = Date.now()): void {
147+
this._lastScroll = timestampToS(timestamp);
148+
}
149+
150+
/** @inheritDoc */
151+
public registerClick(element: HTMLElement): void {
152+
const node = getClosestInteractive(element);
153+
this._handleMultiClick(node as HTMLElement);
154+
}
155+
161156
/** Count multiple clicks on elements. */
162157
private _handleMultiClick(node: HTMLElement): void {
163158
this._getClicks(node).forEach(click => {
@@ -311,3 +306,47 @@ function isClickBreadcrumb(breadcrumb: Breadcrumb): breadcrumb is ClickBreadcrum
311306
function nowInSeconds(): number {
312307
return Date.now() / 1000;
313308
}
309+
310+
/** Update the click detector based on a recording event of rrweb. */
311+
export function updateClickDetectorForRecordingEvent(clickDetector: ReplayClickDetector, event: RecordingEvent): void {
312+
try {
313+
// We interpret a full snapshot as a mutation (this may not be true, but there is no way for us to know)
314+
if (event.type === ReplayEventTypeFullSnapshot) {
315+
clickDetector.registerMutation(event.timestamp);
316+
}
317+
318+
if (!isIncrementalEvent(event)) {
319+
return;
320+
}
321+
322+
const { source } = event.data;
323+
if (source === IncrementalSource.Mutation) {
324+
clickDetector.registerMutation(event.timestamp);
325+
}
326+
327+
if (source === IncrementalSource.Scroll) {
328+
clickDetector.registerScroll(event.timestamp);
329+
}
330+
331+
if (isIncrementalMouseInteraction(event)) {
332+
const { type, id } = event.data;
333+
const node = record.mirror.getNode(id);
334+
335+
if (node instanceof HTMLElement && type === MouseInteractions.Click) {
336+
clickDetector.registerClick(node);
337+
}
338+
}
339+
} catch {
340+
// ignore errors here, e.g. if accessing something that does not exist
341+
}
342+
}
343+
344+
function isIncrementalEvent(event: RecordingEvent): event is IncrementalRecordingEvent {
345+
return event.type === ReplayEventTypeIncrementalSnapshot;
346+
}
347+
348+
function isIncrementalMouseInteraction(
349+
event: IncrementalRecordingEvent,
350+
): event is IncrementalMouseInteractionRecordingEvent {
351+
return event.data.source === IncrementalSource.MouseInteraction;
352+
}

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

+7-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export interface DomHandlerData {
77

88
const INTERACTIVE_SELECTOR = 'button,a';
99

10+
/** Get the closest interactive parent element, or else return the given element. */
11+
export function getClosestInteractive(element: Element): Element {
12+
const closestInteractive = element.closest(INTERACTIVE_SELECTOR);
13+
return closestInteractive || element;
14+
}
15+
1016
/**
1117
* For clicks, we check if the target is inside of a button or link
1218
* If so, we use this as the target instead
@@ -20,8 +26,7 @@ export function getClickTargetNode(event: DomHandlerData['event'] | MouseEvent):
2026
return target;
2127
}
2228

23-
const closestInteractive = target.closest(INTERACTIVE_SELECTOR);
24-
return closestInteractive || target;
29+
return getClosestInteractive(target);
2530
}
2631

2732
/** Get the event target node. */

packages/replay/src/types/replay.ts

+8
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,15 @@ export interface SendBufferedReplayOptions {
441441
export interface ReplayClickDetector {
442442
addListeners(): void;
443443
removeListeners(): void;
444+
445+
/** Handle a click breadcrumb. */
444446
handleClick(breadcrumb: Breadcrumb, node: HTMLElement): void;
447+
/** Register a mutation that happened at a given time. */
448+
registerMutation(timestamp?: number): void;
449+
/** Register a scroll that happened at a given time. */
450+
registerScroll(timestamp?: number): void;
451+
/** Register that a click on an element happened. */
452+
registerClick(element: HTMLElement): void;
445453
}
446454

447455
export interface ReplayContainer {

packages/replay/src/util/handleRecordingEmit.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { EventType } from '@sentry-internal/rrweb';
22
import { logger } from '@sentry/utils';
33

4+
import { updateClickDetectorForRecordingEvent } from '../coreHandlers/handleClick';
45
import { saveSession } from '../session/saveSession';
56
import type { RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '../types';
67
import { addEventSync } from './addEvent';
@@ -29,6 +30,10 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
2930
const isCheckout = _isCheckout || !hadFirstEvent;
3031
hadFirstEvent = true;
3132

33+
if (replay.clickDetector) {
34+
updateClickDetectorForRecordingEvent(replay.clickDetector, event);
35+
}
36+
3237
// The handler returns `true` if we do not want to trigger debounced flush, `false` if we want to debounce flush.
3338
replay.addUpdate(() => {
3439
// The session is always started immediately on pageload/init, but for

0 commit comments

Comments
 (0)