From cd21fa08d96c21e86e4f64517ed70464e40d48c7 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Thu, 6 Feb 2025 16:45:08 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[RUM-8123]=20Introduce=20a?= =?UTF-8?q?=20hook=20to=20assemble=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum-core/src/boot/startRum.spec.ts | 19 ++- packages/rum-core/src/boot/startRum.ts | 32 ++-- packages/rum-core/src/domain/assembly.spec.ts | 143 +++++------------- packages/rum-core/src/domain/assembly.ts | 28 ++-- .../src/domain/contexts/urlContexts.spec.ts | 25 ++- .../src/domain/contexts/urlContexts.ts | 15 ++ .../domain/view/setupViewTest.specHelper.ts | 9 +- .../src/domain/view/trackViews.spec.ts | 40 +++++ .../rum-core/src/domain/view/trackViews.ts | 21 +-- .../src/domain/view/viewCollection.spec.ts | 54 ++++++- .../src/domain/view/viewCollection.ts | 27 +++- packages/rum-core/src/hooks.spec.ts | 52 +++++++ packages/rum-core/src/hooks.ts | 59 ++++++++ packages/rum-core/test/formatValidation.ts | 3 +- packages/rum-core/test/mockContexts.ts | 6 +- 15 files changed, 369 insertions(+), 164 deletions(-) create mode 100644 packages/rum-core/src/hooks.spec.ts create mode 100644 packages/rum-core/src/hooks.ts diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index 5a02224bfd..e8101a9b78 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -39,12 +39,14 @@ import { startViewCollection } from '../domain/view/viewCollection' import type { RumEvent, RumViewEvent } from '../rumEvent.types' import type { LocationChange } from '../browser/locationChangeObservable' import { startLongAnimationFrameCollection } from '../domain/longAnimationFrame/longAnimationFrameCollection' -import type { RumSessionManager } from '..' +import { startViewHistory, type RumSessionManager } from '..' import type { RumConfiguration } from '../domain/configuration' import { RumEventType } from '../rawRumEvent.types' import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext' import type { PageStateHistory } from '../domain/contexts/pageStateHistory' import { createCustomVitalsState } from '../domain/vital/vitalCollection' +import { createHooks } from '../hooks' +import { startUrlContexts } from '../domain/contexts/urlContexts' import { startRum, startRumEventCollection } from './startRum' function collectServerEvents(lifeCycle: LifeCycle) { @@ -66,16 +68,21 @@ function startRumStub( pageStateHistory: PageStateHistory, reportError: (error: RawError) => void ) { + const hooks = createHooks() + const viewHistory = startViewHistory(lifeCycle) + const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable, location) + const { stop: rumEventCollectionStop } = startRumEventCollection( lifeCycle, + hooks, configuration, - location, sessionManager, pageStateHistory, - locationChangeObservable, domMutationObservable, startFeatureFlagContexts(lifeCycle, createCustomerDataTracker(noop)), windowOpenObservable, + urlContexts, + viewHistory, () => ({ context: {}, user: {}, @@ -85,18 +92,22 @@ function startRumStub( ) const { stop: viewCollectionStop } = startViewCollection( lifeCycle, + hooks, configuration, location, domMutationObservable, windowOpenObservable, locationChangeObservable, pageStateHistory, - noopRecorderApi + noopRecorderApi, + viewHistory ) startLongAnimationFrameCollection(lifeCycle, configuration) return { stop: () => { + viewHistory.stop() + urlContexts.stop() rumEventCollectionStop() viewCollectionStop() }, diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 83d3875f5f..8819f370ae 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -23,6 +23,7 @@ import { createWindowOpenObservable } from '../browser/windowOpenObservable' import { startRumAssembly } from '../domain/assembly' import { startInternalContext } from '../domain/contexts/internalContext' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' +import type { ViewHistory } from '../domain/contexts/viewHistory' import { startViewHistory } from '../domain/contexts/viewHistory' import { startRequestCollection } from '../domain/requestCollection' import { startActionCollection } from '../domain/action/actionCollection' @@ -33,8 +34,8 @@ import type { RumSessionManager } from '../domain/rumSessionManager' import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { startRumBatch } from '../transport/startRumBatch' import { startRumEventBridge } from '../transport/startRumEventBridge' +import type { UrlContexts } from '../domain/contexts/urlContexts' import { startUrlContexts } from '../domain/contexts/urlContexts' -import type { LocationChange } from '../browser/locationChangeObservable' import { createLocationChangeObservable } from '../browser/locationChangeObservable' import type { RumConfiguration } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' @@ -51,6 +52,8 @@ import { startCiVisibilityContext } from '../domain/contexts/ciVisibilityContext import { startLongAnimationFrameCollection } from '../domain/longAnimationFrame/longAnimationFrameCollection' import { RumPerformanceEntryType } from '../browser/performanceObservable' import { startLongTaskCollection } from '../domain/longTask/longTaskCollection' +import type { Hooks } from '../hooks' +import { createHooks } from '../hooks' import type { RecorderApi } from './rumPublicApi' export type StartRum = typeof startRum @@ -72,6 +75,7 @@ export function startRum( ) { const cleanupTasks: Array<() => void> = [] const lifeCycle = new LifeCycle() + const hooks = createHooks() lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => sendToExtension('rum', event)) @@ -128,25 +132,27 @@ export function startRum( const domMutationObservable = createDOMMutationObservable() const locationChangeObservable = createLocationChangeObservable(configuration, location) const pageStateHistory = startPageStateHistory(configuration) + const viewHistory = startViewHistory(lifeCycle) + const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable, location) + const { observable: windowOpenObservable, stop: stopWindowOpen } = createWindowOpenObservable() cleanupTasks.push(stopWindowOpen) const { - viewHistory, - urlContexts, actionContexts, addAction, stop: stopRumEventCollection, } = startRumEventCollection( lifeCycle, + hooks, configuration, - location, session, pageStateHistory, - locationChangeObservable, domMutationObservable, featureFlagContexts, windowOpenObservable, + urlContexts, + viewHistory, getCommonContext, reportError ) @@ -164,6 +170,7 @@ export function startRum( stop: stopViewCollection, } = startViewCollection( lifeCycle, + hooks, configuration, location, domMutationObservable, @@ -171,8 +178,10 @@ export function startRum( locationChangeObservable, pageStateHistory, recorderApi, + viewHistory, initialViewOptions ) + cleanupTasks.push(stopViewCollection) const { stop: stopResourceCollection } = startResourceCollection(lifeCycle, configuration, pageStateHistory) @@ -235,20 +244,18 @@ function startRumTelemetry(configuration: RumConfiguration) { export function startRumEventCollection( lifeCycle: LifeCycle, + hooks: Hooks, configuration: RumConfiguration, - location: Location, sessionManager: RumSessionManager, pageStateHistory: PageStateHistory, - locationChangeObservable: Observable, domMutationObservable: Observable, featureFlagContexts: FeatureFlagContexts, windowOpenObservable: Observable, + urlContexts: UrlContexts, + viewHistory: ViewHistory, getCommonContext: () => CommonContext, reportError: (error: RawError) => void ) { - const viewHistory = startViewHistory(lifeCycle) - const urlContexts = startUrlContexts(lifeCycle, locationChangeObservable, location) - const actionCollection = startActionCollection( lifeCycle, domMutationObservable, @@ -263,6 +270,7 @@ export function startRumEventCollection( startRumAssembly( configuration, lifeCycle, + hooks, sessionManager, viewHistory, urlContexts, @@ -275,17 +283,13 @@ export function startRumEventCollection( ) return { - viewHistory, pageStateHistory, - urlContexts, addAction: actionCollection.addAction, actionContexts: actionCollection.actionContexts, stop: () => { actionCollection.stop() ciVisibilityContext.stop() displayContext.stop() - urlContexts.stop() - viewHistory.stop() pageStateHistory.stop() }, } diff --git a/packages/rum-core/src/domain/assembly.spec.ts b/packages/rum-core/src/domain/assembly.spec.ts index 841a6c9f8e..e8ec5ede5b 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -15,16 +15,17 @@ import { createRumSessionManagerMock, createRawRumEvent, mockRumConfiguration, - mockUrlContexts, mockActionContexts, mockDisplayContext, mockViewHistory, mockFeatureFlagContexts, + mockUrlContexts, } from '../../test' import type { RumEventDomainContext } from '../domainContext.types' import type { RawRumActionEvent, RawRumEvent } from '../rawRumEvent.types' import { RumEventType } from '../rawRumEvent.types' import type { RumActionEvent, RumErrorEvent, RumEvent, RumResourceEvent } from '../rumEvent.types' +import { HookNames, createHooks } from '../hooks' import { startRumAssembly } from './assembly' import type { RawRumEventCollectedData } from './lifeCycle' import { LifeCycle, LifeCycleEventType } from './lifeCycle' @@ -390,43 +391,6 @@ describe('rum assembly', () => { }) }) - describe('priority of rum context', () => { - it('should prioritize view customer context over global context', () => { - const { lifeCycle, serverRumEvents, commonContext } = setupAssemblyTestWithDefaults({ - findView: () => ({ - id: '7890', - name: 'view name', - startClocks: {} as ClocksState, - context: { foo: 'baz' }, - }), - }) - commonContext.context = { foo: 'bar' } - - notifyRawRumEvent(lifeCycle, { - rawRumEvent: createRawRumEvent(RumEventType.VIEW), - }) - - expect(serverRumEvents[0].context!.foo).toBe('baz') - }) - - it('should prioritize child customer context over inherited view context', () => { - const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ - findView: () => ({ - id: '7890', - name: 'view name', - startClocks: {} as ClocksState, - context: { foo: 'bar' }, - }), - }) - notifyRawRumEvent(lifeCycle, { - customerContext: { foo: 'baz' }, - rawRumEvent: createRawRumEvent(RumEventType.ACTION), - }) - - expect(serverRumEvents[0].context!.foo).toBe('baz') - }) - }) - describe('rum global context', () => { it('should be merged with event attributes', () => { const { lifeCycle, serverRumEvents, commonContext } = setupAssemblyTestWithDefaults() @@ -570,69 +534,9 @@ describe('rum assembly', () => { }) }) - describe('view context', () => { - it('should be merged with event attributes', () => { - const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults() - notifyRawRumEvent(lifeCycle, { - rawRumEvent: createRawRumEvent(RumEventType.ACTION), - }) - expect(serverRumEvents[0].view).toEqual( - jasmine.objectContaining({ - id: '7890', - name: 'view name', - }) - ) - }) - - it('child event should have view customer context', () => { - const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ - findView: () => ({ - id: '7890', - name: 'view name', - startClocks: {} as ClocksState, - context: { foo: 'bar' }, - }), - }) - notifyRawRumEvent(lifeCycle, { - rawRumEvent: createRawRumEvent(RumEventType.ACTION), - }) - expect(serverRumEvents[0].context).toEqual({ foo: 'bar' }) - }) - }) - describe('service and version', () => { const extraConfigurationOptions = { service: 'default service', version: 'default version' } - it('should come from the init configuration by default', () => { - const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ - partialConfiguration: extraConfigurationOptions, - }) - - notifyRawRumEvent(lifeCycle, { - rawRumEvent: createRawRumEvent(RumEventType.ACTION), - }) - expect(serverRumEvents[0].service).toEqual('default service') - expect(serverRumEvents[0].version).toEqual('default version') - }) - - it('should be overridden by the view context', () => { - const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ - partialConfiguration: extraConfigurationOptions, - findView: () => ({ - service: 'new service', - version: 'new version', - id: '1234', - startClocks: {} as ClocksState, - }), - }) - - notifyRawRumEvent(lifeCycle, { - rawRumEvent: createRawRumEvent(RumEventType.ACTION), - }) - expect(serverRumEvents[0].service).toEqual('new service') - expect(serverRumEvents[0].version).toEqual('new version') - }) - describe('fields service and version', () => { it('it should be modifiable', () => { const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ @@ -657,14 +561,43 @@ describe('rum assembly', () => { }) }) - describe('url context', () => { - it('should be merged with event attributes', () => { - const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults() + describe('assemble hook', () => { + it('should add and override common properties', () => { + const { lifeCycle, hooks, serverRumEvents, commonContext } = setupAssemblyTestWithDefaults({ + partialConfiguration: { service: 'default service', version: 'default version' }, + }) + commonContext.context = { foo: 'global context' } + + hooks.register(HookNames.Assemble, ({ eventType }) => ({ + type: eventType, + service: 'new service', + version: 'new version', + context: { foo: 'bar' }, + view: { id: 'new view id', url: '' }, + })) + + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION), + }) + expect(serverRumEvents[0].service).toEqual('new service') + expect(serverRumEvents[0].version).toEqual('new version') + expect(serverRumEvents[0].context).toEqual({ foo: 'bar' }) + expect(serverRumEvents[0].view.id).toEqual('new view id') + }) + + it('should not override customer context', () => { + const { lifeCycle, hooks, serverRumEvents } = setupAssemblyTestWithDefaults() + + hooks.register(HookNames.Assemble, ({ eventType }) => ({ + type: eventType, + context: { foo: 'bar' }, + })) + notifyRawRumEvent(lifeCycle, { rawRumEvent: createRawRumEvent(RumEventType.ACTION), + customerContext: { foo: 'customer context' }, }) - expect(serverRumEvents[0].view.url).toBe(location.href) - expect(serverRumEvents[0].view.referrer).toBe(document.referrer) + expect(serverRumEvents[0].context).toEqual({ foo: 'customer context' }) }) }) @@ -1046,6 +979,7 @@ function setupAssemblyTestWithDefaults({ findView = () => ({ id: '7890', name: 'view name', startClocks: {} as ClocksState }), }: AssemblyTestParams = {}) { const lifeCycle = new LifeCycle() + const hooks = createHooks() const reportErrorSpy = jasmine.createSpy('reportError') const rumSessionManager = sessionManager ?? createRumSessionManagerMock().setId('1234') const commonContext = { @@ -1064,6 +998,7 @@ function setupAssemblyTestWithDefaults({ startRumAssembly( mockRumConfiguration(partialConfiguration), lifeCycle, + hooks, rumSessionManager, { ...mockViewHistory(), findView: () => findView() }, mockUrlContexts(), @@ -1079,7 +1014,7 @@ function setupAssemblyTestWithDefaults({ subscription.unsubscribe() }) - return { lifeCycle, reportErrorSpy, featureFlagContexts, serverRumEvents, commonContext } + return { lifeCycle, hooks, reportErrorSpy, featureFlagContexts, serverRumEvents, commonContext } } function assertFeatureFlagCollection( diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index 68641e9701..dab1178da9 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -17,20 +17,22 @@ import type { RumEventDomainContext } from '../domainContext.types' import type { RawRumErrorEvent, RawRumEvent, RawRumLongTaskEvent, RawRumResourceEvent } from '../rawRumEvent.types' import { RumEventType } from '../rawRumEvent.types' import type { CommonProperties, RumEvent } from '../rumEvent.types' -import type { FeatureFlagContexts } from './contexts/featureFlagContext' +import type { Hooks } from '../hooks' +import { HookNames } from '../hooks' import { getSyntheticsContext } from './contexts/syntheticsContext' import type { CiVisibilityContext } from './contexts/ciVisibilityContext' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' import type { ViewHistory } from './contexts/viewHistory' import { SessionReplayState, type RumSessionManager } from './rumSessionManager' -import type { UrlContexts } from './contexts/urlContexts' import type { RumConfiguration, FeatureFlagsForEvents } from './configuration' import type { ActionContexts } from './action/actionCollection' import type { DisplayContext } from './contexts/displayContext' import type { CommonContext } from './contexts/commonContext' import type { ModifiableFieldPaths } from './limitModification' import { limitModification } from './limitModification' +import type { FeatureFlagContexts } from './contexts/featureFlagContext' +import type { UrlContexts } from './contexts/urlContexts' // replaced at build time declare const __BUILD_ENV__SDK_VERSION__: string @@ -63,6 +65,7 @@ type Mutable = { -readonly [P in keyof T]: T[P] } export function startRumAssembly( configuration: RumConfiguration, lifeCycle: LifeCycle, + hooks: Hooks, sessionManager: RumSessionManager, viewHistory: ViewHistory, urlContexts: UrlContexts, @@ -156,7 +159,7 @@ export function startRumAssembly( const commonContext = savedCommonContext || getCommonContext() const actionId = actionContexts.findActionId(startTime) - const rumContext: CommonProperties = { + const rumContext: Partial = { _dd: { format_version: 2, drift: currentDrift(), @@ -170,8 +173,6 @@ export function startRumAssembly( id: configuration.applicationId, }, date: timeStampNow(), - service: viewHistoryEntry.service || configuration.service, - version: viewHistoryEntry.version || configuration.version, source: 'browser', session: { id: session.id, @@ -181,12 +182,6 @@ export function startRumAssembly( ? SessionType.CI_TEST : SessionType.USER, }, - view: { - id: viewHistoryEntry.id, - name: viewHistoryEntry.name, - url: urlContext.url, - referrer: urlContext.referrer, - }, feature_flags: findFeatureFlagsContext( rawRumEvent, startTime, @@ -198,10 +193,15 @@ export function startRumAssembly( ci_test: ciVisibilityContext.get(), display: displayContext.get(), connectivity: getConnectivity(), + context: commonContext.context, } - const serverRumEvent = combine(rumContext, rawRumEvent) as RumEvent & Context - serverRumEvent.context = combine(commonContext.context, viewHistoryEntry.context, customerContext) + const serverRumEvent = combine( + rumContext, + hooks.triggerHook(HookNames.Assemble, { eventType: rawRumEvent.type, startTime }) as RumEvent & Context, + { context: customerContext }, + rawRumEvent + ) as RumEvent & Context if (!('has_replay' in serverRumEvent.session)) { ;(serverRumEvent.session as Mutable).has_replay = commonContext.hasReplay @@ -219,7 +219,7 @@ export function startRumAssembly( } if (shouldSend(serverRumEvent, configuration.beforeSend, domainContext, eventRateLimiters)) { - if (isEmptyObject(serverRumEvent.context)) { + if (isEmptyObject(serverRumEvent.context!)) { delete serverRumEvent.context } lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, serverRumEvent) diff --git a/packages/rum-core/src/domain/contexts/urlContexts.spec.ts b/packages/rum-core/src/domain/contexts/urlContexts.spec.ts index f63598be28..d85357deb8 100644 --- a/packages/rum-core/src/domain/contexts/urlContexts.spec.ts +++ b/packages/rum-core/src/domain/contexts/urlContexts.spec.ts @@ -4,6 +4,8 @@ import { clocksNow, relativeToClocks } from '@datadog/browser-core' import { setupLocationObserver } from '../../../test' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { ViewCreatedEvent, ViewEndedEvent } from '../view/trackViews' +import type { Hooks } from '../../hooks' +import { HookNames, createHooks } from '../../hooks' import { startUrlContexts, type UrlContexts } from './urlContexts' describe('urlContexts', () => { @@ -11,13 +13,15 @@ describe('urlContexts', () => { let changeLocation: (to: string) => void let urlContexts: UrlContexts let clock: Clock + let hooks: Hooks beforeEach(() => { clock = mockClock() + hooks = createHooks() const setupResult = setupLocationObserver('http://fake-url.com') changeLocation = setupResult.changeLocation - urlContexts = startUrlContexts(lifeCycle, setupResult.locationChangeObservable, setupResult.fakeLocation) + urlContexts = startUrlContexts(lifeCycle, hooks, setupResult.locationChangeObservable, setupResult.fakeLocation) registerCleanupTask(() => { urlContexts.stop() @@ -131,4 +135,23 @@ describe('urlContexts', () => { expect(urlContexts.findUrl()).toBeUndefined() }) + + describe('assemble hook', () => { + it('should add url properties from the history', () => { + lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, { + startClocks: relativeToClocks(0 as RelativeTime), + } as ViewCreatedEvent) + + const event = hooks.triggerHook(HookNames.Assemble, { eventType: 'view', startTime: 0 as RelativeTime }) + + expect(event).toEqual( + jasmine.objectContaining({ + view: { + url: jasmine.any(String), + referrer: jasmine.any(String), + }, + }) + ) + }) + }) }) diff --git a/packages/rum-core/src/domain/contexts/urlContexts.ts b/packages/rum-core/src/domain/contexts/urlContexts.ts index 3f56393d83..23c1f95d1c 100644 --- a/packages/rum-core/src/domain/contexts/urlContexts.ts +++ b/packages/rum-core/src/domain/contexts/urlContexts.ts @@ -3,6 +3,8 @@ import { SESSION_TIME_OUT_DELAY, relativeNow, createValueHistory } from '@datado import type { LocationChange } from '../../browser/locationChangeObservable' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' +import type { PartialRumEvent, Hooks } from '../../hooks' +import { HookNames } from '../../hooks' /** * We want to attach to an event: @@ -26,6 +28,7 @@ export interface UrlContexts { export function startUrlContexts( lifeCycle: LifeCycle, + hooks: Hooks, locationChangeObservable: Observable, location: Location ) { @@ -71,6 +74,18 @@ export function startUrlContexts( } } + hooks.register(HookNames.Assemble, ({ startTime, eventType }): PartialRumEvent | undefined => { + const { url, referrer } = urlContextHistory.find(startTime)! + + return { + type: eventType, + view: { + url, + referrer, + }, + } + }) + return { findUrl: (startTime?: RelativeTime) => urlContextHistory.find(startTime), getAllEntries: () => urlContextHistory.getAllEntries(), diff --git a/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts b/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts index d437fdbfca..067f0154fe 100644 --- a/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts +++ b/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts @@ -2,6 +2,7 @@ import { Observable, deepClone } from '@datadog/browser-core' import { mockRumConfiguration, setupLocationObserver } from '../../../test' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' +import type { RumConfiguration } from '../configuration' import type { ViewCreatedEvent, ViewEvent, ViewOptions, ViewEndedEvent } from './trackViews' import { trackViews } from './trackViews' @@ -10,12 +11,16 @@ export type ViewTest = ReturnType interface ViewTrackingContext { lifeCycle: LifeCycle initialLocation?: string + partialConfig?: Partial } -export function setupViewTest({ lifeCycle, initialLocation }: ViewTrackingContext, initialViewOptions?: ViewOptions) { +export function setupViewTest( + { lifeCycle, initialLocation, partialConfig }: ViewTrackingContext, + initialViewOptions?: ViewOptions +) { const domMutationObservable = new Observable() const windowOpenObservable = new Observable() - const configuration = mockRumConfiguration() + const configuration = mockRumConfiguration(partialConfig) const { locationChangeObservable, changeLocation } = setupLocationObserver(initialLocation) const { diff --git a/packages/rum-core/src/domain/view/trackViews.spec.ts b/packages/rum-core/src/domain/view/trackViews.spec.ts index b79b53fd1d..297b58b11b 100644 --- a/packages/rum-core/src/domain/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/view/trackViews.spec.ts @@ -970,3 +970,43 @@ describe('view event count', () => { }) }) }) + +describe('service and version', () => { + const lifeCycle = new LifeCycle() + let clock: Clock + let viewTest: ViewTest + + beforeEach(() => { + clock = mockClock() + + registerCleanupTask(() => { + viewTest.stop() + clock.cleanup() + resetExperimentalFeatures() + }) + }) + + it('should come from the init configuration by default', () => { + viewTest = setupViewTest({ lifeCycle, partialConfig: { service: 'service', version: 'version' } }) + + const { getViewUpdate } = viewTest + + expect(getViewUpdate(0).service).toEqual('service') + expect(getViewUpdate(0).version).toEqual('version') + }) + + it('should come from the view option if defined', () => { + viewTest = setupViewTest( + { lifeCycle, partialConfig: { service: 'service', version: 'version' } }, + { + service: 'view service', + version: 'view version', + } + ) + + const { getViewUpdate } = viewTest + + expect(getViewUpdate(0).service).toEqual('view service') + expect(getViewUpdate(0).version).toEqual('view version') + }) +}) diff --git a/packages/rum-core/src/domain/view/trackViews.ts b/packages/rum-core/src/domain/view/trackViews.ts index 8474729f44..9fbb8c2168 100644 --- a/packages/rum-core/src/domain/view/trackViews.ts +++ b/packages/rum-core/src/domain/view/trackViews.ts @@ -215,20 +215,13 @@ function newView( const contextManager = createContextManager() let sessionIsActive = true - let name: string | undefined - let service: string | undefined - let version: string | undefined - let context: Context | undefined - - if (viewOptions) { - name = viewOptions.name - service = viewOptions.service || undefined - version = viewOptions.version || undefined - if (viewOptions.context) { - context = viewOptions.context - // use ContextManager to update the context so we always sanitize it - contextManager.setContext(context) - } + let name = viewOptions?.name + const service = viewOptions?.service || configuration.service + const version = viewOptions?.version || configuration.version + const context = viewOptions?.context + + if (context) { + contextManager.setContext(context) } const viewCreatedEvent = { diff --git a/packages/rum-core/src/domain/view/viewCollection.spec.ts b/packages/rum-core/src/domain/view/viewCollection.spec.ts index dc914b75fe..3badb7dcb3 100644 --- a/packages/rum-core/src/domain/view/viewCollection.spec.ts +++ b/packages/rum-core/src/domain/view/viewCollection.spec.ts @@ -1,11 +1,12 @@ import { Observable } from '@datadog/browser-core' import type { Duration, RelativeTime, ServerDuration, TimeStamp } from '@datadog/browser-core' -import { registerCleanupTask } from '@datadog/browser-core/test' +import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' import type { RecorderApi } from '../../boot/rumPublicApi' import { collectAndValidateRawRumEvents, mockPageStateHistory, mockRumConfiguration, + mockViewHistory, noopRecorderApi, } from '../../../test' import type { RawRumEvent, RawRumViewEvent } from '../../rawRumEvent.types' @@ -15,8 +16,12 @@ import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import { PageState } from '../contexts/pageStateHistory' import type { RumConfiguration } from '../configuration' import type { LocationChange } from '../../browser/locationChangeObservable' -import type { ViewEvent } from './trackViews' +import type { Hooks } from '../../hooks' +import { HookNames, createHooks } from '../../hooks' + +import type { ViewHistoryEntry } from '../contexts/viewHistory' import { startViewCollection } from './viewCollection' +import type { ViewEvent } from './trackViews' const VIEW: ViewEvent = { customTimings: { @@ -69,17 +74,25 @@ const VIEW: ViewEvent = { describe('viewCollection', () => { const lifeCycle = new LifeCycle() + let hooks: Hooks let getReplayStatsSpy: jasmine.Spy let rawRumEvents: Array> = [] - function setupViewCollection(partialConfiguration: Partial = {}) { + function setupViewCollection( + partialConfiguration: Partial = {}, + viewHistoryEntry?: ViewHistoryEntry + ) { + hooks = createHooks() + const viewHistory = mockViewHistory(viewHistoryEntry) getReplayStatsSpy = jasmine.createSpy() const domMutationObservable = new Observable() const windowOpenObservable = new Observable() const locationChangeObservable = new Observable() + const clock = mockClock() const collectionResult = startViewCollection( lifeCycle, + hooks, mockRumConfiguration(partialConfiguration), location, domMutationObservable, @@ -94,14 +107,18 @@ describe('viewCollection', () => { { ...noopRecorderApi, getReplayStats: getReplayStatsSpy, - } + }, + viewHistory ) rawRumEvents = collectAndValidateRawRumEvents(lifeCycle) registerCleanupTask(() => { collectionResult.stop() + viewHistory.stop() + clock.cleanup() }) + return collectionResult } it('should create view from view update', () => { @@ -273,4 +290,33 @@ describe('viewCollection', () => { ).toBe(true) }) }) + + describe('assembly hook', () => { + it('should add view properties from the history', () => { + const viewHistoryEntry: ViewHistoryEntry = { + service: 'service', + version: 'version', + context: { myContext: 'foo' }, + id: 'id', + name: 'name', + startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, + } + + setupViewCollection({ trackViewsManually: true }, viewHistoryEntry) + + const event = hooks.triggerHook(HookNames.Assemble, { eventType: 'view', startTime: 0 as RelativeTime }) + + expect(event).toEqual( + jasmine.objectContaining({ + service: 'service', + version: 'version', + context: { myContext: 'foo' }, + view: { + id: 'id', + name: 'name', + }, + }) + ) + }) + }) }) diff --git a/packages/rum-core/src/domain/view/viewCollection.ts b/packages/rum-core/src/domain/view/viewCollection.ts index e71af225b7..0126475674 100644 --- a/packages/rum-core/src/domain/view/viewCollection.ts +++ b/packages/rum-core/src/domain/view/viewCollection.ts @@ -9,20 +9,25 @@ import { LifeCycleEventType } from '../lifeCycle' import type { LocationChange } from '../../browser/locationChangeObservable' import type { RumConfiguration } from '../configuration' import type { PageStateHistory } from '../contexts/pageStateHistory' -import type { ViewEvent, ViewOptions } from './trackViews' +import type { ViewHistory } from '../contexts/viewHistory' +import type { Hooks, PartialRumEvent } from '../../hooks' +import { HookNames } from '../../hooks' import { trackViews } from './trackViews' +import type { ViewEvent, ViewOptions } from './trackViews' import type { CommonViewMetrics } from './viewMetrics/trackCommonViewMetrics' import type { InitialViewMetrics } from './viewMetrics/trackInitialViewMetrics' export function startViewCollection( lifeCycle: LifeCycle, + hooks: Hooks, configuration: RumConfiguration, location: Location, domMutationObservable: Observable, - pageOpenObserable: Observable, + pageOpenObservable: Observable, locationChangeObservable: Observable, pageStateHistory: PageStateHistory, recorderApi: RecorderApi, + viewHistory: ViewHistory, initialViewOptions?: ViewOptions ) { lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) => @@ -31,11 +36,27 @@ export function startViewCollection( processViewUpdate(view, configuration, recorderApi, pageStateHistory) ) ) + + hooks.register(HookNames.Assemble, ({ startTime, eventType }): PartialRumEvent | undefined => { + const { service, version, id, name, context } = viewHistory.findView(startTime)! + + return { + type: eventType, + service, + version, + context, + view: { + id, + name, + }, + } + }) + return trackViews( location, lifeCycle, domMutationObservable, - pageOpenObserable, + pageOpenObservable, configuration, locationChangeObservable, !configuration.trackViewsManually, diff --git a/packages/rum-core/src/hooks.spec.ts b/packages/rum-core/src/hooks.spec.ts new file mode 100644 index 0000000000..8a23677479 --- /dev/null +++ b/packages/rum-core/src/hooks.spec.ts @@ -0,0 +1,52 @@ +import type { RelativeTime } from '@datadog/browser-core' +import { HookNames, createHooks } from './hooks' + +describe('startHooks', () => { + let hooks: ReturnType + const hookParams = { eventType: 'error', startTime: 1011 as RelativeTime } as any + beforeEach(() => { + hooks = createHooks() + }) + + it('unregister a hook callback', () => { + const callback = jasmine.createSpy().and.returnValue({ service: 'foo' }) + + const { unregister } = hooks.register(HookNames.Assemble, callback) + unregister() + + const result = hooks.triggerHook(HookNames.Assemble, hookParams) + + expect(callback).not.toHaveBeenCalled() + expect(result).toEqual(undefined) + }) + + describe('assemble hook', () => { + it('combines results from multiple callbacks', () => { + const callback1 = jasmine.createSpy().and.returnValue({ type: 'action', service: 'foo' }) + const callback2 = jasmine.createSpy().and.returnValue({ type: 'action', version: 'bar' }) + + hooks.register(HookNames.Assemble, callback1) + hooks.register(HookNames.Assemble, callback2) + + const result = hooks.triggerHook(HookNames.Assemble, hookParams) + + expect(result).toEqual({ type: 'action', service: 'foo', version: 'bar' }) + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + }) + + it('does not combine undefined results from callbacks', () => { + const callback1 = jasmine.createSpy().and.returnValue({ type: 'action', service: 'foo' }) + const callback2 = jasmine.createSpy().and.returnValue(undefined) + + hooks.register(HookNames.Assemble, callback1) + hooks.register(HookNames.Assemble, callback2) + + const result = hooks.triggerHook(HookNames.Assemble, hookParams) + + expect(result).toEqual({ type: 'action', service: 'foo' }) + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/rum-core/src/hooks.ts b/packages/rum-core/src/hooks.ts new file mode 100644 index 0000000000..1d33053aef --- /dev/null +++ b/packages/rum-core/src/hooks.ts @@ -0,0 +1,59 @@ +import { combine } from '@datadog/browser-core' +import type { RelativeTime } from '@datadog/browser-core' +import type { RumEvent } from './rumEvent.types' + +export const enum HookNames { + Assemble, +} + +type RecursivePartialExcept = { + [P in keyof T]?: T[P] extends object ? RecursivePartialExcept : T[P] +} & { + [P in K]: T[P] +} + +// Define a partial RUM event type. +// Ensuring the `type` field is always present improves type checking, especially in conditional logic in hooks (e.g., `if (eventType === 'view')`). +export type PartialRumEvent = RecursivePartialExcept + +// This is a workaround for an issue occurring when the Browser SDK is included in a TypeScript +// project configured with `isolatedModules: true`. Even if the const enum is declared in this +// module, we cannot use it directly to define the EventMap interface keys (TS error: "Cannot access +// ambient const enums when the '--isolatedModules' flag is provided."). +declare const HookNamesAsConst: { + ASSEMBLE: HookNames.Assemble +} +export type HookCallbackMap = { + [HookNamesAsConst.ASSEMBLE]: (param: { + eventType: RumEvent['type'] + startTime: RelativeTime + }) => PartialRumEvent | undefined +} + +export type Hooks = ReturnType + +export function createHooks() { + const callbacks: { [K in HookNames]?: Array } = {} + + return { + register(hookName: K, callback: HookCallbackMap[K]) { + if (!callbacks[hookName]) { + callbacks[hookName] = [] + } + callbacks[hookName]!.push(callback) + return { + unregister: () => { + callbacks[hookName] = callbacks[hookName]!.filter((cb) => cb !== callback) + }, + } + }, + triggerHook( + hookName: K, + param: Parameters[0] + ): ReturnType { + const hookCallbacks = callbacks[hookName] || [] + const results = hookCallbacks.map((callback) => callback(param)) + return combine(...(results as [object, object])) as ReturnType + }, + } +} diff --git a/packages/rum-core/test/formatValidation.ts b/packages/rum-core/test/formatValidation.ts index 645673a851..cd1d6f07a2 100644 --- a/packages/rum-core/test/formatValidation.ts +++ b/packages/rum-core/test/formatValidation.ts @@ -23,7 +23,7 @@ export function collectAndValidateRawRumEvents(lifeCycle: LifeCycle) { function validateRumEventFormat(rawRumEvent: RawRumEvent) { const fakeId = '00000000-aaaa-0000-aaaa-000000000000' - const fakeContext: CommonProperties = { + const fakeContext: Partial = { _dd: { format_version: 2, drift: 0, @@ -51,6 +51,7 @@ function validateRumEventFormat(rawRumEvent: RawRumEvent) { interfaces: ['wifi'], effective_type: '4g', }, + context: {}, } validateRumFormat(combine(fakeContext as CommonProperties & Context, rawRumEvent)) } diff --git a/packages/rum-core/test/mockContexts.ts b/packages/rum-core/test/mockContexts.ts index abd8a39020..314805087a 100644 --- a/packages/rum-core/test/mockContexts.ts +++ b/packages/rum-core/test/mockContexts.ts @@ -2,7 +2,7 @@ import { noop } from '@datadog/browser-core' import type { ActionContexts } from '../src/domain/action/trackClickActions' import type { DisplayContext } from '../src/domain/contexts/displayContext' import type { UrlContexts } from '../src/domain/contexts/urlContexts' -import type { ViewHistory } from '../src/domain/contexts/viewHistory' +import type { ViewHistory, ViewHistoryEntry } from '../src/domain/contexts/viewHistory' import type { FeatureFlagContexts } from '../src/domain/contexts/featureFlagContext' export function mockUrlContexts(fakeLocation: Location = location): UrlContexts { @@ -17,9 +17,9 @@ export function mockUrlContexts(fakeLocation: Location = location): UrlContexts } } -export function mockViewHistory(): ViewHistory { +export function mockViewHistory(view?: Partial): ViewHistory { return { - findView: () => undefined, + findView: () => view as ViewHistoryEntry, stop: noop, getAllEntries: () => [], getDeletedEntries: () => [],