From 2f115669a022de66c2e3982ad8a5b3a1d1b47a6b Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 17 Dec 2025 12:34:12 -0300 Subject: [PATCH 01/47] add s/s action --- .../domain/telemetry/telemetryEvent.types.ts | 17 +++ .../core/src/tools/experimentalFeatures.ts | 1 + packages/rum-core/src/boot/preStartRum.ts | 15 +++ packages/rum-core/src/boot/rumPublicApi.ts | 43 ++++++++ packages/rum-core/src/boot/startRum.ts | 9 +- .../src/domain/action/actionCollection.ts | 102 +++++++++++++++++- packages/rum-core/src/rawRumEvent.types.ts | 5 + 7 files changed, 187 insertions(+), 5 deletions(-) diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index 574760c044..c55deb22c6 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -510,6 +510,8 @@ export type TelemetryCommonFeaturesUsage = | SetViewName | GetViewContext | AddAction + | StartAction + | StopAction | AddError | GetGlobalContext | SetGlobalContext @@ -724,6 +726,21 @@ export interface AddAction { feature: 'add-action' [k: string]: unknown } + +export interface StartAction { + /** + * startAction API + */ + feature: 'start-action' + [k: string]: unknown +} +export interface StopAction { + /** + * stopAction API + */ + feature: 'stop-action' + [k: string]: unknown +} export interface AddError { /** * addError API diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 9ef9342936..c10cd15e9e 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', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 0ea06776e4..244cb6b4f5 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -34,6 +34,8 @@ import type { } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' import { callPluginsMethod } from '../domain/plugins' +import type { CustomActionState, CustomAction } from '../domain/action/actionCollection' +import { startCustomAction, stopCustomAction } from '../domain/action/actionCollection' import type { StartRumResult } from './startRum' import type { RumPublicApiOptions, Strategy } from './rumPublicApi' @@ -41,6 +43,7 @@ export function createPreStartStrategy( { ignoreInitIfSyntheticsWillInjectRum = true, startDeflateWorker }: RumPublicApiOptions, trackingConsentState: TrackingConsentState, customVitalsState: CustomVitalsState, + customActionsState: CustomActionState, doStartRum: ( configuration: RumConfiguration, deflateWorker: DeflateWorker | undefined, @@ -170,6 +173,10 @@ export function createPreStartStrategy( ) } + const addCustomAction = (action: CustomAction) => { + bufferApiCalls.add((startRumResult) => startRumResult.addAction(action)) + } + const strategy: Strategy = { init(initConfiguration, publicApi, errorStack) { if (!initConfiguration) { @@ -253,6 +260,14 @@ export function createPreStartStrategy( bufferApiCalls.add((startRumResult) => startRumResult.addAction(action)) }, + startAction(name, options) { + startCustomAction(customActionsState, name, options) + }, + + stopAction(name, options) { + stopCustomAction(addCustomAction, customActionsState, name, options) + }, + addError(providedError) { bufferApiCalls.add((startRumResult) => startRumResult.addError(providedError)) }, diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 0bd96bb88b..9930c5a037 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -53,6 +53,8 @@ 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/actionCollection' +import { createCustomActionsState } from '../domain/action/actionCollection' import { createPreStartStrategy } from './preStartRum' import type { StartRum, StartRumResult } from './startRum' @@ -168,6 +170,24 @@ export interface RumPublicApi extends PublicApi { */ addAction: (name: string, context?: object) => void + /** + * 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 + + /** + * 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 +543,8 @@ export interface Strategy { accountContext: ContextManager addAction: StartRumResult['addAction'] + startAction: StartRumResult['startAction'] + stopAction: StartRumResult['stopAction'] addError: StartRumResult['addError'] addFeatureFlagEvaluation: StartRumResult['addFeatureFlagEvaluation'] startDurationVital: StartRumResult['startDurationVital'] @@ -539,12 +561,14 @@ export function makeRumPublicApi( ): RumPublicApi { const trackingConsentState = createTrackingConsentState() const customVitalsState = createCustomVitalsState() + const customActionsState = createCustomActionsState() const bufferedDataObservable = startBufferingData().observable let strategy = createPreStartStrategy( options, trackingConsentState, customVitalsState, + customActionsState, (configuration, deflateWorker, initialViewOptions) => { const createEncoder = deflateWorker && options.createDeflateEncoder @@ -559,6 +583,7 @@ export function makeRumPublicApi( createEncoder, trackingConsentState, customVitalsState, + customActionsState, bufferedDataObservable, options.sdkName ) @@ -653,6 +678,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: sanitize(options && options.actionKey) as string | undefined, + }) + }), + + 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: sanitize(options && options.actionKey) as string | undefined, + }) + }), + 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 c956f4e2e3..c9326e32c9 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -24,6 +24,7 @@ import { startInternalContext } from '../domain/contexts/internalContext' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' import { startViewHistory } from '../domain/contexts/viewHistory' import { startRequestCollection } from '../domain/requestCollection' +import type { CustomActionState } from '../domain/action/actionCollection' import { startActionCollection } from '../domain/action/actionCollection' import { startErrorCollection } from '../domain/error/errorCollection' import { startResourceCollection } from '../domain/resource/resourceCollection' @@ -72,6 +73,7 @@ export function startRum( // `trackingConsentState` set to "granted". trackingConsentState: TrackingConsentState, customVitalsState: CustomVitalsState, + customActionsState: CustomActionState, bufferedDataObservable: BufferedObservable, sdkName?: SdkName ) { @@ -135,6 +137,7 @@ export function startRum( recorderApi, initialViewOptions, customVitalsState, + customActionsState, bufferedDataObservable, sdkName, reportError @@ -166,6 +169,7 @@ export function startRumEventCollection( recorderApi: RecorderApi, initialViewOptions: ViewOptions | undefined, customVitalsState: CustomVitalsState, + customActionsState: CustomActionState, bufferedDataObservable: Observable, sdkName: SdkName | undefined, reportError: (error: RawError) => void @@ -196,7 +200,8 @@ export function startRumEventCollection( hooks, domMutationObservable, windowOpenObservable, - configuration + configuration, + customActionsState ) cleanupTasks.push(actionCollection.stop) @@ -254,6 +259,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.ts b/packages/rum-core/src/domain/action/actionCollection.ts index dbd36f8d3e..30612a47be 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,5 +1,15 @@ import type { ClocksState, Context, Observable } from '@datadog/browser-core' -import { noop, combine, toServerDuration, generateUUID, SKIPPED, HookNames } from '@datadog/browser-core' +import { + noop, + combine, + toServerDuration, + generateUUID, + SKIPPED, + HookNames, + clocksNow, + isExperimentalFeatureEnabled, + ExperimentalFeature, +} from '@datadog/browser-core' import { discardNegativeDuration } from '../discardNegativeDuration' import type { RawRumActionEvent } from '../../rawRumEvent.types' import { ActionType, RumEventType } from '../../rawRumEvent.types' @@ -14,8 +24,41 @@ import { trackClickActions } from './trackClickActions' export type { ActionContexts } +export interface ActionOptions { + /** + * Action Type + * + * @default 'custom' + */ + type?: ActionType + + /** + * Action context + */ + context?: any + + /** + * Action key + */ + actionKey?: string +} + +export interface ActionStart extends ActionOptions { + name: string + startClocks: ClocksState +} + +export interface CustomActionState { + actionsByName: Map +} + +export function createCustomActionsState() { + const actionsByName = new Map() + return { actionsByName } +} + export interface CustomAction { - type: typeof ActionType.CUSTOM + type: ActionType name: string startClocks: ClocksState context?: Context @@ -29,7 +72,8 @@ export function startActionCollection( hooks: Hooks, domMutationObservable: Observable, windowOpenObservable: Observable, - configuration: RumConfiguration + configuration: RumConfiguration, + customActionsState: CustomActionState ) { const { unsubscribe: unsubscribeAutoAction } = lifeCycle.subscribe( LifeCycleEventType.AUTO_ACTION_COMPLETED, @@ -77,10 +121,18 @@ export function startActionCollection( )) } + function addCustomAction(action: CustomAction) { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + } + return { addAction: (action: CustomAction) => { lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) }, + startAction: (name: string, options: ActionOptions = {}) => startCustomAction(customActionsState, name, options), + stopAction: (name: string, options: ActionOptions = {}) => { + stopCustomAction(addCustomAction, customActionsState, name, options) + }, actionContexts, stop: () => { unsubscribeAutoAction() @@ -142,5 +194,47 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD } function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { - return action.type !== ActionType.CUSTOM + return action.type === ActionType.CLICK +} + +export function startCustomAction({ actionsByName }: CustomActionState, name: string, options: ActionOptions = {}) { + if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } + + const actionStart: ActionStart = { + name, + startClocks: clocksNow(), + ...options, + } + + actionsByName.set(name, actionStart) +} + +export function stopCustomAction( + stopCallback: (action: CustomAction) => void, + { actionsByName }: CustomActionState, + nameOrRef: string, + options: ActionOptions = {} +) { + if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } + + const actionStart = actionsByName.get(nameOrRef) + + if (!actionStart) { + return + } + + const customAction: CustomAction = { + name: actionStart.name, + type: (options.type ?? actionStart.type) || ActionType.CUSTOM, + startClocks: actionStart.startClocks, + context: combine(actionStart.context, options.context), + } + + stopCallback(customAction) + + actionsByName.delete(nameOrRef) } 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] From 065a6fea6eef1a990325f45c1dec2d3ef5ae2caf Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 17 Dec 2025 16:24:37 -0300 Subject: [PATCH 02/47] Add unit tests for startAction and stopAction --- .../rum-core/src/boot/preStartRum.spec.ts | 46 +++++- .../rum-core/src/boot/rumPublicApi.spec.ts | 61 +++++++ packages/rum-core/src/boot/rumPublicApi.ts | 1 + .../domain/action/actionCollection.spec.ts | 153 +++++++++++++++++- .../src/domain/action/actionCollection.ts | 24 ++- packages/rum-core/test/fixtures.ts | 10 +- .../scenario/rum/manualActions.scenario.ts | 0 7 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 test/e2e/scenario/rum/manualActions.scenario.ts diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 7b922265a5..c5ab3535dd 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,11 +19,12 @@ 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 { createCustomActionsState, type CustomAction, type CustomActionState } from '../domain/action/actionCollection' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' import type { RumPublicApi, Strategy } from './rumPublicApi' @@ -59,7 +61,7 @@ describe('preStartRum', () => { beforeEach(() => { displaySpy = spyOn(display, 'error') - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) }) it('should start when the configuration is valid', () => { @@ -169,6 +171,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -186,6 +189,7 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -200,6 +204,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -216,6 +221,7 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -238,6 +244,7 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) }) @@ -314,7 +321,7 @@ describe('preStartRum', () => { addTiming: addTimingSpy, setViewName: setViewNameSpy, } as unknown as StartRumResult) - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) }) describe('when auto', () => { @@ -368,6 +375,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) strategy.startView({ name: 'foo' }) @@ -454,6 +462,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), (configuration) => { expect(configuration.sessionSampleRate).toEqual(50) done() @@ -477,6 +486,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) const initConfiguration: RumInitConfiguration = { ...DEFAULT_INIT_CONFIGURATION, plugins: [plugin] } @@ -500,6 +510,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) strategy.init( @@ -521,6 +532,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) expect(strategy.getInternalContext()).toBe(undefined) @@ -533,6 +545,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) expect(strategy.getViewContext()).toEqual({}) @@ -545,6 +558,7 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) const stopSessionSpy = jasmine.createSpy() @@ -563,7 +577,7 @@ describe('preStartRum', () => { beforeEach(() => { interceptor = interceptRequests() - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' } }) @@ -590,6 +604,7 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), + createCustomActionsState(), doStartRumSpy ) strategy.init(initConfiguration, PUBLIC_API) @@ -604,7 +619,7 @@ describe('preStartRum', () => { json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }), }) ) - const strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), () => { + const strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), () => { expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) done() return {} as StartRumResult @@ -623,7 +638,7 @@ describe('preStartRum', () => { let strategy: Strategy beforeEach(() => { - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) }) it('addAction', () => { @@ -634,6 +649,7 @@ describe('preStartRum', () => { name: 'foo', type: ActionType.CUSTOM, startClocks: clocksNow(), + duration: 0 as Duration, } strategy.addAction(customAction) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -753,6 +769,22 @@ 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 addActionSpy = jasmine.createSpy() + doStartRumSpy.and.returnValue({ + addAction: addActionSpy, + } as unknown as StartRumResult) + + strategy.startAction('user_login', { type: ActionType.CUSTOM }) + strategy.stopAction('user_login') + + strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + + expect(addActionSpy).toHaveBeenCalled() + }) }) describe('tracking consent', () => { @@ -761,7 +793,7 @@ describe('preStartRum', () => { beforeEach(() => { trackingConsentState = createTrackingConsentState() - strategy = createPreStartStrategy({}, trackingConsentState, createCustomVitalsState(), doStartRumSpy) + strategy = createPreStartStrategy({}, trackingConsentState, createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) }) describe('basic methods instrumentation', () => { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index cb1eedf30f..76f613ee2f 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 @@ -153,6 +155,7 @@ describe('rum public api', () => { context: { bar: 'baz' }, name: 'foo', startClocks: jasmine.any(Object), + duration: jasmine.any(Number), type: ActionType.CUSTOM, handlingStack: jasmine.any(String), }, @@ -756,6 +759,64 @@ 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: 'key123' + }) + + expect(startActionSpy.calls.argsFor(0)[1]).toEqual(jasmine.objectContaining({ + type: ActionType.CUSTOM, + context: { count: 123, nested: { foo: 'bar' } }, + actionKey: 'key123', + })) + }) + }) + 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 9930c5a037..dae7d56056 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -672,6 +672,7 @@ export function makeRumPublicApi( context: sanitize(context) as Context, startClocks: clocksNow(), type: ActionType.CUSTOM, + duration: 0 as Duration, handlingStack, }) addTelemetryUsage({ feature: 'add-action' }) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 99215ef09a..2e0f40b81c 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -1,6 +1,6 @@ import type { Duration, RelativeTime, ServerDuration, TimeStamp } from '@datadog/browser-core' -import { HookNames, Observable } from '@datadog/browser-core' -import { createNewEvent, registerCleanupTask } from '@datadog/browser-core/test' +import { ExperimentalFeature, HookNames, Observable } from '@datadog/browser-core' +import { Clock, createNewEvent, 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' @@ -9,8 +9,8 @@ import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { DefaultTelemetryEventAttributes, Hooks } from '../hooks' import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' -import type { ActionContexts } from './actionCollection' -import { startActionCollection } from './actionCollection' +import type { ActionContexts, CustomActionState } from './actionCollection' +import { createCustomActionsState, startActionCollection } from './actionCollection' import { ActionNameSource } from './actionNameConstants' describe('actionCollection', () => { @@ -19,21 +19,30 @@ describe('actionCollection', () => { let addAction: ReturnType['addAction'] let rawRumEvents: Array> let actionContexts: ActionContexts + let customActionsState: CustomActionState + let startAction: ReturnType['startAction'] + let stopAction: ReturnType['stopAction'] + let clock: Clock beforeEach(() => { const domMutationObservable = new Observable() const windowOpenObservable = new Observable() hooks = createHooks() + clock = mockClock() + customActionsState = createCustomActionsState() const actionCollection = startActionCollection( lifeCycle, hooks, domMutationObservable, windowOpenObservable, - mockRumConfiguration() + mockRumConfiguration(), + customActionsState ) registerCleanupTask(actionCollection.stop) addAction = actionCollection.addAction + startAction = actionCollection.startAction + stopAction = actionCollection.stopAction actionContexts = actionCollection.actionContexts rawRumEvents = collectAndValidateRawRumEvents(lifeCycle) @@ -113,6 +122,7 @@ describe('actionCollection', () => { name: 'foo', startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, type: ActionType.CUSTOM, + duration: 0 as Duration, context: { foo: 'bar' }, }) @@ -158,6 +168,7 @@ describe('actionCollection', () => { name: 'foo', startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, type: ActionType.CUSTOM, + duration: 0 as Duration, handlingStack: 'Error\n at foo\n at bar', }) @@ -213,4 +224,134 @@ describe('actionCollection', () => { expect(telemetryEventAttributes.action?.id).toBeUndefined() }) }) -}) + + describe('startAction / stopAction', () => { + beforeEach(() => { + mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + }) + + 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) + }) + + ;[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 merge contexts with stop precedence on conflicts', () => { + // Merge non-conflicting keys + startAction('action1', { context: { cart: 'abc' } }) + stopAction('action1', { context: { total: 100 } }) + + // Stop overrides on conflict + 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' }, + }) + ) + }) + + it('should handle type precedence: stop > start > default(CUSTOM)', () => { + // Stop overrides start + startAction('action1', { type: ActionType.TAP }) + stopAction('action1', { type: ActionType.SCROLL }) + + // Start used when stop not provided + startAction('action2', { type: ActionType.SWIPE }) + stopAction('action2') + + // Default to CUSTOM when neither provided + 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 }), + }) + ) + }) + + 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('should not create action when actionKey does not match', () => { + startAction('click', { actionKey: 'button1' }) + stopAction('click', { actionKey: 'button2' }) + + expect(rawRumEvents).toHaveSize(0) + }) + }) +}) \ No newline at end of file diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 30612a47be..c9fd2525ec 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,4 +1,4 @@ -import type { ClocksState, Context, Observable } from '@datadog/browser-core' +import type { ClocksState, Context, Duration, Observable } from '@datadog/browser-core' import { noop, combine, @@ -7,6 +7,7 @@ import { SKIPPED, HookNames, clocksNow, + elapsed, isExperimentalFeatureEnabled, ExperimentalFeature, } from '@datadog/browser-core' @@ -61,6 +62,7 @@ export interface CustomAction { type: ActionType name: string startClocks: ClocksState + duration: Duration context?: Context handlingStack?: string } @@ -180,7 +182,7 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD autoActionProperties ) - const duration = isAutoAction(action) ? action.duration : undefined + const duration = action.duration const domainContext: RumActionEventDomainContext = isAutoAction(action) ? { events: action.events } : { handlingStack: action.handlingStack } @@ -197,6 +199,10 @@ function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { return action.type === ActionType.CLICK } +function getActionLookupKey(name: string, actionKey?: string): string { + return actionKey ? `${name}__${actionKey}` : name +} + export function startCustomAction({ actionsByName }: CustomActionState, name: string, options: ActionOptions = {}) { if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { return @@ -208,33 +214,39 @@ export function startCustomAction({ actionsByName }: CustomActionState, name: st ...options, } - actionsByName.set(name, actionStart) + const lookupKey = getActionLookupKey(name, options.actionKey) + actionsByName.set(lookupKey, actionStart) } export function stopCustomAction( stopCallback: (action: CustomAction) => void, { actionsByName }: CustomActionState, - nameOrRef: string, + name: string, options: ActionOptions = {} ) { if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { return } - const actionStart = actionsByName.get(nameOrRef) + const lookupKey = getActionLookupKey(name, options.actionKey) + const actionStart = actionsByName.get(lookupKey) if (!actionStart) { return } + const stopClocks = clocksNow() + const duration = elapsed(actionStart.startClocks.timeStamp, stopClocks.timeStamp) + const customAction: CustomAction = { name: actionStart.name, type: (options.type ?? actionStart.type) || ActionType.CUSTOM, startClocks: actionStart.startClocks, + duration, context: combine(actionStart.context, options.context), } stopCallback(customAction) - actionsByName.delete(nameOrRef) + actionsByName.delete(lookupKey) } diff --git a/packages/rum-core/test/fixtures.ts b/packages/rum-core/test/fixtures.ts index d5c15aceff..54da6f53fb 100644 --- a/packages/rum-core/test/fixtures.ts +++ b/packages/rum-core/test/fixtures.ts @@ -18,12 +18,12 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR type, action: { id: generateUUID(), - target: { - name: 'target', - }, - type: ActionType.CUSTOM, + target: { + name: 'target', }, - date: 0 as TimeStamp, + type: ActionType.CUSTOM, + }, + date: 0 as TimeStamp, }, overrides ) diff --git a/test/e2e/scenario/rum/manualActions.scenario.ts b/test/e2e/scenario/rum/manualActions.scenario.ts new file mode 100644 index 0000000000..e69de29bb2 From aec81c60e829fc19d9135fcc72829fa8a83f94de Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 17 Dec 2025 16:29:24 -0300 Subject: [PATCH 03/47] Linting fixes --- .../rum-core/src/boot/preStartRum.spec.ts | 66 ++++++++++++++----- .../rum-core/src/boot/rumPublicApi.spec.ts | 56 +++++++++------- .../domain/action/actionCollection.spec.ts | 18 ++--- packages/rum-core/test/fixtures.ts | 10 +-- .../scenario/rum/manualActions.scenario.ts | 0 5 files changed, 97 insertions(+), 53 deletions(-) delete mode 100644 test/e2e/scenario/rum/manualActions.scenario.ts diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index c5ab3535dd..78011043a8 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -24,7 +24,7 @@ import { import type { HybridInitConfiguration, RumConfiguration, RumInitConfiguration } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { ActionType, VitalType } from '../rawRumEvent.types' -import { createCustomActionsState, type CustomAction, type CustomActionState } from '../domain/action/actionCollection' +import { createCustomActionsState, type CustomAction } from '../domain/action/actionCollection' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' import type { RumPublicApi, Strategy } from './rumPublicApi' @@ -61,7 +61,13 @@ describe('preStartRum', () => { beforeEach(() => { displaySpy = spyOn(display, 'error') - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) + strategy = createPreStartStrategy( + {}, + createTrackingConsentState(), + createCustomVitalsState(), + createCustomActionsState(), + doStartRumSpy + ) }) it('should start when the configuration is valid', () => { @@ -321,7 +327,13 @@ describe('preStartRum', () => { addTiming: addTimingSpy, setViewName: setViewNameSpy, } as unknown as StartRumResult) - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) + strategy = createPreStartStrategy( + {}, + createTrackingConsentState(), + createCustomVitalsState(), + createCustomActionsState(), + doStartRumSpy + ) }) describe('when auto', () => { @@ -577,7 +589,13 @@ describe('preStartRum', () => { beforeEach(() => { interceptor = interceptRequests() - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) + strategy = createPreStartStrategy( + {}, + createTrackingConsentState(), + createCustomVitalsState(), + createCustomActionsState(), + doStartRumSpy + ) initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' } }) @@ -619,11 +637,17 @@ describe('preStartRum', () => { json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }), }) ) - const strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), () => { - expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) - done() - return {} as StartRumResult - }) + const strategy = createPreStartStrategy( + {}, + createTrackingConsentState(), + createCustomVitalsState(), + createCustomActionsState(), + () => { + expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) + done() + return {} as StartRumResult + } + ) strategy.init( { ...DEFAULT_INIT_CONFIGURATION, @@ -638,7 +662,13 @@ describe('preStartRum', () => { let strategy: Strategy beforeEach(() => { - strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) + strategy = createPreStartStrategy( + {}, + createTrackingConsentState(), + createCustomVitalsState(), + createCustomActionsState(), + doStartRumSpy + ) }) it('addAction', () => { @@ -772,17 +802,17 @@ describe('preStartRum', () => { it('startAction / stopAction', () => { mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) - + const addActionSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addAction: addActionSpy, } as unknown as StartRumResult) - + strategy.startAction('user_login', { type: ActionType.CUSTOM }) strategy.stopAction('user_login') - + strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) - + expect(addActionSpy).toHaveBeenCalled() }) }) @@ -793,7 +823,13 @@ describe('preStartRum', () => { beforeEach(() => { trackingConsentState = createTrackingConsentState() - strategy = createPreStartStrategy({}, trackingConsentState, createCustomVitalsState(), createCustomActionsState(), doStartRumSpy) + strategy = createPreStartStrategy( + {}, + trackingConsentState, + createCustomVitalsState(), + createCustomActionsState(), + doStartRumSpy + ) }) describe('basic methods instrumentation', () => { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 76f613ee2f..fb68d248af 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -772,25 +772,31 @@ describe('rum public api', () => { 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({ + rumPublicApi.startAction('purchase', { type: ActionType.CUSTOM, context: { cart: 'abc' }, - })) - expect(stopActionSpy).toHaveBeenCalledWith('purchase', jasmine.objectContaining({ + }) + 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( @@ -801,19 +807,21 @@ describe('rum public api', () => { noopRecorderApi, noopProfilerApi ) - + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.startAction('action_name', { + rumPublicApi.startAction('action_name', { type: ActionType.CUSTOM, context: { count: 123, nested: { foo: 'bar' } } as any, - actionKey: 'key123' - }) - - expect(startActionSpy.calls.argsFor(0)[1]).toEqual(jasmine.objectContaining({ - type: ActionType.CUSTOM, - context: { count: 123, nested: { foo: 'bar' } }, actionKey: 'key123', - })) + }) + + expect(startActionSpy.calls.argsFor(0)[1]).toEqual( + jasmine.objectContaining({ + type: ActionType.CUSTOM, + context: { count: 123, nested: { foo: 'bar' } }, + actionKey: 'key123', + }) + ) }) }) @@ -1006,7 +1014,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - const sdkName = startRumSpy.calls.argsFor(0)[8] + const sdkName = startRumSpy.calls.argsFor(0)[9] expect(sdkName).toBe('rum-slim') }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 2e0f40b81c..4aa0753683 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -1,6 +1,7 @@ import type { Duration, RelativeTime, ServerDuration, TimeStamp } from '@datadog/browser-core' import { ExperimentalFeature, HookNames, Observable } from '@datadog/browser-core' -import { Clock, createNewEvent, mockClock, mockExperimentalFeatures, registerCleanupTask } from '@datadog/browser-core/test' +import type { Clock } from '@datadog/browser-core/test' +import { createNewEvent, 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' @@ -234,7 +235,7 @@ describe('actionCollection', () => { 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( @@ -261,7 +262,6 @@ describe('actionCollection', () => { expect(rawRumEvents).toHaveSize(1) }) - ;[ActionType.SWIPE, ActionType.TAP, ActionType.SCROLL].forEach((actionType) => { it(`should support ${actionType} action type`, () => { startAction('test_action', { type: actionType }) @@ -283,11 +283,11 @@ describe('actionCollection', () => { // Merge non-conflicting keys startAction('action1', { context: { cart: 'abc' } }) stopAction('action1', { context: { total: 100 } }) - + // Stop overrides on conflict startAction('action2', { context: { status: 'pending' } }) stopAction('action2', { context: { status: 'complete' } }) - + expect(rawRumEvents).toHaveSize(2) expect(rawRumEvents[0].rawRumEvent).toEqual( jasmine.objectContaining({ @@ -305,15 +305,15 @@ describe('actionCollection', () => { // Stop overrides start startAction('action1', { type: ActionType.TAP }) stopAction('action1', { type: ActionType.SCROLL }) - + // Start used when stop not provided startAction('action2', { type: ActionType.SWIPE }) stopAction('action2') - + // Default to CUSTOM when neither provided startAction('action3') stopAction('action3') - + expect(rawRumEvents).toHaveSize(3) expect(rawRumEvents[0].rawRumEvent).toEqual( jasmine.objectContaining({ @@ -354,4 +354,4 @@ describe('actionCollection', () => { expect(rawRumEvents).toHaveSize(0) }) }) -}) \ No newline at end of file +}) diff --git a/packages/rum-core/test/fixtures.ts b/packages/rum-core/test/fixtures.ts index 54da6f53fb..d5c15aceff 100644 --- a/packages/rum-core/test/fixtures.ts +++ b/packages/rum-core/test/fixtures.ts @@ -18,12 +18,12 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR type, action: { id: generateUUID(), - target: { - name: 'target', + target: { + name: 'target', + }, + type: ActionType.CUSTOM, }, - type: ActionType.CUSTOM, - }, - date: 0 as TimeStamp, + date: 0 as TimeStamp, }, overrides ) diff --git a/test/e2e/scenario/rum/manualActions.scenario.ts b/test/e2e/scenario/rum/manualActions.scenario.ts deleted file mode 100644 index e69de29bb2..0000000000 From 270c0cb071de1df8f4a5e182df6dbec53bfa47f9 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 17 Dec 2025 17:16:39 -0300 Subject: [PATCH 04/47] cleanup --- .../rum-core/src/domain/action/actionCollection.spec.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 4aa0753683..14f9ea486a 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -280,11 +280,9 @@ describe('actionCollection', () => { }) it('should merge contexts with stop precedence on conflicts', () => { - // Merge non-conflicting keys startAction('action1', { context: { cart: 'abc' } }) stopAction('action1', { context: { total: 100 } }) - // Stop overrides on conflict startAction('action2', { context: { status: 'pending' } }) stopAction('action2', { context: { status: 'complete' } }) @@ -302,15 +300,12 @@ describe('actionCollection', () => { }) it('should handle type precedence: stop > start > default(CUSTOM)', () => { - // Stop overrides start startAction('action1', { type: ActionType.TAP }) stopAction('action1', { type: ActionType.SCROLL }) - // Start used when stop not provided startAction('action2', { type: ActionType.SWIPE }) stopAction('action2') - - // Default to CUSTOM when neither provided + startAction('action3') stopAction('action3') From b0bc6425dadbb5cefc821121f47bf00f89d67a96 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 17 Dec 2025 19:07:49 -0300 Subject: [PATCH 05/47] fix leaked listener --- packages/rum-core/src/boot/startRum.spec.ts | 3 +++ packages/rum-core/src/domain/action/actionCollection.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index da2fabae33..1c4ad02652 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -28,6 +28,7 @@ import type { RumEvent, RumViewEvent } from '../rumEvent.types' import type { RumConfiguration } from '../domain/configuration' import { RumEventType } from '../rawRumEvent.types' import { createCustomVitalsState } from '../domain/vital/vitalCollection' +import { createCustomActionsState } from '../domain/action/actionCollection' import { createHooks } from '../domain/hooks' import type { RumSessionManager } from '../domain/rumSessionManager' import { startRum, startRumEventCollection } from './startRum' @@ -56,6 +57,7 @@ function startRumStub( noopRecorderApi, undefined, createCustomVitalsState(), + createCustomActionsState(), new Observable(), undefined, reportError @@ -168,6 +170,7 @@ describe('view events', () => { createIdentityEncoder, createTrackingConsentState(TrackingConsent.GRANTED), createCustomVitalsState(), + createCustomActionsState(), new BufferedObservable(100), 'rum' ) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 14f9ea486a..d86510f33b 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -29,7 +29,6 @@ describe('actionCollection', () => { const domMutationObservable = new Observable() const windowOpenObservable = new Observable() hooks = createHooks() - clock = mockClock() customActionsState = createCustomActionsState() const actionCollection = startActionCollection( @@ -228,6 +227,7 @@ describe('actionCollection', () => { describe('startAction / stopAction', () => { beforeEach(() => { + clock = mockClock() mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) }) @@ -305,7 +305,7 @@ describe('actionCollection', () => { startAction('action2', { type: ActionType.SWIPE }) stopAction('action2') - + startAction('action3') stopAction('action3') From a06410a01f2c56222bec2df22484ea305aab37f8 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 30 Dec 2025 13:23:14 +0100 Subject: [PATCH 06/47] Integrated trackEventCounts and integrate with startActionCollection. --- .../rum-core/src/boot/preStartRum.spec.ts | 80 ++----- packages/rum-core/src/boot/preStartRum.ts | 11 +- .../rum-core/src/boot/rumPublicApi.spec.ts | 2 +- packages/rum-core/src/boot/rumPublicApi.ts | 4 - packages/rum-core/src/boot/startRum.spec.ts | 3 - packages/rum-core/src/boot/startRum.ts | 7 +- .../domain/action/actionCollection.spec.ts | 115 +++++++++- .../src/domain/action/actionCollection.ts | 205 +++++++++++------- test/e2e/scenario/rum/actions.scenario.ts | 117 ++++++++++ 9 files changed, 374 insertions(+), 170 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 78011043a8..a8e53bc979 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -24,7 +24,7 @@ import { import type { HybridInitConfiguration, RumConfiguration, RumInitConfiguration } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { ActionType, VitalType } from '../rawRumEvent.types' -import { createCustomActionsState, type CustomAction } from '../domain/action/actionCollection' +import type { CustomAction } from '../domain/action/actionCollection' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' import type { RumPublicApi, Strategy } from './rumPublicApi' @@ -61,13 +61,7 @@ describe('preStartRum', () => { beforeEach(() => { displaySpy = spyOn(display, 'error') - strategy = createPreStartStrategy( - {}, - createTrackingConsentState(), - createCustomVitalsState(), - createCustomActionsState(), - doStartRumSpy - ) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) }) it('should start when the configuration is valid', () => { @@ -177,7 +171,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -195,7 +188,6 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -210,7 +202,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -227,7 +218,6 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -250,7 +240,6 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) }) @@ -327,13 +316,7 @@ describe('preStartRum', () => { addTiming: addTimingSpy, setViewName: setViewNameSpy, } as unknown as StartRumResult) - strategy = createPreStartStrategy( - {}, - createTrackingConsentState(), - createCustomVitalsState(), - createCustomActionsState(), - doStartRumSpy - ) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) }) describe('when auto', () => { @@ -387,7 +370,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) strategy.startView({ name: 'foo' }) @@ -474,7 +456,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), (configuration) => { expect(configuration.sessionSampleRate).toEqual(50) done() @@ -498,7 +479,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) const initConfiguration: RumInitConfiguration = { ...DEFAULT_INIT_CONFIGURATION, plugins: [plugin] } @@ -522,7 +502,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) strategy.init( @@ -544,7 +523,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) expect(strategy.getInternalContext()).toBe(undefined) @@ -557,7 +535,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) expect(strategy.getViewContext()).toEqual({}) @@ -570,7 +547,6 @@ describe('preStartRum', () => { {}, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) const stopSessionSpy = jasmine.createSpy() @@ -589,13 +565,7 @@ describe('preStartRum', () => { beforeEach(() => { interceptor = interceptRequests() - strategy = createPreStartStrategy( - {}, - createTrackingConsentState(), - createCustomVitalsState(), - createCustomActionsState(), - doStartRumSpy - ) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' } }) @@ -622,7 +592,6 @@ describe('preStartRum', () => { }, createTrackingConsentState(), createCustomVitalsState(), - createCustomActionsState(), doStartRumSpy ) strategy.init(initConfiguration, PUBLIC_API) @@ -637,17 +606,11 @@ describe('preStartRum', () => { json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }), }) ) - const strategy = createPreStartStrategy( - {}, - createTrackingConsentState(), - createCustomVitalsState(), - createCustomActionsState(), - () => { - expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) - done() - return {} as StartRumResult - } - ) + const strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), () => { + expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) + done() + return {} as StartRumResult + }) strategy.init( { ...DEFAULT_INIT_CONFIGURATION, @@ -662,13 +625,7 @@ describe('preStartRum', () => { let strategy: Strategy beforeEach(() => { - strategy = createPreStartStrategy( - {}, - createTrackingConsentState(), - createCustomVitalsState(), - createCustomActionsState(), - doStartRumSpy - ) + strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) }) it('addAction', () => { @@ -803,9 +760,11 @@ describe('preStartRum', () => { it('startAction / stopAction', () => { mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) - const addActionSpy = jasmine.createSpy() + const startActionSpy = jasmine.createSpy() + const stopActionSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ - addAction: addActionSpy, + startAction: startActionSpy, + stopAction: stopActionSpy, } as unknown as StartRumResult) strategy.startAction('user_login', { type: ActionType.CUSTOM }) @@ -813,7 +772,8 @@ describe('preStartRum', () => { strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) - expect(addActionSpy).toHaveBeenCalled() + expect(startActionSpy).toHaveBeenCalledWith('user_login', { type: ActionType.CUSTOM }) + expect(stopActionSpy).toHaveBeenCalledWith('user_login', undefined) }) }) @@ -823,13 +783,7 @@ describe('preStartRum', () => { beforeEach(() => { trackingConsentState = createTrackingConsentState() - strategy = createPreStartStrategy( - {}, - trackingConsentState, - createCustomVitalsState(), - createCustomActionsState(), - doStartRumSpy - ) + strategy = createPreStartStrategy({}, trackingConsentState, createCustomVitalsState(), doStartRumSpy) }) describe('basic methods instrumentation', () => { diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 244cb6b4f5..70e3fae7f9 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -34,8 +34,6 @@ import type { } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' import { callPluginsMethod } from '../domain/plugins' -import type { CustomActionState, CustomAction } from '../domain/action/actionCollection' -import { startCustomAction, stopCustomAction } from '../domain/action/actionCollection' import type { StartRumResult } from './startRum' import type { RumPublicApiOptions, Strategy } from './rumPublicApi' @@ -43,7 +41,6 @@ export function createPreStartStrategy( { ignoreInitIfSyntheticsWillInjectRum = true, startDeflateWorker }: RumPublicApiOptions, trackingConsentState: TrackingConsentState, customVitalsState: CustomVitalsState, - customActionsState: CustomActionState, doStartRum: ( configuration: RumConfiguration, deflateWorker: DeflateWorker | undefined, @@ -173,10 +170,6 @@ export function createPreStartStrategy( ) } - const addCustomAction = (action: CustomAction) => { - bufferApiCalls.add((startRumResult) => startRumResult.addAction(action)) - } - const strategy: Strategy = { init(initConfiguration, publicApi, errorStack) { if (!initConfiguration) { @@ -261,11 +254,11 @@ export function createPreStartStrategy( }, startAction(name, options) { - startCustomAction(customActionsState, name, options) + bufferApiCalls.add((startRumResult) => startRumResult.startAction(name, options)) }, stopAction(name, options) { - stopCustomAction(addCustomAction, customActionsState, name, options) + bufferApiCalls.add((startRumResult) => startRumResult.stopAction(name, options)) }, addError(providedError) { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index fb68d248af..f0d9ae36e9 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -1014,7 +1014,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - const sdkName = startRumSpy.calls.argsFor(0)[9] + const sdkName = startRumSpy.calls.argsFor(0)[8] expect(sdkName).toBe('rum-slim') }) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index dae7d56056..d51e75ada6 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -54,7 +54,6 @@ 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/actionCollection' -import { createCustomActionsState } from '../domain/action/actionCollection' import { createPreStartStrategy } from './preStartRum' import type { StartRum, StartRumResult } from './startRum' @@ -561,14 +560,12 @@ export function makeRumPublicApi( ): RumPublicApi { const trackingConsentState = createTrackingConsentState() const customVitalsState = createCustomVitalsState() - const customActionsState = createCustomActionsState() const bufferedDataObservable = startBufferingData().observable let strategy = createPreStartStrategy( options, trackingConsentState, customVitalsState, - customActionsState, (configuration, deflateWorker, initialViewOptions) => { const createEncoder = deflateWorker && options.createDeflateEncoder @@ -583,7 +580,6 @@ export function makeRumPublicApi( createEncoder, trackingConsentState, customVitalsState, - customActionsState, bufferedDataObservable, options.sdkName ) diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index 1c4ad02652..da2fabae33 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -28,7 +28,6 @@ import type { RumEvent, RumViewEvent } from '../rumEvent.types' import type { RumConfiguration } from '../domain/configuration' import { RumEventType } from '../rawRumEvent.types' import { createCustomVitalsState } from '../domain/vital/vitalCollection' -import { createCustomActionsState } from '../domain/action/actionCollection' import { createHooks } from '../domain/hooks' import type { RumSessionManager } from '../domain/rumSessionManager' import { startRum, startRumEventCollection } from './startRum' @@ -57,7 +56,6 @@ function startRumStub( noopRecorderApi, undefined, createCustomVitalsState(), - createCustomActionsState(), new Observable(), undefined, reportError @@ -170,7 +168,6 @@ describe('view events', () => { createIdentityEncoder, createTrackingConsentState(TrackingConsent.GRANTED), createCustomVitalsState(), - createCustomActionsState(), new BufferedObservable(100), 'rum' ) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index c9326e32c9..88905bdca4 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -24,7 +24,6 @@ import { startInternalContext } from '../domain/contexts/internalContext' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' import { startViewHistory } from '../domain/contexts/viewHistory' import { startRequestCollection } from '../domain/requestCollection' -import type { CustomActionState } from '../domain/action/actionCollection' import { startActionCollection } from '../domain/action/actionCollection' import { startErrorCollection } from '../domain/error/errorCollection' import { startResourceCollection } from '../domain/resource/resourceCollection' @@ -73,7 +72,6 @@ export function startRum( // `trackingConsentState` set to "granted". trackingConsentState: TrackingConsentState, customVitalsState: CustomVitalsState, - customActionsState: CustomActionState, bufferedDataObservable: BufferedObservable, sdkName?: SdkName ) { @@ -137,7 +135,6 @@ export function startRum( recorderApi, initialViewOptions, customVitalsState, - customActionsState, bufferedDataObservable, sdkName, reportError @@ -169,7 +166,6 @@ export function startRumEventCollection( recorderApi: RecorderApi, initialViewOptions: ViewOptions | undefined, customVitalsState: CustomVitalsState, - customActionsState: CustomActionState, bufferedDataObservable: Observable, sdkName: SdkName | undefined, reportError: (error: RawError) => void @@ -200,8 +196,7 @@ export function startRumEventCollection( hooks, domMutationObservable, windowOpenObservable, - configuration, - customActionsState + configuration ) cleanupTasks.push(actionCollection.stop) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index d86510f33b..83cf25fed7 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -10,8 +10,8 @@ import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { DefaultTelemetryEventAttributes, Hooks } from '../hooks' import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' -import type { ActionContexts, CustomActionState } from './actionCollection' -import { createCustomActionsState, startActionCollection } from './actionCollection' +import type { ActionContexts } from './actionCollection' +import { startActionCollection } from './actionCollection' import { ActionNameSource } from './actionNameConstants' describe('actionCollection', () => { @@ -20,7 +20,6 @@ describe('actionCollection', () => { let addAction: ReturnType['addAction'] let rawRumEvents: Array> let actionContexts: ActionContexts - let customActionsState: CustomActionState let startAction: ReturnType['startAction'] let stopAction: ReturnType['stopAction'] let clock: Clock @@ -29,15 +28,13 @@ describe('actionCollection', () => { const domMutationObservable = new Observable() const windowOpenObservable = new Observable() hooks = createHooks() - customActionsState = createCustomActionsState() const actionCollection = startActionCollection( lifeCycle, hooks, domMutationObservable, windowOpenObservable, - mockRumConfiguration(), - customActionsState + mockRumConfiguration() ) registerCleanupTask(actionCollection.stop) addAction = actionCollection.addAction @@ -348,5 +345,111 @@ describe('actionCollection', () => { expect(rawRumEvents).toHaveSize(0) }) + + it('should use consistent action ID from start to collected event', () => { + startAction('checkout') + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.id).toBeDefined() + expect(typeof actionEvent.action.id).toBe('string') + expect(actionEvent.action.id.length).toBeGreaterThan(0) + }) + + it('should return custom action ID from actionContexts.findActionId during action', () => { + 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).toBe(actionId as string) + }) + + it('should track error count during custom action', () => { + startAction('checkout') + + const actionId = actionContexts.findActionId() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: actionId }, + error: { message: 'test error' }, + } as any) + + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.error?.count).toBe(1) + }) + + it('should track resource count during custom action', () => { + startAction('load-data') + + const actionId = actionContexts.findActionId() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + action: { id: actionId }, + resource: { type: 'fetch' }, + } as any) + + stopAction('load-data') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.resource?.count).toBe(1) + }) + + it('should track long task count during custom action', () => { + startAction('heavy-computation') + + const actionId = actionContexts.findActionId() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.LONG_TASK, + action: { id: actionId }, + long_task: { duration: 100 }, + } as any) + + stopAction('heavy-computation') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.long_task?.count).toBe(1) + }) + + 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) + }) }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index c9fd2525ec..45d7ca06cc 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,4 +1,4 @@ -import type { ClocksState, Context, Duration, Observable } from '@datadog/browser-core' +import type { ClocksState, Context, Duration, Observable, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' import { noop, combine, @@ -10,6 +10,7 @@ import { elapsed, isExperimentalFeatureEnabled, ExperimentalFeature, + createValueHistory, } from '@datadog/browser-core' import { discardNegativeDuration } from '../discardNegativeDuration' import type { RawRumActionEvent } from '../../rawRumEvent.types' @@ -20,8 +21,9 @@ 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 { trackEventCounts } from '../trackEventCounts' import type { ActionContexts, ClickAction } from './trackClickActions' -import { trackClickActions } from './trackClickActions' +import { ACTION_CONTEXT_TIME_OUT_DELAY, trackClickActions } from './trackClickActions' export type { ActionContexts } @@ -44,27 +46,29 @@ export interface ActionOptions { actionKey?: string } -export interface ActionStart extends ActionOptions { +interface ActiveCustomAction extends ActionOptions { + id: string name: string startClocks: ClocksState + historyEntry: ValueHistoryEntry + eventCountsSubscription: ReturnType } -export interface CustomActionState { - actionsByName: Map -} - -export function createCustomActionsState() { - const actionsByName = new Map() - return { actionsByName } +export interface ActionCounts { + errorCount: number + longTaskCount: number + resourceCount: number } export interface CustomAction { + id?: string type: ActionType name: string startClocks: ClocksState duration: Duration context?: Context handlingStack?: string + counts?: ActionCounts } export type AutoAction = ClickAction @@ -74,9 +78,11 @@ export function startActionCollection( hooks: Hooks, domMutationObservable: Observable, windowOpenObservable: Observable, - configuration: RumConfiguration, - customActionsState: CustomActionState + configuration: RumConfiguration ) { + const customActionHistory = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) + const activeCustomActions = new Map() + const { unsubscribe: unsubscribeAutoAction } = lifeCycle.subscribe( LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) => { @@ -84,6 +90,38 @@ export function startActionCollection( } ) + const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + customActionHistory.reset() + }) + + let clickActionContexts: ActionContexts = { findActionId: noop as () => undefined } + let stopClickActions: () => void = noop + + if (configuration.trackUserInteractions) { + ;({ actionContexts: clickActionContexts, stop: stopClickActions } = trackClickActions( + lifeCycle, + domMutationObservable, + windowOpenObservable, + configuration + )) + } + + const actionContexts: ActionContexts = { + findActionId: (startTime?: RelativeTime) => { + const clickIds = clickActionContexts.findActionId(startTime) + const customIds = customActionHistory.findAll(startTime) + + const allIds = (clickIds ? (Array.isArray(clickIds) ? clickIds : [clickIds]) : ([] as string[])) + .concat(customIds) + .filter(Boolean) + + if (allIds.length === 0) { + return undefined + } + return allIds.length === 1 ? allIds[0] : allIds + }, + } + hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { if ( eventType !== RumEventType.ERROR && @@ -111,43 +149,91 @@ export function startActionCollection( }) ) - let actionContexts: ActionContexts = { findActionId: noop as () => undefined } - let stop: () => void = noop + function startCustomActionInternal(name: string, options: ActionOptions = {}) { + if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } + + const id = generateUUID() + const startClocks = clocksNow() - if (configuration.trackUserInteractions) { - ;({ actionContexts, stop } = trackClickActions( + const historyEntry = customActionHistory.add(id, startClocks.relative) + + const eventCountsSubscription = trackEventCounts({ lifeCycle, - domMutationObservable, - windowOpenObservable, - configuration - )) + isChildEvent: (event) => + event.action !== undefined && + (Array.isArray(event.action.id) ? event.action.id.includes(id) : event.action.id === id), + }) + + const lookupKey = getActionLookupKey(name, options.actionKey) + activeCustomActions.set(lookupKey, { + id, + name, + startClocks, + historyEntry, + eventCountsSubscription, + ...options, + }) } - function addCustomAction(action: CustomAction) { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + function stopCustomActionInternal(name: string, options: ActionOptions = {}) { + if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } + + const lookupKey = getActionLookupKey(name, options.actionKey) + const activeAction = activeCustomActions.get(lookupKey) + + if (!activeAction) { + return + } + + const stopClocks = clocksNow() + const duration = elapsed(activeAction.startClocks.timeStamp, stopClocks.timeStamp) + + activeAction.historyEntry.close(stopClocks.relative) + + const { errorCount, resourceCount, longTaskCount } = activeAction.eventCountsSubscription.eventCounts + activeAction.eventCountsSubscription.stop() + + const customAction: CustomAction = { + id: activeAction.id, + name: activeAction.name, + type: (options.type ?? activeAction.type) || ActionType.CUSTOM, + startClocks: activeAction.startClocks, + duration, + context: combine(activeAction.context, options.context), + counts: { errorCount, resourceCount, longTaskCount }, + } + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(customAction)) + activeCustomActions.delete(lookupKey) } return { addAction: (action: CustomAction) => { lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) }, - startAction: (name: string, options: ActionOptions = {}) => startCustomAction(customActionsState, name, options), - stopAction: (name: string, options: ActionOptions = {}) => { - stopCustomAction(addCustomAction, customActionsState, name, options) - }, + startAction: startCustomActionInternal, + stopAction: stopCustomActionInternal, actionContexts, stop: () => { unsubscribeAutoAction() - stop() + unsubscribeSessionRenewal() + stopClickActions() + customActionHistory.stop() }, } } function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { + const actionId = isAutoAction(action) ? action.id : (action.id ?? generateUUID()) + const autoActionProperties = isAutoAction(action) ? { action: { - id: action.id, + id: actionId, loading_time: discardNegativeDuration(toServerDuration(action.duration)), frustration: { type: action.frustrationTypes, @@ -171,11 +257,22 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD }, } : { + action: { + id: actionId, + ...(action.counts + ? { + error: { count: action.counts.errorCount }, + long_task: { count: action.counts.longTaskCount }, + resource: { count: action.counts.resourceCount }, + } + : {}), + }, context: action.context, } + const actionEvent: RawRumActionEvent = combine( { - action: { id: generateUUID(), target: { name: action.name }, type: action.type }, + action: { target: { name: action.name }, type: action.type }, date: action.startClocks.timeStamp, type: RumEventType.ACTION, }, @@ -196,57 +293,9 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD } function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { - return action.type === ActionType.CLICK + return action.type === ActionType.CLICK && 'events' in action } function getActionLookupKey(name: string, actionKey?: string): string { return actionKey ? `${name}__${actionKey}` : name } - -export function startCustomAction({ actionsByName }: CustomActionState, name: string, options: ActionOptions = {}) { - if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { - return - } - - const actionStart: ActionStart = { - name, - startClocks: clocksNow(), - ...options, - } - - const lookupKey = getActionLookupKey(name, options.actionKey) - actionsByName.set(lookupKey, actionStart) -} - -export function stopCustomAction( - stopCallback: (action: CustomAction) => void, - { actionsByName }: CustomActionState, - name: string, - options: ActionOptions = {} -) { - if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { - return - } - - const lookupKey = getActionLookupKey(name, options.actionKey) - const actionStart = actionsByName.get(lookupKey) - - if (!actionStart) { - return - } - - const stopClocks = clocksNow() - const duration = elapsed(actionStart.startClocks.timeStamp, stopClocks.timeStamp) - - const customAction: CustomAction = { - name: actionStart.name, - type: (options.type ?? actionStart.type) || ActionType.CUSTOM, - startClocks: actionStart.startClocks, - duration, - context: combine(actionStart.context, options.context), - } - - stopCallback(customAction) - - actionsByName.delete(lookupKey) -} diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 633f0a7053..e597eabc8b 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -509,3 +509,120 @@ test.describe('action collection', () => { }) }) }) + +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) => e.action && (Array.isArray(e.action.id) ? e.action.id.includes(actionId!) : e.action.id === 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 waitForServersIdle() + 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) => e.action && (Array.isArray(e.action.id) ? e.action.id.includes(actionId!) : e.action.id === 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('support custom action types') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('swipe_left', { type: 'swipe' }) + window.DD_RUM!.stopAction('swipe_left') + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action.type).toBe('swipe') + }) +}) From b4af4f6f95751b0594a49d8c53e234fa18663869 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 30 Dec 2025 13:25:43 +0100 Subject: [PATCH 07/47] merged rum-events-format --- rum-events-format | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rum-events-format b/rum-events-format index bf49abeaa5..834392ddf7 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit bf49abeaa5414d337c346ce618044dde662a9c1f +Subproject commit 834392ddf77531ed3f383e0808192879490c221d From 3e88bd784f50fd99f5c24cfc645227129fa1cbfb Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 30 Dec 2025 13:28:12 +0100 Subject: [PATCH 08/47] Fix format --- packages/rum-core/src/domain/action/actionCollection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 3fac3768e6..1355ef9fa4 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -5,7 +5,8 @@ import { toServerDuration, generateUUID, SKIPPED, - HookNames, addDuration, + HookNames, + addDuration, clocksNow, elapsed, isExperimentalFeatureEnabled, From f12985569dd9ef8d136b5bbc538059124c6576e8 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 30 Dec 2025 13:34:18 +0100 Subject: [PATCH 09/47] sync schemas --- .../domain/telemetry/telemetryEvent.types.ts | 17 ----------------- packages/rum/src/types/sessionReplay.ts | 4 ++-- rum-events-format | 2 +- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index 794464a666..c7ab57d951 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -510,8 +510,6 @@ export type TelemetryCommonFeaturesUsage = | SetViewName | GetViewContext | AddAction - | StartAction - | StopAction | AddError | GetGlobalContext | SetGlobalContext @@ -738,21 +736,6 @@ export interface AddAction { feature: 'add-action' [k: string]: unknown } - -export interface StartAction { - /** - * startAction API - */ - feature: 'start-action' - [k: string]: unknown -} -export interface StopAction { - /** - * stopAction API - */ - feature: 'stop-action' - [k: string]: unknown -} export interface AddError { /** * addError API diff --git a/packages/rum/src/types/sessionReplay.ts b/packages/rum/src/types/sessionReplay.ts index 8cb22ec203..bceb0a14bf 100644 --- a/packages/rum/src/types/sessionReplay.ts +++ b/packages/rum/src/types/sessionReplay.ts @@ -419,7 +419,7 @@ export type AddDocTypeNodeChange = [ '#doctype' | StringReference, StringOrStringReference, StringOrStringReference, - StringOrStringReference, + StringOrStringReference ] /** * Browser-specific. Schema representing a string, either expressed as a literal or as an index into the string table. @@ -560,7 +560,7 @@ export type VisualViewportChange = [ VisualViewportPageTop, VisualViewportWidth, VisualViewportHeight, - VisualViewportScale, + VisualViewportScale ] /** * The offset of the left edge of the visual viewport from the left edge of the layout viewport in CSS pixels. diff --git a/rum-events-format b/rum-events-format index 834392ddf7..bf49abeaa5 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 834392ddf77531ed3f383e0808192879490c221d +Subproject commit bf49abeaa5414d337c346ce618044dde662a9c1f From 2259509ef7c1a8a44ad0a97add2e37e3980db653 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 30 Dec 2025 13:38:42 +0100 Subject: [PATCH 10/47] fix comma --- packages/rum/src/types/sessionReplay.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rum/src/types/sessionReplay.ts b/packages/rum/src/types/sessionReplay.ts index bceb0a14bf..8cb22ec203 100644 --- a/packages/rum/src/types/sessionReplay.ts +++ b/packages/rum/src/types/sessionReplay.ts @@ -419,7 +419,7 @@ export type AddDocTypeNodeChange = [ '#doctype' | StringReference, StringOrStringReference, StringOrStringReference, - StringOrStringReference + StringOrStringReference, ] /** * Browser-specific. Schema representing a string, either expressed as a literal or as an index into the string table. @@ -560,7 +560,7 @@ export type VisualViewportChange = [ VisualViewportPageTop, VisualViewportWidth, VisualViewportHeight, - VisualViewportScale + VisualViewportScale, ] /** * The offset of the left edge of the visual viewport from the left edge of the layout viewport in CSS pixels. From 446390eadddf3140c0d237666c71b4906513b58f Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 30 Dec 2025 13:46:43 +0100 Subject: [PATCH 11/47] Add telemetry type (remove after merge in R-E-F) --- .../domain/telemetry/telemetryEvent.types.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index c7ab57d951..f2233a503a 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -510,6 +510,8 @@ export type TelemetryCommonFeaturesUsage = | SetViewName | GetViewContext | AddAction + | StartAction + | StopAction | AddError | GetGlobalContext | SetGlobalContext @@ -736,6 +738,22 @@ export interface AddAction { feature: 'add-action' [k: string]: unknown } + +export interface StartAction { + /** + * startAction API + */ + feature: 'start-action' + [k: string]: unknown +} +export interface StopAction { + /** + * stopAction API + */ + feature: 'stop-action' + [k: string]: unknown +} + export interface AddError { /** * addError API From efe5ffc03bfbe4bedfcbf8bb9997a4c291e2ec14 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 31 Dec 2025 10:12:29 +0100 Subject: [PATCH 12/47] Add loading_time to custom actions --- .../src/domain/action/actionCollection.spec.ts | 10 ++++++++++ .../rum-core/src/domain/action/actionCollection.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 97856eebe6..34481f451b 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -353,6 +353,16 @@ describe('actionCollection', () => { expect(rawRumEvents[1].duration).toBe(200 as Duration) }) + 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) + }) + it('should not create action when actionKey does not match', () => { startAction('click', { actionKey: 'button1' }) stopAction('click', { actionKey: 'button2' }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 1355ef9fa4..328a1bba0a 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -270,6 +270,9 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD : { action: { id: actionId, + // We only include loading_time for timed custom actions (startAction/stopAction) + // because instant actions (addAction) have duration: 0. + ...(action.duration > 0 ? { loading_time: toServerDuration(action.duration) } : {}), ...(action.counts ? { error: { count: action.counts.errorCount }, From 2c9597750fce5b1265d79e568e2065a2056921f1 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 31 Dec 2025 13:19:38 +0100 Subject: [PATCH 13/47] wait for fetch to complete in e2e test so to avoid flaky test --- test/e2e/scenario/rum/actions.scenario.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index e597eabc8b..13c6591225 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -554,12 +554,9 @@ test.describe('custom actions with startAction/stopAction', () => { createTest('associate a resource to a custom action') .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) .run(async ({ intakeRegistry, flushEvents, page }) => { - await page.evaluate(() => { + await page.evaluate(async () => { window.DD_RUM!.startAction('load-data') - void fetch('/ok') - }) - await waitForServersIdle() - await page.evaluate(() => { + await fetch('/ok') window.DD_RUM!.stopAction('load-data') }) await flushEvents() From 546fdde433e73b695d361c764321839ff93fe887 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 31 Dec 2025 16:12:09 +0100 Subject: [PATCH 14/47] revert test --- test/e2e/scenario/rum/actions.scenario.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 13c6591225..e597eabc8b 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -554,9 +554,12 @@ test.describe('custom actions with startAction/stopAction', () => { createTest('associate a resource to a custom action') .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) .run(async ({ intakeRegistry, flushEvents, page }) => { - await page.evaluate(async () => { + await page.evaluate(() => { window.DD_RUM!.startAction('load-data') - await fetch('/ok') + void fetch('/ok') + }) + await waitForServersIdle() + await page.evaluate(() => { window.DD_RUM!.stopAction('load-data') }) await flushEvents() From d7a7e1473c9b339a842e961560c9c97323c8971c Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 31 Dec 2025 16:58:56 +0100 Subject: [PATCH 15/47] wait for requests to complete in e2e test so to avoid flaky test --- test/e2e/scenario/rum/actions.scenario.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index e597eabc8b..2141a13a66 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test' -import { createTest, html, waitForServersIdle } from '../../lib/framework' +import { createTest, html, waitForServersIdle, waitForRequests } from '../../lib/framework' test.describe('action collection', () => { createTest('track a click action') @@ -558,7 +558,7 @@ test.describe('custom actions with startAction/stopAction', () => { window.DD_RUM!.startAction('load-data') void fetch('/ok') }) - await waitForServersIdle() + await waitForRequests(page) await page.evaluate(() => { window.DD_RUM!.stopAction('load-data') }) From e8725bad567cdd41a8148c548d0608dfc1b04456 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 5 Jan 2026 14:03:41 +0100 Subject: [PATCH 16/47] Update activeCustomActions to track existing actions --- .../domain/action/actionCollection.spec.ts | 24 ++++++++++++++++++- .../src/domain/action/actionCollection.ts | 10 +++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 34481f451b..fe3dde44fd 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -310,7 +310,7 @@ describe('actionCollection', () => { ) }) - it('should handle type precedence: stop > start > default(CUSTOM)', () => { + it('should handle type precedence', () => { startAction('action1', { type: ActionType.TAP }) stopAction('action1', { type: ActionType.SCROLL }) @@ -370,6 +370,28 @@ describe('actionCollection', () => { expect(rawRumEvents).toHaveSize(0) }) + 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.toBe(firstActionId) + + clock.tick(200) + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.id).toBe(secondActionId as string) + expect(rawRumEvents[0].duration).toBe(200 as Duration) + }) + it('should use consistent action ID from start to collected event', () => { startAction('checkout') stopAction('checkout') diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 328a1bba0a..f07a6fbf2e 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -165,6 +165,15 @@ export function startActionCollection( return } + const lookupKey = getActionLookupKey(name, options.actionKey) + + const existingAction = activeCustomActions.get(lookupKey) + if (existingAction) { + existingAction.historyEntry.close(clocksNow().relative) + existingAction.eventCountsSubscription.stop() + activeCustomActions.delete(lookupKey) + } + const id = generateUUID() const startClocks = clocksNow() @@ -177,7 +186,6 @@ export function startActionCollection( (Array.isArray(event.action.id) ? event.action.id.includes(id) : event.action.id === id), }) - const lookupKey = getActionLookupKey(name, options.actionKey) activeCustomActions.set(lookupKey, { id, name, From 9864c12b4af4d6d9cbfe13e5e0422d2b0703d533 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 5 Jan 2026 14:28:40 +0100 Subject: [PATCH 17/47] Clean up activeCustomActions on session renewal --- packages/rum-core/src/domain/action/actionCollection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index f07a6fbf2e..0ecbc4f2ac 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -94,6 +94,7 @@ export function startActionCollection( const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { customActionHistory.reset() + activeCustomActions.clear() }) let clickActionContexts: ActionContexts = { findActionId: noop as () => undefined } From a19c4d0ac719cd8fed9389b8cc6d9958442f24f9 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 5 Jan 2026 15:08:24 +0100 Subject: [PATCH 18/47] Stop active actions on session renewal, and reset the action context. --- .../domain/action/actionCollection.spec.ts | 35 +++++++++++++++++++ .../src/domain/action/actionCollection.ts | 6 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index fe3dde44fd..0482ad0d05 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -497,5 +497,40 @@ describe('actionCollection', () => { expect(actionEvent.action.resource?.count).toBe(1) expect(actionEvent.action.long_task?.count).toBe(1) }) + + 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) + }) }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 0ecbc4f2ac..2d18e87330 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -93,8 +93,12 @@ export function startActionCollection( ) const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { - customActionHistory.reset() + activeCustomActions.forEach((activeAction) => { + activeAction.historyEntry.remove() + activeAction.eventCountsSubscription.stop() + }) activeCustomActions.clear() + customActionHistory.reset() }) let clickActionContexts: ActionContexts = { findActionId: noop as () => undefined } From 5b40d1e02e8740b13676d0cfce06b7986b09a2de Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 5 Jan 2026 16:40:40 +0100 Subject: [PATCH 19/47] Clean up active custom actions on stop() --- .../src/domain/action/actionCollection.spec.ts | 17 +++++++++++++++++ .../src/domain/action/actionCollection.ts | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 0482ad0d05..15a1cba07d 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -22,6 +22,7 @@ describe('actionCollection', () => { let actionContexts: ActionContexts let startAction: ReturnType['startAction'] let stopAction: ReturnType['stopAction'] + let stopActionCollection: ReturnType['stop'] let clock: Clock beforeEach(() => { @@ -40,6 +41,7 @@ describe('actionCollection', () => { addAction = actionCollection.addAction startAction = actionCollection.startAction stopAction = actionCollection.stopAction + stopActionCollection = actionCollection.stop actionContexts = actionCollection.actionContexts rawRumEvents = collectAndValidateRawRumEvents(lifeCycle) @@ -532,5 +534,20 @@ describe('actionCollection', () => { const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent expect(actionEvent.action.error?.count).toBe(0) }) + + 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/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 2d18e87330..eb97ff7215 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -246,6 +246,11 @@ export function startActionCollection( unsubscribeAutoAction() unsubscribeSessionRenewal() stopClickActions() + activeCustomActions.forEach((activeAction) => { + activeAction.historyEntry.remove() + activeAction.eventCountsSubscription.stop() + }) + activeCustomActions.clear() customActionHistory.stop() }, } From b513153f88626dfbd500222db2b4dbfad68494b3 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 7 Jan 2026 14:48:31 +0100 Subject: [PATCH 20/47] Create trackAction to reuse in actionCollection and trackClickActions. --- .../src/domain/action/actionCollection.ts | 86 ++---- .../src/domain/action/trackAction.spec.ts | 271 ++++++++++++++++++ .../rum-core/src/domain/action/trackAction.ts | 107 +++++++ .../domain/action/trackClickActions.spec.ts | 19 +- .../src/domain/action/trackClickActions.ts | 59 ++-- 5 files changed, 431 insertions(+), 111 deletions(-) create mode 100644 packages/rum-core/src/domain/action/trackAction.spec.ts create mode 100644 packages/rum-core/src/domain/action/trackAction.ts diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index eb97ff7215..77dab759ff 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,4 +1,4 @@ -import type { ClocksState, Context, Duration, Observable, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' +import type { ClocksState, Context, Duration, Observable, RelativeTime } from '@datadog/browser-core' import { noop, combine, @@ -11,7 +11,7 @@ import { elapsed, isExperimentalFeatureEnabled, ExperimentalFeature, - createValueHistory, + relativeNow, } from '@datadog/browser-core' import { discardNegativeDuration } from '../discardNegativeDuration' import type { RawRumActionEvent } from '../../rawRumEvent.types' @@ -22,9 +22,10 @@ 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 { trackEventCounts } from '../trackEventCounts' -import type { ActionContexts, ClickAction } from './trackClickActions' -import { ACTION_CONTEXT_TIME_OUT_DELAY, trackClickActions } from './trackClickActions' +import type { ClickAction } from './trackClickActions' +import { trackClickActions } from './trackClickActions' +import type { ActionContexts, ActionCounts, TrackedAction } from './trackAction' +import { startActionTracker } from './trackAction' export type { ActionContexts } @@ -48,17 +49,8 @@ export interface ActionOptions { } interface ActiveCustomAction extends ActionOptions { - id: string name: string - startClocks: ClocksState - historyEntry: ValueHistoryEntry - eventCountsSubscription: ReturnType -} - -export interface ActionCounts { - errorCount: number - longTaskCount: number - resourceCount: number + trackedAction: TrackedAction } export interface CustomAction { @@ -75,6 +67,7 @@ export interface CustomAction { export type AutoAction = ClickAction export const LONG_TASK_START_TIME_CORRECTION = 1 as Duration + export function startActionCollection( lifeCycle: LifeCycle, hooks: Hooks, @@ -82,7 +75,8 @@ export function startActionCollection( windowOpenObservable: Observable, configuration: RumConfiguration ) { - const customActionHistory = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) + // Shared action tracker for both click and custom actions + const actionTracker = startActionTracker(lifeCycle) const activeCustomActions = new Map() const { unsubscribe: unsubscribeAutoAction } = lifeCycle.subscribe( @@ -93,40 +87,23 @@ export function startActionCollection( ) const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { - activeCustomActions.forEach((activeAction) => { - activeAction.historyEntry.remove() - activeAction.eventCountsSubscription.stop() - }) activeCustomActions.clear() - customActionHistory.reset() }) - let clickActionContexts: ActionContexts = { findActionId: noop as () => undefined } let stopClickActions: () => void = noop if (configuration.trackUserInteractions) { - ;({ actionContexts: clickActionContexts, stop: stopClickActions } = trackClickActions( + ;({ stop: stopClickActions } = trackClickActions( lifeCycle, domMutationObservable, windowOpenObservable, - configuration + configuration, + actionTracker )) } const actionContexts: ActionContexts = { - findActionId: (startTime?: RelativeTime) => { - const clickIds = clickActionContexts.findActionId(startTime) - const customIds = customActionHistory.findAll(startTime) - - const allIds = (clickIds ? (Array.isArray(clickIds) ? clickIds : [clickIds]) : ([] as string[])) - .concat(customIds) - .filter(Boolean) - - if (allIds.length === 0) { - return undefined - } - return allIds.length === 1 ? allIds[0] : allIds - }, + findActionId: (startTime?: RelativeTime) => actionTracker.findActionId(startTime), } hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { @@ -174,29 +151,16 @@ export function startActionCollection( const existingAction = activeCustomActions.get(lookupKey) if (existingAction) { - existingAction.historyEntry.close(clocksNow().relative) - existingAction.eventCountsSubscription.stop() + existingAction.trackedAction.stop(relativeNow()) activeCustomActions.delete(lookupKey) } - const id = generateUUID() const startClocks = clocksNow() - - const historyEntry = customActionHistory.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 = actionTracker.createTrackedAction(startClocks) activeCustomActions.set(lookupKey, { - id, name, - startClocks, - historyEntry, - eventCountsSubscription, + trackedAction, ...options, }) } @@ -214,18 +178,17 @@ export function startActionCollection( } const stopClocks = clocksNow() - const duration = elapsed(activeAction.startClocks.timeStamp, stopClocks.timeStamp) + const duration = elapsed(activeAction.trackedAction.startClocks.timeStamp, stopClocks.timeStamp) - activeAction.historyEntry.close(stopClocks.relative) + activeAction.trackedAction.stop(stopClocks.relative) - const { errorCount, resourceCount, longTaskCount } = activeAction.eventCountsSubscription.eventCounts - activeAction.eventCountsSubscription.stop() + const { errorCount, resourceCount, longTaskCount } = activeAction.trackedAction.eventCounts const customAction: CustomAction = { - id: activeAction.id, + id: activeAction.trackedAction.id, name: activeAction.name, type: (options.type ?? activeAction.type) || ActionType.CUSTOM, - startClocks: activeAction.startClocks, + startClocks: activeAction.trackedAction.startClocks, duration, context: combine(activeAction.context, options.context), counts: { errorCount, resourceCount, longTaskCount }, @@ -247,11 +210,10 @@ export function startActionCollection( unsubscribeSessionRenewal() stopClickActions() activeCustomActions.forEach((activeAction) => { - activeAction.historyEntry.remove() - activeAction.eventCountsSubscription.stop() + activeAction.trackedAction.discard() }) activeCustomActions.clear() - customActionHistory.stop() + actionTracker.stop() }, } } 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..a7d7546ab7 --- /dev/null +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -0,0 +1,271 @@ +import type { RelativeTime, TimeStamp } from '@datadog/browser-core' +import type { Clock } from '@datadog/browser-core/test' +import { mockClock, 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 + let clock: Clock + + beforeEach(() => { + lifeCycle = new LifeCycle() + clock = mockClock() + 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.eventCounts.errorCount).toBe(0) + expect(trackedAction.eventCounts.resourceCount).toBe(0) + expect(trackedAction.eventCounts.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.eventCounts.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.eventCounts.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.eventCounts.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.eventCounts.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.eventCounts.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.eventCounts.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()).toBe(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() + + 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)).toBe(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.eventCounts.errorCount).toBe(0) + }) + }) + + describe('session renewal', () => { + it('should clear all action IDs on session renewal', () => { + const action1 = actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) + const action2 = 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.eventCounts.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.eventCounts.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()).toBe(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.eventCounts.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..7082725aa2 --- /dev/null +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -0,0 +1,107 @@ +import type { ClocksState, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' +import { ONE_MINUTE, generateUUID, createValueHistory } from '@datadog/browser-core' +import type { LifeCycle } from '../lifeCycle' +import { LifeCycleEventType } from '../lifeCycle' +import type { EventCounts } from '../trackEventCounts' +import { trackEventCounts } from '../trackEventCounts' + +export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary + +export interface ActionCounts { + errorCount: number + longTaskCount: number + resourceCount: number +} + +export interface TrackedAction { + id: string + startClocks: ClocksState + eventCounts: EventCounts + 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 { unsubscribe: unsubscribeSessionRenewal } = 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 stopped = false + + 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 stopTracking(endTime?: RelativeTime) { + if (stopped) { + return + } + stopped = true + + if (endTime !== undefined) { + historyEntry.close(endTime) + } else { + historyEntry.remove() + } + + eventCountsSubscription.stop() + activeEventCountSubscriptions.delete(eventCountsSubscription) + } + + return { + id, + startClocks, + get eventCounts() { + return eventCountsSubscription.eventCounts + }, + stop: stopTracking, + discard: () => { + stopTracking() + }, + } + } + + function findActionId(startTime?: RelativeTime): string | string[] | undefined { + const ids = history.findAll(startTime) + if (ids.length === 0) { + return undefined + } + return ids.length === 1 ? ids[0] : ids + } + + function stop() { + unsubscribeSessionRenewal() + 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 e8014105c1..34a6a06bd6 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -9,7 +9,7 @@ import { ExperimentalFeature, } 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' @@ -18,12 +18,14 @@ 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 } 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 type { ActionTracker } from './trackAction' +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 @@ -50,6 +52,7 @@ describe('trackClickActions', () => { let domMutationObservable: Observable let windowOpenObservable: Observable let clock: Clock + let actionTracker: ActionTracker const { events, pushEvent } = eventsCollector() let button: HTMLButtonElement @@ -60,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() @@ -204,7 +211,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', () => { @@ -242,7 +249,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 14d2ff72d4..c89b8b73be 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -1,12 +1,9 @@ -import type { Duration, ClocksState, RelativeTime, TimeStamp, ValueHistory } from '@datadog/browser-core' +import type { Duration, ClocksState, RelativeTime, TimeStamp } from '@datadog/browser-core' import { timeStampNow, Observable, getRelativeTime, - ONE_MINUTE, - generateUUID, elapsed, - createValueHistory, PageExitReason, relativeToClocks, } from '@datadog/browser-core' @@ -14,7 +11,6 @@ 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 +25,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 +52,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, (event) => { if (event.reason === PageExitReason.UNLOADING) { @@ -96,7 +81,7 @@ export function trackClickActions( lifeCycle, domMutationObservable, windowOpenObservable, - history, + actionTracker, stopObservable, appendClickToClickChain, clickActionBase, @@ -107,17 +92,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 +164,7 @@ function startClickAction( lifeCycle: LifeCycle, domMutationObservable: Observable, windowOpenObservable: Observable, - history: ClickActionIdHistory, + actionTracker: ActionTracker, stopObservable: Observable, appendClickToClickChain: (click: Click) => void, clickActionBase: ClickActionBase, @@ -192,7 +172,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 @@ -286,20 +266,14 @@ 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(startClocks) + let status = ClickStatus.ONGOING let activityEndTime: undefined | TimeStamp const frustrationTypes: FrustrationType[] = [] @@ -312,11 +286,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() } @@ -326,7 +299,7 @@ function newClick( stopObservable, get hasError() { - return eventCountsSubscription.eventCounts.errorCount > 0 + return trackedAction.eventCounts.errorCount > 0 }, get hasPageActivity() { return activityEndTime !== undefined @@ -339,7 +312,7 @@ function newClick( 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() @@ -347,11 +320,11 @@ function newClick( return } - const { resourceCount, errorCount, longTaskCount } = eventCountsSubscription.eventCounts + const { resourceCount, errorCount, longTaskCount } = trackedAction.eventCounts const clickAction: ClickAction = { duration: activityEndTime && elapsed(startClocks.timeStamp, activityEndTime), startClocks, - id, + id: trackedAction.id, frustrationTypes, counts: { resourceCount, From f13b695ac0daeaa1dbc465de323537108d3a072e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 7 Jan 2026 15:02:34 +0100 Subject: [PATCH 21/47] Fix linter --- packages/rum-core/src/domain/action/trackAction.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/domain/action/trackAction.spec.ts b/packages/rum-core/src/domain/action/trackAction.spec.ts index a7d7546ab7..b7d2ebd933 100644 --- a/packages/rum-core/src/domain/action/trackAction.spec.ts +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -9,11 +9,9 @@ import { startActionTracker } from './trackAction' describe('trackAction', () => { let lifeCycle: LifeCycle let actionTracker: ActionTracker - let clock: Clock beforeEach(() => { lifeCycle = new LifeCycle() - clock = mockClock() actionTracker = startActionTracker(lifeCycle) registerCleanupTask(() => actionTracker.stop()) }) @@ -206,6 +204,8 @@ describe('trackAction', () => { const action1 = actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) const action2 = actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) + expect(action1.id).not.toEqual(action2.id) + expect(actionTracker.findActionId()).toBeDefined() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) From 969a859c1fe29946e25f28647bd40e68656297c0 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 7 Jan 2026 15:23:34 +0100 Subject: [PATCH 22/47] Fix eslint config and linter --- eslint.config.mjs | 2 +- packages/rum-core/src/domain/action/trackAction.spec.ts | 3 +-- packages/rum-core/src/domain/action/trackClickActions.spec.ts | 3 +-- packages/rum-core/src/domain/action/trackClickActions.ts | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) 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/rum-core/src/domain/action/trackAction.spec.ts b/packages/rum-core/src/domain/action/trackAction.spec.ts index b7d2ebd933..07e753787f 100644 --- a/packages/rum-core/src/domain/action/trackAction.spec.ts +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -1,6 +1,5 @@ import type { RelativeTime, TimeStamp } from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' -import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' +import { registerCleanupTask } from '@datadog/browser-core/test' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import { RumEventType } from '../../rawRumEvent.types' import type { ActionTracker, TrackedAction } from './trackAction' diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 34a6a06bd6..75d187c9e9 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -18,13 +18,12 @@ 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 './trackAction' +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 type { ActionTracker } from './trackAction' import { startActionTracker } from './trackAction' // Used to wait some time after the creation of an action diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index c89b8b73be..0cfc850ab0 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -1,4 +1,4 @@ -import type { Duration, ClocksState, RelativeTime, TimeStamp } from '@datadog/browser-core' +import type { Duration, ClocksState, TimeStamp } from '@datadog/browser-core' import { timeStampNow, Observable, From f8c506efd45bc98438efc0ee8b367b29707e5afe Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 7 Jan 2026 16:09:23 +0100 Subject: [PATCH 23/47] Prevent collision in getActionLookupKey --- .../domain/action/actionCollection.spec.ts | 24 +++++++++++++++++++ .../src/domain/action/actionCollection.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 15a1cba07d..6f81a0d096 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -549,5 +549,29 @@ describe('actionCollection', () => { expect(rawRumEvents).toHaveSize(0) }) + + 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' } }), + }) + ) + }) }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 77dab759ff..2941e8f8ed 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -291,5 +291,5 @@ function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { } function getActionLookupKey(name: string, actionKey?: string): string { - return actionKey ? `${name}__${actionKey}` : name + return JSON.stringify({ name, actionKey }) } From c3ddeebeff659fcd31faf4ff8034480dc6e54770 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 7 Jan 2026 17:10:51 +0100 Subject: [PATCH 24/47] Remove unneded test, update getActionLookupKey test --- .../src/domain/action/actionCollection.spec.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 6f81a0d096..dae9abad56 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -365,13 +365,6 @@ describe('actionCollection', () => { expect(actionEvent.action.loading_time).toBe((500 * 1e6) as ServerDuration) }) - it('should not create action when actionKey does not match', () => { - startAction('click', { actionKey: 'button1' }) - stopAction('click', { actionKey: 'button2' }) - - expect(rawRumEvents).toHaveSize(0) - }) - it('should clean up previous action when startAction is called twice with same key', () => { startAction('checkout') const firstActionId = actionContexts.findActionId() @@ -551,20 +544,20 @@ describe('actionCollection', () => { }) it('getActionLookupKey should not collide', () => { - startAction('foo__bar') + 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 bar') stopAction('foo', { actionKey: 'bar' }) expect(rawRumEvents).toHaveSize(2) expect(rawRumEvents[0].rawRumEvent).toEqual( jasmine.objectContaining({ - action: jasmine.objectContaining({ target: { name: 'foo__bar' } }), + action: jasmine.objectContaining({ target: { name: 'foo bar' } }), }) ) expect(rawRumEvents[1].rawRumEvent).toEqual( From e1f451283ff2d7b9fe9397e489c14a43141ea20a Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 7 Jan 2026 17:47:14 +0100 Subject: [PATCH 25/47] Remove telemetry events --- .../domain/telemetry/telemetryEvent.types.ts | 17 ----------------- packages/rum-core/src/boot/rumPublicApi.ts | 4 ++-- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index f2233a503a..6bb2ec6f2d 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -510,8 +510,6 @@ export type TelemetryCommonFeaturesUsage = | SetViewName | GetViewContext | AddAction - | StartAction - | StopAction | AddError | GetGlobalContext | SetGlobalContext @@ -739,21 +737,6 @@ export interface AddAction { [k: string]: unknown } -export interface StartAction { - /** - * startAction API - */ - feature: 'start-action' - [k: string]: unknown -} -export interface StopAction { - /** - * stopAction API - */ - feature: 'stop-action' - [k: string]: unknown -} - export interface AddError { /** * addError API diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index d51e75ada6..6a3dd118c4 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -676,7 +676,7 @@ export function makeRumPublicApi( }, startAction: monitor((name, options) => { - addTelemetryUsage({ feature: 'start-action' }) + // addTelemetryUsage({ feature: 'start-action' }) strategy.startAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, context: sanitize(options && options.context) as Context, @@ -685,7 +685,7 @@ export function makeRumPublicApi( }), stopAction: monitor((name, options) => { - addTelemetryUsage({ feature: 'stop-action' }) + // addTelemetryUsage({ feature: 'stop-action' }) strategy.stopAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, context: sanitize(options && options.context) as Context, From fda20bba86472d97996cdde0413244f4ff44261c Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 7 Jan 2026 17:50:32 +0100 Subject: [PATCH 26/47] remove space --- packages/core/src/domain/telemetry/telemetryEvent.types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index 6bb2ec6f2d..c7ab57d951 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -736,7 +736,6 @@ export interface AddAction { feature: 'add-action' [k: string]: unknown } - export interface AddError { /** * addError API From 80850cfddbf0704e7c1170b9d829a0c41d92b586 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 12 Jan 2026 15:04:28 +0100 Subject: [PATCH 27/47] Return an array of action IDs in findActionId --- .../rum-core/src/domain/action/actionCollection.spec.ts | 6 +++--- packages/rum-core/src/domain/action/trackAction.spec.ts | 6 +++--- packages/rum-core/src/domain/action/trackAction.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index dae9abad56..1188a79d3f 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -376,14 +376,14 @@ describe('actionCollection', () => { const secondActionId = actionContexts.findActionId() expect(secondActionId).toBeDefined() - expect(secondActionId).not.toBe(firstActionId) + 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).toBe(secondActionId as string) + expect(actionEvent.action.id).toEqual((secondActionId as string[])[0]) expect(rawRumEvents[0].duration).toBe(200 as Duration) }) @@ -408,7 +408,7 @@ describe('actionCollection', () => { expect(rawRumEvents).toHaveSize(1) const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.id).toBe(actionId as string) + expect(actionEvent.action.id).toEqual((actionId as string[])[0]) }) it('should track error count during custom action', () => { diff --git a/packages/rum-core/src/domain/action/trackAction.spec.ts b/packages/rum-core/src/domain/action/trackAction.spec.ts index 07e753787f..e89d5c0e77 100644 --- a/packages/rum-core/src/domain/action/trackAction.spec.ts +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -130,7 +130,7 @@ describe('trackAction', () => { const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } const trackedAction = actionTracker.createTrackedAction(startClocks) - expect(actionTracker.findActionId()).toBe(trackedAction.id) + expect(actionTracker.findActionId()).toEqual([trackedAction.id]) }) it('should return undefined for actions that were stopped without end time', () => { @@ -148,7 +148,7 @@ describe('trackAction', () => { trackedAction.stop(200 as RelativeTime) - expect(actionTracker.findActionId(150 as RelativeTime)).toBe(trackedAction.id) + expect(actionTracker.findActionId(150 as RelativeTime)).toEqual([trackedAction.id]) }) it('should return undefined for events outside the action time range', () => { @@ -243,7 +243,7 @@ describe('trackAction', () => { timeStamp: 1000 as TimeStamp, }) - expect(actionTracker.findActionId()).toBe(trackedAction.id) + expect(actionTracker.findActionId()).toEqual([trackedAction.id]) actionTracker.stop() diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts index 7082725aa2..03ddbabd37 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -88,7 +88,7 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { if (ids.length === 0) { return undefined } - return ids.length === 1 ? ids[0] : ids + return ids } function stop() { From ee43b9324598f147ad91e0057bfef8d1029817f4 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 13 Jan 2026 09:56:25 +0100 Subject: [PATCH 28/47] run format --- packages/rum-core/src/domain/action/trackClickActions.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 67804789d8..a631cefbc3 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -1,11 +1,5 @@ import type { Duration, ClocksState, TimeStamp } from '@datadog/browser-core' -import { - timeStampNow, - Observable, - getRelativeTime, - elapsed, - relativeToClocks, -} from '@datadog/browser-core' +import { timeStampNow, Observable, getRelativeTime, elapsed, relativeToClocks } from '@datadog/browser-core' import type { FrustrationType } from '../../rawRumEvent.types' import { ActionType } from '../../rawRumEvent.types' import type { LifeCycle } from '../lifeCycle' From 7946916f02f53e174db8f8f77b4e621de20859ad Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 13 Jan 2026 11:36:40 +0100 Subject: [PATCH 29/47] support start time for pre init action tracking --- .../rum-core/src/boot/preStartRum.spec.ts | 17 ++++- packages/rum-core/src/boot/preStartRum.ts | 6 +- .../src/domain/action/actionCollection.ts | 21 ++++-- test/e2e/scenario/rum/actions.scenario.ts | 64 +++++++++++++++++-- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index a8e53bc979..b493b455bd 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -767,13 +767,26 @@ describe('preStartRum', () => { stopAction: stopActionSpy, } as unknown as StartRumResult) + const beforeStart = clocksNow() strategy.startAction('user_login', { type: ActionType.CUSTOM }) + const beforeStop = clocksNow() strategy.stopAction('user_login') strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) - expect(startActionSpy).toHaveBeenCalledWith('user_login', { type: ActionType.CUSTOM }) - expect(stopActionSpy).toHaveBeenCalledWith('user_login', undefined) + expect(startActionSpy).toHaveBeenCalledWith( + 'user_login', + jasmine.objectContaining({ + type: ActionType.CUSTOM, + startClocks: jasmine.objectContaining({ relative: beforeStart.relative }), + }) + ) + expect(stopActionSpy).toHaveBeenCalledWith( + 'user_login', + jasmine.objectContaining({ + stopClocks: jasmine.objectContaining({ relative: beforeStop.relative }), + }) + ) }) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 70e3fae7f9..883fac26bd 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -254,11 +254,13 @@ export function createPreStartStrategy( }, startAction(name, options) { - bufferApiCalls.add((startRumResult) => startRumResult.startAction(name, options)) + const startClocks = clocksNow() + bufferApiCalls.add((startRumResult) => startRumResult.startAction(name, { ...options, startClocks })) }, stopAction(name, options) { - bufferApiCalls.add((startRumResult) => startRumResult.stopAction(name, options)) + const stopClocks = clocksNow() + bufferApiCalls.add((startRumResult) => startRumResult.stopAction(name, { ...options, stopClocks })) }, addError(providedError) { diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 2941e8f8ed..c13dd61fed 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -11,7 +11,6 @@ import { elapsed, isExperimentalFeatureEnabled, ExperimentalFeature, - relativeNow, } from '@datadog/browser-core' import { discardNegativeDuration } from '../discardNegativeDuration' import type { RawRumActionEvent } from '../../rawRumEvent.types' @@ -46,6 +45,16 @@ export interface ActionOptions { * Action key */ actionKey?: string + + /** + * @internal - used to preserve timing for pre-init calls + */ + startClocks?: ClocksState + + /** + * @internal - used to preserve timing for pre-init calls + */ + stopClocks?: ClocksState } interface ActiveCustomAction extends ActionOptions { @@ -151,17 +160,19 @@ export function startActionCollection( const existingAction = activeCustomActions.get(lookupKey) if (existingAction) { - existingAction.trackedAction.stop(relativeNow()) + existingAction.trackedAction.discard() activeCustomActions.delete(lookupKey) } - const startClocks = clocksNow() + const startClocks = options.startClocks ?? clocksNow() const trackedAction = actionTracker.createTrackedAction(startClocks) activeCustomActions.set(lookupKey, { name, trackedAction, - ...options, + type: options.type, + context: options.context, + actionKey: options.actionKey, }) } @@ -177,7 +188,7 @@ export function startActionCollection( return } - const stopClocks = clocksNow() + const stopClocks = options.stopClocks ?? clocksNow() const duration = elapsed(activeAction.trackedAction.startClocks.timeStamp, stopClocks.timeStamp) activeAction.trackedAction.stop(stopClocks.relative) diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 2141a13a66..4c4077045d 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -1,6 +1,10 @@ import { test, expect } from '@playwright/test' 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') .withRum({ trackUserInteractions: true }) @@ -545,9 +549,7 @@ test.describe('custom actions with startAction/stopAction', () => { expect(errorEvents.length).toBeGreaterThanOrEqual(1) const actionId = actionEvents[0].action.id - const relatedError = errorEvents.find( - (e) => e.action && (Array.isArray(e.action.id) ? e.action.id.includes(actionId!) : e.action.id === actionId) - ) + const relatedError = errorEvents.find((e) => hasActionId(e, actionId!)) expect(relatedError).toBeDefined() }) @@ -571,9 +573,7 @@ test.describe('custom actions with startAction/stopAction', () => { expect(actionEvents[0].action.resource?.count).toBe(1) const actionId = actionEvents[0].action.id - const relatedResource = resourceEvents.find( - (e) => e.action && (Array.isArray(e.action.id) ? e.action.id.includes(actionId!) : e.action.id === actionId) - ) + const relatedResource = resourceEvents.find((e) => hasActionId(e, actionId!)) expect(relatedResource).toBeDefined() }) @@ -625,4 +625,56 @@ test.describe('custom actions with startAction/stopAction', () => { expect(actionEvents).toHaveLength(1) expect(actionEvents[0].action.type).toBe('swipe') }) + + 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() + }) }) From 1b690e5e49ba1f70b0154ab1979fc848526df2e0 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 13 Jan 2026 13:12:35 +0100 Subject: [PATCH 30/47] modify to test that the clocks are captured at call time --- packages/rum-core/src/boot/preStartRum.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index b493b455bd..ba4d996386 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -767,9 +767,7 @@ describe('preStartRum', () => { stopAction: stopActionSpy, } as unknown as StartRumResult) - const beforeStart = clocksNow() strategy.startAction('user_login', { type: ActionType.CUSTOM }) - const beforeStop = clocksNow() strategy.stopAction('user_login') strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) @@ -778,13 +776,19 @@ describe('preStartRum', () => { 'user_login', jasmine.objectContaining({ type: ActionType.CUSTOM, - startClocks: jasmine.objectContaining({ relative: beforeStart.relative }), + startClocks: jasmine.objectContaining({ + relative: jasmine.any(Number), + timeStamp: jasmine.any(Number), + }), }) ) expect(stopActionSpy).toHaveBeenCalledWith( 'user_login', jasmine.objectContaining({ - stopClocks: jasmine.objectContaining({ relative: beforeStop.relative }), + stopClocks: jasmine.objectContaining({ + relative: jasmine.any(Number), + timeStamp: jasmine.any(Number), + }), }) ) }) From 3e0bbd99d09cfc56023b9f9f5e5a9ab47f112fe0 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 10:13:16 +0100 Subject: [PATCH 31/47] Remove actionKey, make experimental docs, pass clocks to actionCollection. Change stop and discard signatures. --- .../rum-core/src/boot/preStartRum.spec.ts | 15 +++++------ packages/rum-core/src/boot/preStartRum.ts | 4 +-- .../rum-core/src/boot/rumPublicApi.spec.ts | 1 - packages/rum-core/src/boot/rumPublicApi.ts | 5 ++-- .../src/domain/action/actionCollection.ts | 26 +++++-------------- .../rum-core/src/domain/action/trackAction.ts | 10 +++---- .../src/domain/action/trackClickActions.ts | 8 ++---- 7 files changed, 23 insertions(+), 46 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index ba4d996386..7a0ba277fb 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -776,19 +776,18 @@ describe('preStartRum', () => { 'user_login', jasmine.objectContaining({ type: ActionType.CUSTOM, - startClocks: jasmine.objectContaining({ - relative: jasmine.any(Number), - timeStamp: jasmine.any(Number), - }), + }), + jasmine.objectContaining({ + relative: jasmine.any(Number), + timeStamp: jasmine.any(Number), }) ) expect(stopActionSpy).toHaveBeenCalledWith( 'user_login', + undefined, jasmine.objectContaining({ - stopClocks: jasmine.objectContaining({ - relative: jasmine.any(Number), - timeStamp: jasmine.any(Number), - }), + relative: jasmine.any(Number), + timeStamp: jasmine.any(Number), }) ) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 883fac26bd..c55ab48257 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -255,12 +255,12 @@ export function createPreStartStrategy( startAction(name, options) { const startClocks = clocksNow() - bufferApiCalls.add((startRumResult) => startRumResult.startAction(name, { ...options, startClocks })) + bufferApiCalls.add((startRumResult) => startRumResult.startAction(name, options, startClocks)) }, stopAction(name, options) { const stopClocks = clocksNow() - bufferApiCalls.add((startRumResult) => startRumResult.stopAction(name, { ...options, stopClocks })) + bufferApiCalls.add((startRumResult) => startRumResult.stopAction(name, options, stopClocks)) }, addError(providedError) { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index f0d9ae36e9..0f010e0c0c 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -819,7 +819,6 @@ describe('rum public api', () => { jasmine.objectContaining({ type: ActionType.CUSTOM, context: { count: 123, nested: { foo: 'bar' } }, - actionKey: 'key123', }) ) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 6a3dd118c4..4c3bf6d2d0 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -170,7 +170,7 @@ export interface RumPublicApi extends PublicApi { addAction: (name: string, context?: object) => void /** - * Start a custom action, stored in `@action` + * [Experimental] start a custom action, stored in `@action` * * @category Data Collection * @param name - Name of the action @@ -179,7 +179,7 @@ export interface RumPublicApi extends PublicApi { startAction: (name: string, options?: ActionOptions) => void /** - * Stop a custom action, stored in `@action` + * [Experimental] stop a custom action, stored in `@action` * * @category Data Collection * @param name - Name of the action @@ -680,7 +680,6 @@ export function makeRumPublicApi( strategy.startAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, context: sanitize(options && options.context) as Context, - actionKey: sanitize(options && options.actionKey) as string | undefined, }) }), diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index c13dd61fed..b853d9c628 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,4 +1,4 @@ -import type { ClocksState, Context, Duration, Observable, RelativeTime } from '@datadog/browser-core' +import type { ClocksState, Context, Duration, Observable } from '@datadog/browser-core' import { noop, combine, @@ -45,17 +45,7 @@ export interface ActionOptions { * Action key */ actionKey?: string - - /** - * @internal - used to preserve timing for pre-init calls - */ - startClocks?: ClocksState - - /** - * @internal - used to preserve timing for pre-init calls - */ - stopClocks?: ClocksState -} + } interface ActiveCustomAction extends ActionOptions { name: string @@ -112,7 +102,7 @@ export function startActionCollection( } const actionContexts: ActionContexts = { - findActionId: (startTime?: RelativeTime) => actionTracker.findActionId(startTime), + findActionId: actionTracker.findActionId, } hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { @@ -151,7 +141,7 @@ export function startActionCollection( }) ) - function startCustomActionInternal(name: string, options: ActionOptions = {}) { + function startCustomActionInternal(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { return } @@ -164,7 +154,6 @@ export function startActionCollection( activeCustomActions.delete(lookupKey) } - const startClocks = options.startClocks ?? clocksNow() const trackedAction = actionTracker.createTrackedAction(startClocks) activeCustomActions.set(lookupKey, { @@ -176,7 +165,7 @@ export function startActionCollection( }) } - function stopCustomActionInternal(name: string, options: ActionOptions = {}) { + function stopCustomActionInternal(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { return } @@ -188,13 +177,10 @@ export function startActionCollection( return } - const stopClocks = options.stopClocks ?? clocksNow() const duration = elapsed(activeAction.trackedAction.startClocks.timeStamp, stopClocks.timeStamp) activeAction.trackedAction.stop(stopClocks.relative) - const { errorCount, resourceCount, longTaskCount } = activeAction.trackedAction.eventCounts - const customAction: CustomAction = { id: activeAction.trackedAction.id, name: activeAction.name, @@ -202,7 +188,7 @@ export function startActionCollection( startClocks: activeAction.trackedAction.startClocks, duration, context: combine(activeAction.context, options.context), - counts: { errorCount, resourceCount, longTaskCount }, + counts: activeAction.trackedAction.eventCounts, } lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(customAction)) diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts index 03ddbabd37..3a09abd52b 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -17,7 +17,7 @@ export interface TrackedAction { id: string startClocks: ClocksState eventCounts: EventCounts - stop: (endTime?: RelativeTime) => void + stop: (endTime: RelativeTime) => void discard: () => void } @@ -54,7 +54,7 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { }) activeEventCountSubscriptions.add(eventCountsSubscription) - function stopTracking(endTime?: RelativeTime) { + function stopOrDiscard(endTime?: RelativeTime) { if (stopped) { return } @@ -76,10 +76,8 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { get eventCounts() { return eventCountsSubscription.eventCounts }, - stop: stopTracking, - discard: () => { - stopTracking() - }, + stop: stopOrDiscard, + discard: stopOrDiscard } } diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index a631cefbc3..3da1b8f3ed 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -314,17 +314,13 @@ function newClick( return } - const { resourceCount, errorCount, longTaskCount } = trackedAction.eventCounts + const { errorCount, longTaskCount, resourceCount } = trackedAction.eventCounts const clickAction: ClickAction = { duration: activityEndTime && elapsed(startClocks.timeStamp, activityEndTime), startClocks, id: trackedAction.id, frustrationTypes, - counts: { - resourceCount, - errorCount, - longTaskCount, - }, + counts: { errorCount, longTaskCount, resourceCount }, events: domEvents ?? [startEvent], event: startEvent, ...clickActionBase, From 40e8331b861a1d645f6fca3b535934f5a8eb8eae Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 10:44:29 +0100 Subject: [PATCH 32/47] Created trackCustomActions, revamp processAction, edit clickAction. --- .../domain/action/actionCollection.spec.ts | 342 +-------------- .../src/domain/action/actionCollection.ts | 229 +++-------- .../src/domain/action/trackAction.spec.ts | 28 +- .../rum-core/src/domain/action/trackAction.ts | 20 +- .../src/domain/action/trackClickActions.ts | 18 +- .../domain/action/trackCustomActions.spec.ts | 388 ++++++++++++++++++ .../src/domain/action/trackCustomActions.ts | 129 ++++++ 7 files changed, 618 insertions(+), 536 deletions(-) create mode 100644 packages/rum-core/src/domain/action/trackCustomActions.spec.ts create mode 100644 packages/rum-core/src/domain/action/trackCustomActions.ts diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 1188a79d3f..15be96c9e3 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -1,7 +1,6 @@ import type { Duration, RelativeTime, ServerDuration, TimeStamp } from '@datadog/browser-core' -import { addDuration, ExperimentalFeature, HookNames, Observable } from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' -import { createNewEvent, mockClock, mockExperimentalFeatures, registerCleanupTask } from '@datadog/browser-core/test' +import { addDuration, HookNames, Observable } from '@datadog/browser-core' +import { createNewEvent, registerCleanupTask } from '@datadog/browser-core/test' import { collectAndValidateRawRumEvents, mockRumConfiguration } from '../../../test' import type { RawRumActionEvent, RawRumEvent } from '../../rawRumEvent.types' import { RumEventType, ActionType } from '../../rawRumEvent.types' @@ -20,10 +19,6 @@ describe('actionCollection', () => { let addAction: ReturnType['addAction'] let rawRumEvents: Array> let actionContexts: ActionContexts - let startAction: ReturnType['startAction'] - let stopAction: ReturnType['stopAction'] - let stopActionCollection: ReturnType['stop'] - let clock: Clock beforeEach(() => { const domMutationObservable = new Observable() @@ -39,9 +34,6 @@ describe('actionCollection', () => { ) registerCleanupTask(actionCollection.stop) addAction = actionCollection.addAction - startAction = actionCollection.startAction - stopAction = actionCollection.stopAction - stopActionCollection = actionCollection.stop actionContexts = actionCollection.actionContexts rawRumEvents = collectAndValidateRawRumEvents(lifeCycle) @@ -94,6 +86,7 @@ describe('actionCollection', () => { }, type: ActionType.CLICK, }, + context: undefined, date: jasmine.any(Number), type: RumEventType.ACTION, _dd: { @@ -238,333 +231,4 @@ describe('actionCollection', () => { }) }) - describe('startAction / stopAction', () => { - beforeEach(() => { - clock = mockClock() - mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) - }) - - 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) - }) - ;[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 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' }, - }) - ) - }) - - it('should handle type precedence', () => { - 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 }), - }) - ) - }) - - 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('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) - }) - - 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) - }) - - it('should use consistent action ID from start to collected event', () => { - startAction('checkout') - stopAction('checkout') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.id).toBeDefined() - expect(typeof actionEvent.action.id).toBe('string') - expect(actionEvent.action.id.length).toBeGreaterThan(0) - }) - - it('should return custom action ID from actionContexts.findActionId during action', () => { - 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 track error count during custom action', () => { - startAction('checkout') - - const actionId = actionContexts.findActionId() - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.ERROR, - action: { id: actionId }, - error: { message: 'test error' }, - } as any) - - stopAction('checkout') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.error?.count).toBe(1) - }) - - it('should track resource count during custom action', () => { - startAction('load-data') - - const actionId = actionContexts.findActionId() - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - action: { id: actionId }, - resource: { type: 'fetch' }, - } as any) - - stopAction('load-data') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.resource?.count).toBe(1) - }) - - it('should track long task count during custom action', () => { - startAction('heavy-computation') - - const actionId = actionContexts.findActionId() - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.LONG_TASK, - action: { id: actionId }, - long_task: { duration: 100 }, - } as any) - - stopAction('heavy-computation') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.long_task?.count).toBe(1) - }) - - 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) - }) - - 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) - }) - - 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) - }) - - 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' } }), - }) - ) - }) - }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index b853d9c628..39d475c769 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,20 +1,8 @@ import type { ClocksState, Context, Duration, Observable } from '@datadog/browser-core' -import { - noop, - combine, - toServerDuration, - generateUUID, - SKIPPED, - HookNames, - addDuration, - clocksNow, - elapsed, - isExperimentalFeatureEnabled, - ExperimentalFeature, -} from '@datadog/browser-core' +import { noop, toServerDuration, generateUUID, SKIPPED, HookNames, addDuration } 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' @@ -23,44 +11,20 @@ import type { DefaultRumEventAttributes, DefaultTelemetryEventAttributes, Hooks import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' -import type { ActionContexts, ActionCounts, TrackedAction } from './trackAction' +import type { ActionContexts, ActionCounts } from './trackAction' import { startActionTracker } from './trackAction' +import type { ActionOptions, CustomAction } from './trackCustomActions' +import { trackCustomActions } from './trackCustomActions' -export type { ActionContexts } +export type { ActionContexts, ActionOptions } -export interface ActionOptions { - /** - * Action Type - * - * @default 'custom' - */ - type?: ActionType - - /** - * Action context - */ - context?: any - - /** - * Action key - */ - actionKey?: string - } - -interface ActiveCustomAction extends ActionOptions { - name: string - trackedAction: TrackedAction -} - -export interface CustomAction { - id?: string - type: ActionType +export interface InstantCustomAction { + type: CustomAction['type'] name: string startClocks: ClocksState duration: Duration context?: Context handlingStack?: string - counts?: ActionCounts } export type AutoAction = ClickAction @@ -76,7 +40,6 @@ export function startActionCollection( ) { // Shared action tracker for both click and custom actions const actionTracker = startActionTracker(lifeCycle) - const activeCustomActions = new Map() const { unsubscribe: unsubscribeAutoAction } = lifeCycle.subscribe( LifeCycleEventType.AUTO_ACTION_COMPLETED, @@ -85,10 +48,6 @@ export function startActionCollection( } ) - const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { - activeCustomActions.clear() - }) - let stopClickActions: () => void = noop if (configuration.trackUserInteractions) { @@ -101,6 +60,10 @@ export function startActionCollection( )) } + const customActions = trackCustomActions(lifeCycle, actionTracker, (action) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + }) + const actionContexts: ActionContexts = { findActionId: actionTracker.findActionId, } @@ -141,152 +104,84 @@ export function startActionCollection( }) ) - function startCustomActionInternal(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { - if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { - return - } - - const lookupKey = getActionLookupKey(name, options.actionKey) - - const existingAction = activeCustomActions.get(lookupKey) - if (existingAction) { - existingAction.trackedAction.discard() - activeCustomActions.delete(lookupKey) - } - - const trackedAction = actionTracker.createTrackedAction(startClocks) - - activeCustomActions.set(lookupKey, { - name, - trackedAction, - type: options.type, - context: options.context, - actionKey: options.actionKey, - }) - } - - function stopCustomActionInternal(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { - if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { - return - } - - const lookupKey = getActionLookupKey(name, options.actionKey) - const activeAction = activeCustomActions.get(lookupKey) - - if (!activeAction) { - return - } - - const duration = elapsed(activeAction.trackedAction.startClocks.timeStamp, stopClocks.timeStamp) - - activeAction.trackedAction.stop(stopClocks.relative) - - const customAction: CustomAction = { - id: activeAction.trackedAction.id, - name: activeAction.name, - type: (options.type ?? activeAction.type) || ActionType.CUSTOM, - startClocks: activeAction.trackedAction.startClocks, - duration, - context: combine(activeAction.context, options.context), - counts: activeAction.trackedAction.eventCounts, - } - - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(customAction)) - activeCustomActions.delete(lookupKey) - } - return { - addAction: (action: CustomAction) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + addAction: (action: InstantCustomAction) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processInstantAction(action)) }, - startAction: startCustomActionInternal, - stopAction: stopCustomActionInternal, + startAction: customActions.startAction, + stopAction: customActions.stopAction, actionContexts, stop: () => { unsubscribeAutoAction() - unsubscribeSessionRenewal() stopClickActions() - activeCustomActions.forEach((activeAction) => { - activeAction.trackedAction.discard() - }) - activeCustomActions.clear() + customActions.stop() actionTracker.stop() }, } } function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { - const actionId = isAutoAction(action) ? action.id : (action.id ?? generateUUID()) - - const autoActionProperties = isAutoAction(action) - ? { - action: { - id: actionId, - 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: { + const isAuto = isAutoAction(action) + + const actionEvent: RawRumActionEvent = { + type: RumEventType.ACTION, + date: action.startClocks.timeStamp, + action: { + id: action.id, + loading_time: discardNegativeDuration(toServerDuration(action.duration)), + target: { name: action.name }, + type: action.type, + ...(action.counts && { + error: { count: action.counts.errorCount }, + long_task: { count: action.counts.longTaskCount }, + resource: { count: action.counts.resourceCount }, + }), + frustration: isAuto ? { type: action.frustrationTypes } : undefined, + }, + context: isAuto ? undefined : action.context, + _dd: isAuto + ? { action: { target: action.target, position: action.position, name_source: action.nameSource, }, - }, - } - : { - action: { - id: actionId, - // We only include loading_time for timed custom actions (startAction/stopAction) - // because instant actions (addAction) have duration: 0. - ...(action.duration > 0 ? { loading_time: toServerDuration(action.duration) } : {}), - ...(action.counts - ? { - error: { count: action.counts.errorCount }, - long_task: { count: action.counts.longTaskCount }, - resource: { count: action.counts.resourceCount }, - } - : {}), - }, - context: action.context, - } - - const actionEvent: RawRumActionEvent = combine( - { - action: { target: { name: action.name }, type: action.type }, - date: action.startClocks.timeStamp, - type: RumEventType.ACTION, - }, - autoActionProperties - ) + } + : undefined, + } - const duration = action.duration - const domainContext: RumActionEventDomainContext = isAutoAction(action) + const domainContext: RumActionEventDomainContext = isAuto ? { events: action.events } : { handlingStack: action.handlingStack } return { rawRumEvent: actionEvent, - duration, + duration: action.duration, startTime: action.startClocks.relative, domainContext, } } -function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { - return action.type === ActionType.CLICK && 'events' in action +function processInstantAction(action: InstantCustomAction): RawRumEventCollectedData { + const actionEvent: RawRumActionEvent = { + type: RumEventType.ACTION, + date: action.startClocks.timeStamp, + action: { + id: generateUUID(), + target: { name: action.name }, + type: action.type, + }, + context: action.context, + } + + return { + rawRumEvent: actionEvent, + duration: action.duration, + startTime: action.startClocks.relative, + domainContext: { handlingStack: action.handlingStack }, + } } -function getActionLookupKey(name: string, actionKey?: string): string { - return JSON.stringify({ name, actionKey }) +function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { + 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 index e89d5c0e77..dbbc01edf0 100644 --- a/packages/rum-core/src/domain/action/trackAction.spec.ts +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -44,9 +44,9 @@ describe('trackAction', () => { const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } const trackedAction = actionTracker.createTrackedAction(startClocks) - expect(trackedAction.eventCounts.errorCount).toBe(0) - expect(trackedAction.eventCounts.resourceCount).toBe(0) - expect(trackedAction.eventCounts.longTaskCount).toBe(0) + expect(trackedAction.counts.errorCount).toBe(0) + expect(trackedAction.counts.resourceCount).toBe(0) + expect(trackedAction.counts.longTaskCount).toBe(0) }) }) @@ -65,7 +65,7 @@ describe('trackAction', () => { error: { message: 'test error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(1) + expect(trackedAction.counts.errorCount).toBe(1) }) it('should count resources associated with the action', () => { @@ -75,7 +75,7 @@ describe('trackAction', () => { resource: { type: 'fetch' }, } as any) - expect(trackedAction.eventCounts.resourceCount).toBe(1) + expect(trackedAction.counts.resourceCount).toBe(1) }) it('should count long tasks associated with the action', () => { @@ -85,7 +85,7 @@ describe('trackAction', () => { long_task: { duration: 100 }, } as any) - expect(trackedAction.eventCounts.longTaskCount).toBe(1) + expect(trackedAction.counts.longTaskCount).toBe(1) }) it('should count events when action ID is in an array', () => { @@ -95,7 +95,7 @@ describe('trackAction', () => { error: { message: 'test error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(1) + expect(trackedAction.counts.errorCount).toBe(1) }) it('should not count events for other actions', () => { @@ -105,7 +105,7 @@ describe('trackAction', () => { error: { message: 'test error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(0) + expect(trackedAction.counts.errorCount).toBe(0) }) it('should stop counting events after action is stopped', () => { @@ -117,7 +117,7 @@ describe('trackAction', () => { error: { message: 'test error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(0) + expect(trackedAction.counts.errorCount).toBe(0) }) }) @@ -137,7 +137,7 @@ describe('trackAction', () => { const startClocks = { relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp } const trackedAction = actionTracker.createTrackedAction(startClocks) - trackedAction.stop() + trackedAction.stop(200 as RelativeTime) expect(actionTracker.findActionId()).toBeUndefined() }) @@ -194,7 +194,7 @@ describe('trackAction', () => { error: { message: 'test error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(0) + expect(trackedAction.counts.errorCount).toBe(0) }) }) @@ -222,7 +222,7 @@ describe('trackAction', () => { error: { message: 'first error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(1) + expect(trackedAction.counts.errorCount).toBe(1) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) @@ -232,7 +232,7 @@ describe('trackAction', () => { error: { message: 'second error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(1) + expect(trackedAction.counts.errorCount).toBe(1) }) }) @@ -264,7 +264,7 @@ describe('trackAction', () => { error: { message: 'test error' }, } as any) - expect(trackedAction.eventCounts.errorCount).toBe(0) + 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 index 3a09abd52b..67a21ef229 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -1,8 +1,7 @@ -import type { ClocksState, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' -import { ONE_MINUTE, generateUUID, createValueHistory } from '@datadog/browser-core' +import type { ClocksState, Duration, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' +import { ONE_MINUTE, generateUUID, createValueHistory, elapsed } from '@datadog/browser-core' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' -import type { EventCounts } from '../trackEventCounts' import { trackEventCounts } from '../trackEventCounts' export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary @@ -16,7 +15,8 @@ export interface ActionCounts { export interface TrackedAction { id: string startClocks: ClocksState - eventCounts: EventCounts + duration: Duration | undefined + counts: ActionCounts stop: (endTime: RelativeTime) => void discard: () => void } @@ -45,6 +45,7 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { const id = generateUUID() const historyEntry: ValueHistoryEntry = history.add(id, startClocks.relative) let stopped = false + let duration: Duration | undefined const eventCountsSubscription = trackEventCounts({ lifeCycle, @@ -62,6 +63,7 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { if (endTime !== undefined) { historyEntry.close(endTime) + duration = elapsed(startClocks.relative, endTime) } else { historyEntry.remove() } @@ -73,11 +75,15 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { return { id, startClocks, - get eventCounts() { - return eventCountsSubscription.eventCounts + get duration() { + return duration + }, + get counts() { + const { errorCount, longTaskCount, resourceCount } = eventCountsSubscription.eventCounts + return { errorCount, longTaskCount, resourceCount } }, stop: stopOrDiscard, - discard: stopOrDiscard + discard: stopOrDiscard, } } diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 3da1b8f3ed..edd0bd9956 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -1,5 +1,5 @@ import type { Duration, ClocksState, TimeStamp } from '@datadog/browser-core' -import { timeStampNow, Observable, getRelativeTime, elapsed, relativeToClocks } from '@datadog/browser-core' +import { timeStampNow, Observable, getRelativeTime, relativeToClocks } from '@datadog/browser-core' import type { FrustrationType } from '../../rawRumEvent.types' import { ActionType } from '../../rawRumEvent.types' import type { LifeCycle } from '../lifeCycle' @@ -265,8 +265,7 @@ function newClick( clickActionBase: ClickActionBase, startEvent: MouseEventOnElement ) { - const startClocks = relativeToClocks(startEvent.timeStamp) - const trackedAction: TrackedAction = actionTracker.createTrackedAction(startClocks) + const trackedAction: TrackedAction = actionTracker.createTrackedAction(relativeToClocks(startEvent.timeStamp)) let status = ClickStatus.ONGOING let activityEndTime: undefined | TimeStamp @@ -293,7 +292,7 @@ function newClick( stopObservable, get hasError() { - return trackedAction.eventCounts.errorCount > 0 + return trackedAction.counts.errorCount > 0 }, get hasPageActivity() { return activityEndTime !== undefined @@ -302,7 +301,9 @@ function newClick( addFrustration: (frustrationType: FrustrationType) => { frustrationTypes.push(frustrationType) }, - startClocks, + get startClocks() { + return trackedAction.startClocks + }, isStopped: () => status === ClickStatus.STOPPED || status === ClickStatus.FINALIZED, @@ -314,13 +315,12 @@ function newClick( return } - const { errorCount, longTaskCount, resourceCount } = trackedAction.eventCounts const clickAction: ClickAction = { - duration: activityEndTime && elapsed(startClocks.timeStamp, activityEndTime), - startClocks, + startClocks: trackedAction.startClocks, + duration: trackedAction.duration, id: trackedAction.id, frustrationTypes, - counts: { errorCount, longTaskCount, resourceCount }, + 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..c0bdc9cb81 --- /dev/null +++ b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts @@ -0,0 +1,388 @@ +import type { Duration, RelativeTime, 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 } from '../lifeCycle' +import { LifeCycle, LifeCycleEventType } from '../lifeCycle' +import { createHooks } from '../hooks' +import type { RumMutationRecord } from '../../browser/domMutationObservable' +import type { ActionContexts } from './actionCollection' +import { startActionCollection } from './actionCollection' + +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') + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.id).toBeDefined() + expect(typeof actionEvent.action.id).toBe('string') + expect(actionEvent.action.id.length).toBeGreaterThan(0) + }) + + it('should return custom action ID from actionContexts.findActionId during action', () => { + 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 track error count during custom action', () => { + startAction('checkout') + + const actionId = actionContexts.findActionId() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: actionId }, + error: { message: 'test error' }, + } as any) + + stopAction('checkout') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.error?.count).toBe(1) + }) + + it('should track resource count during custom action', () => { + startAction('load-data') + + const actionId = actionContexts.findActionId() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + action: { id: actionId }, + resource: { type: 'fetch' }, + } as any) + + stopAction('load-data') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.resource?.count).toBe(1) + }) + + it('should track long task count during custom action', () => { + startAction('heavy-computation') + + const actionId = actionContexts.findActionId() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.LONG_TASK, + action: { id: actionId }, + long_task: { duration: 100 }, + } as any) + + stopAction('heavy-computation') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.long_task?.count).toBe(1) + }) + + 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..0a95d9cd1a --- /dev/null +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -0,0 +1,129 @@ +import type { ClocksState, Context, Duration } from '@datadog/browser-core' +import { + clocksNow, + combine, + isExperimentalFeatureEnabled, + ExperimentalFeature, +} from '@datadog/browser-core' +import { ActionType } from '../../rawRumEvent.types' +import type { LifeCycle } from '../lifeCycle' +import { LifeCycleEventType } 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 +} + +interface ActiveCustomAction extends ActionOptions { + name: string + trackedAction: TrackedAction +} + +export interface CustomAction { + id: string + type: ActionType + name: string + startClocks: ClocksState + duration: Duration + context?: Context + handlingStack?: string + counts: ActionCounts +} + +export function trackCustomActions( + lifeCycle: LifeCycle, + actionTracker: ActionTracker, + onCustomActionCompleted: (action: CustomAction) => void +) { + const activeCustomActions = new Map() + + const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + activeCustomActions.clear() + }) + + function startCustomAction(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { + if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } + + const lookupKey = getActionLookupKey(name, options.actionKey) + + const existingAction = activeCustomActions.get(lookupKey) + if (existingAction) { + existingAction.trackedAction.discard() + activeCustomActions.delete(lookupKey) + } + + const trackedAction = actionTracker.createTrackedAction(startClocks) + + activeCustomActions.set(lookupKey, { + name, + trackedAction, + type: options.type, + context: options.context, + actionKey: options.actionKey, + }) + } + + function stopCustomAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { + if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } + + const lookupKey = getActionLookupKey(name, options.actionKey) + 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) || ActionType.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() { + unsubscribeSessionRenewal() + activeCustomActions.forEach((activeAction) => { + activeAction.trackedAction.discard() + }) + activeCustomActions.clear() + } + + return { + startAction: startCustomAction, + stopAction: stopCustomAction, + stop, + } +} + +function getActionLookupKey(name: string, actionKey?: string): string { + return JSON.stringify({ name, actionKey }) +} + From 8e6763f918801d5690bbf6b78a3344b62a59d662 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 10:51:08 +0100 Subject: [PATCH 33/47] remove actionKey, run formatter. --- packages/rum-core/src/boot/rumPublicApi.spec.ts | 1 - packages/rum-core/src/boot/rumPublicApi.ts | 1 - .../rum-core/src/domain/action/actionCollection.spec.ts | 1 - .../rum-core/src/domain/action/trackCustomActions.spec.ts | 1 - packages/rum-core/src/domain/action/trackCustomActions.ts | 8 +------- 5 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 0f010e0c0c..2cc1d99359 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -812,7 +812,6 @@ describe('rum public api', () => { rumPublicApi.startAction('action_name', { type: ActionType.CUSTOM, context: { count: 123, nested: { foo: 'bar' } } as any, - actionKey: 'key123', }) expect(startActionSpy.calls.argsFor(0)[1]).toEqual( diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 4c3bf6d2d0..6fdf60da8c 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -688,7 +688,6 @@ export function makeRumPublicApi( strategy.stopAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, context: sanitize(options && options.context) as Context, - actionKey: sanitize(options && options.actionKey) as string | undefined, }) }), diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 15be96c9e3..2ef45d3552 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -230,5 +230,4 @@ describe('actionCollection', () => { expect(telemetryEventAttributes.action?.id).toBeUndefined() }) }) - }) diff --git a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts index c0bdc9cb81..6933e56c90 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts @@ -385,4 +385,3 @@ describe('trackCustomActions', () => { }) }) }) - diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackCustomActions.ts index 0a95d9cd1a..fb1dff342d 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -1,10 +1,5 @@ import type { ClocksState, Context, Duration } from '@datadog/browser-core' -import { - clocksNow, - combine, - isExperimentalFeatureEnabled, - ExperimentalFeature, -} from '@datadog/browser-core' +import { clocksNow, combine, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' import { ActionType } from '../../rawRumEvent.types' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' @@ -126,4 +121,3 @@ export function trackCustomActions( function getActionLookupKey(name: string, actionKey?: string): string { return JSON.stringify({ name, actionKey }) } - From feead72811223516fee48c06961a05eb8921105b Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 11:05:54 +0100 Subject: [PATCH 34/47] Linting fixes --- packages/rum-core/src/domain/action/actionCollection.ts | 6 ++---- .../rum-core/src/domain/action/trackCustomActions.spec.ts | 4 ++-- .../rum-core/src/domain/contexts/internalContext.spec.ts | 2 +- packages/rum-core/src/domain/contexts/internalContext.ts | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 39d475c769..3a6a92113d 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -11,13 +11,11 @@ import type { DefaultRumEventAttributes, DefaultTelemetryEventAttributes, Hooks import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' -import type { ActionContexts, ActionCounts } from './trackAction' +import type { ActionContexts } from './trackAction' import { startActionTracker } from './trackAction' -import type { ActionOptions, CustomAction } from './trackCustomActions' +import type { CustomAction } from './trackCustomActions' import { trackCustomActions } from './trackCustomActions' -export type { ActionContexts, ActionOptions } - export interface InstantCustomAction { type: CustomAction['type'] name: string diff --git a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts index 6933e56c90..2a14de66bd 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts @@ -1,4 +1,4 @@ -import type { Duration, RelativeTime, ServerDuration } from '@datadog/browser-core' +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' @@ -9,8 +9,8 @@ import type { RawRumEventCollectedData } from '../lifeCycle' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' -import type { ActionContexts } from './actionCollection' import { startActionCollection } from './actionCollection' +import type { ActionContexts } from './trackAction' describe('trackCustomActions', () => { const lifeCycle = new LifeCycle() 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' From d6a11c0cda83a90078d2fd2e730570a00f1985e0 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 11:27:12 +0100 Subject: [PATCH 35/47] Linting fixes. --- packages/rum-core/src/boot/rumPublicApi.ts | 2 +- packages/rum-core/src/domain/action/actionCollection.ts | 6 +++--- packages/rum-core/src/domain/action/trackAction.ts | 2 +- .../rum-core/src/domain/action/trackCustomActions.spec.ts | 3 +-- packages/rum-core/src/domain/action/trackCustomActions.ts | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 6fdf60da8c..bc9c8f6076 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -53,7 +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/actionCollection' +import type { ActionOptions } from '../domain/action/trackCustomActions' import { createPreStartStrategy } from './preStartRum' import type { StartRum, StartRumResult } from './startRum' diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 3a6a92113d..1aac4272d2 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -9,12 +9,12 @@ 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 { ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' -import type { ActionContexts } from './trackAction' +import type { ClickAction } from './trackClickActions' import { startActionTracker } from './trackAction' -import type { CustomAction } from './trackCustomActions' +import type { ActionContexts } from './trackAction' import { trackCustomActions } from './trackCustomActions' +import type { CustomAction } from './trackCustomActions' export interface InstantCustomAction { type: CustomAction['type'] diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts index 67a21ef229..cd6ce2343e 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -1,7 +1,7 @@ import type { ClocksState, Duration, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' import { ONE_MINUTE, generateUUID, createValueHistory, elapsed } from '@datadog/browser-core' -import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' +import type { LifeCycle } from '../lifeCycle' import { trackEventCounts } from '../trackEventCounts' export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary diff --git a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts index 2a14de66bd..67e178abe0 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts @@ -5,8 +5,7 @@ import { mockClock, mockExperimentalFeatures, registerCleanupTask } from '@datad import { collectAndValidateRawRumEvents, mockRumConfiguration } from '../../../test' import type { RawRumActionEvent, RawRumEvent } from '../../rawRumEvent.types' import { RumEventType, ActionType } from '../../rawRumEvent.types' -import type { RawRumEventCollectedData } from '../lifeCycle' -import { LifeCycle, LifeCycleEventType } from '../lifeCycle' +import { type RawRumEventCollectedData, LifeCycle, LifeCycleEventType } from '../lifeCycle' import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import { startActionCollection } from './actionCollection' diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackCustomActions.ts index fb1dff342d..b4914a2671 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -1,8 +1,8 @@ import type { ClocksState, Context, Duration } from '@datadog/browser-core' import { clocksNow, combine, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' import { ActionType } from '../../rawRumEvent.types' -import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' +import type { LifeCycle } from '../lifeCycle' import type { ActionCounts, ActionTracker, TrackedAction } from './trackAction' export interface ActionOptions { From 02fa207bd81dfcc64b50acf525e19f6abb54a14d Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 12:00:12 +0100 Subject: [PATCH 36/47] Re add actionKey, created TrackedActionMetadata. --- .../rum-core/src/boot/preStartRum.spec.ts | 8 +++- .../rum-core/src/boot/rumPublicApi.spec.ts | 2 + packages/rum-core/src/boot/rumPublicApi.ts | 2 + .../rum-core/src/domain/action/trackAction.ts | 20 ++++++++-- .../src/domain/action/trackCustomActions.ts | 40 ++++++++----------- 5 files changed, 44 insertions(+), 28 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 7a0ba277fb..16c7cbd8c0 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -24,9 +24,9 @@ import { 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' @@ -637,6 +637,12 @@ describe('preStartRum', () => { type: ActionType.CUSTOM, startClocks: clocksNow(), duration: 0 as Duration, + id: '123', + counts: { + errorCount: 0, + longTaskCount: 0, + resourceCount: 0, + }, } strategy.addAction(customAction) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 2cc1d99359..1c6c0c0dda 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -812,12 +812,14 @@ describe('rum public api', () => { 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', }) ) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index bc9c8f6076..0ec118f36a 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -680,6 +680,7 @@ export function makeRumPublicApi( strategy.startAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, context: sanitize(options && options.context) as Context, + actionKey: options && options.actionKey, }) }), @@ -688,6 +689,7 @@ export function makeRumPublicApi( strategy.stopAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, context: sanitize(options && options.context) as Context, + actionKey: options && options.actionKey, }) }), diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts index cd6ce2343e..2ce0cfc9cc 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -1,8 +1,9 @@ -import type { ClocksState, Duration, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' +import type { ClocksState, Context, 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 { trackEventCounts } from '../trackEventCounts' +import type { ActionType } from '../../rawRumEvent.types' export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary @@ -12,7 +13,14 @@ export interface ActionCounts { resourceCount: number } -export interface TrackedAction { +export interface TrackedActionMetadata { + name?: string + type?: ActionType + context?: Context + actionKey?: string +} + +export interface TrackedAction extends TrackedActionMetadata { id: string startClocks: ClocksState duration: Duration | undefined @@ -26,7 +34,7 @@ export interface ActionContexts { } export interface ActionTracker { - createTrackedAction: (startClocks: ClocksState) => TrackedAction + createTrackedAction: (startClocks: ClocksState, metadata?: TrackedActionMetadata) => TrackedAction findActionId: (startTime?: RelativeTime) => string | string[] | undefined stop: () => void } @@ -41,7 +49,7 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { activeEventCountSubscriptions.clear() }) - function createTrackedAction(startClocks: ClocksState): TrackedAction { + function createTrackedAction(startClocks: ClocksState, metadata?: TrackedActionMetadata): TrackedAction { const id = generateUUID() const historyEntry: ValueHistoryEntry = history.add(id, startClocks.relative) let stopped = false @@ -75,6 +83,10 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { return { id, startClocks, + name: metadata?.name, + type: metadata?.type, + context: metadata?.context, + actionKey: metadata?.actionKey, get duration() { return duration }, diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackCustomActions.ts index b4914a2671..6cabd1c444 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -24,11 +24,6 @@ export interface ActionOptions { actionKey?: string } -interface ActiveCustomAction extends ActionOptions { - name: string - trackedAction: TrackedAction -} - export interface CustomAction { id: string type: ActionType @@ -45,7 +40,7 @@ export function trackCustomActions( actionTracker: ActionTracker, onCustomActionCompleted: (action: CustomAction) => void ) { - const activeCustomActions = new Map() + const activeCustomActions = new Map() const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { activeCustomActions.clear() @@ -60,19 +55,18 @@ export function trackCustomActions( const existingAction = activeCustomActions.get(lookupKey) if (existingAction) { - existingAction.trackedAction.discard() + existingAction.discard() activeCustomActions.delete(lookupKey) } - const trackedAction = actionTracker.createTrackedAction(startClocks) - - activeCustomActions.set(lookupKey, { + const trackedAction = actionTracker.createTrackedAction(startClocks, { name, - trackedAction, type: options.type, context: options.context, actionKey: options.actionKey, }) + + activeCustomActions.set(lookupKey, trackedAction) } function stopCustomAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { @@ -81,22 +75,22 @@ export function trackCustomActions( } const lookupKey = getActionLookupKey(name, options.actionKey) - const activeAction = activeCustomActions.get(lookupKey) + const trackedAction = activeCustomActions.get(lookupKey) - if (!activeAction) { + if (!trackedAction) { return } - activeAction.trackedAction.stop(stopClocks.relative) + trackedAction.stop(stopClocks.relative) const customAction: CustomAction = { - id: activeAction.trackedAction.id, - name: activeAction.name, - type: (options.type ?? activeAction.type) || ActionType.CUSTOM, - startClocks: activeAction.trackedAction.startClocks, - duration: activeAction.trackedAction.duration!, - context: combine(activeAction.context, options.context), - counts: activeAction.trackedAction.counts, + id: trackedAction.id, + name: trackedAction.name!, + type: (options.type ?? trackedAction.type) || ActionType.CUSTOM, + startClocks: trackedAction.startClocks, + duration: trackedAction.duration!, + context: combine(trackedAction.context, options.context), + counts: trackedAction.counts, } onCustomActionCompleted(customAction) @@ -105,8 +99,8 @@ export function trackCustomActions( function stop() { unsubscribeSessionRenewal() - activeCustomActions.forEach((activeAction) => { - activeAction.trackedAction.discard() + activeCustomActions.forEach((trackedAction) => { + trackedAction.discard() }) activeCustomActions.clear() } From c28599f245a906fd30a64a350fcc3fab801551ab Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 12:44:24 +0100 Subject: [PATCH 37/47] Fix typecheck --- packages/rum-core/src/domain/action/actionCollection.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 2ef45d3552..746727ed4a 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 { 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() From 20019e376c6d0cc9c2c91a3ca68b2c8d6b9f95c3 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 13:43:41 +0100 Subject: [PATCH 38/47] Change getActionLookupKey --- packages/rum-core/src/domain/action/trackCustomActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackCustomActions.ts index 6cabd1c444..4c2e4dfabd 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -113,5 +113,5 @@ export function trackCustomActions( } function getActionLookupKey(name: string, actionKey?: string): string { - return JSON.stringify({ name, actionKey }) + return actionKey ?? name } From 0dc0e6a079ee613d31dcedb3b66bbe8242469ca1 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 14:59:10 +0100 Subject: [PATCH 39/47] TEST REDUCE BUNDLE SIZE --- .../src/domain/action/actionCollection.ts | 27 +++++++++---- .../rum-core/src/domain/action/trackAction.ts | 23 ++++------- .../domain/action/trackClickActions.spec.ts | 20 +++++----- .../domain/action/trackCustomActions.spec.ts | 38 +++++++++---------- .../src/domain/action/trackCustomActions.ts | 27 +++---------- packages/rum-core/src/rawRumEvent.types.ts | 5 --- 6 files changed, 62 insertions(+), 78 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 1aac4272d2..7499a0d1e4 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,5 +1,14 @@ import type { ClocksState, Context, Duration, Observable } from '@datadog/browser-core' -import { noop, toServerDuration, generateUUID, SKIPPED, HookNames, addDuration } 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 { RumEventType } from '../../rawRumEvent.types' @@ -58,9 +67,13 @@ export function startActionCollection( )) } - const customActions = trackCustomActions(lifeCycle, actionTracker, (action) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) - }) + 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, @@ -106,13 +119,13 @@ export function startActionCollection( addAction: (action: InstantCustomAction) => { lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processInstantAction(action)) }, - startAction: customActions.startAction, - stopAction: customActions.stopAction, + startAction: customActions?.startAction ?? noop, + stopAction: customActions?.stopAction ?? noop, actionContexts, stop: () => { unsubscribeAutoAction() stopClickActions() - customActions.stop() + customActions?.stop() actionTracker.stop() }, } diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts index 2ce0cfc9cc..71df6a493e 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -2,16 +2,13 @@ import type { ClocksState, Context, Duration, RelativeTime, ValueHistoryEntry } 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' import type { ActionType } from '../../rawRumEvent.types' export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary -export interface ActionCounts { - errorCount: number - longTaskCount: number - resourceCount: number -} +export type ActionCounts = EventCounts export interface TrackedActionMetadata { name?: string @@ -43,9 +40,9 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { const history = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) const activeEventCountSubscriptions = new Set>() - const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + const sessionRenewalSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { history.reset() - activeEventCountSubscriptions.forEach((subscription) => subscription.stop()) + activeEventCountSubscriptions.forEach((s) => s.stop()) activeEventCountSubscriptions.clear() }) @@ -83,16 +80,12 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { return { id, startClocks, - name: metadata?.name, - type: metadata?.type, - context: metadata?.context, - actionKey: metadata?.actionKey, + ...metadata, get duration() { return duration }, get counts() { - const { errorCount, longTaskCount, resourceCount } = eventCountsSubscription.eventCounts - return { errorCount, longTaskCount, resourceCount } + return eventCountsSubscription.eventCounts }, stop: stopOrDiscard, discard: stopOrDiscard, @@ -108,8 +101,8 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { } function stop() { - unsubscribeSessionRenewal() - activeEventCountSubscriptions.forEach((subscription) => subscription.stop()) + sessionRenewalSubscription.unsubscribe() + activeEventCountSubscriptions.forEach((s) => s.stop()) activeEventCountSubscriptions.clear() history.reset() history.stop() diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 446fb3005a..f6f9efab06 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -118,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', @@ -142,7 +142,7 @@ describe('trackClickActions', () => { }, position: { x: 50, y: 50 }, events: [domEvent], - }, + }), ]) }) @@ -169,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', () => { diff --git a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts index 67e178abe0..b56d824e58 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts @@ -113,28 +113,26 @@ describe('trackCustomActions', () => { }) 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 support custom action type', () => { + startAction('test_action', { type: ActionType.CUSTOM }) + stopAction('test_action') + + expect(rawRumEvents).toHaveSize(1) + expect(rawRumEvents[0].rawRumEvent).toEqual( + jasmine.objectContaining({ + type: RumEventType.ACTION, + action: jasmine.objectContaining({ + type: ActionType.CUSTOM, + }), + }) + ) }) it('should handle type precedence (stop overrides start)', () => { - startAction('action1', { type: ActionType.TAP }) - stopAction('action1', { type: ActionType.SCROLL }) + startAction('action1', { type: ActionType.CUSTOM }) + stopAction('action1', { type: ActionType.CLICK }) - startAction('action2', { type: ActionType.SWIPE }) + startAction('action2', { type: ActionType.CLICK }) stopAction('action2') startAction('action3') @@ -143,12 +141,12 @@ describe('trackCustomActions', () => { expect(rawRumEvents).toHaveSize(3) expect(rawRumEvents[0].rawRumEvent).toEqual( jasmine.objectContaining({ - action: jasmine.objectContaining({ type: ActionType.SCROLL }), + action: jasmine.objectContaining({ type: ActionType.CLICK }), }) ) expect(rawRumEvents[1].rawRumEvent).toEqual( jasmine.objectContaining({ - action: jasmine.objectContaining({ type: ActionType.SWIPE }), + action: jasmine.objectContaining({ type: ActionType.CLICK }), }) ) expect(rawRumEvents[2].rawRumEvent).toEqual( diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackCustomActions.ts index 4c2e4dfabd..918fb6722b 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -1,5 +1,5 @@ import type { ClocksState, Context, Duration } from '@datadog/browser-core' -import { clocksNow, combine, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' +import { clocksNow, combine } from '@datadog/browser-core' import { ActionType } from '../../rawRumEvent.types' import { LifeCycleEventType } from '../lifeCycle' import type { LifeCycle } from '../lifeCycle' @@ -42,16 +42,10 @@ export function trackCustomActions( ) { const activeCustomActions = new Map() - const { unsubscribe: unsubscribeSessionRenewal } = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { - activeCustomActions.clear() - }) + lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => activeCustomActions.clear()) function startCustomAction(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { - if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { - return - } - - const lookupKey = getActionLookupKey(name, options.actionKey) + const lookupKey = options.actionKey ?? name const existingAction = activeCustomActions.get(lookupKey) if (existingAction) { @@ -70,11 +64,7 @@ export function trackCustomActions( } function stopCustomAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { - if (!isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { - return - } - - const lookupKey = getActionLookupKey(name, options.actionKey) + const lookupKey = options.actionKey ?? name const trackedAction = activeCustomActions.get(lookupKey) if (!trackedAction) { @@ -98,10 +88,7 @@ export function trackCustomActions( } function stop() { - unsubscribeSessionRenewal() - activeCustomActions.forEach((trackedAction) => { - trackedAction.discard() - }) + activeCustomActions.forEach((trackedAction) => trackedAction.discard()) activeCustomActions.clear() } @@ -111,7 +98,3 @@ export function trackCustomActions( stop, } } - -function getActionLookupKey(name: string, actionKey?: string): string { - return actionKey ?? name -} diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index b599188859..9229ae390b 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -337,11 +337,6 @@ 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] From 53b70d9553181d2193d412432883cd3b2976ed5a Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 15:31:10 +0100 Subject: [PATCH 40/47] SECOND TEST REDUCE BUNDLE SIZE --- .../rum-core/src/boot/preStartRum.spec.ts | 2 + .../src/domain/action/actionCollection.ts | 75 ++++++++----------- .../rum-core/src/domain/action/trackAction.ts | 57 ++++++-------- test/e2e/scenario/rum/actions.scenario.ts | 14 ---- 4 files changed, 56 insertions(+), 92 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 16c7cbd8c0..3335cb4dfa 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -642,6 +642,8 @@ describe('preStartRum', () => { errorCount: 0, longTaskCount: 0, resourceCount: 0, + actionCount: 0, + frustrationCount: 0, }, } strategy.addAction(customAction) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 7499a0d1e4..dcd4c078e7 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -15,7 +15,6 @@ 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 { trackClickActions } from './trackClickActions' @@ -134,59 +133,49 @@ export function startActionCollection( function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { const isAuto = isAutoAction(action) - const actionEvent: RawRumActionEvent = { - type: RumEventType.ACTION, - date: action.startClocks.timeStamp, - action: { - id: action.id, - loading_time: discardNegativeDuration(toServerDuration(action.duration)), - target: { name: action.name }, - type: action.type, - ...(action.counts && { + return { + rawRumEvent: { + type: RumEventType.ACTION, + date: action.startClocks.timeStamp, + action: { + id: action.id, + loading_time: discardNegativeDuration(toServerDuration(action.duration)), + target: { name: action.name }, + type: action.type, error: { count: action.counts.errorCount }, long_task: { count: action.counts.longTaskCount }, resource: { count: action.counts.resourceCount }, - }), - frustration: isAuto ? { type: action.frustrationTypes } : undefined, + frustration: isAuto ? { type: action.frustrationTypes } : undefined, + }, + context: isAuto ? undefined : action.context, + _dd: isAuto + ? { + action: { + target: action.target, + position: action.position, + name_source: action.nameSource, + }, + } + : undefined, }, - context: isAuto ? undefined : action.context, - _dd: isAuto - ? { - action: { - target: action.target, - position: action.position, - name_source: action.nameSource, - }, - } - : undefined, - } - - const domainContext: RumActionEventDomainContext = isAuto - ? { events: action.events } - : { handlingStack: action.handlingStack } - - return { - rawRumEvent: actionEvent, duration: action.duration, startTime: action.startClocks.relative, - domainContext, + domainContext: isAuto ? { events: action.events } : { handlingStack: action.handlingStack }, } } function processInstantAction(action: InstantCustomAction): RawRumEventCollectedData { - const actionEvent: RawRumActionEvent = { - type: RumEventType.ACTION, - date: action.startClocks.timeStamp, - action: { - id: generateUUID(), - target: { name: action.name }, - type: action.type, - }, - context: action.context, - } - return { - rawRumEvent: actionEvent, + rawRumEvent: { + type: RumEventType.ACTION, + date: action.startClocks.timeStamp, + action: { + id: generateUUID(), + target: { name: action.name }, + type: action.type, + }, + context: action.context, + }, duration: action.duration, startTime: action.startClocks.relative, domainContext: { handlingStack: action.handlingStack }, diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts index 71df6a493e..f918d15c26 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -38,43 +38,30 @@ export interface ActionTracker { export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { const history = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) - const activeEventCountSubscriptions = new Set>() + const activeSubs = new Set>() const sessionRenewalSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { history.reset() - activeEventCountSubscriptions.forEach((s) => s.stop()) - activeEventCountSubscriptions.clear() + activeSubs.forEach((s) => s.stop()) + activeSubs.clear() }) function createTrackedAction(startClocks: ClocksState, metadata?: TrackedActionMetadata): TrackedAction { const id = generateUUID() const historyEntry: ValueHistoryEntry = history.add(id, startClocks.relative) - let stopped = false let duration: Duration | undefined - const eventCountsSubscription = trackEventCounts({ + const sub = trackEventCounts({ lifeCycle, isChildEvent: (event) => event.action !== undefined && (Array.isArray(event.action.id) ? event.action.id.includes(id) : event.action.id === id), }) - activeEventCountSubscriptions.add(eventCountsSubscription) + activeSubs.add(sub) - function stopOrDiscard(endTime?: RelativeTime) { - if (stopped) { - return - } - stopped = true - - if (endTime !== undefined) { - historyEntry.close(endTime) - duration = elapsed(startClocks.relative, endTime) - } else { - historyEntry.remove() - } - - eventCountsSubscription.stop() - activeEventCountSubscriptions.delete(eventCountsSubscription) + function cleanup() { + sub.stop() + activeSubs.delete(sub) } return { @@ -85,32 +72,32 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { return duration }, get counts() { - return eventCountsSubscription.eventCounts + return sub.eventCounts + }, + stop(endTime: RelativeTime) { + historyEntry.close(endTime) + duration = elapsed(startClocks.relative, endTime) + cleanup() + }, + discard() { + historyEntry.remove() + cleanup() }, - stop: stopOrDiscard, - discard: stopOrDiscard, } } function findActionId(startTime?: RelativeTime): string | string[] | undefined { const ids = history.findAll(startTime) - if (ids.length === 0) { - return undefined - } - return ids + return ids.length ? ids : undefined } function stop() { sessionRenewalSubscription.unsubscribe() - activeEventCountSubscriptions.forEach((s) => s.stop()) - activeEventCountSubscriptions.clear() + activeSubs.forEach((s) => s.stop()) + activeSubs.clear() history.reset() history.stop() } - return { - createTrackedAction, - findActionId, - stop, - } + return { createTrackedAction, findActionId, stop } } diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 4c4077045d..e425b16740 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -612,20 +612,6 @@ test.describe('custom actions with startAction/stopAction', () => { ) }) - createTest('support custom action types') - .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) - .run(async ({ intakeRegistry, flushEvents, page }) => { - await page.evaluate(() => { - window.DD_RUM!.startAction('swipe_left', { type: 'swipe' }) - window.DD_RUM!.stopAction('swipe_left') - }) - await flushEvents() - - const actionEvents = intakeRegistry.rumActionEvents - expect(actionEvents).toHaveLength(1) - expect(actionEvents[0].action.type).toBe('swipe') - }) - createTest('preserve timing when startAction is called before init') .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) .withRumInit((configuration) => { From 66ce58632efb58b0e4fee8a32446f8456d2e392e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 14 Jan 2026 16:09:44 +0100 Subject: [PATCH 41/47] Add back action types --- .../src/domain/action/trackAction.spec.ts | 5 +- .../domain/action/trackCustomActions.spec.ts | 100 ++++-------------- packages/rum-core/src/rawRumEvent.types.ts | 5 + 3 files changed, 26 insertions(+), 84 deletions(-) diff --git a/packages/rum-core/src/domain/action/trackAction.spec.ts b/packages/rum-core/src/domain/action/trackAction.spec.ts index dbbc01edf0..bef0402814 100644 --- a/packages/rum-core/src/domain/action/trackAction.spec.ts +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -200,10 +200,7 @@ describe('trackAction', () => { describe('session renewal', () => { it('should clear all action IDs on session renewal', () => { - const action1 = actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) - const action2 = actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) - - expect(action1.id).not.toEqual(action2.id) + actionTracker.createTrackedAction({ relative: 100 as RelativeTime, timeStamp: 1000 as TimeStamp }) expect(actionTracker.findActionId()).toBeDefined() diff --git a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts index b56d824e58..8da494eab6 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.spec.ts @@ -79,17 +79,6 @@ describe('trackCustomActions', () => { it('should use consistent action ID from start to collected event', () => { startAction('checkout') - stopAction('checkout') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.id).toBeDefined() - expect(typeof actionEvent.action.id).toBe('string') - expect(actionEvent.action.id.length).toBeGreaterThan(0) - }) - - it('should return custom action ID from actionContexts.findActionId during action', () => { - startAction('checkout') const actionId = actionContexts.findActionId() expect(actionId).toBeDefined() @@ -113,26 +102,28 @@ describe('trackCustomActions', () => { }) describe('action types', () => { - it('should support custom action type', () => { - startAction('test_action', { type: ActionType.CUSTOM }) - stopAction('test_action') - - expect(rawRumEvents).toHaveSize(1) - expect(rawRumEvents[0].rawRumEvent).toEqual( - jasmine.objectContaining({ - type: RumEventType.ACTION, - action: jasmine.objectContaining({ - type: ActionType.CUSTOM, - }), - }) - ) + ;[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.CUSTOM }) - stopAction('action1', { type: ActionType.CLICK }) + startAction('action1', { type: ActionType.TAP }) + stopAction('action1', { type: ActionType.SCROLL }) - startAction('action2', { type: ActionType.CLICK }) + startAction('action2', { type: ActionType.SWIPE }) stopAction('action2') startAction('action3') @@ -141,12 +132,12 @@ describe('trackCustomActions', () => { expect(rawRumEvents).toHaveSize(3) expect(rawRumEvents[0].rawRumEvent).toEqual( jasmine.objectContaining({ - action: jasmine.objectContaining({ type: ActionType.CLICK }), + action: jasmine.objectContaining({ type: ActionType.SCROLL }), }) ) expect(rawRumEvents[1].rawRumEvent).toEqual( jasmine.objectContaining({ - action: jasmine.objectContaining({ type: ActionType.CLICK }), + action: jasmine.objectContaining({ type: ActionType.SWIPE }), }) ) expect(rawRumEvents[2].rawRumEvent).toEqual( @@ -245,57 +236,6 @@ describe('trackCustomActions', () => { }) describe('event counting', () => { - it('should track error count during custom action', () => { - startAction('checkout') - - const actionId = actionContexts.findActionId() - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.ERROR, - action: { id: actionId }, - error: { message: 'test error' }, - } as any) - - stopAction('checkout') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.error?.count).toBe(1) - }) - - it('should track resource count during custom action', () => { - startAction('load-data') - - const actionId = actionContexts.findActionId() - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - action: { id: actionId }, - resource: { type: 'fetch' }, - } as any) - - stopAction('load-data') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.resource?.count).toBe(1) - }) - - it('should track long task count during custom action', () => { - startAction('heavy-computation') - - const actionId = actionContexts.findActionId() - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.LONG_TASK, - action: { id: actionId }, - long_task: { duration: 100 }, - } as any) - - stopAction('heavy-computation') - - expect(rawRumEvents).toHaveSize(1) - const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent - expect(actionEvent.action.long_task?.count).toBe(1) - }) - it('should include counts in the action event', () => { startAction('complex-action') 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] From c0a3fd56b3c4ae6c6d09f12763269dc67b51687c Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Thu, 15 Jan 2026 12:15:50 +0100 Subject: [PATCH 42/47] Fix const names, removed InstantCustomAction, remove TrackedActionMetadata --- .../rum-core/src/boot/preStartRum.spec.ts | 11 +--- .../rum-core/src/boot/rumPublicApi.spec.ts | 1 - packages/rum-core/src/boot/rumPublicApi.ts | 1 - .../domain/action/actionCollection.spec.ts | 3 - .../src/domain/action/actionCollection.ts | 59 ++++++------------- .../rum-core/src/domain/action/trackAction.ts | 37 +++++------- .../src/domain/action/trackCustomActions.ts | 48 ++++++++------- 7 files changed, 61 insertions(+), 99 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 3335cb4dfa..e753e7c586 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -632,19 +632,10 @@ 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(), - duration: 0 as Duration, - id: '123', - counts: { - errorCount: 0, - longTaskCount: 0, - resourceCount: 0, - actionCount: 0, - frustrationCount: 0, - }, } strategy.addAction(customAction) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 1c6c0c0dda..671375ad50 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -155,7 +155,6 @@ describe('rum public api', () => { context: { bar: 'baz' }, name: 'foo', startClocks: jasmine.any(Object), - duration: jasmine.any(Number), type: ActionType.CUSTOM, handlingStack: jasmine.any(String), }, diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 0ec118f36a..4203822bfd 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -668,7 +668,6 @@ export function makeRumPublicApi( context: sanitize(context) as Context, startClocks: clocksNow(), type: ActionType.CUSTOM, - duration: 0 as Duration, handlingStack, }) addTelemetryUsage({ feature: 'add-action' }) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 746727ed4a..0704050d67 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -86,7 +86,6 @@ describe('actionCollection', () => { }, type: ActionType.CLICK, }, - context: undefined, date: jasmine.any(Number), type: RumEventType.ACTION, _dd: { @@ -114,7 +113,6 @@ describe('actionCollection', () => { name: 'foo', startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, type: ActionType.CUSTOM, - duration: 0 as Duration, context: { foo: 'bar' }, }) @@ -160,7 +158,6 @@ describe('actionCollection', () => { name: 'foo', startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, type: ActionType.CUSTOM, - duration: 0 as Duration, handlingStack: 'Error\n at foo\n at bar', }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index dcd4c078e7..71fedb5e1d 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -24,15 +24,6 @@ import type { ActionContexts } from './trackAction' import { trackCustomActions } from './trackCustomActions' import type { CustomAction } from './trackCustomActions' -export interface InstantCustomAction { - type: CustomAction['type'] - name: string - startClocks: ClocksState - duration: Duration - context?: Context - handlingStack?: string -} - export type AutoAction = ClickAction export const LONG_TASK_START_TIME_CORRECTION = 1 as Duration @@ -115,8 +106,8 @@ export function startActionCollection( ) return { - addAction: (action: InstantCustomAction) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processInstantAction(action)) + addAction: (action: Omit) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction({ id: generateUUID(), ...action })) }, startAction: customActions?.startAction ?? noop, stopAction: customActions?.stopAction ?? noop, @@ -132,6 +123,7 @@ export function startActionCollection( function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { const isAuto = isAutoAction(action) + const loadingTime = discardNegativeDuration(toServerDuration(action.duration)) return { rawRumEvent: { @@ -139,24 +131,27 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD date: action.startClocks.timeStamp, action: { id: action.id, - loading_time: discardNegativeDuration(toServerDuration(action.duration)), target: { name: action.name }, type: action.type, - error: { count: action.counts.errorCount }, - long_task: { count: action.counts.longTaskCount }, - resource: { count: action.counts.resourceCount }, - frustration: isAuto ? { type: action.frustrationTypes } : undefined, + ...(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 } }), }, - context: isAuto ? undefined : action.context, - _dd: isAuto + ...(isAuto ? { - action: { - target: action.target, - position: action.position, - name_source: action.nameSource, + _dd: { + action: { + target: action.target, + position: action.position, + name_source: action.nameSource, + }, }, } - : undefined, + : { context: action.context }), }, duration: action.duration, startTime: action.startClocks.relative, @@ -164,24 +159,6 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD } } -function processInstantAction(action: InstantCustomAction): RawRumEventCollectedData { - return { - rawRumEvent: { - type: RumEventType.ACTION, - date: action.startClocks.timeStamp, - action: { - id: generateUUID(), - target: { name: action.name }, - type: action.type, - }, - context: action.context, - }, - duration: action.duration, - startTime: action.startClocks.relative, - domainContext: { handlingStack: action.handlingStack }, - } -} - function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { return 'events' in action } diff --git a/packages/rum-core/src/domain/action/trackAction.ts b/packages/rum-core/src/domain/action/trackAction.ts index f918d15c26..4295495809 100644 --- a/packages/rum-core/src/domain/action/trackAction.ts +++ b/packages/rum-core/src/domain/action/trackAction.ts @@ -1,23 +1,15 @@ -import type { ClocksState, Context, Duration, RelativeTime, ValueHistoryEntry } from '@datadog/browser-core' +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' -import type { ActionType } from '../../rawRumEvent.types' export const ACTION_CONTEXT_TIME_OUT_DELAY = 5 * ONE_MINUTE // arbitrary export type ActionCounts = EventCounts -export interface TrackedActionMetadata { - name?: string - type?: ActionType - context?: Context - actionKey?: string -} - -export interface TrackedAction extends TrackedActionMetadata { +export interface TrackedAction { id: string startClocks: ClocksState duration: Duration | undefined @@ -31,48 +23,47 @@ export interface ActionContexts { } export interface ActionTracker { - createTrackedAction: (startClocks: ClocksState, metadata?: TrackedActionMetadata) => TrackedAction + 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 activeSubs = new Set>() + const activeEventCountSubscriptions = new Set>() const sessionRenewalSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { history.reset() - activeSubs.forEach((s) => s.stop()) - activeSubs.clear() + activeEventCountSubscriptions.forEach((subscription) => subscription.stop()) + activeEventCountSubscriptions.clear() }) - function createTrackedAction(startClocks: ClocksState, metadata?: TrackedActionMetadata): TrackedAction { + function createTrackedAction(startClocks: ClocksState): TrackedAction { const id = generateUUID() const historyEntry: ValueHistoryEntry = history.add(id, startClocks.relative) let duration: Duration | undefined - const sub = trackEventCounts({ + const eventCountsSubscription = trackEventCounts({ lifeCycle, isChildEvent: (event) => event.action !== undefined && (Array.isArray(event.action.id) ? event.action.id.includes(id) : event.action.id === id), }) - activeSubs.add(sub) + activeEventCountSubscriptions.add(eventCountsSubscription) function cleanup() { - sub.stop() - activeSubs.delete(sub) + eventCountsSubscription.stop() + activeEventCountSubscriptions.delete(eventCountsSubscription) } return { id, startClocks, - ...metadata, get duration() { return duration }, get counts() { - return sub.eventCounts + return eventCountsSubscription.eventCounts }, stop(endTime: RelativeTime) { historyEntry.close(endTime) @@ -93,8 +84,8 @@ export function startActionTracker(lifeCycle: LifeCycle): ActionTracker { function stop() { sessionRenewalSubscription.unsubscribe() - activeSubs.forEach((s) => s.stop()) - activeSubs.clear() + activeEventCountSubscriptions.forEach((subscription) => subscription.stop()) + activeEventCountSubscriptions.clear() history.reset() history.stop() } diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackCustomActions.ts index 918fb6722b..302fe0f8d6 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.ts +++ b/packages/rum-core/src/domain/action/trackCustomActions.ts @@ -1,6 +1,7 @@ import type { ClocksState, Context, Duration } from '@datadog/browser-core' import { clocksNow, combine } from '@datadog/browser-core' -import { ActionType } from '../../rawRumEvent.types' +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' @@ -29,10 +30,17 @@ export interface CustomAction { type: ActionType name: string startClocks: ClocksState - duration: Duration + duration?: Duration context?: Context handlingStack?: string - counts: ActionCounts + counts?: ActionCounts +} + +interface ActiveCustomAction { + name: string + type?: ActionType + context?: Context + trackedAction: TrackedAction } export function trackCustomActions( @@ -40,7 +48,7 @@ export function trackCustomActions( actionTracker: ActionTracker, onCustomActionCompleted: (action: CustomAction) => void ) { - const activeCustomActions = new Map() + const activeCustomActions = new Map() lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => activeCustomActions.clear()) @@ -49,38 +57,38 @@ export function trackCustomActions( const existingAction = activeCustomActions.get(lookupKey) if (existingAction) { - existingAction.discard() + existingAction.trackedAction.discard() activeCustomActions.delete(lookupKey) } - const trackedAction = actionTracker.createTrackedAction(startClocks, { + const trackedAction = actionTracker.createTrackedAction(startClocks) + + activeCustomActions.set(lookupKey, { name, type: options.type, context: options.context, - actionKey: options.actionKey, + trackedAction, }) - - activeCustomActions.set(lookupKey, trackedAction) } function stopCustomAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { const lookupKey = options.actionKey ?? name - const trackedAction = activeCustomActions.get(lookupKey) + const activeAction = activeCustomActions.get(lookupKey) - if (!trackedAction) { + if (!activeAction) { return } - trackedAction.stop(stopClocks.relative) + activeAction.trackedAction.stop(stopClocks.relative) const customAction: CustomAction = { - id: trackedAction.id, - name: trackedAction.name!, - type: (options.type ?? trackedAction.type) || ActionType.CUSTOM, - startClocks: trackedAction.startClocks, - duration: trackedAction.duration!, - context: combine(trackedAction.context, options.context), - counts: trackedAction.counts, + 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) @@ -88,7 +96,7 @@ export function trackCustomActions( } function stop() { - activeCustomActions.forEach((trackedAction) => trackedAction.discard()) + activeCustomActions.forEach((activeAction) => activeAction.trackedAction.discard()) activeCustomActions.clear() } From 06d75659a318b51b1523844490047eebbebc5015 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Thu, 15 Jan 2026 12:20:47 +0100 Subject: [PATCH 43/47] Linter --- packages/rum-core/src/domain/action/actionCollection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 71fedb5e1d..d67be549b7 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,4 +1,4 @@ -import type { ClocksState, Context, Duration, Observable } from '@datadog/browser-core' +import type { Duration, Observable } from '@datadog/browser-core' import { noop, toServerDuration, From 7754bcc7bf88fd04e31d75fa5a3b5068e8b9463f Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 20 Jan 2026 12:33:08 +0100 Subject: [PATCH 44/47] Renamed trackCustomActions to trackManualActions, moved addAction to trackManualActions --- .../rum-core/src/boot/preStartRum.spec.ts | 8 ++-- .../rum-core/src/boot/rumPublicApi.spec.ts | 29 +++++++++++- packages/rum-core/src/boot/rumPublicApi.ts | 17 +++++-- .../src/domain/action/actionCollection.ts | 41 ++++++----------- ...ons.spec.ts => trackManualActions.spec.ts} | 8 ++-- ...CustomActions.ts => trackManualActions.ts} | 45 ++++++++++--------- 6 files changed, 86 insertions(+), 62 deletions(-) rename packages/rum-core/src/domain/action/{trackCustomActions.spec.ts => trackManualActions.spec.ts} (97%) rename packages/rum-core/src/domain/action/{trackCustomActions.ts => trackManualActions.ts} (63%) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index e753e7c586..ec869cbaa7 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -26,7 +26,7 @@ import type { ViewOptions } from '../domain/view/trackViews' import { ActionType, VitalType } from '../rawRumEvent.types' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' -import type { CustomAction } from '../domain/action/trackCustomActions' +import type { ManualAction } from '../domain/action/trackManualActions' import type { RumPublicApi, Strategy } from './rumPublicApi' import type { StartRumResult } from './startRum' import { createPreStartStrategy } from './preStartRum' @@ -632,14 +632,14 @@ describe('preStartRum', () => { const addActionSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addAction: addActionSpy } as unknown as StartRumResult) - const customAction: Omit = { + const manualAction: Omit = { name: 'foo', type: ActionType.CUSTOM, startClocks: clocksNow(), } - strategy.addAction(customAction) + strategy.addAction(manualAction) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) - expect(addActionSpy).toHaveBeenCalledOnceWith(customAction) + expect(addActionSpy).toHaveBeenCalledOnceWith(manualAction) }) it('addError', () => { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 671375ad50..542a67e975 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -1,7 +1,7 @@ import type { RelativeTime, DeflateWorker, TimeStamp } from '@datadog/browser-core' -import { ONE_SECOND, display, DefaultPrivacyLevel, timeStampToClocks } from '@datadog/browser-core' +import { ONE_SECOND, display, DefaultPrivacyLevel, timeStampToClocks, ExperimentalFeature } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { mockClock } from '@datadog/browser-core/test' +import { mockClock, mockExperimentalFeatures } from '@datadog/browser-core/test' import { noopRecorderApi, noopProfilerApi } from '../../test' import { ActionType, VitalType } from '../rawRumEvent.types' import type { DurationVitalReference } from '../domain/vital/vitalCollection' @@ -760,6 +760,8 @@ describe('rum public api', () => { describe('startAction / stopAction', () => { it('should call startAction and stopAction on the strategy', () => { + mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + const startActionSpy = jasmine.createSpy() const stopActionSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( @@ -797,6 +799,8 @@ describe('rum public api', () => { }) it('should sanitize startAction and stopAction inputs', () => { + mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + const startActionSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -822,6 +826,27 @@ describe('rum public api', () => { }) ) }) + + it('should not call startAction/stopAction when feature flag is disabled', () => { + 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 }) + rumPublicApi.stopAction('purchase') + + expect(startActionSpy).not.toHaveBeenCalled() + expect(stopActionSpy).not.toHaveBeenCalled() + }) }) describe('addDurationVital', () => { diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 4203822bfd..474d3b448a 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -32,6 +32,8 @@ import { CustomerContextKey, defineContextMethod, startBufferingData, + isExperimentalFeatureEnabled, + ExperimentalFeature, } from '@datadog/browser-core' import type { LifeCycle } from '../domain/lifeCycle' @@ -53,7 +55,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 type { ActionOptions } from '../domain/action/trackManualActions' import { createPreStartStrategy } from './preStartRum' import type { StartRum, StartRumResult } from './startRum' @@ -170,16 +172,16 @@ export interface RumPublicApi extends PublicApi { addAction: (name: string, context?: object) => void /** - * [Experimental] start a custom action, stored in `@action` + * [Experimental] Start an action, stored in `@action` * * @category Data Collection * @param name - Name of the action - * @param options - Options of the action + * @param options - Options of the action (@default type: 'custom') */ startAction: (name: string, options?: ActionOptions) => void /** - * [Experimental] stop a custom action, stored in `@action` + * [Experimental] Stop an action, stored in `@action` * * @category Data Collection * @param name - Name of the action @@ -675,6 +677,10 @@ export function makeRumPublicApi( }, startAction: monitor((name, options) => { + // Check feature flag only after init; pre-init calls should be buffered + if (strategy.initConfiguration && !isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } // addTelemetryUsage({ feature: 'start-action' }) strategy.startAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, @@ -684,6 +690,9 @@ export function makeRumPublicApi( }), stopAction: monitor((name, options) => { + if (strategy.initConfiguration && !isExperimentalFeatureEnabled(ExperimentalFeature.START_STOP_ACTION)) { + return + } // addTelemetryUsage({ feature: 'stop-action' }) strategy.stopAction(sanitize(name)!, { type: sanitize(options && options.type) as ActionType | undefined, diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index d67be549b7..74736b2c8b 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -1,14 +1,5 @@ import type { Duration, Observable } from '@datadog/browser-core' -import { - noop, - toServerDuration, - generateUUID, - SKIPPED, - HookNames, - addDuration, - isExperimentalFeatureEnabled, - ExperimentalFeature, -} from '@datadog/browser-core' +import { noop, toServerDuration, SKIPPED, HookNames, addDuration } from '@datadog/browser-core' import { discardNegativeDuration } from '../discardNegativeDuration' import type { RawRumActionEvent } from '../../rawRumEvent.types' import { RumEventType } from '../../rawRumEvent.types' @@ -21,8 +12,8 @@ import { trackClickActions } from './trackClickActions' import type { ClickAction } from './trackClickActions' import { startActionTracker } from './trackAction' import type { ActionContexts } from './trackAction' -import { trackCustomActions } from './trackCustomActions' -import type { CustomAction } from './trackCustomActions' +import { trackManualActions } from './trackManualActions' +import type { ManualAction } from './trackManualActions' export type AutoAction = ClickAction @@ -35,7 +26,7 @@ export function startActionCollection( windowOpenObservable: Observable, configuration: RumConfiguration ) { - // Shared action tracker for both click and custom actions + // Shared action tracker for both click and manual actions const actionTracker = startActionTracker(lifeCycle) const { unsubscribe: unsubscribeAutoAction } = lifeCycle.subscribe( @@ -57,13 +48,9 @@ export function startActionCollection( )) } - 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 manualActions = trackManualActions(lifeCycle, actionTracker, (action) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + }) const actionContexts: ActionContexts = { findActionId: actionTracker.findActionId, @@ -106,22 +93,20 @@ export function startActionCollection( ) return { - addAction: (action: Omit) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction({ id: generateUUID(), ...action })) - }, - startAction: customActions?.startAction ?? noop, - stopAction: customActions?.stopAction ?? noop, + addAction: manualActions.addAction, + startAction: manualActions.startAction, + stopAction: manualActions.stopAction, actionContexts, stop: () => { unsubscribeAutoAction() stopClickActions() - customActions?.stop() + manualActions.stop() actionTracker.stop() }, } } -function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { +function processAction(action: AutoAction | ManualAction): RawRumEventCollectedData { const isAuto = isAutoAction(action) const loadingTime = discardNegativeDuration(toServerDuration(action.duration)) @@ -159,6 +144,6 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD } } -function isAutoAction(action: AutoAction | CustomAction): action is AutoAction { +function isAutoAction(action: AutoAction | ManualAction): action is AutoAction { return 'events' in action } diff --git a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts b/packages/rum-core/src/domain/action/trackManualActions.spec.ts similarity index 97% rename from packages/rum-core/src/domain/action/trackCustomActions.spec.ts rename to packages/rum-core/src/domain/action/trackManualActions.spec.ts index 8da494eab6..42ba8bf0fe 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackManualActions.spec.ts @@ -11,7 +11,7 @@ import type { RumMutationRecord } from '../../browser/domMutationObservable' import { startActionCollection } from './actionCollection' import type { ActionContexts } from './trackAction' -describe('trackCustomActions', () => { +describe('trackManualActions', () => { const lifeCycle = new LifeCycle() let rawRumEvents: Array> let actionContexts: ActionContexts @@ -90,7 +90,7 @@ describe('trackCustomActions', () => { expect(actionEvent.action.id).toEqual((actionId as string[])[0]) }) - it('should include loading_time for timed custom actions', () => { + it('should include loading_time for timed manual actions', () => { startAction('checkout') clock.tick(500) stopAction('checkout') @@ -269,7 +269,7 @@ describe('trackCustomActions', () => { }) describe('session renewal', () => { - it('should discard active custom actions on session renewal', () => { + it('should discard active manual actions on session renewal', () => { startAction('cross-session-action') const actionIdBeforeRenewal = actionContexts.findActionId() @@ -306,7 +306,7 @@ describe('trackCustomActions', () => { }) describe('cleanup', () => { - it('should clean up active custom actions on stop()', () => { + it('should clean up active manual actions on stop()', () => { startAction('active-when-stopped') const actionIdBeforeStop = actionContexts.findActionId() diff --git a/packages/rum-core/src/domain/action/trackCustomActions.ts b/packages/rum-core/src/domain/action/trackManualActions.ts similarity index 63% rename from packages/rum-core/src/domain/action/trackCustomActions.ts rename to packages/rum-core/src/domain/action/trackManualActions.ts index 302fe0f8d6..fe006d96a6 100644 --- a/packages/rum-core/src/domain/action/trackCustomActions.ts +++ b/packages/rum-core/src/domain/action/trackManualActions.ts @@ -1,5 +1,5 @@ import type { ClocksState, Context, Duration } from '@datadog/browser-core' -import { clocksNow, combine } from '@datadog/browser-core' +import { clocksNow, combine, generateUUID } from '@datadog/browser-core' import type { ActionType } from '../../rawRumEvent.types' import { ActionType as ActionTypeEnum } from '../../rawRumEvent.types' import { LifeCycleEventType } from '../lifeCycle' @@ -25,7 +25,7 @@ export interface ActionOptions { actionKey?: string } -export interface CustomAction { +export interface ManualAction { id: string type: ActionType name: string @@ -36,34 +36,34 @@ export interface CustomAction { counts?: ActionCounts } -interface ActiveCustomAction { +interface ActiveManualAction { name: string type?: ActionType context?: Context trackedAction: TrackedAction } -export function trackCustomActions( +export function trackManualActions( lifeCycle: LifeCycle, actionTracker: ActionTracker, - onCustomActionCompleted: (action: CustomAction) => void + onManualActionCompleted: (action: ManualAction) => void ) { - const activeCustomActions = new Map() + const activeManualActions = new Map() - lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => activeCustomActions.clear()) + lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => activeManualActions.clear()) - function startCustomAction(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { + function startManualAction(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { const lookupKey = options.actionKey ?? name - const existingAction = activeCustomActions.get(lookupKey) + const existingAction = activeManualActions.get(lookupKey) if (existingAction) { existingAction.trackedAction.discard() - activeCustomActions.delete(lookupKey) + activeManualActions.delete(lookupKey) } const trackedAction = actionTracker.createTrackedAction(startClocks) - activeCustomActions.set(lookupKey, { + activeManualActions.set(lookupKey, { name, type: options.type, context: options.context, @@ -71,9 +71,9 @@ export function trackCustomActions( }) } - function stopCustomAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { + function stopManualAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { const lookupKey = options.actionKey ?? name - const activeAction = activeCustomActions.get(lookupKey) + const activeAction = activeManualActions.get(lookupKey) if (!activeAction) { return @@ -81,7 +81,7 @@ export function trackCustomActions( activeAction.trackedAction.stop(stopClocks.relative) - const customAction: CustomAction = { + const manualAction: ManualAction = { id: activeAction.trackedAction.id, name: activeAction.name, type: (options.type ?? activeAction.type) || ActionTypeEnum.CUSTOM, @@ -91,18 +91,23 @@ export function trackCustomActions( counts: activeAction.trackedAction.counts, } - onCustomActionCompleted(customAction) - activeCustomActions.delete(lookupKey) + onManualActionCompleted(manualAction) + activeManualActions.delete(lookupKey) + } + + function addInstantAction(action: Omit) { + onManualActionCompleted({ id: generateUUID(), ...action }) } function stop() { - activeCustomActions.forEach((activeAction) => activeAction.trackedAction.discard()) - activeCustomActions.clear() + activeManualActions.forEach((activeAction) => activeAction.trackedAction.discard()) + activeManualActions.clear() } return { - startAction: startCustomAction, - stopAction: stopCustomAction, + addAction: addInstantAction, + startAction: startManualAction, + stopAction: stopManualAction, stop, } } From 17846f034e124583700a11324a7f773de531a85e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 21 Jan 2026 12:36:52 +0100 Subject: [PATCH 45/47] Renamed ActiveManualAction to ManualActionStart, simplified tests --- .../src/domain/action/trackAction.spec.ts | 58 +++---------------- .../src/domain/action/trackManualActions.ts | 4 +- 2 files changed, 9 insertions(+), 53 deletions(-) diff --git a/packages/rum-core/src/domain/action/trackAction.spec.ts b/packages/rum-core/src/domain/action/trackAction.spec.ts index bef0402814..acea03cc6d 100644 --- a/packages/rum-core/src/domain/action/trackAction.spec.ts +++ b/packages/rum-core/src/domain/action/trackAction.spec.ts @@ -16,13 +16,15 @@ describe('trackAction', () => { }) describe('createTrackedAction', () => { - it('should generate a unique action ID', () => { + it('should have an ID, start clocks and event counts to zero', () => { 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) + expect(trackedAction.startClocks).toBe(startClocks) + expect(trackedAction.counts.errorCount).toBe(0) + expect(trackedAction.counts.resourceCount).toBe(0) + expect(trackedAction.counts.longTaskCount).toBe(0) }) it('should create distinct IDs for each tracked action', () => { @@ -32,22 +34,6 @@ describe('trackAction', () => { 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', () => { @@ -58,7 +44,7 @@ describe('trackAction', () => { trackedAction = actionTracker.createTrackedAction(startClocks) }) - it('should count errors associated with the action', () => { + it('should count child events associated with the action', () => { lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: RumEventType.ERROR, action: { id: trackedAction.id }, @@ -68,37 +54,7 @@ describe('trackAction', () => { 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', () => { + it('should not count child events unrelated to the action', () => { lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: RumEventType.ERROR, action: { id: 'other-action-id' }, diff --git a/packages/rum-core/src/domain/action/trackManualActions.ts b/packages/rum-core/src/domain/action/trackManualActions.ts index fe006d96a6..19e63d4604 100644 --- a/packages/rum-core/src/domain/action/trackManualActions.ts +++ b/packages/rum-core/src/domain/action/trackManualActions.ts @@ -36,7 +36,7 @@ export interface ManualAction { counts?: ActionCounts } -interface ActiveManualAction { +interface ManualActionStart { name: string type?: ActionType context?: Context @@ -48,7 +48,7 @@ export function trackManualActions( actionTracker: ActionTracker, onManualActionCompleted: (action: ManualAction) => void ) { - const activeManualActions = new Map() + const activeManualActions = new Map() lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => activeManualActions.clear()) From 24048823dd97b74fde5e07fc79eed445902eea75 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 21 Jan 2026 12:56:42 +0100 Subject: [PATCH 46/47] Add ERROR_CLICK frustration to manual actions --- .../rum-core/src/boot/preStartRum.spec.ts | 2 +- .../domain/action/actionCollection.spec.ts | 3 ++ .../src/domain/action/actionCollection.ts | 2 +- .../domain/action/trackManualActions.spec.ts | 52 ++++++++++++++++++- .../src/domain/action/trackManualActions.ts | 15 ++++-- test/e2e/scenario/rum/actions.scenario.ts | 29 +++++++++++ 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index ec869cbaa7..abc69d98a6 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -632,7 +632,7 @@ describe('preStartRum', () => { const addActionSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addAction: addActionSpy } as unknown as StartRumResult) - const manualAction: Omit = { + const manualAction: Omit = { name: 'foo', type: ActionType.CUSTOM, startClocks: clocksNow(), diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index bd0e8669b3..2cdb868f30 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -124,6 +124,9 @@ describe('actionCollection', () => { name: 'foo', }, type: ActionType.CUSTOM, + frustration: { + type: [], + }, }, date: jasmine.any(Number), type: RumEventType.ACTION, diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 74736b2c8b..b277d41658 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -124,7 +124,7 @@ function processAction(action: AutoAction | ManualAction): RawRumEventCollectedD long_task: { count: action.counts.longTaskCount }, resource: { count: action.counts.resourceCount }, }), - ...(isAuto && { frustration: { type: action.frustrationTypes } }), + frustration: { type: action.frustrationTypes }, }, ...(isAuto ? { diff --git a/packages/rum-core/src/domain/action/trackManualActions.spec.ts b/packages/rum-core/src/domain/action/trackManualActions.spec.ts index 42ba8bf0fe..21343856e7 100644 --- a/packages/rum-core/src/domain/action/trackManualActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackManualActions.spec.ts @@ -4,7 +4,7 @@ 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 { RumEventType, ActionType, FrustrationType } from '../../rawRumEvent.types' import { type RawRumEventCollectedData, LifeCycle, LifeCycleEventType } from '../lifeCycle' import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' @@ -321,4 +321,54 @@ describe('trackManualActions', () => { expect(rawRumEvents).toHaveSize(0) }) }) + + describe('frustration detection', () => { + it('should include ERROR_CLICK frustration when action has errors', () => { + startAction('error-action') + + const actionId = actionContexts.findActionId() + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + action: { id: actionId }, + } as any) + + stopAction('error-action') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.frustration?.type).toEqual([FrustrationType.ERROR_CLICK]) + }) + + it('should have empty frustration array when action has no errors', () => { + startAction('success-action') + stopAction('success-action') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.frustration?.type).toEqual([]) + }) + + it('should include ERROR_CLICK frustration when action has multiple errors', () => { + startAction('multi-error-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) + + stopAction('multi-error-action') + + expect(rawRumEvents).toHaveSize(1) + const actionEvent = rawRumEvents[0].rawRumEvent as RawRumActionEvent + expect(actionEvent.action.frustration?.type).toEqual([FrustrationType.ERROR_CLICK]) + expect(actionEvent.action.error?.count).toBe(2) + }) + }) }) diff --git a/packages/rum-core/src/domain/action/trackManualActions.ts b/packages/rum-core/src/domain/action/trackManualActions.ts index 19e63d4604..9eb252aace 100644 --- a/packages/rum-core/src/domain/action/trackManualActions.ts +++ b/packages/rum-core/src/domain/action/trackManualActions.ts @@ -1,7 +1,7 @@ import type { ClocksState, Context, Duration } from '@datadog/browser-core' import { clocksNow, combine, generateUUID } from '@datadog/browser-core' -import type { ActionType } from '../../rawRumEvent.types' -import { ActionType as ActionTypeEnum } from '../../rawRumEvent.types' +import type { ActionType, FrustrationType } from '../../rawRumEvent.types' +import { ActionType as ActionTypeEnum, FrustrationType as FrustrationTypeEnum } from '../../rawRumEvent.types' import { LifeCycleEventType } from '../lifeCycle' import type { LifeCycle } from '../lifeCycle' import type { ActionCounts, ActionTracker, TrackedAction } from './trackAction' @@ -34,6 +34,7 @@ export interface ManualAction { context?: Context handlingStack?: string counts?: ActionCounts + frustrationTypes: FrustrationType[] } interface ManualActionStart { @@ -81,6 +82,11 @@ export function trackManualActions( activeAction.trackedAction.stop(stopClocks.relative) + const frustrationTypes: FrustrationType[] = [] + if (activeAction.trackedAction.counts.errorCount > 0) { + frustrationTypes.push(FrustrationTypeEnum.ERROR_CLICK) + } + const manualAction: ManualAction = { id: activeAction.trackedAction.id, name: activeAction.name, @@ -89,14 +95,15 @@ export function trackManualActions( duration: activeAction.trackedAction.duration, context: combine(activeAction.context, options.context), counts: activeAction.trackedAction.counts, + frustrationTypes, } onManualActionCompleted(manualAction) activeManualActions.delete(lookupKey) } - function addInstantAction(action: Omit) { - onManualActionCompleted({ id: generateUUID(), ...action }) + function addInstantAction(action: Omit) { + onManualActionCompleted({ id: generateUUID(), frustrationTypes: [], ...action }) } function stop() { diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 55d7770b05..1046ad5209 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -610,6 +610,35 @@ test.describe('custom actions with startAction/stopAction', () => { expect(relatedError).toBeDefined() }) + createTest('include error_click frustration when action has errors') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('error-action') + window.DD_RUM!.addError(new Error('Something went wrong')) + window.DD_RUM!.stopAction('error-action') + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action.frustration?.type).toContain('error_click') + }) + + createTest('have empty frustration when action has no errors') + .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.startAction('success-action') + window.DD_RUM!.stopAction('success-action') + }) + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action.frustration?.type).toEqual([]) + }) + createTest('associate a resource to a custom action') .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) .run(async ({ intakeRegistry, flushEvents, page }) => { From 27d07cf0c842268ca4867276ab8c6373034bdc73 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 21 Jan 2026 13:07:38 +0100 Subject: [PATCH 47/47] Simplify tests --- test/e2e/scenario/rum/actions.scenario.ts | 31 ++--------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 1046ad5209..d0ffee0752 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -603,6 +603,7 @@ test.describe('custom actions with startAction/stopAction', () => { expect(actionEvents).toHaveLength(1) expect(actionEvents[0].action.error?.count).toBe(1) + expect(actionEvents[0].action.frustration?.type).toContain('error_click') expect(errorEvents.length).toBeGreaterThanOrEqual(1) const actionId = actionEvents[0].action.id @@ -610,35 +611,6 @@ test.describe('custom actions with startAction/stopAction', () => { expect(relatedError).toBeDefined() }) - createTest('include error_click frustration when action has errors') - .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) - .run(async ({ intakeRegistry, flushEvents, page }) => { - await page.evaluate(() => { - window.DD_RUM!.startAction('error-action') - window.DD_RUM!.addError(new Error('Something went wrong')) - window.DD_RUM!.stopAction('error-action') - }) - await flushEvents() - - const actionEvents = intakeRegistry.rumActionEvents - expect(actionEvents).toHaveLength(1) - expect(actionEvents[0].action.frustration?.type).toContain('error_click') - }) - - createTest('have empty frustration when action has no errors') - .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) - .run(async ({ intakeRegistry, flushEvents, page }) => { - await page.evaluate(() => { - window.DD_RUM!.startAction('success-action') - window.DD_RUM!.stopAction('success-action') - }) - await flushEvents() - - const actionEvents = intakeRegistry.rumActionEvents - expect(actionEvents).toHaveLength(1) - expect(actionEvents[0].action.frustration?.type).toEqual([]) - }) - createTest('associate a resource to a custom action') .withRum({ enableExperimentalFeatures: ['start_stop_action'] }) .run(async ({ intakeRegistry, flushEvents, page }) => { @@ -657,6 +629,7 @@ test.describe('custom actions with startAction/stopAction', () => { expect(actionEvents).toHaveLength(1) expect(actionEvents[0].action.resource?.count).toBe(1) + expect(actionEvents[0].action.frustration?.type).toEqual([]) const actionId = actionEvents[0].action.id const relatedResource = resourceEvents.find((e) => hasActionId(e, actionId!))