diff --git a/packages/core/test/emulate/createNewEvent.ts b/packages/core/test/emulate/createNewEvent.ts index 4d9d298802..6dd20e687e 100644 --- a/packages/core/test/emulate/createNewEvent.ts +++ b/packages/core/test/emulate/createNewEvent.ts @@ -1,11 +1,9 @@ +import type { MouseEventOnElement } from '@datadog/browser-rum-core' import type { TrustableEvent } from '../../src' import { objectEntries } from '../../src' export function createNewEvent(eventName: 'click', properties?: Partial): MouseEvent -export function createNewEvent( - eventName: 'pointerup', - properties?: Partial -): PointerEvent & { target: Element } +export function createNewEvent(eventName: 'pointerup', properties?: Partial): MouseEventOnElement export function createNewEvent(eventName: 'message', properties?: Partial): MessageEvent export function createNewEvent( eventName: 'securitypolicyviolation', diff --git a/packages/rum-core/src/domain/action/interactionSelectorCache.spec.ts b/packages/rum-core/src/domain/action/interactionSelectorCache.spec.ts new file mode 100644 index 0000000000..e910fda51c --- /dev/null +++ b/packages/rum-core/src/domain/action/interactionSelectorCache.spec.ts @@ -0,0 +1,35 @@ +import { relativeNow } from '@datadog/browser-core' +import { mockClock } from '@datadog/browser-core/test' +import type { Clock } from '@datadog/browser-core/test' +import { + updateInteractionSelector, + getInteractionSelector, + interactionSelectorCache, + CLICK_ACTION_MAX_DURATION, +} from './interactionSelectorCache' + +describe('interactionSelectorCache', () => { + let clock: Clock + beforeEach(() => { + clock = mockClock() + }) + + afterEach(() => { + clock.cleanup() + }) + + it('should delete the selector after getting it', () => { + const timestamp = relativeNow() + updateInteractionSelector(timestamp, 'selector') + expect(getInteractionSelector(timestamp)).toBe('selector') + expect(interactionSelectorCache.get(timestamp)).toBeUndefined() + }) + + it('should delete outdated selectors', () => { + const timestamp = relativeNow() + updateInteractionSelector(timestamp, 'selector') + expect(getInteractionSelector(timestamp)).toBe('selector') + clock.tick(CLICK_ACTION_MAX_DURATION) + expect(interactionSelectorCache.get(timestamp)).toBeUndefined() + }) +}) diff --git a/packages/rum-core/src/domain/action/interactionSelectorCache.ts b/packages/rum-core/src/domain/action/interactionSelectorCache.ts new file mode 100644 index 0000000000..24e153c8bc --- /dev/null +++ b/packages/rum-core/src/domain/action/interactionSelectorCache.ts @@ -0,0 +1,21 @@ +import { elapsed, ONE_SECOND, relativeNow } from '@datadog/browser-core' +import type { RelativeTime } from '@datadog/browser-core' + +// Maximum duration for click actions +export const CLICK_ACTION_MAX_DURATION = 10 * ONE_SECOND +export const interactionSelectorCache = new Map() + +export function getInteractionSelector(relativeTimestamp: RelativeTime) { + const selector = interactionSelectorCache.get(relativeTimestamp) + interactionSelectorCache.delete(relativeTimestamp) + return selector +} + +export function updateInteractionSelector(relativeTimestamp: RelativeTime, selector: string) { + interactionSelectorCache.set(relativeTimestamp, selector) + interactionSelectorCache.forEach((_, relativeTimestamp) => { + if (elapsed(relativeTimestamp, relativeNow()) > CLICK_ACTION_MAX_DURATION) { + interactionSelectorCache.delete(relativeTimestamp) + } + }) +} diff --git a/packages/rum-core/src/domain/action/listenActionEvents.ts b/packages/rum-core/src/domain/action/listenActionEvents.ts index 76e75a0f4b..a04a8c278d 100644 --- a/packages/rum-core/src/domain/action/listenActionEvents.ts +++ b/packages/rum-core/src/domain/action/listenActionEvents.ts @@ -1,7 +1,12 @@ import { addEventListener, DOM_EVENT } from '@datadog/browser-core' +import type { RelativeTime } from '@datadog/browser-core' import type { RumConfiguration } from '../configuration' -export type MouseEventOnElement = PointerEvent & { target: Element } +export type ExtraPointerEventFields = { + target: Element + timeStamp: RelativeTime +} +export type MouseEventOnElement = PointerEvent & ExtraPointerEventFields export interface UserActivity { selection: boolean diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 436f244708..838b359f64 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -17,8 +17,9 @@ import { PAGE_ACTIVITY_VALIDATION_DELAY } from '../waitPageActivityEnd' import type { RumConfiguration } from '../configuration' import type { ActionContexts } from './actionCollection' import type { ClickAction } from './trackClickActions' -import { finalizeClicks, CLICK_ACTION_MAX_DURATION, trackClickActions } from './trackClickActions' +import { finalizeClicks, trackClickActions } from './trackClickActions' import { MAX_DURATION_BETWEEN_CLICKS } from './clickChain' +import { getInteractionSelector, CLICK_ACTION_MAX_DURATION } from './interactionSelectorCache' // Used to wait some time after the creation of an action const BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY = PAGE_ACTIVITY_VALIDATION_DELAY * 0.8 @@ -149,7 +150,6 @@ describe('trackClickActions', () => { clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) domMutationObservable.notify() lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, createFakeErrorEvent()) - clock.tick(EXPIRE_DELAY) lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, createFakeErrorEvent()) @@ -425,15 +425,35 @@ describe('trackClickActions', () => { }) }) + describe('interactionSelectorCache', () => { + it('should add pointer down to the map', () => { + startClickActionsTracking() + const timeStamp = relativeNow() + + emulateClick({ eventProperty: { timeStamp } }) + expect(getInteractionSelector(timeStamp)).toBe('#button') + }) + + it('should add pointerup to the map', () => { + startClickActionsTracking() + const timeStamp = relativeNow() + + emulateClick({ eventProperty: { timeStamp } }) + expect(getInteractionSelector(timeStamp)).toBe('#button') + }) + }) + function emulateClick({ target = button, activity, + eventProperty, }: { target?: HTMLElement activity?: { delay?: number on?: 'pointerup' | 'click' | 'pointerdown' } + eventProperty?: { [key: string]: any } } = {}) { const targetPosition = target.getBoundingClientRect() const offsetX = targetPosition.width / 2 @@ -446,6 +466,7 @@ describe('trackClickActions', () => { offsetY, timeStamp: timeStampNow(), isPrimary: true, + ...eventProperty, } target.dispatchEvent(createNewEvent('pointerdown', eventProperties)) emulateActivityIfNeeded('pointerdown') diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index e6d8553c70..fa4a10bc2e 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -8,7 +8,6 @@ import { ONE_MINUTE, generateUUID, clocksNow, - ONE_SECOND, elapsed, createValueHistory, } from '@datadog/browser-core' @@ -27,6 +26,7 @@ import { getActionNameFromElement } from './getActionNameFromElement' import type { MouseEventOnElement, UserActivity } from './listenActionEvents' import { listenActionEvents } from './listenActionEvents' import { computeFrustration } from './computeFrustration' +import { CLICK_ACTION_MAX_DURATION, updateInteractionSelector } from './interactionSelectorCache' interface ActionCounts { errorCount: number @@ -58,8 +58,6 @@ export interface ActionContexts { type ClickActionIdHistory = ValueHistory -// Maximum duration for click actions -export const CLICK_ACTION_MAX_DURATION = 10 * ONE_SECOND export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary export function trackClickActions( @@ -176,6 +174,11 @@ function startClickAction( const click = newClick(lifeCycle, history, getUserActivity, clickActionBase, startEvent) appendClickToClickChain(click) + const selector = clickActionBase?.target?.selector + if (selector) { + updateInteractionSelector(startEvent.timeStamp, selector) + } + const { stop: stopWaitPageActivityEnd } = waitPageActivityEnd( lifeCycle, domMutationObservable, @@ -224,13 +227,17 @@ function computeClickActionBase( configuration: RumConfiguration ): ClickActionBase { const rect = event.target.getBoundingClientRect() + const selector = getSelectorFromElement(event.target, configuration.actionNameAttribute) + if (selector) { + updateInteractionSelector(event.timeStamp, selector) + } return { type: ActionType.CLICK, target: { width: Math.round(rect.width), height: Math.round(rect.height), - selector: getSelectorFromElement(event.target, configuration.actionNameAttribute), + selector, }, position: { // Use clientX and Y because for SVG element offsetX and Y are relatives to the element diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts index 83b4cf67c1..d657a95c34 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts @@ -1,5 +1,5 @@ import type { Duration, RelativeTime } from '@datadog/browser-core' -import { elapsed, resetExperimentalFeatures } from '@datadog/browser-core' +import { elapsed, relativeNow, resetExperimentalFeatures } from '@datadog/browser-core' import { registerCleanupTask } from '@datadog/browser-core/test' import { appendElement, @@ -16,6 +16,7 @@ import type { RumPerformanceEventTiming, } from '../../../browser/performanceObservable' import { ViewLoadingType } from '../../../rawRumEvent.types' +import { getInteractionSelector, updateInteractionSelector } from '../../action/interactionSelectorCache' import { trackInteractionToNextPaint, trackViewInteractionCount, @@ -247,6 +248,22 @@ describe('trackInteractionToNextPaint', () => { expect(getInteractionToNextPaint()?.targetSelector).toEqual('#bar') }) + + it('should check interactionSelectorCache for entries', () => { + startINPTracking() + const startTime = relativeNow() + updateInteractionSelector(startTime, '#foo') + + newInteraction({ + interactionId: 1, + duration: 1 as Duration, + startTime, + target: undefined, + }) + + expect(getInteractionToNextPaint()?.targetSelector).toEqual('#foo') + expect(getInteractionSelector(startTime)).toBeUndefined() + }) }) }) diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.ts b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.ts index dd0e20299e..10e019bef3 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.ts @@ -9,6 +9,7 @@ import type { RumFirstInputTiming, RumPerformanceEventTiming } from '../../../br import { ViewLoadingType } from '../../../rawRumEvent.types' import { getSelectorFromElement } from '../../getSelectorFromElement' import { isElementNode } from '../../../browser/htmlDomUtils' +import { getInteractionSelector } from '../../action/interactionSelectorCache' import type { RumConfiguration } from '../../configuration' import { getInteractionCount, initInteractionCountPolyfill } from './interactionCountPolyfill' @@ -66,14 +67,12 @@ export function trackInteractionToNextPaint( if (newInteraction && newInteraction.duration !== interactionToNextPaint) { interactionToNextPaint = newInteraction.duration interactionToNextPaintStartTime = elapsed(viewStart, newInteraction.startTime) - - if (newInteraction.target && isElementNode(newInteraction.target)) { + interactionToNextPaintTargetSelector = getInteractionSelector(newInteraction.startTime) + if (!interactionToNextPaintTargetSelector && newInteraction.target && isElementNode(newInteraction.target)) { interactionToNextPaintTargetSelector = getSelectorFromElement( newInteraction.target, configuration.actionNameAttribute ) - } else { - interactionToNextPaintTargetSelector = undefined } } } diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index 9ec3a70ab0..c7687443b7 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -39,3 +39,4 @@ export { isLongDataUrl, sanitizeDataUrl, MAX_ATTRIBUTE_VALUE_CHAR_LENGTH } from export * from './domain/privacy' export { SessionReplayState } from './domain/rumSessionManager' export type { RumPlugin } from './domain/plugins' +export type { MouseEventOnElement } from './domain/action/listenActionEvents' diff --git a/packages/rum-core/test/createFakeClick.ts b/packages/rum-core/test/createFakeClick.ts index 53c2da67d2..56dee4c14c 100644 --- a/packages/rum-core/test/createFakeClick.ts +++ b/packages/rum-core/test/createFakeClick.ts @@ -1,7 +1,7 @@ import { clocksNow, Observable, timeStampNow } from '@datadog/browser-core' import { createNewEvent } from '@datadog/browser-core/test' import type { Click } from '../src/domain/action/trackClickActions' -import type { UserActivity } from '../src/domain/action/listenActionEvents' +import type { MouseEventOnElement, UserActivity } from '../src/domain/action/listenActionEvents' export type FakeClick = Readonly> @@ -14,7 +14,7 @@ export function createFakeClick({ hasError?: boolean hasPageActivity?: boolean userActivity?: Partial - event?: Partial + event?: Partial } = {}) { const stopObservable = new Observable() let isStopped = false