diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index b7cfd8422d..c054d48e48 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -306,7 +306,7 @@ export function createPreStartStrategy( }, startDurationVital(name, options) { - return startDurationVital(customVitalsState, name, options) + return startDurationVital(customVitalsState, undefined, name, options) }, stopDurationVital(name, options) { diff --git a/packages/rum-core/src/domain/action/trackManualActions.ts b/packages/rum-core/src/domain/action/trackManualActions.ts index 3bb8c494f1..2bf496e81e 100644 --- a/packages/rum-core/src/domain/action/trackManualActions.ts +++ b/packages/rum-core/src/domain/action/trackManualActions.ts @@ -5,6 +5,7 @@ import { ActionType as ActionTypeEnum, FrustrationType as FrustrationTypeEnum } import type { EventCounts } from '../trackEventCounts' import { startEventTracker } from '../eventTracker' import type { LifeCycle } from '../lifeCycle' +import { LifeCycleEventType } from '../lifeCycle' import { isActionChildEvent } from './isActionChildEvent' export type ActionCounts = EventCounts @@ -51,7 +52,7 @@ export function trackManualActions(lifeCycle: LifeCycle, onManualActionCompleted function startManualAction(name: string, options: ActionOptions = {}, startClocks = clocksNow()) { const lookupKey = options.actionKey ?? name - actionTracker.start( + const startedManualAction = actionTracker.start( lookupKey, startClocks, { @@ -60,6 +61,8 @@ export function trackManualActions(lifeCycle: LifeCycle, onManualActionCompleted }, { isChildEvent: isActionChildEvent } ) + + lifeCycle.notify(LifeCycleEventType.ACTION_STARTED, startedManualAction) } function stopManualAction(name: string, options: ActionOptions = {}, stopClocks = clocksNow()) { diff --git a/packages/rum-core/src/domain/eventTracker.ts b/packages/rum-core/src/domain/eventTracker.ts index ff8ec5c65f..78c0a8eed1 100644 --- a/packages/rum-core/src/domain/eventTracker.ts +++ b/packages/rum-core/src/domain/eventTracker.ts @@ -27,7 +27,7 @@ export interface StartOptions { } export interface EventTracker { - start: (key: string, startClocks: ClocksState, data: TData, options?: StartOptions) => void + start: (key: string, startClocks: ClocksState, data: TData, options?: StartOptions) => TrackedEventData stop: (key: string, stopClocks: ClocksState, data?: Partial) => StoppedEvent | undefined discard: (key: string) => DiscardedEvent | undefined getCounts: (key: string) => EventCounts | undefined @@ -35,7 +35,7 @@ export interface EventTracker { stopAll: () => void } -interface TrackedEventData { +export interface TrackedEventData { id: string key: string startClocks: ClocksState @@ -66,7 +66,7 @@ export function startEventTracker(lifeCycle: LifeCycle): EventTracker { const id = generateUUID() const historyEntry = history.add(id, startClocks.relative) @@ -83,14 +83,17 @@ export function startEventTracker(lifeCycle: LifeCycle): EventTracker = { id, key, startClocks, data, historyEntry, eventCounts, - }) + } + + keyedEvents.set(key, trackedEventData) + return trackedEventData } function stop(key: string, stopClocks: ClocksState, extraData?: Partial): StoppedEvent | undefined { diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index 348e071939..46551a778f 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -5,6 +5,9 @@ import type { RawRumEvent, AssembledRumEvent } from '../rawRumEvent.types' import type { RequestCompleteEvent, RequestStartEvent } from './requestCollection' import type { AutoAction } from './action/actionCollection' import type { ViewEvent, ViewCreatedEvent, ViewEndedEvent, BeforeViewUpdateEvent } from './view/trackViews' +import type { DurationVitalStart } from './vital/vitalCollection' +import type { TrackedEventData } from './eventTracker' +import type { ActionEventData } from './action/trackManualActions' export const enum LifeCycleEventType { // Contexts (like viewHistory) should be opened using prefixed BEFORE_XXX events and closed using prefixed AFTER_XXX events @@ -35,6 +38,8 @@ export const enum LifeCycleEventType { RAW_RUM_EVENT_COLLECTED, RUM_EVENT_COLLECTED, RAW_ERROR_COLLECTED, + ACTION_STARTED, + VITAL_STARTED, } // This is a workaround for an issue occurring when the Browser SDK is included in a TypeScript @@ -51,6 +56,7 @@ export const enum LifeCycleEventType { // * https://github.com/DataDog/browser-sdk/issues/2208 // * https://github.com/microsoft/TypeScript/issues/54152 declare const LifeCycleEventTypeAsConst: { + ACTION_STARTED: LifeCycleEventType.ACTION_STARTED AUTO_ACTION_COMPLETED: LifeCycleEventType.AUTO_ACTION_COMPLETED BEFORE_VIEW_CREATED: LifeCycleEventType.BEFORE_VIEW_CREATED VIEW_CREATED: LifeCycleEventType.VIEW_CREATED @@ -66,11 +72,13 @@ declare const LifeCycleEventTypeAsConst: { RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED RUM_EVENT_COLLECTED: LifeCycleEventType.RUM_EVENT_COLLECTED RAW_ERROR_COLLECTED: LifeCycleEventType.RAW_ERROR_COLLECTED + VITAL_STARTED: LifeCycleEventType.VITAL_STARTED } // Note: this interface needs to be exported even if it is not used outside of this module, else TS // fails to build the rum-core package with error TS4058 export interface LifeCycleEventMap { + [LifeCycleEventTypeAsConst.ACTION_STARTED]: TrackedEventData [LifeCycleEventTypeAsConst.AUTO_ACTION_COMPLETED]: AutoAction [LifeCycleEventTypeAsConst.BEFORE_VIEW_CREATED]: ViewCreatedEvent [LifeCycleEventTypeAsConst.VIEW_CREATED]: ViewCreatedEvent @@ -89,6 +97,7 @@ export interface LifeCycleEventMap { error: RawError customerContext?: Context } + [LifeCycleEventTypeAsConst.VITAL_STARTED]: DurationVitalStart } export interface RawRumEventCollectedData { diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts index 302c5ae01a..40e56c70d5 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts @@ -32,39 +32,48 @@ describe('vitalCollection', () => { it('should create duration vital from a vital reference', () => { const cbSpy = jasmine.createSpy() - const vitalRef = startDurationVital(vitalsState, 'foo') + const vitalRef = startDurationVital(vitalsState, lifeCycle, 'foo') clock.tick(100) stopDurationVital(cbSpy, vitalsState, vitalRef) - expect(cbSpy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ name: 'foo', duration: 100 })) + expect(cbSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ name: 'foo', duration: 100 }), + jasmine.any(String) + ) }) it('should create duration vital from a vital name', () => { const cbSpy = jasmine.createSpy() - startDurationVital(vitalsState, 'foo') + startDurationVital(vitalsState, lifeCycle, 'foo') clock.tick(100) stopDurationVital(cbSpy, vitalsState, 'foo') - expect(cbSpy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ name: 'foo', duration: 100 })) + expect(cbSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ name: 'foo', duration: 100 }), + jasmine.any(String) + ) }) it('should only create a single duration vital from a vital name', () => { const cbSpy = jasmine.createSpy() - startDurationVital(vitalsState, 'foo') + startDurationVital(vitalsState, lifeCycle, 'foo') clock.tick(100) stopDurationVital(cbSpy, vitalsState, 'foo') clock.tick(100) stopDurationVital(cbSpy, vitalsState, 'foo') - expect(cbSpy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ name: 'foo', duration: 100 })) + expect(cbSpy).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ name: 'foo', duration: 100 }), + jasmine.any(String) + ) }) it('should not create multiple duration vitals by calling "stopDurationVital" on the same vital ref multiple times', () => { const cbSpy = jasmine.createSpy() - const vital = startDurationVital(vitalsState, 'foo') + const vital = startDurationVital(vitalsState, lifeCycle, 'foo') stopDurationVital(cbSpy, vitalsState, vital) stopDurationVital(cbSpy, vitalsState, vital) @@ -74,7 +83,7 @@ describe('vitalCollection', () => { it('should not create multiple duration vitals by calling "stopDurationVital" on the same vital name multiple times', () => { const cbSpy = jasmine.createSpy() - startDurationVital(vitalsState, 'bar') + startDurationVital(vitalsState, lifeCycle, 'bar') stopDurationVital(cbSpy, vitalsState, 'bar') stopDurationVital(cbSpy, vitalsState, 'bar') @@ -84,9 +93,9 @@ describe('vitalCollection', () => { it('should create multiple duration vitals from multiple vital refs', () => { const cbSpy = jasmine.createSpy() - const vitalRef1 = startDurationVital(vitalsState, 'foo', { description: 'component 1' }) + const vitalRef1 = startDurationVital(vitalsState, lifeCycle, 'foo', { description: 'component 1' }) clock.tick(100) - const vitalRef2 = startDurationVital(vitalsState, 'foo', { description: 'component 2' }) + const vitalRef2 = startDurationVital(vitalsState, lifeCycle, 'foo', { description: 'component 2' }) clock.tick(100) stopDurationVital(cbSpy, vitalsState, vitalRef2) clock.tick(100) @@ -95,56 +104,70 @@ describe('vitalCollection', () => { expect(cbSpy).toHaveBeenCalledTimes(2) expect(cbSpy.calls.argsFor(0)).toEqual([ jasmine.objectContaining({ description: 'component 2', duration: 100 }), + jasmine.any(String), ]) expect(cbSpy.calls.argsFor(1)).toEqual([ jasmine.objectContaining({ description: 'component 1', duration: 300 }), + jasmine.any(String), ]) }) it('should merge startDurationVital and stopDurationVital description', () => { const cbSpy = jasmine.createSpy() - startDurationVital(vitalsState, 'both-undefined') + startDurationVital(vitalsState, lifeCycle, 'both-undefined') stopDurationVital(cbSpy, vitalsState, 'both-undefined') - startDurationVital(vitalsState, 'start-defined', { description: 'start-defined' }) + startDurationVital(vitalsState, lifeCycle, 'start-defined', { description: 'start-defined' }) stopDurationVital(cbSpy, vitalsState, 'start-defined') - startDurationVital(vitalsState, 'stop-defined') + startDurationVital(vitalsState, lifeCycle, 'stop-defined') stopDurationVital(cbSpy, vitalsState, 'stop-defined', { description: 'stop-defined' }) - startDurationVital(vitalsState, 'both-defined', { description: 'start-defined' }) + startDurationVital(vitalsState, lifeCycle, 'both-defined', { description: 'start-defined' }) stopDurationVital(cbSpy, vitalsState, 'both-defined', { description: 'stop-defined' }) expect(cbSpy).toHaveBeenCalledTimes(4) - expect(cbSpy.calls.argsFor(0)).toEqual([jasmine.objectContaining({ description: undefined })]) - expect(cbSpy.calls.argsFor(1)).toEqual([jasmine.objectContaining({ description: 'start-defined' })]) - expect(cbSpy.calls.argsFor(2)).toEqual([jasmine.objectContaining({ description: 'stop-defined' })]) - expect(cbSpy.calls.argsFor(3)).toEqual([jasmine.objectContaining({ description: 'stop-defined' })]) + expect(cbSpy.calls.argsFor(0)).toEqual([ + jasmine.objectContaining({ description: undefined }), + jasmine.any(String), + ]) + expect(cbSpy.calls.argsFor(1)).toEqual([ + jasmine.objectContaining({ description: 'start-defined' }), + jasmine.any(String), + ]) + expect(cbSpy.calls.argsFor(2)).toEqual([ + jasmine.objectContaining({ description: 'stop-defined' }), + jasmine.any(String), + ]) + expect(cbSpy.calls.argsFor(3)).toEqual([ + jasmine.objectContaining({ description: 'stop-defined' }), + jasmine.any(String), + ]) }) it('should merge startDurationVital and stopDurationVital contexts', () => { const cbSpy = jasmine.createSpy() - const vitalRef1 = startDurationVital(vitalsState, 'both-undefined') + const vitalRef1 = startDurationVital(vitalsState, lifeCycle, 'both-undefined') stopDurationVital(cbSpy, vitalsState, vitalRef1) - const vitalRef2 = startDurationVital(vitalsState, 'start-defined', { + const vitalRef2 = startDurationVital(vitalsState, lifeCycle, 'start-defined', { context: { start: 'defined' }, }) stopDurationVital(cbSpy, vitalsState, vitalRef2) - const vitalRef3 = startDurationVital(vitalsState, 'stop-defined', { + const vitalRef3 = startDurationVital(vitalsState, lifeCycle, 'stop-defined', { context: { stop: 'defined' }, }) stopDurationVital(cbSpy, vitalsState, vitalRef3) - const vitalRef4 = startDurationVital(vitalsState, 'both-defined', { + const vitalRef4 = startDurationVital(vitalsState, lifeCycle, 'both-defined', { context: { start: 'defined' }, }) stopDurationVital(cbSpy, vitalsState, vitalRef4, { context: { stop: 'defined' } }) - const vitalRef5 = startDurationVital(vitalsState, 'stop-precedence', { + const vitalRef5 = startDurationVital(vitalsState, lifeCycle, 'stop-precedence', { context: { precedence: 'start' }, }) stopDurationVital(cbSpy, vitalsState, vitalRef5, { context: { precedence: 'stop' } }) diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts index 9250ca5031..441e60ffdb 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -68,6 +68,7 @@ export interface DurationVitalReference { } export interface DurationVitalStart extends DurationVitalOptions { + id: string name: string startClocks: ClocksState handlingStack?: string @@ -116,6 +117,12 @@ export function startVitalCollection( } } + function addDurationVitalWithId(vital: DurationVital, vitalId: string) { + if (isValid(vital)) { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processVital(vital, vitalId)) + } + } + function addOperationStepVital( name: string, stepType: 'start' | 'end', @@ -145,19 +152,22 @@ export function startVitalCollection( addOperationStepVital, addDurationVital, startDurationVital: (name: string, options: DurationVitalOptions = {}) => - startDurationVital(customVitalsState, name, options), + startDurationVital(customVitalsState, lifeCycle, name, options), stopDurationVital: (nameOrRef: string | DurationVitalReference, options: DurationVitalOptions = {}) => { - stopDurationVital(addDurationVital, customVitalsState, nameOrRef, options) + stopDurationVital(addDurationVitalWithId, customVitalsState, nameOrRef, options) }, } } export function startDurationVital( { vitalsByName, vitalsByReference }: CustomVitalsState, + lifeCycle: LifeCycle | undefined, // the lifecycle might not be defined in case datadogRum is not initialized yet name: string, options: DurationVitalOptions = {} ) { + const vitalId = generateUUID() const vital = { + id: vitalId, name, startClocks: clocksNow(), ...options, @@ -171,11 +181,15 @@ export function startDurationVital( // To avoid memory leaks caused by the creation of numerous references (e.g., from improper useEffect implementations), we use a WeakMap. vitalsByReference.set(reference, vital) + if (lifeCycle) { + lifeCycle.notify(LifeCycleEventType.VITAL_STARTED, vital) + } + return reference } export function stopDurationVital( - stopCallback: (vital: DurationVital) => void, + stopCallback: (vital: DurationVital, vitalId: string) => void, { vitalsByName, vitalsByReference }: CustomVitalsState, nameOrRef: string | DurationVitalReference, options: DurationVitalOptions = {} @@ -186,7 +200,7 @@ export function stopDurationVital( return } - stopCallback(buildDurationVital(vitalStart, vitalStart.startClocks, options, clocksNow())) + stopCallback(buildDurationVital(vitalStart, vitalStart.startClocks, options, clocksNow()), vitalStart.id) if (typeof nameOrRef === 'string') { vitalsByName.delete(nameOrRef) @@ -212,10 +226,13 @@ function buildDurationVital( } } -function processVital(vital: DurationVital | OperationStepVital): RawRumEventCollectedData { +function processVital( + vital: DurationVital | OperationStepVital, + vitalId?: string +): RawRumEventCollectedData { const { startClocks, type, name, description, context, handlingStack } = vital const vitalData = { - id: generateUUID(), + id: vitalId ?? generateUUID(), type, name, description, diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index 8a7f4fe953..dcffb9f557 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -676,15 +676,15 @@ export type RumResourceEvent = CommonProperties & */ readonly size?: number /** - * Size in octet of the resource before removing any applied content encodings + * Size in octet of the response body before removing any applied content encodings */ readonly encoded_body_size?: number /** - * Size in octet of the resource after removing any applied encoding + * Size in octet of the response body after removing any applied encoding */ readonly decoded_body_size?: number /** - * Size in octet of the fetched resource + * Size in octet of the fetched response resource */ readonly transfer_size?: number /** @@ -829,6 +829,38 @@ export type RumResourceEvent = CommonProperties & | 'video' [k: string]: unknown } + /** + * Request properties + */ + readonly request?: { + /** + * Size in octet of the request body sent over the network (after encoding) + */ + readonly encoded_body_size?: number + /** + * Size in octet of the request body before any encoding + */ + readonly decoded_body_size?: number + /** + * HTTP headers of the resource request + */ + readonly headers?: { + [k: string]: string + } + [k: string]: unknown + } + /** + * Response properties + */ + readonly response?: { + /** + * HTTP headers of the resource response + */ + readonly headers?: { + [k: string]: string + } + [k: string]: unknown + } /** * GraphQL requests parameters */ @@ -2016,6 +2048,24 @@ export interface ViewPerformanceData { * CSS selector path of the interacted element for the INP interaction */ readonly target_selector?: string + /** + * Sub-parts of the INP + */ + sub_parts?: { + /** + * Time from the start of the input event to the start of the processing of the event + */ + readonly input_delay: number + /** + * Event handler execution time + */ + readonly processing_time: number + /** + * Rendering time happening after processing + */ + readonly presentation_delay: number + [k: string]: unknown + } [k: string]: unknown } /** diff --git a/packages/rum/src/domain/profiling/actionHistory.spec.ts b/packages/rum/src/domain/profiling/actionHistory.spec.ts new file mode 100644 index 0000000000..49d963a896 --- /dev/null +++ b/packages/rum/src/domain/profiling/actionHistory.spec.ts @@ -0,0 +1,262 @@ +import { noop, relativeToClocks, type Duration, type RelativeTime } from '@datadog/browser-core' +import { LifeCycle, LifeCycleEventType, RumEventType } from '@datadog/browser-rum-core' +import { createRawRumEvent } from '@datadog/browser-rum-core/test' +import { createActionHistory } from './actionHistory' + +describe('actionHistory', () => { + let lifeCycle: LifeCycle + + beforeEach(() => { + lifeCycle = new LifeCycle() + }) + + const fakeDomainContext = { + performanceEntry: {} as PerformanceEntry, + } + + describe('createActionHistory', () => { + it('should create a history', () => { + const history = createActionHistory(lifeCycle) + expect(history).toBeDefined() + }) + + it('should add action information to history when RAW_RUM_EVENT_COLLECTED is triggered with action event', () => { + const history = createActionHistory(lifeCycle) + const startClocks = relativeToClocks(10 as RelativeTime) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-123', + duration: 20 as Duration, + }, + }), + startClocks, + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + expect(history.findAll(5 as RelativeTime, 30 as RelativeTime)).toEqual([ + { + id: 'action-123', + label: '', + startClocks, + duration: 20 as Duration, + }, + ]) + }) + + it('should not add events to history for non-action events', () => { + const history = createActionHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW, { + view: { + id: 'view-123', + }, + }), + startClocks: relativeToClocks(50 as RelativeTime), + duration: 10 as Duration, + domainContext: { location: window.location }, + }) + + expect(history.findAll(40 as RelativeTime, 30 as RelativeTime)).toEqual([]) + }) + + it('should store multiple action IDs with their time ranges', () => { + const history = createActionHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-1', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-2', + duration: 30 as Duration, + }, + }), + startClocks: relativeToClocks(50 as RelativeTime), + duration: 30 as Duration, + domainContext: fakeDomainContext, + }) + + expect(history.findAll(5 as RelativeTime, 30 as RelativeTime).map((action) => action.id)).toEqual(['action-1']) + expect(history.findAll(45 as RelativeTime, 40 as RelativeTime).map((action) => action.id)).toEqual(['action-2']) + expect(history.findAll(0 as RelativeTime, 100 as RelativeTime).map((action) => action.id)).toEqual([ + 'action-2', + 'action-1', + ]) + }) + + it('should handle overlapping action time ranges', () => { + const history = createActionHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-1', + duration: 40 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 40 as Duration, + domainContext: fakeDomainContext, + }) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-2', + duration: 40 as Duration, + }, + }), + startClocks: relativeToClocks(30 as RelativeTime), + duration: 40 as Duration, + domainContext: fakeDomainContext, + }) + + expect(history.findAll(35 as RelativeTime, 20 as RelativeTime).map((action) => action.id)).toEqual([ + 'action-2', + 'action-1', + ]) + }) + + it('should add a action to the history with duration 0 when ACTION_STARTED is triggered', () => { + const history = createActionHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.ACTION_STARTED, { + key: 'action-1', + id: 'action-1', + data: { name: 'action-1' }, + startClocks: relativeToClocks(10 as RelativeTime), + historyEntry: { + startTime: 10 as RelativeTime, + endTime: 10 as RelativeTime, + value: 'action-1', + remove: noop, + close: noop, + }, + }) + + const matchingActions = history.findAll(10 as RelativeTime, 10 as RelativeTime) + + expect(matchingActions[0].id).toEqual('action-1') + expect(matchingActions[0].duration).toBeUndefined() + }) + + it('should add a action to the history when ACTION_STARTED is triggered, and close it when RAW_RUM_EVENT_COLLECTED is triggered', () => { + const history = createActionHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.ACTION_STARTED, { + key: 'action-1', + id: 'action-1', + data: { name: 'action-1' }, + startClocks: relativeToClocks(10 as RelativeTime), + historyEntry: { + startTime: 10 as RelativeTime, + endTime: 10 as RelativeTime, + value: 'action-1', + remove: noop, + close: noop, + }, + }) + + expect(history.findAll(10 as RelativeTime, 10 as RelativeTime).map((action) => action.id)).toEqual(['action-1']) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-1', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + const matchingActions = history.findAll(10 as RelativeTime, 30 as RelativeTime) + + expect(matchingActions[0].id).toEqual('action-1') + expect(matchingActions[0].duration).toEqual(20 as Duration) + }) + + it('should be able to handle multiple actions being started and stopped', () => { + const history = createActionHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.ACTION_STARTED, { + key: 'action-1', + id: 'action-1', + data: { name: 'action-1' }, + startClocks: relativeToClocks(10 as RelativeTime), + historyEntry: { + startTime: 10 as RelativeTime, + endTime: 10 as RelativeTime, + value: 'action-1', + remove: noop, + close: noop, + }, + }) + + lifeCycle.notify(LifeCycleEventType.ACTION_STARTED, { + key: 'action-2', + id: 'action-2', + data: { name: 'action-2' }, + startClocks: relativeToClocks(10 as RelativeTime), + historyEntry: { + startTime: 10 as RelativeTime, + endTime: 10 as RelativeTime, + value: 'action-2', + remove: noop, + close: noop, + }, + }) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-2', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + let matchingActions = history.findAll(10 as RelativeTime, 30 as RelativeTime) + + expect(matchingActions.map((action) => action.id)).toEqual(['action-2', 'action-1']) + + expect(matchingActions.map((action) => action.duration)).toEqual([20 as Duration, undefined]) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION, { + action: { + id: 'action-1', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + matchingActions = history.findAll(10 as RelativeTime, 30 as RelativeTime) + + expect(matchingActions.map((action) => action.id)).toEqual(['action-2', 'action-1']) + + expect(matchingActions.map((action) => action.duration)).toEqual([20 as Duration, 20 as Duration]) + }) + }) +}) diff --git a/packages/rum/src/domain/profiling/actionHistory.ts b/packages/rum/src/domain/profiling/actionHistory.ts new file mode 100644 index 0000000000..8385ae78d2 --- /dev/null +++ b/packages/rum/src/domain/profiling/actionHistory.ts @@ -0,0 +1,64 @@ +import type { ClocksState, Duration, ValueHistoryEntry } from '@datadog/browser-core' +import { addDuration, createValueHistory, SESSION_TIME_OUT_DELAY } from '@datadog/browser-core' +import type { LifeCycle } from '@datadog/browser-rum-core' +import { LifeCycleEventType } from '@datadog/browser-rum-core' + +export const ACTION_ID_HISTORY_TIME_OUT_DELAY = SESSION_TIME_OUT_DELAY + +export interface ActionContext { + id: string + label: string + duration?: Duration + startClocks: ClocksState +} + +export function createActionHistory(lifeCycle: LifeCycle) { + const history = createValueHistory({ + expireDelay: ACTION_ID_HISTORY_TIME_OUT_DELAY, + }) + + const startedActions = new Map>() + + lifeCycle.subscribe(LifeCycleEventType.ACTION_STARTED, (actionStart) => { + const startedActionHistoryEntry = history.add( + { + id: actionStart.id, + label: '', + startClocks: actionStart.startClocks, + duration: undefined, + }, + actionStart.startClocks.relative + ) + + startedActions.set(actionStart.id, startedActionHistoryEntry) + }) + + lifeCycle.subscribe(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, ({ rawRumEvent, startClocks, duration }) => { + if (rawRumEvent.type === 'action') { + const durationForEntry = duration ?? (0 as Duration) + if (startedActions.has(rawRumEvent.action.id)) { + const actionHistoryEntry = startedActions.get(rawRumEvent.action.id) + + if (actionHistoryEntry) { + actionHistoryEntry.value.duration = duration! + actionHistoryEntry.close(addDuration(startClocks.relative, durationForEntry)) + startedActions.delete(rawRumEvent.action.id) + } + } else { + history + .add( + { + id: rawRumEvent.action.id, + label: '', + startClocks, + duration, + }, + startClocks.relative + ) + .close(addDuration(startClocks.relative, durationForEntry)) + } + } + }) + + return history +} diff --git a/packages/rum/src/domain/profiling/profiler.spec.ts b/packages/rum/src/domain/profiling/profiler.spec.ts index a09608f427..d5afbe3fae 100644 --- a/packages/rum/src/domain/profiling/profiler.spec.ts +++ b/packages/rum/src/domain/profiling/profiler.spec.ts @@ -33,6 +33,10 @@ import type { ProfilingContextManager } from './profilingContext' import { startProfilingContext } from './profilingContext' import type { ProfileEventPayload } from './transport/assembly' import { createLongTaskHistory, type LongTaskContext } from './longTaskHistory' +import type { ActionContext } from './actionHistory' +import { createActionHistory } from './actionHistory' +import type { VitalContext } from './vitalHistory' +import { createVitalHistory } from './vitalHistory' describe('profiler', () => { // Store the original pathname @@ -73,6 +77,8 @@ describe('profiler', () => { sampleInterval: 10, longTasks: [], views: [], + actions: [], + vitals: [], }) const viewHistory = mockViewHistory( @@ -94,6 +100,16 @@ describe('profiler', () => { }) replaceMockable(createLongTaskHistory, () => longTaskHistory) + const actionHistory = createValueHistory({ + expireDelay: ONE_DAY, + }) + replaceMockable(createActionHistory, () => actionHistory) + + const vitalHistory = createValueHistory({ + expireDelay: ONE_DAY, + }) + replaceMockable(createVitalHistory, () => vitalHistory) + // Start collection of profile. const profiler = createRumProfiler( mockRumConfiguration({ trackLongTasks: true, profilingSampleRate: 100 }), @@ -117,6 +133,12 @@ describe('profiler', () => { addLongTask: (longTask: LongTaskContext) => { longTaskHistory.add(longTask, relativeNow()).close(addDuration(relativeNow(), longTask.duration)) }, + addAction: (action: ActionContext) => { + actionHistory.add(action, relativeNow()).close(addDuration(relativeNow(), action.duration ?? (0 as Duration))) + }, + addVital: (vital: VitalContext) => { + vitalHistory.add(vital, relativeNow()).close(addDuration(relativeNow(), vital.duration ?? (0 as Duration))) + }, } } @@ -289,6 +311,188 @@ describe('profiler', () => { ]) }) + it('should collect actions happening during a profiling session', async () => { + const clock = mockClock() + const { profiler, profilingContextManager, addAction } = setupProfiler() + + // Start collection of profile. + profiler.start() + await waitForBoolean(() => profiler.isRunning()) + + expect(profilingContextManager.get()?.status).toBe('running') + addAction({ + id: 'action-id-1', + label: 'action-label-1', + startClocks: clocksNow(), + duration: 50 as Duration, + }) + clock.tick(50) + + addAction({ + id: 'action-id-2', + label: 'action-label-2', + startClocks: clocksNow(), + duration: 100 as Duration, + }) + + // Stop first profiling session (sync - state changes immediately) + clock.tick(105) + profiler.stop() + expect(profiler.isStopped()).toBe(true) + + // Flush microtasks for first session's data collection + await waitNextMicrotask() + + // start a new profiling session + profiler.start() + await waitForBoolean(() => profiler.isRunning()) + + addAction({ + id: 'action-id-3', + label: 'action-label-3', + startClocks: clocksNow(), + duration: 100 as Duration, + }) + + clock.tick(500) + + // stop the second profiling session (sync - state changes immediately) + profiler.stop() + expect(profiler.isStopped()).toBe(true) + expect(profilingContextManager.get()?.status).toBe('stopped') + + // Data collection uses Promises (microtasks), not setTimeout. + // With mockClock(), we can't use waitForBoolean (which polls via setTimeout). + // Flush microtasks: one for profiler.stop() Promise, one for transport.send() + await waitNextMicrotask() + await waitNextMicrotask() + + expect(interceptor.requests.length).toBe(2) + + const requestOne = await readFormDataRequest(interceptor.requests[0]) + const requestTwo = await readFormDataRequest(interceptor.requests[1]) + + const traceOne = requestOne['wall-time.json'] + const traceTwo = requestTwo['wall-time.json'] + + expect(requestOne.event.action?.id.length).toBe(2) + expect(traceOne.actions).toEqual([ + { + id: 'action-id-2', + startClocks: jasmine.any(Object), + duration: 100 as Duration, + label: 'action-label-2', + }, + { + id: 'action-id-1', + startClocks: jasmine.any(Object), + duration: 50 as Duration, + label: 'action-label-1', + }, + ]) + + expect(requestTwo.event.action?.id.length).toBe(1) + expect(traceTwo.actions).toEqual([ + { + id: 'action-id-3', + startClocks: jasmine.any(Object), + duration: 100 as Duration, + label: 'action-label-3', + }, + ]) + }) + + it('should collect vitals happening during a profiling session', async () => { + const clock = mockClock() + const { profiler, profilingContextManager, addVital } = setupProfiler() + + // Start collection of profile. + profiler.start() + await waitForBoolean(() => profiler.isRunning()) + + expect(profilingContextManager.get()?.status).toBe('running') + addVital({ + id: 'vital-id-1', + label: 'vital-label-1', + startClocks: clocksNow(), + duration: 50 as Duration, + }) + clock.tick(50) + + addVital({ + id: 'vital-id-2', + label: 'vital-label-2', + startClocks: clocksNow(), + duration: 100 as Duration, + }) + + // Stop first profiling session (sync - state changes immediately) + clock.tick(105) + profiler.stop() + expect(profiler.isStopped()).toBe(true) + + // Flush microtasks for first session's data collection + await waitNextMicrotask() + + // start a new profiling session + profiler.start() + await waitForBoolean(() => profiler.isRunning()) + + addVital({ + id: 'vital-id-3', + label: 'vital-label-3', + startClocks: clocksNow(), + duration: 100 as Duration, + }) + + clock.tick(500) + + // stop the second profiling session (sync - state changes immediately) + profiler.stop() + expect(profiler.isStopped()).toBe(true) + expect(profilingContextManager.get()?.status).toBe('stopped') + + // Data collection uses Promises (microtasks), not setTimeout. + // With mockClock(), we can't use waitForBoolean (which polls via setTimeout). + // Flush microtasks: one for profiler.stop() Promise, one for transport.send() + await waitNextMicrotask() + await waitNextMicrotask() + + expect(interceptor.requests.length).toBe(2) + + const requestOne = await readFormDataRequest(interceptor.requests[0]) + const requestTwo = await readFormDataRequest(interceptor.requests[1]) + + const traceOne = requestOne['wall-time.json'] + const traceTwo = requestTwo['wall-time.json'] + + expect(requestOne.event.vital?.id.length).toBe(2) + expect(traceOne.vitals).toEqual([ + { + id: 'vital-id-2', + startClocks: jasmine.any(Object), + duration: 100 as Duration, + label: 'vital-label-2', + }, + { + id: 'vital-id-1', + startClocks: jasmine.any(Object), + duration: 50 as Duration, + label: 'vital-label-1', + }, + ]) + + expect(requestTwo.event.vital?.id.length).toBe(1) + expect(traceTwo.vitals).toEqual([ + { + id: 'vital-id-3', + startClocks: jasmine.any(Object), + duration: 100 as Duration, + label: 'vital-label-3', + }, + ]) + }) + it('should collect views and set default view name in the Profile', async () => { const initialViewEntry = { id: 'view-user', diff --git a/packages/rum/src/domain/profiling/profiler.ts b/packages/rum/src/domain/profiling/profiler.ts index e41ac3404b..08c5dfe2ba 100644 --- a/packages/rum/src/domain/profiling/profiler.ts +++ b/packages/rum/src/domain/profiling/profiler.ts @@ -36,6 +36,8 @@ import type { ProfilingContextManager } from './profilingContext' import { getCustomOrDefaultViewName } from './utils/getCustomOrDefaultViewName' import { assembleProfilingPayload } from './transport/assembly' import { createLongTaskHistory } from './longTaskHistory' +import { createActionHistory } from './actionHistory' +import { createVitalHistory } from './vitalHistory' export const DEFAULT_RUM_PROFILER_CONFIGURATION: RUMProfilerConfiguration = { sampleIntervalMs: 10, // Sample stack trace every 10ms @@ -60,6 +62,8 @@ export function createRumProfiler( // Global clean-up tasks for listeners that are not specific to a profiler instance (eg. visibility change, before unload) const globalCleanupTasks: Array<() => void> = [] const longTaskHistory = mockable(createLongTaskHistory)(lifeCycle) + const actionHistory = mockable(createActionHistory)(lifeCycle) + const vitalHistory = mockable(createVitalHistory)(lifeCycle) let instance: RumProfilerInstance = { state: 'stopped', stateReason: 'initializing' } @@ -231,6 +235,8 @@ export function createRumProfiler( const endClocks = clocksNow() const duration = elapsed(startClocks.timeStamp, endClocks.timeStamp) const longTasks = longTaskHistory.findAll(startClocks.relative, duration) + const actions = actionHistory.findAll(startClocks.relative, duration) + const vitals = vitalHistory.findAll(startClocks.relative, duration) const isBelowDurationThreshold = duration < profilerConfiguration.minProfileDurationMs const isBelowSampleThreshold = getNumberOfSamples(trace.samples) < profilerConfiguration.minNumberOfSamples @@ -246,6 +252,8 @@ export function createRumProfiler( endClocks, clocksOrigin: clocksOrigin(), longTasks, + actions, + vitals, views, sampleInterval: profilerConfiguration.sampleIntervalMs, }) diff --git a/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.spec.ts b/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.spec.ts index dad8d62065..5185563533 100644 --- a/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.spec.ts +++ b/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.spec.ts @@ -35,6 +35,8 @@ describe('buildProfileEventAttributes', () => { sampleInterval: 10, views: [], longTasks: [], + actions: [], + vitals: [], resources: [], frames: [], stacks: [], diff --git a/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.ts b/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.ts index f62f4846a6..3954855d70 100644 --- a/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.ts +++ b/packages/rum/src/domain/profiling/transport/buildProfileEventAttributes.ts @@ -1,4 +1,4 @@ -import type { BrowserProfilerTrace, RumViewEntry } from '../../../types' +import type { BrowserProfilerTrace, RumProfilerVitalEntry, RumViewEntry } from '../../../types' export interface ProfileEventAttributes { application: { @@ -14,6 +14,14 @@ export interface ProfileEventAttributes { long_task?: { id: string[] } + action?: { + id: string[] + label: string[] + } + vital?: { + id: string[] + label: string[] + } } /** @@ -35,6 +43,11 @@ export function buildProfileEventAttributes( const longTaskIds: string[] = profilerTrace.longTasks.map((longTask) => longTask.id).filter((id) => id !== undefined) + const actionIds: string[] = + profilerTrace.actions?.map((longTask) => longTask.id).filter((id) => id !== undefined) ?? [] + + const { ids: vitalIds, labels: vitalLabels } = extractVitalIdsAndLabels(profilerTrace.vitals) + const attributes: ProfileEventAttributes = { application: { id: applicationId } } if (sessionId) { @@ -46,6 +59,12 @@ export function buildProfileEventAttributes( if (longTaskIds.length) { attributes.long_task = { id: longTaskIds } } + if (actionIds.length) { + attributes.action = { id: actionIds, label: [] } + } + if (vitalIds.length) { + attributes.vital = { id: vitalIds, label: vitalLabels } + } return attributes } @@ -64,3 +83,24 @@ function extractViewIdsAndNames(views: RumViewEntry[]): { ids: string[]; names: return result } + +function extractVitalIdsAndLabels(vitals?: RumProfilerVitalEntry[]): { + ids: string[] + labels: string[] +} { + const result: { ids: string[]; labels: string[] } = { ids: [], labels: [] } + + if (!vitals) { + return result + } + + for (const vital of vitals) { + result.ids.push(vital.id) + result.labels.push(vital.label) + } + + // Remove duplicates + result.labels = Array.from(new Set(result.labels)) + + return result +} diff --git a/packages/rum/src/domain/profiling/vitalHistory.spec.ts b/packages/rum/src/domain/profiling/vitalHistory.spec.ts new file mode 100644 index 0000000000..600a9eba1c --- /dev/null +++ b/packages/rum/src/domain/profiling/vitalHistory.spec.ts @@ -0,0 +1,238 @@ +import { relativeToClocks, type Duration, type RelativeTime } from '@datadog/browser-core' +import { LifeCycle, LifeCycleEventType, RumEventType } from '@datadog/browser-rum-core' +import { createRawRumEvent } from '@datadog/browser-rum-core/test' +import { createVitalHistory } from './vitalHistory' + +describe('vitalHistory', () => { + let lifeCycle: LifeCycle + + beforeEach(() => { + lifeCycle = new LifeCycle() + }) + + const fakeDomainContext = { + performanceEntry: {} as PerformanceEntry, + } + + describe('createVitalHistory', () => { + it('should create a history', () => { + const history = createVitalHistory(lifeCycle) + expect(history).toBeDefined() + }) + + it('should add vital information to history when RAW_RUM_EVENT_COLLECTED is triggered with vital event', () => { + const history = createVitalHistory(lifeCycle) + const startClocks = relativeToClocks(10 as RelativeTime) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-123', + name: 'vital-name', + duration: 20 as Duration, + }, + }), + startClocks, + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + expect(history.findAll(5 as RelativeTime, 30 as RelativeTime)).toEqual([ + { + id: 'vital-123', + startClocks, + duration: 20 as Duration, + label: 'vital-name', + }, + ]) + }) + + it('should not add events to history for non-vital events', () => { + const history = createVitalHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW, { + view: { + id: 'view-123', + }, + }), + startClocks: relativeToClocks(50 as RelativeTime), + duration: 10 as Duration, + domainContext: { location: window.location }, + }) + + expect(history.findAll(40 as RelativeTime, 30 as RelativeTime)).toEqual([]) + }) + + it('should store multiple vital IDs with their time ranges', () => { + const history = createVitalHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-1', + name: 'vital-name-1', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-2', + name: 'vital-name-2', + duration: 30 as Duration, + }, + }), + startClocks: relativeToClocks(50 as RelativeTime), + duration: 30 as Duration, + domainContext: fakeDomainContext, + }) + + expect(history.findAll(5 as RelativeTime, 30 as RelativeTime).map((vital) => vital.id)).toEqual(['vital-1']) + expect(history.findAll(45 as RelativeTime, 40 as RelativeTime).map((vital) => vital.id)).toEqual(['vital-2']) + expect(history.findAll(0 as RelativeTime, 100 as RelativeTime).map((vital) => vital.id)).toEqual([ + 'vital-2', + 'vital-1', + ]) + }) + + it('should handle overlapping vital time ranges', () => { + const history = createVitalHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-1', + name: 'vital-name-1', + duration: 40 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 40 as Duration, + domainContext: fakeDomainContext, + }) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-2', + name: 'vital-name-2', + duration: 40 as Duration, + }, + }), + startClocks: relativeToClocks(30 as RelativeTime), + duration: 40 as Duration, + domainContext: fakeDomainContext, + }) + + expect(history.findAll(35 as RelativeTime, 20 as RelativeTime).map((vital) => vital.id)).toEqual([ + 'vital-2', + 'vital-1', + ]) + }) + + it('should add a vital to the history with duration 0 when VITAL_STARTED is triggered', () => { + const history = createVitalHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.VITAL_STARTED, { + id: 'vital-1', + name: 'vital-name-1', + startClocks: relativeToClocks(10 as RelativeTime), + }) + + const matchingVitals = history.findAll(10 as RelativeTime, 10 as RelativeTime) + + expect(matchingVitals[0].id).toEqual('vital-1') + expect(matchingVitals[0].duration).toBeUndefined() + }) + + it('should add a vital to the history when VITAL_STARTED is triggered, and close it when RAW_RUM_EVENT_COLLECTED is triggered', () => { + const history = createVitalHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.VITAL_STARTED, { + id: 'vital-1', + name: 'vital-name-1', + startClocks: relativeToClocks(10 as RelativeTime), + }) + + expect(history.findAll(10 as RelativeTime, 10 as RelativeTime).map((vital) => vital.id)).toEqual(['vital-1']) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-1', + name: 'vital-name-1', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + const matchingVitals = history.findAll(10 as RelativeTime, 30 as RelativeTime) + + expect(matchingVitals[0].id).toEqual('vital-1') + expect(matchingVitals[0].duration).toEqual(20 as Duration) + }) + + it('should be able to handle multiple vitals being started and stopped', () => { + const history = createVitalHistory(lifeCycle) + + lifeCycle.notify(LifeCycleEventType.VITAL_STARTED, { + id: 'vital-1', + name: 'vital-name-1', + startClocks: relativeToClocks(10 as RelativeTime), + }) + + lifeCycle.notify(LifeCycleEventType.VITAL_STARTED, { + id: 'vital-2', + name: 'vital-name-2', + startClocks: relativeToClocks(10 as RelativeTime), + }) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-2', + name: 'vital-name-2', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + let matchingVitals = history.findAll(10 as RelativeTime, 30 as RelativeTime) + + expect(matchingVitals.map((vital) => vital.id)).toEqual(['vital-2', 'vital-1']) + + expect(matchingVitals.map((vital) => vital.duration)).toEqual([20 as Duration, undefined]) + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VITAL, { + vital: { + id: 'vital-1', + name: 'vital-name-1', + duration: 20 as Duration, + }, + }), + startClocks: relativeToClocks(10 as RelativeTime), + duration: 20 as Duration, + domainContext: fakeDomainContext, + }) + + matchingVitals = history.findAll(10 as RelativeTime, 30 as RelativeTime) + + expect(matchingVitals.map((vital) => vital.id)).toEqual(['vital-2', 'vital-1']) + + expect(matchingVitals.map((vital) => vital.duration)).toEqual([20 as Duration, 20 as Duration]) + }) + }) +}) diff --git a/packages/rum/src/domain/profiling/vitalHistory.ts b/packages/rum/src/domain/profiling/vitalHistory.ts new file mode 100644 index 0000000000..74e8b7c641 --- /dev/null +++ b/packages/rum/src/domain/profiling/vitalHistory.ts @@ -0,0 +1,63 @@ +import type { ClocksState, Duration, ValueHistoryEntry } from '@datadog/browser-core' +import { addDuration, createValueHistory, SESSION_TIME_OUT_DELAY } from '@datadog/browser-core' +import type { LifeCycle } from '@datadog/browser-rum-core' +import { LifeCycleEventType } from '@datadog/browser-rum-core' + +export const VITAL_ID_HISTORY_TIME_OUT_DELAY = SESSION_TIME_OUT_DELAY + +export interface VitalContext { + id: string + label: string + duration?: Duration + startClocks: ClocksState +} + +export function createVitalHistory(lifeCycle: LifeCycle) { + const history = createValueHistory({ + expireDelay: VITAL_ID_HISTORY_TIME_OUT_DELAY, + }) + + const startedVitals = new Map>() + + lifeCycle.subscribe(LifeCycleEventType.VITAL_STARTED, (vitalStart) => { + const startedVitalHistoryEntry = history.add( + { + id: vitalStart.id, + startClocks: vitalStart.startClocks, + duration: undefined, + label: vitalStart.name, + }, + vitalStart.startClocks.relative + ) + + startedVitals.set(vitalStart.id, startedVitalHistoryEntry) + }) + + lifeCycle.subscribe(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, ({ rawRumEvent, startClocks, duration }) => { + if (rawRumEvent.type === 'vital') { + if (startedVitals.has(rawRumEvent.vital.id)) { + const vitalHistoryEntry = startedVitals.get(rawRumEvent.vital.id) + + if (vitalHistoryEntry) { + vitalHistoryEntry.value.duration = duration! + vitalHistoryEntry.close(addDuration(startClocks.relative, duration!)) + startedVitals.delete(rawRumEvent.vital.id) + } + } else { + history + .add( + { + id: rawRumEvent.vital.id, + startClocks, + duration, + label: rawRumEvent.vital.name, + }, + startClocks.relative + ) + .close(addDuration(startClocks.relative, duration!)) + } + } + }) + + return history +} diff --git a/packages/rum/src/types/profiling.ts b/packages/rum/src/types/profiling.ts index bf751ccade..47a052e10d 100644 --- a/packages/rum/src/types/profiling.ts +++ b/packages/rum/src/types/profiling.ts @@ -24,6 +24,32 @@ export type BrowserProfileEvent = ProfileCommonProperties & { */ readonly clock_drift: number } + /** + * Action properties. + */ + readonly action?: { + /** + * Array of action IDs. + */ + readonly id: string[] + /** + * Array of action labels. + */ + readonly label: string[] + } + /** + * Vital properties. + */ + readonly vital?: { + /** + * Array of vital IDs. + */ + readonly id: string[] + /** + * Array of vital labels. + */ + readonly label: string[] + } } /** @@ -130,6 +156,14 @@ export interface BrowserProfilerTrace { * List of detected long tasks. */ readonly longTasks: RumProfilerLongTaskEntry[] + /** + * List of detected vital entries. + */ + readonly vitals?: RumProfilerVitalEntry[] + /** + * List of detected action entries. + */ + readonly actions?: RumProfilerActionEntry[] /** * List of detected navigation entries. */ @@ -204,7 +238,7 @@ export interface RumProfilerLongTaskEntry { */ readonly id?: string /** - * Duration in ns of the long task or long animation frame. + * Duration in ms of the long task or long animation frame. */ readonly duration: number /** @@ -213,6 +247,42 @@ export interface RumProfilerLongTaskEntry { readonly entryType: 'longtask' | 'long-animation-frame' startClocks: ClocksState } +/** + * Schema of a vital entry recorded during profiling. + */ +export interface RumProfilerVitalEntry { + /** + * RUM Vital id. + */ + readonly id: string + /** + * RUM Vital label. + */ + readonly label: string + /** + * Duration in ms of the vital. + */ + readonly duration?: number + startClocks: ClocksState +} +/** + * Schema of a action entry recorded during profiling. + */ +export interface RumProfilerActionEntry { + /** + * RUM Action id. + */ + readonly id: string + /** + * RUM Action label. + */ + readonly label: string + /** + * Duration in ms of the duration vital. + */ + readonly duration?: number + startClocks: ClocksState +} /** * Schema of a RUM view entry recorded during profiling. */ diff --git a/rum-events-format b/rum-events-format index 8dc61166ee..8c1cda6ac5 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 8dc61166ee818608892d13b6565ff04a3f2a7fe9 +Subproject commit 8c1cda6ac58acc090f9d4491448aa4d458783c7b diff --git a/test/e2e/scenario/profiling.scenario.ts b/test/e2e/scenario/profiling.scenario.ts index 3f03fb1219..8273056540 100644 --- a/test/e2e/scenario/profiling.scenario.ts +++ b/test/e2e/scenario/profiling.scenario.ts @@ -8,10 +8,12 @@ test.describe('profiling', () => { }) createTest('send profile events when profiling is enabled') - .withRum({ profilingSampleRate: 100 }) + .withRum({ profilingSampleRate: 100, trackUserInteractions: true }) .withBasePath('/?js-profiling=true') .run(async ({ intakeRegistry, flushEvents, page }) => { await generateLongTask(page) + await generateAction(page) + await generateVital(page) await flushEvents() @@ -20,6 +22,8 @@ test.describe('profiling', () => { // Extract RUM event IDs for verification const viewIds = intakeRegistry.rumViewEvents.map((event) => event.view.id) const longTaskIds = intakeRegistry.rumLongTaskEvents.map((event) => event.long_task.id) + const actionIds = intakeRegistry.rumActionEvents.map((event) => event.action.id) + const vitalIds = intakeRegistry.rumVitalEvents.map((event) => event.vital.id) const profileEvent = intakeRegistry.profileEvents[0] @@ -38,6 +42,14 @@ test.describe('profiling', () => { long_task: { id: expect.arrayOf(expect.any(String)), }, + action: { + id: expect.arrayOf(expect.any(String)), + label: expect.arrayOf(expect.any(String)), + }, + vital: { + id: expect.arrayOf(expect.any(String)), + label: expect.arrayOf(expect.any(String)), + }, attachments: ['wall-time.json'], start: expect.any(String), // ISO 8601 date string end: expect.any(String), // ISO 8601 date string @@ -90,6 +102,23 @@ test.describe('profiling', () => { timeStamp: expect.any(Number), }, })), + actions: actionIds.map((actionId) => ({ + id: actionId, + label: expect.any(String), + startClocks: { + relative: expect.any(Number), + timeStamp: expect.any(Number), + }, + })), + vitals: vitalIds.map((vitalId) => ({ + duration: expect.any(Number), + id: vitalId, + label: expect.any(String), + startClocks: { + relative: expect.any(Number), + timeStamp: expect.any(Number), + }, + })), views: viewIds.map((viewId) => ({ startClocks: { relative: expect.any(Number), @@ -167,3 +196,14 @@ async function generateLongTask(page: Page, durationMs = 500) { durationMs ) } + +async function generateAction(page: Page) { + await page.evaluate(() => window.DD_RUM!.addAction('testAction')) +} + +async function generateVital(page: Page, durationMs = 50) { + await page.evaluate( + (duration) => window.DD_RUM!.addDurationVital('testVitals', { startTime: Date.now() - duration, duration }), + durationMs + ) +}