From 00980fceb732a6a34f5c6d5edc3fcd0d041e15c7 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Thu, 1 Feb 2024 14:27:38 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20rum-events-form?= =?UTF-8?q?at?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum-core/src/rumEvent.types.ts | 133 +++++++++++++++++++++++- rum-events-format | 2 +- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index 3277684a13..1adedf6a4c 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -6,7 +6,13 @@ /** * Schema of all properties of a RUM event */ -export type RumEvent = RumActionEvent | RumErrorEvent | RumLongTaskEvent | RumResourceEvent | RumViewEvent +export type RumEvent = + | RumActionEvent + | RumErrorEvent + | RumLongTaskEvent + | RumResourceEvent + | RumViewEvent + | RumVitalEvent /** * Schema of all properties of an Action event */ @@ -233,7 +239,7 @@ export type RumErrorEvent = CommonProperties & /** * HTTP method of the resource */ - readonly method: 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'PATCH' + readonly method: 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'PATCH' | 'TRACE' | 'OPTIONS' | 'CONNECT' /** * HTTP Status code of the resource */ @@ -276,6 +282,96 @@ export type RumErrorEvent = CommonProperties & } [k: string]: unknown } + /** + * Description of each thread in the process when error happened. + */ + threads?: { + /** + * Name of the thread (e.g. 'Thread 0'). + */ + readonly name: string + /** + * Tells if the thread crashed. + */ + readonly crashed: boolean + /** + * Unsymbolicated stack trace of the given thread. + */ + readonly stack: string + /** + * Platform-specific state of the thread when its state was captured (CPU registers dump for iOS, thread state enum for Android, etc.). + */ + readonly state?: string + [k: string]: unknown + }[] + /** + * Description of each binary image (native libraries; for Android: .so files) loaded or referenced by the process/application. + */ + readonly binary_images?: { + /** + * Build UUID that uniquely identifies the binary image. + */ + readonly uuid: string + /** + * Name of the library. + */ + readonly name: string + /** + * Determines if it's a system or user library. + */ + readonly is_system: boolean + /** + * Library's load address (hexadecimal). + */ + readonly load_address?: string + /** + * Max value from the library address range (hexadecimal). + */ + readonly max_address?: string + /** + * CPU architecture from the library. + */ + readonly arch?: string + [k: string]: unknown + }[] + /** + * A boolean value saying if any of the stack traces was truncated due to minification. + */ + readonly was_truncated?: boolean + /** + * Platform-specific metadata of the error event. + */ + readonly meta?: { + /** + * The CPU architecture of the process that crashed. + */ + readonly code_type?: string + /** + * Parent process information. + */ + readonly parent_process?: string + /** + * A client-generated 16-byte UUID of the incident. + */ + readonly incident_identifier?: string + /** + * The name of the crashed process. + */ + readonly process?: string + /** + * The name of the corresponding BSD termination signal. (in case of iOS crash) + */ + readonly exception_type?: string + /** + * CPU specific information about the exception encoded into 64-bit hexadecimal number preceded by the signal code. + */ + readonly exception_codes?: string + /** + * The location of the executable. + */ + readonly path?: string + [k: string]: unknown + } [k: string]: unknown } /** @@ -372,7 +468,7 @@ export type RumResourceEvent = CommonProperties & /** * HTTP method of the resource */ - readonly method?: 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'PATCH' + readonly method?: 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'PATCH' | 'TRACE' | 'OPTIONS' | 'CONNECT' /** * URL of the resource */ @@ -894,6 +990,37 @@ export type RumViewEvent = CommonProperties & } [k: string]: unknown } +/** + * Schema of all properties of a Vital event + */ +export type RumVitalEvent = CommonProperties & + ViewContainerSchema & { + /** + * RUM event type + */ + readonly type: 'vital' + /** + * Vital properties + */ + readonly vital: { + /** + * Type of the vital + */ + readonly type: 'duration' + /** + * UUID of the vital + */ + readonly id: string + /** + * User custom vital. As vital name is used as facet path, it must contain only letters, digits, or the characters - _ . @ $ + */ + readonly custom?: { + [k: string]: number + } + [k: string]: unknown + } + [k: string]: unknown + } /** * Schema of common properties of RUM events diff --git a/rum-events-format b/rum-events-format index 389581be98..c3747b3fac 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 389581be98dcf8efbfcfe7bffaa32d53f960fb6f +Subproject commit c3747b3facf75e51cbad4c32f77ec3894f5a7249 From 71f56d260623736bf198c345ef64d16682221d1f Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Thu, 1 Feb 2024 16:51:50 +0100 Subject: [PATCH 2/7] Add new event type support --- .../components/tabs/eventsTab/eventRow.tsx | 15 +++++++++++++++ packages/rum-core/src/domain/assembly.ts | 1 + .../src/domain/trackEventCounts.spec.ts | 11 ++++++----- .../rum-core/src/domain/trackEventCounts.ts | 2 +- packages/rum-core/src/domainContext.types.ts | 6 +++++- packages/rum-core/src/index.ts | 1 + packages/rum-core/src/rawRumEvent.types.ts | 18 ++++++++++++++++++ packages/rum-core/test/fixtures.ts | 19 +++++++++++++++++-- packages/rum/src/entries/main.ts | 1 + 9 files changed, 65 insertions(+), 9 deletions(-) diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx index 064750e741..fac86ccf06 100644 --- a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx +++ b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx @@ -11,6 +11,7 @@ import type { RumLongTaskEvent, RumResourceEvent, RumViewEvent, + RumVitalEvent, } from '../../../../../../packages/rum-core/src/rumEvent.types' import type { SdkEvent } from '../../../sdkEvent' import { isTelemetryEvent, isLogEvent, isRumEvent } from '../../../sdkEvent' @@ -32,6 +33,7 @@ const RUM_EVENT_TYPE_COLOR = { view: 'blue', resource: 'cyan', telemetry: 'teal', + vital: 'orange', } const LOG_STATUS_COLOR = { @@ -260,6 +262,8 @@ export const EventDescription = React.memo(({ event }: { event: SdkEvent }) => { return case 'action': return + case 'vital': + return } } else if (isLogEvent(event)) { return @@ -322,6 +326,17 @@ function LongTaskDescription({ event }: { event: RumLongTaskEvent }) { ) } +function VitalDescription({ event }: { event: RumVitalEvent }) { + const vitalName = Object.keys(event.vital.custom!)[0] + const vitalValue = event.vital.custom![vitalName] + return ( + <> + Custom {event.vital.type} vital: {vitalName} of{' '} + {vitalValue} + + ) +} + function ErrorDescription({ event }: { event: RumErrorEvent }) { return ( <> diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index 182efbb477..3717bb4dd1 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -101,6 +101,7 @@ export function startRumAssembly( VIEW_MODIFIABLE_FIELD_PATHS ), [RumEventType.LONG_TASK]: assign({}, USER_CUSTOMIZABLE_FIELD_PATHS, VIEW_MODIFIABLE_FIELD_PATHS), + [RumEventType.VITAL]: assign({}, USER_CUSTOMIZABLE_FIELD_PATHS, VIEW_MODIFIABLE_FIELD_PATHS), } const eventRateLimiters = { [RumEventType.ERROR]: createEventRateLimiter( diff --git a/packages/rum-core/src/domain/trackEventCounts.spec.ts b/packages/rum-core/src/domain/trackEventCounts.spec.ts index 429abe49ee..bd22d4dd56 100644 --- a/packages/rum-core/src/domain/trackEventCounts.spec.ts +++ b/packages/rum-core/src/domain/trackEventCounts.spec.ts @@ -27,11 +27,12 @@ describe('trackEventCounts', () => { notifyCollectedRawRumEvent({ type: RumEventType.LONG_TASK }) expect(eventCounts.longTaskCount).toBe(1) }) - - it("doesn't track views", () => { - const { eventCounts } = trackEventCounts({ lifeCycle, isChildEvent: () => true }) - notifyCollectedRawRumEvent({ type: RumEventType.VIEW }) - expect(objectValues(eventCounts as unknown as { [key: string]: number }).every((value) => value === 0)).toBe(true) + ;[RumEventType.VIEW, RumEventType.VITAL].forEach((eventType) => { + it(`doesn't track ${eventType} events`, () => { + const { eventCounts } = trackEventCounts({ lifeCycle, isChildEvent: () => true }) + notifyCollectedRawRumEvent({ type: eventType }) + expect(objectValues(eventCounts as unknown as { [key: string]: number }).every((value) => value === 0)).toBe(true) + }) }) it('tracks actions', () => { diff --git a/packages/rum-core/src/domain/trackEventCounts.ts b/packages/rum-core/src/domain/trackEventCounts.ts index 79cdfbb14b..c7a0cc856b 100644 --- a/packages/rum-core/src/domain/trackEventCounts.ts +++ b/packages/rum-core/src/domain/trackEventCounts.ts @@ -30,7 +30,7 @@ export function trackEventCounts({ } const subscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event): void => { - if (event.type === 'view' || !isChildEvent(event)) { + if (event.type === 'view' || event.type === 'vital' || !isChildEvent(event)) { return } switch (event.type) { diff --git a/packages/rum-core/src/domainContext.types.ts b/packages/rum-core/src/domainContext.types.ts index e18a5ec3fa..7d57927044 100644 --- a/packages/rum-core/src/domainContext.types.ts +++ b/packages/rum-core/src/domainContext.types.ts @@ -14,7 +14,9 @@ export type RumEventDomainContext = T extends RumE ? RumErrorEventDomainContext : T extends RumEventType.LONG_TASK ? RumLongTaskEventDomainContext - : never + : T extends RumEventType.VITAL + ? RumVitalEventDomainContext + : never export interface RumViewEventDomainContext { location: Readonly @@ -48,3 +50,5 @@ export interface RumErrorEventDomainContext { export interface RumLongTaskEventDomainContext { performanceEntry: PerformanceEntry } + +export interface RumVitalEventDomainContext {} diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index 8085d1c139..6e1c8a7272 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -8,6 +8,7 @@ export { RumViewEvent, RumResourceEvent, RumLongTaskEvent, + RumVitalEvent, } from './rumEvent.types' export { RumLongTaskEventDomainContext, diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index a537c58318..357668dbe7 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -18,6 +18,7 @@ export const enum RumEventType { LONG_TASK = 'long_task', VIEW = 'view', RESOURCE = 'resource', + VITAL = 'vital', } export interface RawRumResourceEvent { @@ -215,12 +216,29 @@ export const enum FrustrationType { DEAD_CLICK = 'dead_click', } +export interface RawRumVitalEvent { + date: TimeStamp + type: RumEventType.VITAL + vital: { + id: string + type: VitalType + custom: { + [key: string]: number + } + } +} + +export const enum VitalType { + DURATION = 'duration', +} + export type RawRumEvent = | RawRumErrorEvent | RawRumResourceEvent | RawRumViewEvent | RawRumLongTaskEvent | RawRumActionEvent + | RawRumVitalEvent export interface RumContext { date: TimeStamp diff --git a/packages/rum-core/test/fixtures.ts b/packages/rum-core/test/fixtures.ts index f23e3279b3..668a0733b6 100644 --- a/packages/rum-core/test/fixtures.ts +++ b/packages/rum-core/test/fixtures.ts @@ -8,7 +8,6 @@ import { relativeNow, ResourceType, } from '@datadog/browser-core' -import { RumPerformanceEntryType } from '../src/browser/performanceCollection' import type { RumFirstInputTiming, RumLargestContentfulPaintTiming, @@ -19,8 +18,9 @@ import type { RumPerformancePaintTiming, RumPerformanceResourceTiming, } from '../src/browser/performanceCollection' +import { RumPerformanceEntryType } from '../src/browser/performanceCollection' import type { RawRumEvent } from '../src/rawRumEvent.types' -import { ActionType, RumEventType, ViewLoadingType } from '../src/rawRumEvent.types' +import { VitalType, ActionType, RumEventType, ViewLoadingType } from '../src/rawRumEvent.types' export function createRawRumEvent(type: RumEventType, overrides?: Context): RawRumEvent { switch (type) { @@ -39,6 +39,21 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR }, overrides ) + case RumEventType.VITAL: + return combine( + { + type, + date: 0 as TimeStamp, + vital: { + id: generateUUID(), + type: VitalType.DURATION, + custom: { + timing: 0 as ServerDuration, + }, + }, + }, + overrides + ) case RumEventType.LONG_TASK: return combine( { diff --git a/packages/rum/src/entries/main.ts b/packages/rum/src/entries/main.ts index a3b08cccb4..895cc7509e 100644 --- a/packages/rum/src/entries/main.ts +++ b/packages/rum/src/entries/main.ts @@ -18,6 +18,7 @@ export { RumLongTaskEvent, RumResourceEvent, RumViewEvent, + RumVitalEvent, // Events context RumEventDomainContext, RumViewEventDomainContext, From 2b42f97cb77fc3ce5b837eff839340766f0a0b69 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Thu, 1 Feb 2024 16:48:14 +0100 Subject: [PATCH 3/7] wire new APIs --- .../core/src/tools/experimentalFeatures.ts | 1 + .../rum-core/src/boot/preStartRum.spec.ts | 24 +++++++ packages/rum-core/src/boot/preStartRum.ts | 8 +++ .../rum-core/src/boot/rumPublicApi.spec.ts | 63 +++++++++++++++++++ packages/rum-core/src/boot/rumPublicApi.ts | 19 ++++++ packages/rum-core/src/boot/startRum.ts | 4 ++ .../src/domain/vital/vitalCollection.ts | 19 ++++++ 7 files changed, 138 insertions(+) create mode 100644 packages/rum-core/src/domain/vital/vitalCollection.ts diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 94748fe737..e0c2300ab0 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -18,6 +18,7 @@ export enum ExperimentalFeature { ZERO_LCP_TELEMETRY = 'zero_lcp_telemetry', DISABLE_REPLAY_INLINE_CSS = 'disable_replay_inline_css', WRITABLE_RESOURCE_GRAPHQL = 'writable_resource_graphql', + CUSTOM_VITALS = 'custom_vitals', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index c85db81074..0909e6fdd1 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -486,5 +486,29 @@ describe('preStartRum', () => { strategy.init(DEFAULT_INIT_CONFIGURATION) expect(addFeatureFlagEvaluationSpy).toHaveBeenCalledOnceWith(key, value) }) + + it('startDurationVital', () => { + const startDurationVitalSpy = jasmine.createSpy() + doStartRumSpy.and.returnValue({ + startDurationVital: startDurationVitalSpy, + } as unknown as StartRumResult) + + const vitalStart = { name: 'timing', startClocks: clocksNow() } + strategy.startDurationVital(vitalStart) + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(startDurationVitalSpy).toHaveBeenCalledOnceWith(vitalStart) + }) + + it('stopDurationVital', () => { + const stopDurationVitalSpy = jasmine.createSpy() + doStartRumSpy.and.returnValue({ + stopDurationVital: stopDurationVitalSpy, + } as unknown as StartRumResult) + + const vitalStop = { name: 'timing', stopClocks: clocksNow() } + strategy.stopDurationVital(vitalStop) + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(stopDurationVitalSpy).toHaveBeenCalledOnceWith(vitalStop) + }) }) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 959bf46a0a..97ccb44913 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -157,6 +157,14 @@ export function createPreStartStrategy( addFeatureFlagEvaluation(key, value) { bufferApiCalls.add((startRumResult) => startRumResult.addFeatureFlagEvaluation(key, value)) }, + + startDurationVital(vitalStart) { + bufferApiCalls.add((startRumResult) => startRumResult.startDurationVital(vitalStart)) + }, + + stopDurationVital(vitalStart) { + bufferApiCalls.add((startRumResult) => startRumResult.stopDurationVital(vitalStart)) + }, } } diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 09b0a87c16..03bc85779b 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -1,5 +1,8 @@ import type { RelativeTime, Context, DeflateWorker, CustomerDataTrackerManager } from '@datadog/browser-core' import { + addExperimentalFeatures, + ExperimentalFeature, + resetExperimentalFeatures, ONE_SECOND, display, DefaultPrivacyLevel, @@ -25,6 +28,8 @@ const noopStartRum = (): ReturnType => ({ viewContexts: {} as any, session: {} as any, stopSession: () => undefined, + startDurationVital: () => undefined, + stopDurationVital: () => undefined, stop: () => undefined, }) const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } @@ -270,9 +275,11 @@ describe('rum public api', () => { it('should generate a handling stack', () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + function triggerError() { rumPublicApi.addError(new Error('message')) } + triggerError() expect(addErrorSpy).toHaveBeenCalledTimes(1) const stacktrace = addErrorSpy.calls.argsFor(0)[0].handlingStack @@ -714,6 +721,62 @@ describe('rum public api', () => { }) }) + describe('startDurationVital', () => { + afterEach(() => { + resetExperimentalFeatures() + }) + + it('should not expose startDurationVital when ff is disabled', () => { + const rumPublicApi = makeRumPublicApi(noopStartRum, noopRecorderApi) + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + expect((rumPublicApi as any).startDurationVital).toBeUndefined() + }) + + it('should call startDurationVital on the startRum result when ff is enabled', () => { + addExperimentalFeatures([ExperimentalFeature.CUSTOM_VITALS]) + const startDurationVitalSpy = jasmine.createSpy() + const rumPublicApi = makeRumPublicApi( + () => ({ + ...noopStartRum(), + startDurationVital: startDurationVitalSpy, + }), + noopRecorderApi + ) + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + ;(rumPublicApi as any).startDurationVital('foo') + expect(startDurationVitalSpy).toHaveBeenCalledWith({ name: 'foo', startClocks: jasmine.any(Object) }) + }) + }) + + describe('stopDurationVital', () => { + afterEach(() => { + resetExperimentalFeatures() + }) + + it('should not expose stopDurationVital when ff is disabled', () => { + const rumPublicApi = makeRumPublicApi(noopStartRum, noopRecorderApi) + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + expect((rumPublicApi as any).stopDurationVital).toBeUndefined() + }) + + it('should call stopDurationVital on the startRum result when ff is enabled', () => { + addExperimentalFeatures([ExperimentalFeature.CUSTOM_VITALS]) + const stopDurationVitalSpy = jasmine.createSpy() + const rumPublicApi = makeRumPublicApi( + () => ({ + ...noopStartRum(), + stopDurationVital: stopDurationVitalSpy, + }), + noopRecorderApi + ) + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + ;(rumPublicApi as any).stopDurationVital('foo') + expect(stopDurationVitalSpy).toHaveBeenCalledWith({ name: 'foo', stopClocks: jasmine.any(Object) }) + }) + }) + it('should provide sdk version', () => { const rumPublicApi = makeRumPublicApi(noopStartRum, noopRecorderApi) expect(rumPublicApi.version).toBe('test') diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index b0c4932cd2..0a9d57ef5e 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -8,6 +8,8 @@ import type { DeflateEncoder, } from '@datadog/browser-core' import { + isExperimentalFeatureEnabled, + ExperimentalFeature, CustomerDataType, assign, createContextManager, @@ -80,6 +82,8 @@ export interface Strategy { addAction: StartRumResult['addAction'] addError: StartRumResult['addError'] addFeatureFlagEvaluation: StartRumResult['addFeatureFlagEvaluation'] + startDurationVital: StartRumResult['startDurationVital'] + stopDurationVital: StartRumResult['stopDurationVital'] } export function makeRumPublicApi(startRumImpl: StartRum, recorderApi: RecorderApi, options: RumPublicApiOptions = {}) { @@ -98,6 +102,21 @@ export function makeRumPublicApi(startRumImpl: StartRum, recorderApi: RecorderAp getCommonContext, (initConfiguration, configuration, deflateWorker, initialViewOptions) => { + if (isExperimentalFeatureEnabled(ExperimentalFeature.CUSTOM_VITALS)) { + ;(rumPublicApi as any).startDurationVital = monitor((name: string) => { + strategy.startDurationVital({ + name: sanitize(name)!, + startClocks: clocksNow(), + }) + }) + ;(rumPublicApi as any).stopDurationVital = monitor((name: string) => { + strategy.stopDurationVital({ + name: sanitize(name)!, + stopClocks: clocksNow(), + }) + }) + } + if (initConfiguration.storeContextsAcrossPages) { storeContextManager(configuration, globalContextManager, RUM_STORAGE_KEY, CustomerDataType.GlobalContext) storeContextManager(configuration, userContextManager, RUM_STORAGE_KEY, CustomerDataType.User) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 779ce0e708..1a9d757a8c 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -44,6 +44,7 @@ import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry import { startPageStateHistory } from '../domain/contexts/pageStateHistory' import type { CommonContext } from '../domain/contexts/commonContext' import { startDisplayContext } from '../domain/contexts/displayContext' +import { startVitalCollection } from '../domain/vital/vitalCollection' import type { RecorderApi } from './rumPublicApi' export type StartRum = typeof startRum @@ -161,6 +162,7 @@ export function startRum( const { stop: stopPerformanceCollection } = startPerformanceCollection(lifeCycle, configuration) cleanupTasks.push(stopPerformanceCollection) + const vitalCollection = startVitalCollection(lifeCycle) const internalContext = startInternalContext( configuration.applicationId, session, @@ -180,6 +182,8 @@ export function startRum( session, stopSession: () => session.expire(), getInternalContext: internalContext.get, + startDurationVital: vitalCollection.startDurationVital, + stopDurationVital: vitalCollection.stopDurationVital, stop: () => { cleanupTasks.forEach((task) => task()) }, diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts new file mode 100644 index 0000000000..2ed1ba1a77 --- /dev/null +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -0,0 +1,19 @@ +import type { ClocksState } from '@datadog/browser-core' +import type { LifeCycle } from '../lifeCycle' + +export interface DurationVitalStart { + name: string + startClocks: ClocksState +} + +export interface DurationVitalStop { + name: string + stopClocks: ClocksState +} + +export function startVitalCollection(lifecycle: LifeCycle) { + return { + startDurationVital: (vitalStart: DurationVitalStart) => {}, + stopDurationVital: (vitalStop: DurationVitalStop) => {}, + } +} From 7b4cc1c606b29fb3326fffc30a030c812575678d Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Thu, 1 Feb 2024 17:37:07 +0100 Subject: [PATCH 4/7] duration vital logic --- .../src/domain/vital/vitalCollection.spec.ts | 98 +++++++++++++++++++ .../src/domain/vital/vitalCollection.ts | 52 +++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 packages/rum-core/src/domain/vital/vitalCollection.spec.ts diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts new file mode 100644 index 0000000000..223a2d664b --- /dev/null +++ b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts @@ -0,0 +1,98 @@ +import { clocksNow } from '@datadog/browser-core' +import type { TestSetupBuilder } from '../../../test' +import { setup } from '../../../test' +import type { RawRumVitalEvent } from '../../rawRumEvent.types' +import { VitalType, RumEventType } from '../../rawRumEvent.types' +import { startVitalCollection } from './vitalCollection' + +describe('vitalCollection', () => { + let setupBuilder: TestSetupBuilder + let vitalCollection: ReturnType + + beforeEach(() => { + setupBuilder = setup() + .withFakeClock() + .beforeBuild(({ lifeCycle }) => { + vitalCollection = startVitalCollection(lifeCycle) + }) + }) + + describe('custom duration', () => { + it('should create duration vital from start/stop API', () => { + const { rawRumEvents, clock } = setupBuilder.build() + + vitalCollection.startDurationVital({ name: 'foo', startClocks: clocksNow() }) + clock.tick(100) + vitalCollection.stopDurationVital({ name: 'foo', stopClocks: clocksNow() }) + + expect(rawRumEvents.length).toBe(1) + expect((rawRumEvents[0].rawRumEvent as RawRumVitalEvent).vital.custom.foo).toBe(100) + }) + + it('should not create duration vital without calling the stop API', () => { + const { rawRumEvents } = setupBuilder.build() + + vitalCollection.startDurationVital({ name: 'foo', startClocks: clocksNow() }) + + expect(rawRumEvents.length).toBe(0) + }) + + it('should not create duration vital without calling the start API', () => { + const { rawRumEvents } = setupBuilder.build() + + vitalCollection.stopDurationVital({ name: 'foo', stopClocks: clocksNow() }) + + expect(rawRumEvents.length).toBe(0) + }) + + it('should create multiple duration vitals from start/stop API', () => { + const { rawRumEvents, clock } = setupBuilder.build() + + vitalCollection.startDurationVital({ name: 'foo', startClocks: clocksNow() }) + clock.tick(100) + vitalCollection.startDurationVital({ name: 'bar', startClocks: clocksNow() }) + clock.tick(100) + vitalCollection.stopDurationVital({ name: 'bar', stopClocks: clocksNow() }) + clock.tick(100) + vitalCollection.stopDurationVital({ name: 'foo', stopClocks: clocksNow() }) + + expect(rawRumEvents.length).toBe(2) + expect((rawRumEvents[0].rawRumEvent as RawRumVitalEvent).vital.custom.bar).toBe(100) + expect((rawRumEvents[1].rawRumEvent as RawRumVitalEvent).vital.custom.foo).toBe(300) + }) + + it('should discard a previous start with the same name', () => { + const { rawRumEvents, clock } = setupBuilder.build() + + vitalCollection.startDurationVital({ name: 'foo', startClocks: clocksNow() }) + clock.tick(100) + vitalCollection.startDurationVital({ name: 'foo', startClocks: clocksNow() }) + clock.tick(100) + vitalCollection.stopDurationVital({ name: 'foo', stopClocks: clocksNow() }) + + expect(rawRumEvents.length).toBe(1) + expect((rawRumEvents[0].rawRumEvent as RawRumVitalEvent).vital.custom.foo).toBe(100) + }) + }) + + it('should collect raw rum event from duration vital', () => { + const { rawRumEvents } = setupBuilder.build() + + vitalCollection.startDurationVital({ name: 'foo', startClocks: clocksNow() }) + vitalCollection.stopDurationVital({ name: 'foo', stopClocks: clocksNow() }) + + expect(rawRumEvents[0].startTime).toEqual(jasmine.any(Number)) + expect(rawRumEvents[0].rawRumEvent).toEqual({ + date: jasmine.any(Number), + vital: { + id: jasmine.any(String), + type: VitalType.DURATION, + custom: { + foo: 0, + }, + }, + type: RumEventType.VITAL, + }) + expect(rawRumEvents[0].domainContext).toEqual({}) + }) +}) diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts index 2ed1ba1a77..102b21ed99 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -1,5 +1,9 @@ import type { ClocksState } from '@datadog/browser-core' -import type { LifeCycle } from '../lifeCycle' +import { elapsed, generateUUID } from '@datadog/browser-core' +import { LifeCycleEventType } from '../lifeCycle' +import type { LifeCycle, RawRumEventCollectedData } from '../lifeCycle' +import type { RawRumVitalEvent } from '../../rawRumEvent.types' +import { RumEventType, VitalType } from '../../rawRumEvent.types' export interface DurationVitalStart { name: string @@ -11,9 +15,49 @@ export interface DurationVitalStop { stopClocks: ClocksState } -export function startVitalCollection(lifecycle: LifeCycle) { +interface Vital { + name: string + type: VitalType + startClocks: ClocksState + value: number +} + +export function startVitalCollection(lifeCycle: LifeCycle) { + const vitalStartsByName: { [name: string]: DurationVitalStart } = {} + return { + startDurationVital: (vitalStart: DurationVitalStart) => { + vitalStartsByName[vitalStart.name] = vitalStart + }, + stopDurationVital: (vitalStop: DurationVitalStop) => { + const vitalStart = vitalStartsByName[vitalStop.name] + if (!vitalStart) { + return + } + const vital = { + name: vitalStart.name, + type: VitalType.DURATION, + startClocks: vitalStart.startClocks, + value: elapsed(vitalStart.startClocks.timeStamp, vitalStop.stopClocks.timeStamp), + } + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processVital(vital)) + }, + } +} + +function processVital(vital: Vital): RawRumEventCollectedData { return { - startDurationVital: (vitalStart: DurationVitalStart) => {}, - stopDurationVital: (vitalStop: DurationVitalStop) => {}, + rawRumEvent: { + date: vital.startClocks.timeStamp, + vital: { + id: generateUUID(), + type: vital.type, + custom: { + [vital.name]: vital.value, + }, + }, + type: RumEventType.VITAL, + }, + startTime: vital.startClocks.relative, + domainContext: {}, } } From 402ef40be711c30a1a065377252ddb0a0b4d6747 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Mon, 5 Feb 2024 14:36:54 +0100 Subject: [PATCH 5/7] add vitals e2e scenario --- test/e2e/lib/framework/intakeRegistry.ts | 17 +++++++++++++++- test/e2e/scenario/rum/vitals.scenario.ts | 26 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 test/e2e/scenario/rum/vitals.scenario.ts diff --git a/test/e2e/lib/framework/intakeRegistry.ts b/test/e2e/lib/framework/intakeRegistry.ts index 240b450166..73ac46e6f6 100644 --- a/test/e2e/lib/framework/intakeRegistry.ts +++ b/test/e2e/lib/framework/intakeRegistry.ts @@ -1,5 +1,12 @@ import type { LogsEvent } from '@datadog/browser-logs' -import type { RumEvent, RumActionEvent, RumErrorEvent, RumResourceEvent, RumViewEvent } from '@datadog/browser-rum' +import type { + RumEvent, + RumActionEvent, + RumErrorEvent, + RumResourceEvent, + RumViewEvent, + RumVitalEvent, +} from '@datadog/browser-rum' import type { TelemetryEvent, TelemetryErrorEvent, TelemetryConfigurationEvent } from '@datadog/browser-core' import type { BrowserSegment } from '@datadog/browser-rum/src/types' import type { BrowserSegmentMetadataAndSegmentSizes } from '@datadog/browser-rum/src/domain/segmentCollection' @@ -94,6 +101,10 @@ export class IntakeRegistry { return this.rumEvents.filter(isRumViewEvent) } + get rumVitalEvents() { + return this.rumEvents.filter(isRumVitalEvent) + } + // // Telemetry // @@ -155,6 +166,10 @@ function isRumErrorEvent(event: RumEvent): event is RumErrorEvent { return event.type === 'error' } +function isRumVitalEvent(event: RumEvent): event is RumVitalEvent { + return event.type === 'vital' +} + function isTelemetryEvent(event: RumEvent | TelemetryEvent): event is TelemetryEvent { return event.type === 'telemetry' } diff --git a/test/e2e/scenario/rum/vitals.scenario.ts b/test/e2e/scenario/rum/vitals.scenario.ts new file mode 100644 index 0000000000..b461b6efad --- /dev/null +++ b/test/e2e/scenario/rum/vitals.scenario.ts @@ -0,0 +1,26 @@ +import { createTest, flushEvents } from '../../lib/framework' +import { browserExecuteAsync } from '../../lib/helpers/browser' + +describe('vital collection', () => { + createTest('send custom duration vital') + .withRum({ + enableExperimentalFeatures: ['custom_vitals'], + }) + .run(async ({ intakeRegistry }) => { + await browserExecuteAsync((done) => { + // TODO remove cast and unsafe calls when removing the flag + const global = window.DD_RUM! as any + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + global.startDurationVital('foo') + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + global.stopDurationVital('foo') + done() + }, 5) + }) + await flushEvents() + + expect(intakeRegistry.rumVitalEvents.length).toBe(1) + expect(intakeRegistry.rumVitalEvents[0].vital.custom).toEqual({ foo: jasmine.any(Number) }) + }) +}) From 9772870d391d53f84de1c2f3c8c904a19b38aee6 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Thu, 8 Feb 2024 16:52:39 +0100 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=91=8C=20use=20Map=20for=20vitalStart?= =?UTF-8?q?sByName?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum-core/src/domain/vital/vitalCollection.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts index 102b21ed99..7a6eadf838 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -23,13 +23,13 @@ interface Vital { } export function startVitalCollection(lifeCycle: LifeCycle) { - const vitalStartsByName: { [name: string]: DurationVitalStart } = {} + const vitalStartsByName = new Map() return { startDurationVital: (vitalStart: DurationVitalStart) => { - vitalStartsByName[vitalStart.name] = vitalStart + vitalStartsByName.set(vitalStart.name, vitalStart) }, stopDurationVital: (vitalStop: DurationVitalStop) => { - const vitalStart = vitalStartsByName[vitalStop.name] + const vitalStart = vitalStartsByName.get(vitalStop.name) if (!vitalStart) { return } From 5752874788553bd92cbaa9fa9b914feac0c60085 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Fri, 9 Feb 2024 14:38:35 +0100 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=91=8C=20demote=20Vital=20interface?= =?UTF-8?q?=20to=20DurationVital?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum-core/src/domain/vital/vitalCollection.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts index 7a6eadf838..278033d51f 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -1,4 +1,4 @@ -import type { ClocksState } from '@datadog/browser-core' +import type { ClocksState, Duration } from '@datadog/browser-core' import { elapsed, generateUUID } from '@datadog/browser-core' import { LifeCycleEventType } from '../lifeCycle' import type { LifeCycle, RawRumEventCollectedData } from '../lifeCycle' @@ -15,11 +15,11 @@ export interface DurationVitalStop { stopClocks: ClocksState } -interface Vital { +interface DurationVital { name: string - type: VitalType + type: VitalType.DURATION startClocks: ClocksState - value: number + value: Duration } export function startVitalCollection(lifeCycle: LifeCycle) { @@ -44,7 +44,7 @@ export function startVitalCollection(lifeCycle: LifeCycle) { } } -function processVital(vital: Vital): RawRumEventCollectedData { +function processVital(vital: DurationVital): RawRumEventCollectedData { return { rawRumEvent: { date: vital.startClocks.timeStamp,