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/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts
index 2d582edf91..77fbfd220c 100644
--- a/packages/core/src/tools/experimentalFeatures.ts
+++ b/packages/core/src/tools/experimentalFeatures.ts
@@ -19,6 +19,7 @@ export enum ExperimentalFeature {
DISABLE_REPLAY_INLINE_CSS = 'disable_replay_inline_css',
WRITABLE_RESOURCE_GRAPHQL = 'writable_resource_graphql',
TRACKING_CONSENT = 'tracking_consent',
+ 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 991d427bb3..afd73b5555 100644
--- a/packages/rum-core/src/boot/preStartRum.spec.ts
+++ b/packages/rum-core/src/boot/preStartRum.spec.ts
@@ -510,6 +510,30 @@ 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)
+ })
})
describe('tracking consent', () => {
diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts
index 9228b69228..7ba71d62bf 100644
--- a/packages/rum-core/src/boot/preStartRum.ts
+++ b/packages/rum-core/src/boot/preStartRum.ts
@@ -163,6 +163,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 84a40f0adf..8f50aa1ba1 100644
--- a/packages/rum-core/src/boot/rumPublicApi.ts
+++ b/packages/rum-core/src/boot/rumPublicApi.ts
@@ -9,6 +9,8 @@ import type {
TrackingConsent,
} from '@datadog/browser-core'
import {
+ isExperimentalFeatureEnabled,
+ ExperimentalFeature,
CustomerDataType,
assign,
createContextManager,
@@ -82,6 +84,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 = {}) {
@@ -102,6 +106,21 @@ export function makeRumPublicApi(startRumImpl: StartRum, recorderApi: RecorderAp
trackingConsentState,
(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 470a71fc0f..12e912741e 100644
--- a/packages/rum-core/src/boot/startRum.ts
+++ b/packages/rum-core/src/boot/startRum.ts
@@ -45,6 +45,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
@@ -169,6 +170,7 @@ export function startRum(
const { stop: stopPerformanceCollection } = startPerformanceCollection(lifeCycle, configuration)
cleanupTasks.push(stopPerformanceCollection)
+ const vitalCollection = startVitalCollection(lifeCycle)
const internalContext = startInternalContext(
configuration.applicationId,
session,
@@ -188,6 +190,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/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/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
new file mode 100644
index 0000000000..278033d51f
--- /dev/null
+++ b/packages/rum-core/src/domain/vital/vitalCollection.ts
@@ -0,0 +1,63 @@
+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'
+import type { RawRumVitalEvent } from '../../rawRumEvent.types'
+import { RumEventType, VitalType } from '../../rawRumEvent.types'
+
+export interface DurationVitalStart {
+ name: string
+ startClocks: ClocksState
+}
+
+export interface DurationVitalStop {
+ name: string
+ stopClocks: ClocksState
+}
+
+interface DurationVital {
+ name: string
+ type: VitalType.DURATION
+ startClocks: ClocksState
+ value: Duration
+}
+
+export function startVitalCollection(lifeCycle: LifeCycle) {
+ const vitalStartsByName = new Map()
+ return {
+ startDurationVital: (vitalStart: DurationVitalStart) => {
+ vitalStartsByName.set(vitalStart.name, vitalStart)
+ },
+ stopDurationVital: (vitalStop: DurationVitalStop) => {
+ const vitalStart = vitalStartsByName.get(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: DurationVital): RawRumEventCollectedData {
+ return {
+ rawRumEvent: {
+ date: vital.startClocks.timeStamp,
+ vital: {
+ id: generateUUID(),
+ type: vital.type,
+ custom: {
+ [vital.name]: vital.value,
+ },
+ },
+ type: RumEventType.VITAL,
+ },
+ startTime: vital.startClocks.relative,
+ domainContext: {},
+ }
+}
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/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/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,
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
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) })
+ })
+})