diff --git a/eslint.config.mjs b/eslint.config.mjs index 600ddf8f45..0c04bbf93a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,7 @@ export default tseslint.config( 'coverage', 'rum-events-format', '.yarn', - 'playwright-report', + '**/playwright-report', 'docs', 'developer-extension/.wxt', 'developer-extension/.output', diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 873161d599..cbdd479711 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -19,6 +19,7 @@ export enum ExperimentalFeature { FEATURE_OPERATION_VITAL = 'feature_operation_vital', SHORT_SESSION_INVESTIGATION = 'short_session_investigation', AVOID_FETCH_KEEPALIVE = 'avoid_fetch_keepalive', + START_STOP_ACTION = 'start_stop_action', USE_CHANGE_RECORDS = 'use_change_records', SOURCE_CODE_CONTEXT = 'source_code_context', } diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 7b922265a5..e753e7c586 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -10,6 +10,7 @@ import { DefaultPrivacyLevel, resetExperimentalFeatures, resetFetchObservable, + ExperimentalFeature, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -18,13 +19,14 @@ import { mockClock, mockEventBridge, mockSyntheticsWorkerValues, + mockExperimentalFeatures, } from '@datadog/browser-core/test' import type { HybridInitConfiguration, RumConfiguration, RumInitConfiguration } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { ActionType, VitalType } from '../rawRumEvent.types' -import type { CustomAction } from '../domain/action/actionCollection' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' +import type { CustomAction } from '../domain/action/trackCustomActions' import type { RumPublicApi, Strategy } from './rumPublicApi' import type { StartRumResult } from './startRum' import { createPreStartStrategy } from './preStartRum' @@ -630,7 +632,7 @@ describe('preStartRum', () => { const addActionSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addAction: addActionSpy } as unknown as StartRumResult) - const customAction: CustomAction = { + const customAction: Omit = { name: 'foo', type: ActionType.CUSTOM, startClocks: clocksNow(), @@ -753,6 +755,41 @@ describe('preStartRum', () => { strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) expect(addOperationStepVitalSpy).toHaveBeenCalledOnceWith('foo', 'start', undefined, undefined) }) + + it('startAction / stopAction', () => { + mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + + const startActionSpy = jasmine.createSpy() + const stopActionSpy = jasmine.createSpy() + doStartRumSpy.and.returnValue({ + startAction: startActionSpy, + stopAction: stopActionSpy, + } as unknown as StartRumResult) + + strategy.startAction('user_login', { type: ActionType.CUSTOM }) + strategy.stopAction('user_login') + + strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + + expect(startActionSpy).toHaveBeenCalledWith( + 'user_login', + jasmine.objectContaining({ + type: ActionType.CUSTOM, + }), + jasmine.objectContaining({ + relative: jasmine.any(Number), + timeStamp: jasmine.any(Number), + }) + ) + expect(stopActionSpy).toHaveBeenCalledWith( + 'user_login', + undefined, + jasmine.objectContaining({ + relative: jasmine.any(Number), + timeStamp: jasmine.any(Number), + }) + ) + }) }) describe('tracking consent', () => { diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 0ea06776e4..c55ab48257 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -253,6 +253,16 @@ export function createPreStartStrategy( bufferApiCalls.add((startRumResult) => startRumResult.addAction(action)) }, + startAction(name, options) { + const startClocks = clocksNow() + bufferApiCalls.add((startRumResult) => startRumResult.startAction(name, options, startClocks)) + }, + + stopAction(name, options) { + const stopClocks = clocksNow() + bufferApiCalls.add((startRumResult) => startRumResult.stopAction(name, options, stopClocks)) + }, + addError(providedError) { bufferApiCalls.add((startRumResult) => startRumResult.addError(providedError)) }, diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index cb1eedf30f..671375ad50 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -36,6 +36,8 @@ const noopStartRum = (): ReturnType => ({ hooks: {} as any, telemetry: {} as any, addOperationStepVital: () => undefined, + startAction: () => undefined, + stopAction: () => undefined, }) const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } const FAKE_WORKER = {} as DeflateWorker @@ -756,6 +758,72 @@ describe('rum public api', () => { }) }) + describe('startAction / stopAction', () => { + it('should call startAction and stopAction on the strategy', () => { + const startActionSpy = jasmine.createSpy() + const stopActionSpy = jasmine.createSpy() + const rumPublicApi = makeRumPublicApi( + () => ({ + ...noopStartRum(), + startAction: startActionSpy, + stopAction: stopActionSpy, + }), + noopRecorderApi, + noopProfilerApi + ) + + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + rumPublicApi.startAction('purchase', { + type: ActionType.CUSTOM, + context: { cart: 'abc' }, + }) + rumPublicApi.stopAction('purchase', { + context: { total: 100 }, + }) + + expect(startActionSpy).toHaveBeenCalledWith( + 'purchase', + jasmine.objectContaining({ + type: ActionType.CUSTOM, + context: { cart: 'abc' }, + }) + ) + expect(stopActionSpy).toHaveBeenCalledWith( + 'purchase', + jasmine.objectContaining({ + context: { total: 100 }, + }) + ) + }) + + it('should sanitize startAction and stopAction inputs', () => { + const startActionSpy = jasmine.createSpy() + const rumPublicApi = makeRumPublicApi( + () => ({ + ...noopStartRum(), + startAction: startActionSpy, + }), + noopRecorderApi, + noopProfilerApi + ) + + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + rumPublicApi.startAction('action_name', { + type: ActionType.CUSTOM, + context: { count: 123, nested: { foo: 'bar' } } as any, + actionKey: 'action_key', + }) + + expect(startActionSpy.calls.argsFor(0)[1]).toEqual( + jasmine.objectContaining({ + type: ActionType.CUSTOM, + context: { count: 123, nested: { foo: 'bar' } }, + actionKey: 'action_key', + }) + ) + }) + }) + describe('addDurationVital', () => { it('should call addDurationVital on the startRum result', () => { const addDurationVitalSpy = jasmine.createSpy() diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 0bd96bb88b..4203822bfd 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -53,6 +53,7 @@ import { callPluginsMethod } from '../domain/plugins' import type { Hooks } from '../domain/hooks' import type { SdkName } from '../domain/contexts/defaultContext' import type { LongTaskContexts } from '../domain/longTask/longTaskCollection' +import type { ActionOptions } from '../domain/action/trackCustomActions' import { createPreStartStrategy } from './preStartRum' import type { StartRum, StartRumResult } from './startRum' @@ -168,6 +169,24 @@ export interface RumPublicApi extends PublicApi { */ addAction: (name: string, context?: object) => void + /** + * [Experimental] start a custom action, stored in `@action` + * + * @category Data Collection + * @param name - Name of the action + * @param options - Options of the action + */ + startAction: (name: string, options?: ActionOptions) => void + + /** + * [Experimental] stop a custom action, stored in `@action` + * + * @category Data Collection + * @param name - Name of the action + * @param options - Options of the action + */ + stopAction: (name: string, options?: ActionOptions) => void + /** * Add a custom error, stored in `@error`. * @@ -523,6 +542,8 @@ export interface Strategy { accountContext: ContextManager addAction: StartRumResult['addAction'] + startAction: StartRumResult['startAction'] + stopAction: StartRumResult['stopAction'] addError: StartRumResult['addError'] addFeatureFlagEvaluation: StartRumResult['addFeatureFlagEvaluation'] startDurationVital: StartRumResult['startDurationVital'] @@ -653,6 +674,24 @@ export function makeRumPublicApi( }) }, + startAction: monitor((name, options) => { + // addTelemetryUsage({ feature: 'start-action' }) + strategy.startAction(sanitize(name)!, { + type: sanitize(options && options.type) as ActionType | undefined, + context: sanitize(options && options.context) as Context, + actionKey: options && options.actionKey, + }) + }), + + stopAction: monitor((name, options) => { + // addTelemetryUsage({ feature: 'stop-action' }) + strategy.stopAction(sanitize(name)!, { + type: sanitize(options && options.type) as ActionType | undefined, + context: sanitize(options && options.context) as Context, + actionKey: options && options.actionKey, + }) + }), + addError: (error, context) => { const handlingStack = createHandlingStack('error') callMonitored(() => { diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 1e7eff1b9a..bec354011c 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -258,6 +258,8 @@ export function startRumEventCollection( return { addAction: actionCollection.addAction, + startAction: actionCollection.startAction, + stopAction: actionCollection.stopAction, addEvent: eventCollection.addEvent, addError, addTiming, diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 92821534a9..bd0e8669b3 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -9,9 +9,9 @@ import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { AssembleHookParams, DefaultTelemetryEventAttributes, Hooks } from '../hooks' import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' -import type { ActionContexts } from './actionCollection' import { LONG_TASK_START_TIME_CORRECTION, startActionCollection } from './actionCollection' import { ActionNameSource } from './actionNameConstants' +import type { ActionContexts } from './trackAction' describe('actionCollection', () => { const lifeCycle = new LifeCycle() diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 4bf36878ff..d67be549b7 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,30 +1,33 @@ -import type { ClocksState, Context, Duration, Observable } from '@datadog/browser-core' -import { noop, combine, toServerDuration, generateUUID, SKIPPED, HookNames, addDuration } from '@datadog/browser-core' +import type { Duration, Observable } from '@datadog/browser-core' +import { + noop, + toServerDuration, + generateUUID, + SKIPPED, + HookNames, + addDuration, + isExperimentalFeatureEnabled, + ExperimentalFeature, +} from '@datadog/browser-core' import { discardNegativeDuration } from '../discardNegativeDuration' import type { RawRumActionEvent } from '../../rawRumEvent.types' -import { ActionType, RumEventType } from '../../rawRumEvent.types' +import { RumEventType } from '../../rawRumEvent.types' import type { LifeCycle, RawRumEventCollectedData } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' import type { RumConfiguration } from '../configuration' -import type { RumActionEventDomainContext } from '../../domainContext.types' import type { DefaultRumEventAttributes, DefaultTelemetryEventAttributes, Hooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' -import type { ActionContexts, ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' - -export type { ActionContexts } - -export interface CustomAction { - type: typeof ActionType.CUSTOM - name: string - startClocks: ClocksState - context?: Context - handlingStack?: string -} +import type { ClickAction } from './trackClickActions' +import { startActionTracker } from './trackAction' +import type { ActionContexts } from './trackAction' +import { trackCustomActions } from './trackCustomActions' +import type { CustomAction } from './trackCustomActions' export type AutoAction = ClickAction export const LONG_TASK_START_TIME_CORRECTION = 1 as Duration + export function startActionCollection( lifeCycle: LifeCycle, hooks: Hooks, @@ -32,6 +35,9 @@ export function startActionCollection( windowOpenObservable: Observable, configuration: RumConfiguration ) { + // Shared action tracker for both click and custom actions + const actionTracker = startActionTracker(lifeCycle) + const { unsubscribe: unsubscribeAutoAction } = lifeCycle.subscribe( LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) => { @@ -39,6 +45,30 @@ export function startActionCollection( } ) + let stopClickActions: () => void = noop + + if (configuration.trackUserInteractions) { + ;({ stop: stopClickActions } = trackClickActions( + lifeCycle, + domMutationObservable, + windowOpenObservable, + configuration, + actionTracker + )) + } + + let customActions: ReturnType | undefined + + if (isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + customActions = trackCustomActions(lifeCycle, actionTracker, (action) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + }) + } + + const actionContexts: ActionContexts = { + findActionId: actionTracker.findActionId, + } + hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { if ( eventType !== RumEventType.ERROR && @@ -75,82 +105,60 @@ export function startActionCollection( }) ) - let actionContexts: ActionContexts = { findActionId: noop as () => undefined } - let stop: () => void = noop - - if (configuration.trackUserInteractions) { - ;({ actionContexts, stop } = trackClickActions( - lifeCycle, - domMutationObservable, - windowOpenObservable, - configuration - )) - } - return { - addAction: (action: CustomAction) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + addAction: (action: Omit) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction({ id: generateUUID(), ...action })) }, + startAction: customActions?.startAction ?? noop, + stopAction: customActions?.stopAction ?? noop, actionContexts, stop: () => { unsubscribeAutoAction() - stop() + stopClickActions() + customActions?.stop() + actionTracker.stop() }, } } function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { - const autoActionProperties = isAutoAction(action) - ? { - action: { - id: action.id, - loading_time: discardNegativeDuration(toServerDuration(action.duration)), - frustration: { - type: action.frustrationTypes, - }, - error: { - count: action.counts.errorCount, - }, - long_task: { - count: action.counts.longTaskCount, - }, - resource: { - count: action.counts.resourceCount, - }, - }, - _dd: { - action: { - target: action.target, - position: action.position, - name_source: action.nameSource, - }, - }, - } - : { - context: action.context, - } - const actionEvent: RawRumActionEvent = combine( - { - action: { id: generateUUID(), target: { name: action.name }, type: action.type }, - date: action.startClocks.timeStamp, - type: RumEventType.ACTION, - }, - autoActionProperties - ) - - const duration = isAutoAction(action) ? action.duration : undefined - const domainContext: RumActionEventDomainContext = isAutoAction(action) - ? { events: action.events } - : { handlingStack: action.handlingStack } + const isAuto = isAutoAction(action) + const loadingTime = discardNegativeDuration(toServerDuration(action.duration)) return { - rawRumEvent: actionEvent, - duration, + rawRumEvent: { + type: RumEventType.ACTION, + date: action.startClocks.timeStamp, + action: { + id: action.id, + target: { name: action.name }, + type: action.type, + ...(loadingTime !== undefined && { loading_time: loadingTime }), + ...(action.counts && { + error: { count: action.counts.errorCount }, + long_task: { count: action.counts.longTaskCount }, + resource: { count: action.counts.resourceCount }, + }), + ...(isAuto && { frustration: { type: action.frustrationTypes } }), + }, + ...(isAuto + ? { + _dd: { + action: { + target: action.target, + position: action.position, + name_source: action.nameSource, + }, + }, + } + : { context: action.context }), + }, + duration: action.duration, startTime: action.startClocks.relative, - domainContext, + domainContext: isAuto ? { events: action.events } : { handlingStack: action.handlingStack }, } } function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { - return action.type !== ActionType.CUSTOM + return 'events' in action } diff --git a/packages/rum-core/src/domain/action/trackAction.spec.ts b/packages/rum-core/src/domain/action/trackAction.spec.ts new file mode 100644 index 0000000000..bef0402814 --- /dev/null +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -0,0 +1,267 @@ +import type { RelativeTime, TimeStamp } from '@datadog/browser-core' +import { registerCleanupTask } from '@datadog/browser-core/test' +import { LifeCycle, LifeCycleEventType } from '../lifeCycle' +import { RumEventType } from '../../rawRumEvent.types' +import type { ActionTracker, TrackedAction } from './trackAction' +import { startActionTracker } from './trackAction' + +describe('trackAction', () => { + let lifeCycle: LifeCycle + let actionTracker: ActionTracker + + beforeEach(() => { + lifeCycle = new LifeCycle() + actionTracker = startActionTracker(lifeCycle) + registerCleanupTask(() => actionTracker.stop()) + }) + + describe('createTrackedAction', () => { + it('should generate a unique action ID', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + expect(trackedAction.id).toBeDefined() + expect(typeof trackedAction.id).toBe('string') + expect(trackedAction.id.length).toBeGreaterThan(0) + }) + + it('should create distinct IDs for each tracked action', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const action1 = actionTracker.createTrackedAction(startClocks) + const action2 = actionTracker.createTrackedAction(startClocks) + + expect(action1.id).not.toBe(action2.id) + }) + + it('should store the start clocks', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + expect(trackedAction.startClocks).toBe(startClocks) + }) + + it('should initialize event counts to zero', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + expect(trackedAction.counts.errorCount).toBe(0) + expect(trackedAction.counts.resourceCount).toBe(0) + expect(trackedAction.counts.longTaskCount).toBe(0) + }) + }) + + describe('event counting', () => { + let trackedAction: TrackedAction + + beforeEach(() => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + trackedAction = actionTracker.createTrackedAction(startClocks) + }) + + it('should count errors associated with the action', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: trackedAction.id }, + error: { message: 'test error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(1) + }) + + it('should count resources associated with the action', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + action: { id: trackedAction.id }, + resource: { type: 'fetch' }, + } as any) + + expect(trackedAction.counts.resourceCount).toBe(1) + }) + + it('should count long tasks associated with the action', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.LONG_TASK, + action: { id: trackedAction.id }, + long_task: { duration: 100 }, + } as any) + + expect(trackedAction.counts.longTaskCount).toBe(1) + }) + + it('should count events when action ID is in an array', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: ['other-id', trackedAction.id] }, + error: { message: 'test error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(1) + }) + + it('should not count events for other actions', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: 'other-action-id' }, + error: { message: 'test error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(0) + }) + + it('should stop counting events after action is stopped', () => { + trackedAction.stop(200 as RelativeTime) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: trackedAction.id }, + error: { message: 'test error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(0) + }) + }) + + describe('findActionId', () => { + it('should return undefined when no actions are tracked', () => { + expect(actionTracker.findActionId()).toBeUndefined() + }) + + it('should return the action ID when one action is active', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + expect(actionTracker.findActionId()).toEqual([trackedAction.id]) + }) + + it('should return undefined for actions that were stopped without end time', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + trackedAction.stop(200 as RelativeTime) + + expect(actionTracker.findActionId()).toBeUndefined() + }) + + it('should return the action ID for events within the action time range', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + trackedAction.stop(200 as RelativeTime) + + expect(actionTracker.findActionId(150 as RelativeTime)).toEqual([trackedAction.id]) + }) + + it('should return undefined for events outside the action time range', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + trackedAction.stop(200 as RelativeTime) + + expect(actionTracker.findActionId(250 as RelativeTime)).toBeUndefined() + }) + + it('should return array of IDs when multiple actions are active', () => { + const action1 = actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) + const action2 = actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) + + const result = actionTracker.findActionId() + + expect(Array.isArray(result)).toBeTrue() + expect(result).toContain(action1.id) + expect(result).toContain(action2.id) + }) + }) + + describe('discard', () => { + it('should remove the action from history', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + trackedAction.discard() + + expect(actionTracker.findActionId()).toBeUndefined() + }) + + it('should stop counting events after discard', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + trackedAction.discard() + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: trackedAction.id }, + error: { message: 'test error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(0) + }) + }) + + describe('session renewal', () => { + it('should clear all action IDs on session renewal', () => { + actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) + + expect(actionTracker.findActionId()).toBeDefined() + + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + expect(actionTracker.findActionId()).toBeUndefined() + }) + + it('should stop event counting on session renewal', () => { + const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } + const trackedAction = actionTracker.createTrackedAction(startClocks) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: trackedAction.id }, + error: { message: 'first error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(1) + + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: trackedAction.id }, + error: { message: 'second error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(1) + }) + }) + + describe('stop', () => { + it('should clean up all resources', () => { + const trackedAction = actionTracker.createTrackedAction({ + relative: 100 as RelativeTime, + timeStamp: 1000 as TimeStamp, + }) + + expect(actionTracker.findActionId()).toEqual([trackedAction.id]) + + actionTracker.stop() + + expect(actionTracker.findActionId()).toBeUndefined() + }) + + it('should stop all active event count subscriptions', () => { + const trackedAction = actionTracker.createTrackedAction({ + relative: 100 as RelativeTime, + timeStamp: 1000 as TimeStamp, + }) + + actionTracker.stop() + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: trackedAction.id }, + error: { message: 'test error' }, + } as any) + + expect(trackedAction.counts.errorCount).toBe(0) + }) + }) +}) diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts new file mode 100644 index 0000000000..4295495809 --- /dev/null +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -0,0 +1,94 @@ +import type { ClocksState, Duration, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' +import { ONE_MINUTE, generateUUID, createValueHistory, elapsed } from '@datadog/browser-core' +import { LifeCycleEventType } from '../lifeCycle' +import type { LifeCycle } from '../lifeCycle' +import type { EventCounts } from '../trackEventCounts' +import { trackEventCounts } from '../trackEventCounts' + +export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary + +export type ActionCounts = EventCounts + +export interface TrackedAction { + id: string + startClocks: ClocksState + duration: Duration | undefined + counts: ActionCounts + stop: (endTime: RelativeTime) => void + discard: () => void +} + +export interface ActionContexts { + findActionId: (startTime?: RelativeTime) => string | string[] | undefined +} + +export interface ActionTracker { + createTrackedAction: (startClocks: ClocksState) => TrackedAction + findActionId: (startTime?: RelativeTime) => string | string[] | undefined + stop: () => void +} + +export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { + const history = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) + const activeEventCountSubscriptions = new Set>() + + const sessionRenewalSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + history.reset() + activeEventCountSubscriptions.forEach((subscription) => subscription.stop()) + activeEventCountSubscriptions.clear() + }) + + function createTrackedAction(startClocks: ClocksState): TrackedAction { + const id = generateUUID() + const historyEntry: ValueHistoryEntry = history.add(id, startClocks.relative) + let duration: Duration | undefined + + const eventCountsSubscription = trackEventCounts({ + lifeCycle, + isChildEvent: (event) => + event.action !== undefined && + (Array.isArray(event.action.id) ? event.action.id.includes(id) : event.action.id === id), + }) + activeEventCountSubscriptions.add(eventCountsSubscription) + + function cleanup() { + eventCountsSubscription.stop() + activeEventCountSubscriptions.delete(eventCountsSubscription) + } + + return { + id, + startClocks, + get duration() { + return duration + }, + get counts() { + return eventCountsSubscription.eventCounts + }, + stop(endTime: RelativeTime) { + historyEntry.close(endTime) + duration = elapsed(startClocks.relative, endTime) + cleanup() + }, + discard() { + historyEntry.remove() + cleanup() + }, + } + } + + function findActionId(startTime?: RelativeTime): string | string[] | undefined { + const ids = history.findAll(startTime) + return ids.length ? ids : undefined + } + + function stop() { + sessionRenewalSubscription.unsubscribe() + activeEventCountSubscriptions.forEach((subscription) => subscription.stop()) + activeEventCountSubscriptions.clear() + history.reset() + history.stop() + } + + return { createTrackedAction, findActionId, stop } +} diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 5e3db3981e..d75fe28bcd 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -10,7 +10,7 @@ import { PageExitReason, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { createNewEvent, mockClock, mockExperimentalFeatures } from '@datadog/browser-core/test' +import { createNewEvent, mockClock, mockExperimentalFeatures, registerCleanupTask } from '@datadog/browser-core/test' import { createFakeClick, createMutationRecord, mockRumConfiguration } from '../../../test' import type { AssembledRumEvent } from '../../rawRumEvent.types' import { RumEventType, ActionType, FrustrationType } from '../../rawRumEvent.types' @@ -19,12 +19,13 @@ import { PAGE_ACTIVITY_VALIDATION_DELAY } from '../waitPageActivityEnd' import type { RumConfiguration } from '../configuration' import type { BrowserWindow } from '../privacy' import type { RumMutationRecord } from '../../browser/domMutationObservable' -import type { ActionContexts } from './actionCollection' +import type { ActionContexts, ActionTracker } from './trackAction' import type { ClickAction } from './trackClickActions' import { finalizeClicks, trackClickActions } from './trackClickActions' import { MAX_DURATION_BETWEEN_CLICKS } from './clickChain' import { getInteractionSelector, CLICK_ACTION_MAX_DURATION } from './interactionSelectorCache' import { ActionNameSource } from './actionNameConstants' +import { startActionTracker } from './trackAction' // Used to wait some time after the creation of an action const BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY = PAGE_ACTIVITY_VALIDATION_DELAY * 0.8 @@ -51,6 +52,7 @@ describe('trackClickActions', () => { let domMutationObservable: Observable let windowOpenObservable: Observable let clock: Clock + let actionTracker: ActionTracker const { events, pushEvent } = eventsCollector() let button: HTMLButtonElement @@ -61,14 +63,18 @@ describe('trackClickActions', () => { function startClickActionsTracking(partialConfig: Partial = {}) { const subscription = lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, pushEvent) + actionTracker = startActionTracker(lifeCycle) + registerCleanupTask(() => actionTracker.stop()) + const trackClickActionsResult = trackClickActions( lifeCycle, domMutationObservable, windowOpenObservable, - mockRumConfiguration(partialConfig) + mockRumConfiguration(partialConfig), + actionTracker ) - findActionId = trackClickActionsResult.actionContexts.findActionId + findActionId = actionTracker.findActionId stopClickActionsTracking = () => { trackClickActionsResult.stop() subscription.unsubscribe() @@ -112,12 +118,12 @@ describe('trackClickActions', () => { clock.tick(EXPIRE_DELAY) const domEvent = createNewEvent('pointerup', { target: document.createElement('button') }) expect(events).toEqual([ - { - counts: { + jasmine.objectContaining({ + counts: jasmine.objectContaining({ errorCount: 0, longTaskCount: 0, resourceCount: 0, - }, + }), duration: BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY as Duration, id: jasmine.any(String), name: 'Click me', @@ -136,7 +142,7 @@ describe('trackClickActions', () => { }, position: { x: 50, y: 50 }, events: [domEvent], - }, + }), ]) }) @@ -163,11 +169,13 @@ describe('trackClickActions', () => { expect(events.length).toBe(1) const clickAction = events[0] - expect(clickAction.counts).toEqual({ - errorCount: 2, - longTaskCount: 0, - resourceCount: 0, - }) + expect(clickAction.counts).toEqual( + jasmine.objectContaining({ + errorCount: 2, + longTaskCount: 0, + resourceCount: 0, + }) + ) }) it('does not count child events unrelated to the click action', () => { @@ -205,7 +213,7 @@ describe('trackClickActions', () => { clock.tick(EXPIRE_DELAY) expect(events).toEqual([]) - expect(findActionId()).toEqual([]) + expect(findActionId()).toBeUndefined() }) it('ongoing click action is stopped on view end', () => { @@ -258,7 +266,7 @@ describe('trackClickActions', () => { clock.tick(EXPIRE_DELAY) expect(events.length).toBe(1) expect(events[0].frustrationTypes).toEqual([FrustrationType.DEAD_CLICK]) - expect(findActionId()).toEqual([]) + expect(findActionId()).toBeUndefined() }) it('does not set a duration for dead clicks', () => { diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index c53cb13e1c..8ba151b544 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -1,20 +1,10 @@ -import type { Duration, ClocksState, RelativeTime, TimeStamp, ValueHistory } from '@datadog/browser-core' -import { - timeStampNow, - Observable, - getRelativeTime, - ONE_MINUTE, - generateUUID, - elapsed, - createValueHistory, - relativeToClocks, -} from '@datadog/browser-core' +import type { Duration, ClocksState, TimeStamp } from '@datadog/browser-core' +import { timeStampNow, Observable, getRelativeTime, relativeToClocks } from '@datadog/browser-core' import { isNodeShadowHost } from '../../browser/htmlDomUtils' import type { FrustrationType } from '../../rawRumEvent.types' import { ActionType } from '../../rawRumEvent.types' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' -import { trackEventCounts } from '../trackEventCounts' import { PAGE_ACTIVITY_VALIDATION_DELAY, waitPageActivityEnd } from '../waitPageActivityEnd' import { getSelectorFromElement } from '../getSelectorFromElement' import { getNodePrivacyLevel } from '../privacy' @@ -29,6 +19,7 @@ import type { MouseEventOnElement, UserActivity } from './listenActionEvents' import { listenActionEvents } from './listenActionEvents' import { computeFrustration } from './computeFrustration' import { CLICK_ACTION_MAX_DURATION, updateInteractionSelector } from './interactionSelectorCache' +import type { ActionTracker, TrackedAction } from './trackAction' interface ActionCounts { errorCount: number @@ -55,28 +46,16 @@ export interface ClickAction { events: Event[] } -export interface ActionContexts { - findActionId: (startTime?: RelativeTime) => string | string[] | undefined -} - -type ClickActionIdHistory = ValueHistory - -export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary - export function trackClickActions( lifeCycle: LifeCycle, domMutationObservable: Observable, windowOpenObservable: Observable, - configuration: RumConfiguration + configuration: RumConfiguration, + actionTracker: ActionTracker ) { - const history: ClickActionIdHistory = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) const stopObservable = new Observable() let currentClickChain: ClickChain | undefined - lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { - history.reset() - }) - lifeCycle.subscribe(LifeCycleEventType.VIEW_ENDED, stopClickChain) lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, stopClickChain) @@ -92,7 +71,7 @@ export function trackClickActions( lifeCycle, domMutationObservable, windowOpenObservable, - history, + actionTracker, stopObservable, appendClickToClickChain, clickActionBase, @@ -103,17 +82,12 @@ export function trackClickActions( }, }) - const actionContexts: ActionContexts = { - findActionId: (startTime?: RelativeTime) => history.findAll(startTime), - } - return { stop: () => { stopClickChain() stopObservable.notify() stopActionEventsListener() }, - actionContexts, } function appendClickToClickChain(click: Click) { @@ -184,7 +158,7 @@ function startClickAction( lifeCycle: LifeCycle, domMutationObservable: Observable, windowOpenObservable: Observable, - history: ClickActionIdHistory, + actionTracker: ActionTracker, stopObservable: Observable, appendClickToClickChain: (click: Click) => void, clickActionBase: ClickActionBase, @@ -192,7 +166,7 @@ function startClickAction( getUserActivity: () => UserActivity, hadActivityOnPointerDown: () => boolean ) { - const click = newClick(lifeCycle, history, getUserActivity, clickActionBase, startEvent) + const click = newClick(lifeCycle, actionTracker, getUserActivity, clickActionBase, startEvent) appendClickToClickChain(click) const selector = clickActionBase?.target?.selector @@ -304,20 +278,13 @@ export type Click = ReturnType function newClick( lifeCycle: LifeCycle, - history: ClickActionIdHistory, + actionTracker: ActionTracker, getUserActivity: () => UserActivity, clickActionBase: ClickActionBase, startEvent: MouseEventOnElement ) { - const id = generateUUID() - const startClocks = relativeToClocks(startEvent.timeStamp) - const historyEntry = history.add(id, startClocks.relative) - const eventCountsSubscription = trackEventCounts({ - lifeCycle, - isChildEvent: (event) => - event.action !== undefined && - (Array.isArray(event.action.id) ? event.action.id.includes(id) : event.action.id === id), - }) + const trackedAction: TrackedAction = actionTracker.createTrackedAction(relativeToClocks(startEvent.timeStamp)) + let status = ClickStatus.ONGOING let activityEndTime: undefined | TimeStamp const frustrationTypes: FrustrationType[] = [] @@ -330,11 +297,10 @@ function newClick( activityEndTime = newActivityEndTime status = ClickStatus.STOPPED if (activityEndTime) { - historyEntry.close(getRelativeTime(activityEndTime)) + trackedAction.stop(getRelativeTime(activityEndTime)) } else { - historyEntry.remove() + trackedAction.discard() } - eventCountsSubscription.stop() stopObservable.notify() } @@ -344,7 +310,7 @@ function newClick( stopObservable, get hasError() { - return eventCountsSubscription.eventCounts.errorCount > 0 + return trackedAction.counts.errorCount > 0 }, get hasPageActivity() { return activityEndTime !== undefined @@ -353,11 +319,13 @@ function newClick( addFrustration: (frustrationType: FrustrationType) => { frustrationTypes.push(frustrationType) }, - startClocks, + get startClocks() { + return trackedAction.startClocks + }, isStopped: () => status === ClickStatus.STOPPED || status === ClickStatus.FINALIZED, - clone: () => newClick(lifeCycle, history, getUserActivity, clickActionBase, startEvent), + clone: () => newClick(lifeCycle, actionTracker, getUserActivity, clickActionBase, startEvent), validate: (domEvents?: Event[]) => { stop() @@ -365,17 +333,12 @@ function newClick( return } - const { resourceCount, errorCount, longTaskCount } = eventCountsSubscription.eventCounts const clickAction: ClickAction = { - duration: activityEndTime && elapsed(startClocks.timeStamp, activityEndTime), - startClocks, - id, + startClocks: trackedAction.startClocks, + duration: trackedAction.duration, + id: trackedAction.id, frustrationTypes, - counts: { - resourceCount, - errorCount, - longTaskCount, - }, + counts: trackedAction.counts, events: domEvents ?? [startEvent], event: startEvent, ...clickActionBase, diff --git a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts new file mode 100644 index 0000000000..8da494eab6 --- /dev/null +++ b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts @@ -0,0 +1,324 @@ +import type { Duration, ServerDuration } from '@datadog/browser-core' +import { ExperimentalFeature, Observable } from '@datadog/browser-core' +import type { Clock } from '@datadog/browser-core/test' +import { mockClock, mockExperimentalFeatures, registerCleanupTask } from '@datadog/browser-core/test' +import { collectAndValidateRawRumEvents, mockRumConfiguration } from '../../../test' +import type { RawRumActionEvent, RawRumEvent } from '../../rawRumEvent.types' +import { RumEventType, ActionType } from '../../rawRumEvent.types' +import { type RawRumEventCollectedData, LifeCycle, LifeCycleEventType } from '../lifeCycle' +import { createHooks } from '../hooks' +import type { RumMutationRecord } from '../../browser/domMutationObservable' +import { startActionCollection } from './actionCollection' +import type { ActionContexts } from './trackAction' + +describe('trackCustomActions', () => { + const lifeCycle = new LifeCycle() + let rawRumEvents: Array> + let actionContexts: ActionContexts + let startAction: ReturnType['startAction'] + let stopAction: ReturnType['stopAction'] + let stopActionCollection: ReturnType['stop'] + let clock: Clock + + beforeEach(() => { + clock = mockClock() + mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + + const domMutationObservable = new Observable() + const windowOpenObservable = new Observable() + const hooks = createHooks() + + const actionCollection = startActionCollection( + lifeCycle, + hooks, + domMutationObservable, + windowOpenObservable, + mockRumConfiguration() + ) + registerCleanupTask(actionCollection.stop) + startAction = actionCollection.startAction + stopAction = actionCollection.stopAction + stopActionCollection = actionCollection.stop + actionContexts = actionCollection.actionContexts + + rawRumEvents = collectAndValidateRawRumEvents(lifeCycle) + }) + + describe('basic functionality', () => { + it('should create action with duration from name-based tracking', () => { + startAction('user_login') + clock.tick(500) + stopAction('user_login') + + expect(rawRumEvents).toHaveSize(1) + expect(rawRumEvents[0].duration).toBe(500 as Duration) + expect(rawRumEvents[0].rawRumEvent).toEqual( + jasmine.objectContaining({ + type: RumEventType.ACTION, + action: jasmine.objectContaining({ + target: { name: 'user_login' }, + type: ActionType.CUSTOM, + }), + }) + ) + }) + + it('should not create action if stopped without starting', () => { + stopAction('never_started') + + expect(rawRumEvents).toHaveSize(0) + }) + + it('should only create action once when stopped multiple times', () => { + startAction('foo') + stopAction('foo') + stopAction('foo') + + expect(rawRumEvents).toHaveSize(1) + }) + + it('should use consistent action ID from start to collected event', () => { + startAction('checkout') + + const actionId = actionContexts.findActionId() + expect(actionId).toBeDefined() + + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.id).toEqual((actionId as string[])[0]) + }) + + it('should include loading_time for timed custom actions', () => { + startAction('checkout') + clock.tick(500) + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.loading_time).toBe((500 * 1e6) as ServerDuration) + }) + }) + + describe('action types', () => { + ;[ActionType.SWIPE, ActionType.TAP, ActionType.SCROLL].forEach((actionType) => { + it(`should support ${actionType} action type`, () => { + startAction('test_action', { type: actionType }) + stopAction('test_action') + + expect(rawRumEvents).toHaveSize(1) + expect(rawRumEvents[0].rawRumEvent).toEqual( + jasmine.objectContaining({ + type: RumEventType.ACTION, + action: jasmine.objectContaining({ + type: actionType, + }), + }) + ) + }) + }) + + it('should handle type precedence (stop overrides start)', () => { + startAction('action1', { type: ActionType.TAP }) + stopAction('action1', { type: ActionType.SCROLL }) + + startAction('action2', { type: ActionType.SWIPE }) + stopAction('action2') + + startAction('action3') + stopAction('action3') + + expect(rawRumEvents).toHaveSize(3) + expect(rawRumEvents[0].rawRumEvent).toEqual( + jasmine.objectContaining({ + action: jasmine.objectContaining({ type: ActionType.SCROLL }), + }) + ) + expect(rawRumEvents[1].rawRumEvent).toEqual( + jasmine.objectContaining({ + action: jasmine.objectContaining({ type: ActionType.SWIPE }), + }) + ) + expect(rawRumEvents[2].rawRumEvent).toEqual( + jasmine.objectContaining({ + action: jasmine.objectContaining({ type: ActionType.CUSTOM }), + }) + ) + }) + }) + + describe('context merging', () => { + it('should merge contexts with stop precedence on conflicts', () => { + startAction('action1', { context: { cart: 'abc' } }) + stopAction('action1', { context: { total: 100 } }) + + startAction('action2', { context: { status: 'pending' } }) + stopAction('action2', { context: { status: 'complete' } }) + + expect(rawRumEvents).toHaveSize(2) + expect(rawRumEvents[0].rawRumEvent).toEqual( + jasmine.objectContaining({ + context: { cart: 'abc', total: 100 }, + }) + ) + expect(rawRumEvents[1].rawRumEvent).toEqual( + jasmine.objectContaining({ + context: { status: 'complete' }, + }) + ) + }) + }) + + describe('actionKey', () => { + it('should support actionKey for tracking same name multiple times', () => { + startAction('click', { actionKey: 'button1' }) + startAction('click', { actionKey: 'button2' }) + + clock.tick(100) + stopAction('click', { actionKey: 'button2' }) + + clock.tick(100) + stopAction('click', { actionKey: 'button1' }) + + expect(rawRumEvents).toHaveSize(2) + expect(rawRumEvents[0].duration).toBe(100 as Duration) + expect(rawRumEvents[1].duration).toBe(200 as Duration) + }) + + it('getActionLookupKey should not collide', () => { + startAction('foo bar') + startAction('foo', { actionKey: 'bar' }) + + const actionIds = actionContexts.findActionId() + expect(Array.isArray(actionIds)).toBeTrue() + expect((actionIds as string[]).length).toBe(2) + + stopAction('foo bar') + stopAction('foo', { actionKey: 'bar' }) + + expect(rawRumEvents).toHaveSize(2) + expect(rawRumEvents[0].rawRumEvent).toEqual( + jasmine.objectContaining({ + action: jasmine.objectContaining({ target: { name: 'foo bar' } }), + }) + ) + expect(rawRumEvents[1].rawRumEvent).toEqual( + jasmine.objectContaining({ + action: jasmine.objectContaining({ target: { name: 'foo' } }), + }) + ) + }) + }) + + describe('duplicate start handling', () => { + it('should clean up previous action when startAction is called twice with same key', () => { + startAction('checkout') + const firstActionId = actionContexts.findActionId() + expect(firstActionId).toBeDefined() + + clock.tick(100) + + startAction('checkout') + const secondActionId = actionContexts.findActionId() + expect(secondActionId).toBeDefined() + + expect(secondActionId).not.toEqual(firstActionId) + + clock.tick(200) + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.id).toEqual((secondActionId as string[])[0]) + expect(rawRumEvents[0].duration).toBe(200 as Duration) + }) + }) + + describe('event counting', () => { + it('should include counts in the action event', () => { + startAction('complex-action') + + const actionId = actionContexts.findActionId() + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: actionId }, + } as any) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: actionId }, + } as any) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + action: { id: actionId }, + } as any) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.LONG_TASK, + action: { id: actionId }, + } as any) + + stopAction('complex-action') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.error?.count).toBe(2) + expect(actionEvent.action.resource?.count).toBe(1) + expect(actionEvent.action.long_task?.count).toBe(1) + }) + }) + + describe('session renewal', () => { + it('should discard active custom actions on session renewal', () => { + startAction('cross-session-action') + + const actionIdBeforeRenewal = actionContexts.findActionId() + expect(actionIdBeforeRenewal).toBeDefined() + + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + expect(actionContexts.findActionId()).toBeUndefined() + + stopAction('cross-session-action') + + expect(rawRumEvents).toHaveSize(0) + }) + + it('should stop event count subscriptions on session renewal', () => { + startAction('tracked-action') + + const actionId = actionContexts.findActionId() + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: actionId }, + } as any) + + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + startAction('tracked-action') + stopAction('tracked-action') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.error?.count).toBe(0) + }) + }) + + describe('cleanup', () => { + it('should clean up active custom actions on stop()', () => { + startAction('active-when-stopped') + + const actionIdBeforeStop = actionContexts.findActionId() + expect(actionIdBeforeStop).toBeDefined() + + stopActionCollection() + + expect(actionContexts.findActionId()).toBeUndefined() + + stopAction('active-when-stopped') + + expect(rawRumEvents).toHaveSize(0) + }) + }) +}) diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackCustomActions.ts new file mode 100644 index 0000000000..302fe0f8d6 --- /dev/null +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -0,0 +1,108 @@ +import type { ClocksState, Context, Duration } from '@datadog/browser-core' +import { clocksNow, combine } from '@datadog/browser-core' +import type { ActionType } from '../../rawRumEvent.types' +import { ActionType as ActionTypeEnum } from '../../rawRumEvent.types' +import { LifeCycleEventType } from '../lifeCycle' +import type { LifeCycle } from '../lifeCycle' +import type { ActionCounts, ActionTracker, TrackedAction } from './trackAction' + +export interface ActionOptions { + /** + * Action Type + * + * @default 'custom' + */ + type?: ActionType + + /** + * Action context + */ + context?: any + + /** + * Action key + */ + actionKey?: string +} + +export interface CustomAction { + id: string + type: ActionType + name: string + startClocks: ClocksState + duration?: Duration + context?: Context + handlingStack?: string + counts?: ActionCounts +} + +interface ActiveCustomAction { + name: string + type?: ActionType + context?: Context + trackedAction: TrackedAction +} + +export function trackCustomActions( + lifeCycle: LifeCycle, + actionTracker: ActionTracker, + onCustomActionCompleted: (action: CustomAction) => void +) { + const activeCustomActions = new Map() + + lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => activeCustomActions.clear()) + + function startCustomAction(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { + const lookupKey = options.actionKey ?? name + + const existingAction = activeCustomActions.get(lookupKey) + if (existingAction) { + existingAction.trackedAction.discard() + activeCustomActions.delete(lookupKey) + } + + const trackedAction = actionTracker.createTrackedAction(startClocks) + + activeCustomActions.set(lookupKey, { + name, + type: options.type, + context: options.context, + trackedAction, + }) + } + + function stopCustomAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { + const lookupKey = options.actionKey ?? name + const activeAction = activeCustomActions.get(lookupKey) + + if (!activeAction) { + return + } + + activeAction.trackedAction.stop(stopClocks.relative) + + const customAction: CustomAction = { + id: activeAction.trackedAction.id, + name: activeAction.name, + type: (options.type ?? activeAction.type) || ActionTypeEnum.CUSTOM, + startClocks: activeAction.trackedAction.startClocks, + duration: activeAction.trackedAction.duration, + context: combine(activeAction.context, options.context), + counts: activeAction.trackedAction.counts, + } + + onCustomActionCompleted(customAction) + activeCustomActions.delete(lookupKey) + } + + function stop() { + activeCustomActions.forEach((activeAction) => activeAction.trackedAction.discard()) + activeCustomActions.clear() + } + + return { + startAction: startCustomAction, + stopAction: stopCustomAction, + stop, + } +} diff --git a/packages/rum-core/src/domain/contexts/internalContext.spec.ts b/packages/rum-core/src/domain/contexts/internalContext.spec.ts index c24e97c558..377dd22868 100644 --- a/packages/rum-core/src/domain/contexts/internalContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/internalContext.spec.ts @@ -1,8 +1,8 @@ import { noop, type RelativeTime } from '@datadog/browser-core' import { buildLocation } from '@datadog/browser-core/test' import { createRumSessionManagerMock } from '../../../test' -import type { ActionContexts } from '../action/actionCollection' import type { RumSessionManager } from '../rumSessionManager' +import type { ActionContexts } from '../action/trackAction' import { startInternalContext } from './internalContext' import type { ViewHistory } from './viewHistory' import type { UrlContexts } from './urlContexts' diff --git a/packages/rum-core/src/domain/contexts/internalContext.ts b/packages/rum-core/src/domain/contexts/internalContext.ts index 6e401872a9..d0b7913b99 100644 --- a/packages/rum-core/src/domain/contexts/internalContext.ts +++ b/packages/rum-core/src/domain/contexts/internalContext.ts @@ -1,6 +1,6 @@ import type { RelativeTime, RumInternalContext } from '@datadog/browser-core' -import type { ActionContexts } from '../action/actionCollection' import type { RumSessionManager } from '../rumSessionManager' +import type { ActionContexts } from '../action/trackAction' import type { ViewHistory } from './viewHistory' import type { UrlContexts } from './urlContexts' diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index 9229ae390b..b599188859 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -337,6 +337,11 @@ export interface RawRumActionEvent { export const ActionType = { CLICK: 'click', CUSTOM: 'custom', + TAP: 'tap', + SCROLL: 'scroll', + SWIPE: 'swipe', + APPLICATION_START: 'application_start', + BACK: 'back', } as const export type ActionType = (typeof ActionType)[keyof typeof ActionType] diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 4f7a0d3586..55d7770b05 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -1,5 +1,9 @@ import { test, expect } from '@playwright/test' -import { createTest, html, waitForServersIdle } from '../../lib/framework' +import { createTest, html, waitForServersIdle, waitForRequests } from '../../lib/framework' + +function hasActionId(event: { action?: { id?: string | string[] } }, actionId: string): boolean { + return [event.action?.id].flat().includes(actionId) +} test.describe('action collection', () => { createTest('track a click action') @@ -566,3 +570,154 @@ test.describe('action collection with shadow DOM', () => { expect(actionEvents[0].action?.target?.name).toBe('Shadow Button') }) }) + +test.describe('custom actions with startAction/stopAction', () => { + createTest('track a custom action with startAction/stopAction') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('checkout') + window.DD_RUM!.stopAction('checkout') + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action.target?.name).toBe('checkout') + expect(actionEvents[0].action.type).toBe('custom') + expect(actionEvents[0].action.id).toBeDefined() + }) + + createTest('associate an error to a custom action') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('checkout') + window.DD_RUM!.addError(new Error('Payment failed')) + window.DD_RUM!.stopAction('checkout') + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + const errorEvents = intakeRegistry.rumErrorEvents + + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action.error?.count).toBe(1) + expect(errorEvents.length).toBeGreaterThanOrEqual(1) + + const actionId = actionEvents[0].action.id + const relatedError = errorEvents.find((e) => hasActionId(e, actionId!)) + expect(relatedError).toBeDefined() + }) + + createTest('associate a resource to a custom action') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('load-data') + void fetch('/ok') + }) + await waitForRequests(page) + await page.evaluate(() => { + window.DD_RUM!.stopAction('load-data') + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + const resourceEvents = intakeRegistry.rumResourceEvents.filter((e) => e.resource.type === 'fetch') + + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action.resource?.count).toBe(1) + + const actionId = actionEvents[0].action.id + const relatedResource = resourceEvents.find((e) => hasActionId(e, actionId!)) + expect(relatedResource).toBeDefined() + }) + + createTest('track multiple concurrent custom actions with actionKey') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('click', { actionKey: 'button1' }) + window.DD_RUM!.startAction('click', { actionKey: 'button2' }) + window.DD_RUM!.stopAction('click', { actionKey: 'button2' }) + window.DD_RUM!.stopAction('click', { actionKey: 'button1' }) + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(2) + expect(actionEvents[0].action.id).not.toBe(actionEvents[1].action.id) + }) + + createTest('merge contexts from start and stop') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('purchase', { context: { cart_id: 'abc123' } }) + window.DD_RUM!.stopAction('purchase', { context: { total: 99.99 } }) + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].context).toEqual( + expect.objectContaining({ + cart_id: 'abc123', + total: 99.99, + }) + ) + }) + + createTest('preserve timing when startAction is called before init') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .withRumInit((configuration) => { + window.DD_RUM!.startAction('pre_init_action') + + setTimeout(() => { + window.DD_RUM!.init(configuration) + window.DD_RUM!.stopAction('pre_init_action') + }, 50) + }) + .run(async ({ intakeRegistry, flushEvents }) => { + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action.target?.name).toBe('pre_init_action') + expect(actionEvents[0].action.loading_time).toBeGreaterThanOrEqual(40 * 1e6) + }) + + createTest('attribute errors and resources to action started before init') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .withRumInit((configuration) => { + window.DD_RUM!.startAction('pre_init_action') + + setTimeout(() => { + window.DD_RUM!.init(configuration) + + window.DD_RUM!.addError(new Error('Test error')) + void fetch('/ok') + }, 10) + }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await waitForRequests(page) + + await page.evaluate(() => { + window.DD_RUM!.stopAction('pre_init_action') + }) + + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + + const actionId = actionEvents[0].action.id + const relatedError = intakeRegistry.rumErrorEvents.find((e) => hasActionId(e, actionId!)) + expect(relatedError).toBeDefined() + + const fetchResources = intakeRegistry.rumResourceEvents.filter((e) => e.resource.type === 'fetch') + const relatedFetch = fetchResources.find((e) => hasActionId(e, actionId!)) + expect(relatedFetch).toBeDefined() + }) +})