diff --git a/packages/core/src/tools/utils.ts b/packages/core/src/tools/utils.ts index 5637c53224..a1df461b71 100644 --- a/packages/core/src/tools/utils.ts +++ b/packages/core/src/tools/utils.ts @@ -527,6 +527,13 @@ type Combined = A extends null ? B : B extends null ? A : Merged export function combine(a: A, b: B): Combined export function combine(a: A, b: B, c: C): Combined, C> export function combine(a: A, b: B, c: C, d: D): Combined, C>, D> +export function combine( + a: A, + b: B, + c: C, + d: D, + e: E +): Combined, C>, D>, E> export function combine(...sources: any[]): unknown { let destination: any diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index 0eac37e3e9..24ef7c432d 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -1,15 +1,16 @@ -import { RelativeTime, Configuration, Observable } from '@datadog/browser-core' +import { RelativeTime, Configuration, Observable, noop, relativeNow } from '@datadog/browser-core' import { RumSession } from '@datadog/browser-rum-core' import { createRumSessionMock, RumSessionMock } from '../../test/mockRumSession' import { isIE } from '../../../core/test/specHelper' import { noopRecorderApi, setup, TestSetupBuilder } from '../../test/specHelper' -import { RumPerformanceNavigationTiming } from '../browser/performanceCollection' +import { RumPerformanceNavigationTiming, RumPerformanceEntry } from '../browser/performanceCollection' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' import { SESSION_KEEP_ALIVE_INTERVAL, THROTTLE_VIEW_UPDATE_PERIOD } from '../domain/rumEventsCollection/view/trackViews' import { startViewCollection } from '../domain/rumEventsCollection/view/viewCollection' import { RumEvent } from '../rumEvent.types' import { LocationChange } from '../browser/locationChangeObservable' +import { startLongTaskCollection } from '../domain/rumEventsCollection/longTask/longTaskCollection' import { startRumEventCollection } from './startRum' function collectServerEvents(lifeCycle: LifeCycle) { @@ -33,7 +34,9 @@ function startRum( applicationId, lifeCycle, configuration, + location, session, + locationChangeObservable, () => ({ context: {}, user: {}, @@ -48,6 +51,8 @@ function startRum( foregroundContexts, noopRecorderApi ) + + startLongTaskCollection(lifeCycle) return { stop: () => { rumEventCollectionStop() @@ -190,7 +195,7 @@ describe('rum session keep alive', () => { }) }) -describe('rum view url', () => { +describe('rum events url', () => { const FAKE_NAVIGATION_ENTRY: RumPerformanceNavigationTiming = { domComplete: 456 as RelativeTime, domContentLoadedEventEnd: 345 as RelativeTime, @@ -232,6 +237,34 @@ describe('rum view url', () => { setupBuilder.cleanup() }) + it('should attach the url corresponding to the start of the event', () => { + const { lifeCycle, clock, changeLocation } = setupBuilder + .withFakeClock() + .withFakeLocation('http://foo.com/') + .build() + clock.tick(10) + changeLocation('http://foo.com/?bar=bar') + clock.tick(10) + changeLocation('http://foo.com/?bar=qux') + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, { + entryType: 'longtask', + startTime: relativeNow() - 5, + toJSON: noop, + duration: 5, + } as RumPerformanceEntry) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(serverRumEvents.length).toBe(3) + const [firstViewUpdate, longTaskEvent, lastViewUpdate] = serverRumEvents + + expect(firstViewUpdate.view.url).toBe('http://foo.com/') + expect(lastViewUpdate.view.url).toBe('http://foo.com/') + + expect(longTaskEvent.view.url).toBe('http://foo.com/?bar=bar') + }) + it('should keep the same URL when updating a view ended by a URL change', () => { const { changeLocation } = setupBuilder.withFakeLocation('http://foo.com/').build() diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 4596a41ee3..3cfaeb44fd 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -1,4 +1,4 @@ -import { combine, Configuration, InternalMonitoring } from '@datadog/browser-core' +import { combine, Configuration, InternalMonitoring, Observable } from '@datadog/browser-core' import { createDOMMutationObservable } from '../browser/domMutationObservable' import { startPerformanceCollection } from '../browser/performanceCollection' import { startRumAssembly } from '../domain/assembly' @@ -15,7 +15,8 @@ import { startViewCollection } from '../domain/rumEventsCollection/view/viewColl import { RumSession, startRumSession } from '../domain/rumSession' import { CommonContext } from '../rawRumEvent.types' import { startRumBatch } from '../transport/batch' -import { createLocationChangeObservable } from '../browser/locationChangeObservable' +import { startUrlContexts } from '../domain/urlContexts' +import { createLocationChangeObservable, LocationChange } from '../browser/locationChangeObservable' import { RecorderApi, RumInitConfiguration } from './rumPublicApi' export function startRum( @@ -41,11 +42,13 @@ export function startRum( ) ) - const { parentContexts, foregroundContexts } = startRumEventCollection( + const { parentContexts, foregroundContexts, urlContexts } = startRumEventCollection( initConfiguration.applicationId, lifeCycle, configuration, + location, session, + locationChangeObservable, getCommonContext ) @@ -69,7 +72,7 @@ export function startRum( startRequestCollection(lifeCycle, configuration) startPerformanceCollection(lifeCycle, configuration) - const internalContext = startInternalContext(initConfiguration.applicationId, session, parentContexts) + const internalContext = startInternalContext(initConfiguration.applicationId, session, parentContexts, urlContexts) return { addAction, @@ -87,18 +90,22 @@ export function startRumEventCollection( applicationId: string, lifeCycle: LifeCycle, configuration: Configuration, + location: Location, session: RumSession, + locationChangeObservable: Observable, getCommonContext: () => CommonContext ) { const parentContexts = startParentContexts(lifeCycle, session) + const urlContexts = startUrlContexts(lifeCycle, locationChangeObservable, location) const foregroundContexts = startForegroundContexts() const batch = startRumBatch(configuration, lifeCycle) - startRumAssembly(applicationId, configuration, lifeCycle, session, parentContexts, getCommonContext) + startRumAssembly(applicationId, configuration, lifeCycle, session, parentContexts, urlContexts, getCommonContext) return { parentContexts, foregroundContexts, + urlContexts, stop: () => { // prevent batch from previous tests to keep running and send unwanted requests // could be replaced by stopping all the component when they will all have a stop method diff --git a/packages/rum-core/src/domain/assembly.spec.ts b/packages/rum-core/src/domain/assembly.spec.ts index e98aaffb57..4a121d0a18 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -35,17 +35,23 @@ describe('rum assembly', () => { }, view: { id: 'abcde', - referrer: 'url', - url: 'url', }, }), }) - .beforeBuild(({ applicationId, configuration, lifeCycle, session, parentContexts }) => { + .beforeBuild(({ applicationId, configuration, lifeCycle, session, parentContexts, urlContexts }) => { serverRumEvents = [] lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (serverRumEvent) => serverRumEvents.push(serverRumEvent) ) - startRumAssembly(applicationId, configuration, lifeCycle, session, parentContexts, () => commonContext) + startRumAssembly( + applicationId, + configuration, + lifeCycle, + session, + parentContexts, + urlContexts, + () => commonContext + ) }) }) @@ -431,15 +437,22 @@ describe('rum assembly', () => { notifyRawRumEvent(lifeCycle, { rawRumEvent: createRawRumEvent(RumEventType.ACTION), }) - expect(serverRumEvents[0].view).toEqual({ - id: 'abcde', - referrer: 'url', - url: 'url', - }) + expect(serverRumEvents[0].view.id).toBe('abcde') expect(serverRumEvents[0].session.id).toBe('1234') }) }) + describe('url context', () => { + it('should be merged with event attributes', () => { + const { lifeCycle, fakeLocation } = setupBuilder.build() + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.ACTION), + }) + expect(serverRumEvents[0].view.url).toBe(fakeLocation.href!) + expect(serverRumEvents[0].view.referrer).toBe(document.referrer) + }) + }) + describe('event generation condition', () => { it('when tracked, it should generate event', () => { const { lifeCycle } = setupBuilder.build() diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index a764b53b85..b27555e8ce 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -29,6 +29,7 @@ import { RumEvent } from '../rumEvent.types' import { LifeCycle, LifeCycleEventType } from './lifeCycle' import { ParentContexts } from './parentContexts' import { RumSession, RumSessionPlan } from './rumSession' +import { UrlContexts } from './urlContexts' export interface BrowserWindow extends Window { _DATADOG_SYNTHETICS_PUBLIC_ID?: string @@ -65,6 +66,7 @@ export function startRumAssembly( lifeCycle: LifeCycle, session: RumSession, parentContexts: ParentContexts, + urlContexts: UrlContexts, getCommonContext: () => CommonContext ) { const reportError = (error: RawError) => { @@ -80,7 +82,8 @@ export function startRumAssembly( LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, ({ startTime, rawRumEvent, domainContext, savedCommonContext, customerContext }) => { const viewContext = parentContexts.findView(startTime) - if (session.isTracked() && viewContext && viewContext.session.id === session.getId()) { + const urlContext = urlContexts.findUrl(startTime) + if (session.isTracked() && viewContext && urlContext && viewContext.session.id === session.getId()) { const actionContext = parentContexts.findAction(startTime) const commonContext = savedCommonContext || getCommonContext() const rumContext: RumContext = { @@ -103,8 +106,8 @@ export function startRumAssembly( synthetics: getSyntheticsContext(), } const serverRumEvent = (needToAssembleWithAction(rawRumEvent) - ? combine(rumContext, viewContext, actionContext, rawRumEvent) - : combine(rumContext, viewContext, rawRumEvent)) as RumEvent & Context + ? combine(rumContext, urlContext, viewContext, actionContext, rawRumEvent) + : combine(rumContext, urlContext, viewContext, rawRumEvent)) as RumEvent & Context serverRumEvent.context = combine(commonContext.context, customerContext) diff --git a/packages/rum-core/src/domain/contextHistory.spec.ts b/packages/rum-core/src/domain/contextHistory.spec.ts index fef4021ada..29b870a454 100644 --- a/packages/rum-core/src/domain/contextHistory.spec.ts +++ b/packages/rum-core/src/domain/contextHistory.spec.ts @@ -5,12 +5,12 @@ import { CLEAR_OLD_CONTEXTS_INTERVAL, ContextHistory } from './contextHistory' const EXPIRE_DELAY = 10 * ONE_MINUTE describe('contextHistory', () => { - let contextHistory: ContextHistory<{ value: string }, { value: string }> + let contextHistory: ContextHistory<{ value: string }> let clock: Clock beforeEach(() => { clock = mockClock() - contextHistory = new ContextHistory((raw) => ({ value: raw.value }), EXPIRE_DELAY) + contextHistory = new ContextHistory(EXPIRE_DELAY) }) afterEach(() => { diff --git a/packages/rum-core/src/domain/contextHistory.ts b/packages/rum-core/src/domain/contextHistory.ts index 9f9bf92be1..22fb937369 100644 --- a/packages/rum-core/src/domain/contextHistory.ts +++ b/packages/rum-core/src/domain/contextHistory.ts @@ -8,22 +8,22 @@ interface PreviousContext { export const CLEAR_OLD_CONTEXTS_INTERVAL = ONE_MINUTE -export class ContextHistory { - private current: Raw | undefined +export class ContextHistory { + private current: Context | undefined private currentStart: RelativeTime | undefined - private previousContexts: Array> = [] + private previousContexts: Array> = [] private clearOldContextsInterval: number - constructor(private buildContext: (r: Raw) => Built, private expireDelay: number) { + constructor(private expireDelay: number) { this.clearOldContextsInterval = setInterval(() => this.clearOldContexts(), CLEAR_OLD_CONTEXTS_INTERVAL) } find(startTime?: RelativeTime) { - if (startTime === undefined) { - return this.current ? this.buildContext(this.current) : undefined - } - if (this.current !== undefined && this.currentStart !== undefined && startTime >= this.currentStart) { - return this.buildContext(this.current) + if ( + startTime === undefined || + (this.current !== undefined && this.currentStart !== undefined && startTime >= this.currentStart) + ) { + return this.current } for (const previousContext of this.previousContexts) { if (startTime > previousContext.endTime) { @@ -36,7 +36,7 @@ export class ContextHistory { return undefined } - setCurrent(current: Raw, startTime: RelativeTime) { + setCurrent(current: Context, startTime: RelativeTime) { this.current = current this.currentStart = startTime } @@ -54,7 +54,7 @@ export class ContextHistory { if (this.current !== undefined && this.currentStart !== undefined) { this.previousContexts.unshift({ endTime, - context: this.buildContext(this.current), + context: this.current, startTime: this.currentStart, }) this.clearCurrent() diff --git a/packages/rum-core/src/domain/internalContext.spec.ts b/packages/rum-core/src/domain/internalContext.spec.ts index 97b5cf7e4c..8cf6315199 100644 --- a/packages/rum-core/src/domain/internalContext.spec.ts +++ b/packages/rum-core/src/domain/internalContext.spec.ts @@ -1,11 +1,14 @@ import { createRumSessionMock } from 'packages/rum-core/test/mockRumSession' +import { RelativeTime } from '@datadog/browser-core' import { setup, TestSetupBuilder } from '../../test/specHelper' import { startInternalContext } from './internalContext' import { ParentContexts } from './parentContexts' +import { UrlContexts } from './urlContexts' describe('internal context', () => { let setupBuilder: TestSetupBuilder let parentContextsStub: Partial + let findUrlSpy: jasmine.Spy let internalContext: ReturnType beforeEach(() => { @@ -21,15 +24,14 @@ describe('internal context', () => { }, view: { id: 'abcde', - referrer: 'referrer', - url: 'url', }, }), } setupBuilder = setup() .withParentContexts(parentContextsStub) - .beforeBuild(({ applicationId, session, parentContexts }) => { - internalContext = startInternalContext(applicationId, session, parentContexts) + .beforeBuild(({ applicationId, session, parentContexts, urlContexts }) => { + findUrlSpy = spyOn(urlContexts, 'findUrl').and.callThrough() + internalContext = startInternalContext(applicationId, session, parentContexts, urlContexts) }) }) @@ -38,7 +40,7 @@ describe('internal context', () => { }) it('should return current internal context', () => { - setupBuilder.build() + const { fakeLocation } = setupBuilder.build() expect(internalContext.get()).toEqual({ application_id: 'appId', @@ -48,8 +50,8 @@ describe('internal context', () => { }, view: { id: 'abcde', - referrer: 'referrer', - url: 'url', + referrer: document.referrer, + url: fakeLocation.href!, }, }) }) @@ -66,5 +68,6 @@ describe('internal context', () => { expect(parentContextsStub.findView).toHaveBeenCalledWith(123) expect(parentContextsStub.findAction).toHaveBeenCalledWith(123) + expect(findUrlSpy).toHaveBeenCalledWith(123 as RelativeTime) }) }) diff --git a/packages/rum-core/src/domain/internalContext.ts b/packages/rum-core/src/domain/internalContext.ts index e1251bdf2c..de275a948d 100644 --- a/packages/rum-core/src/domain/internalContext.ts +++ b/packages/rum-core/src/domain/internalContext.ts @@ -2,16 +2,23 @@ import { RelativeTime } from '@datadog/browser-core' import { InternalContext } from '../rawRumEvent.types' import { ParentContexts } from './parentContexts' import { RumSession } from './rumSession' +import { UrlContexts } from './urlContexts' /** * Internal context keep returning v1 format * to not break compatibility with logs data format */ -export function startInternalContext(applicationId: string, session: RumSession, parentContexts: ParentContexts) { +export function startInternalContext( + applicationId: string, + session: RumSession, + parentContexts: ParentContexts, + urlContexts: UrlContexts +) { return { get: (startTime?: number): InternalContext | undefined => { const viewContext = parentContexts.findView(startTime as RelativeTime) - if (session.isTracked() && viewContext && viewContext.session.id) { + const urlContext = urlContexts.findUrl(startTime as RelativeTime) + if (session.isTracked() && viewContext && urlContext && viewContext.session.id) { const actionContext = parentContexts.findAction(startTime as RelativeTime) return { application_id: applicationId, @@ -21,7 +28,10 @@ export function startInternalContext(applicationId: string, session: RumSession, id: actionContext.action.id, } : undefined, - view: viewContext.view, + view: { + ...viewContext.view, + ...urlContext.view, + }, } } }, diff --git a/packages/rum-core/src/domain/parentContexts.spec.ts b/packages/rum-core/src/domain/parentContexts.spec.ts index 7501752d76..a7168b313d 100644 --- a/packages/rum-core/src/domain/parentContexts.spec.ts +++ b/packages/rum-core/src/domain/parentContexts.spec.ts @@ -18,10 +18,8 @@ describe('parentContexts', () => { function buildViewCreatedEvent(partialViewCreatedEvent: Partial = {}): ViewCreatedEvent { return { - location, startClocks, id: FAKE_ID, - referrer: 'http://foo.com', ...partialViewCreatedEvent, } } @@ -113,17 +111,6 @@ describe('parentContexts', () => { expect(parentContexts.findView()!.view.id).toEqual(newViewId) }) - it('should return the current url with the current view', () => { - const { lifeCycle, fakeLocation, changeLocation } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, buildViewCreatedEvent({ location: fakeLocation as Location })) - expect(parentContexts.findView()!.view.url).toBe('http://fake-url.com/') - - changeLocation('/foo') - - expect(parentContexts.findView()!.view.url).toBe('http://fake-url.com/foo') - }) - it('should return the view name with the view', () => { const { lifeCycle } = setupBuilder.build() diff --git a/packages/rum-core/src/domain/parentContexts.ts b/packages/rum-core/src/domain/parentContexts.ts index 8a8ce3969c..49d9056207 100644 --- a/packages/rum-core/src/domain/parentContexts.ts +++ b/packages/rum-core/src/domain/parentContexts.ts @@ -16,38 +16,20 @@ export interface ParentContexts { } export function startParentContexts(lifeCycle: LifeCycle, session: RumSession): ParentContexts { - const viewContextHistory = new ContextHistory( - buildCurrentViewContext, - VIEW_CONTEXT_TIME_OUT_DELAY - ) + const viewContextHistory = new ContextHistory(VIEW_CONTEXT_TIME_OUT_DELAY) - const actionContextHistory = new ContextHistory( - buildCurrentActionContext, - ACTION_CONTEXT_TIME_OUT_DELAY - ) + const actionContextHistory = new ContextHistory(ACTION_CONTEXT_TIME_OUT_DELAY) lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { - viewContextHistory.setCurrent( - { - sessionId: session.getId(), - ...view, - }, - view.startClocks.relative - ) + viewContextHistory.setCurrent(buildViewContext(view, session.getId()), view.startClocks.relative) }) lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) => { // A view can be updated after its end. We have to ensure that the view being updated is the // most recently created. const current = viewContextHistory.getCurrent() - if (current && current.id === view.id) { - viewContextHistory.setCurrent( - { - sessionId: current.sessionId, - ...view, - }, - view.startClocks.relative - ) + if (current && current.view.id === view.id) { + viewContextHistory.setCurrent(buildViewContext(view, current.session.id), view.startClocks.relative) } }) @@ -56,7 +38,7 @@ export function startParentContexts(lifeCycle: LifeCycle, session: RumSession): }) lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_CREATED, (action) => { - actionContextHistory.setCurrent(action, action.startClocks.relative) + actionContextHistory.setCurrent(buildActionContext(action), action.startClocks.relative) }) lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, (action: AutoAction) => { @@ -76,22 +58,20 @@ export function startParentContexts(lifeCycle: LifeCycle, session: RumSession): actionContextHistory.reset() }) - function buildCurrentViewContext(current: ViewCreatedEvent & { sessionId?: string }) { + function buildViewContext(view: ViewCreatedEvent, sessionId: string | undefined) { return { session: { - id: current.sessionId, + id: sessionId, }, view: { - id: current.id, - name: current.name, - referrer: current.referrer, - url: current.location.href, + id: view.id, + name: view.name, }, } } - function buildCurrentActionContext(current: AutoActionCreatedEvent) { - return { action: { id: current.id } } + function buildActionContext(action: AutoActionCreatedEvent) { + return { action: { id: action.id } } } return { diff --git a/packages/rum-core/src/domain/rumEventsCollection/action/trackActions.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/action/trackActions.spec.ts index 6f72964caf..698ae61d09 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/action/trackActions.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/action/trackActions.spec.ts @@ -80,9 +80,7 @@ describe('trackActions', () => { expect(createSpy).toHaveBeenCalled() lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { - location, id: 'fake', - referrer: 'http://foo.com', startClocks: (jasmine.any(Object) as unknown) as ClocksState, }) clock.tick(EXPIRE_DELAY) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts index 6184e5c830..5f7b86314a 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts @@ -45,44 +45,26 @@ describe('track views automatically', () => { }) describe('location changes', () => { - it('should update view location on search change', () => { - const { changeLocation } = setupBuilder.build() - const { getViewCreateCount, getViewCreate, getViewUpdate, getViewUpdateCount } = viewTest - - changeLocation('/foo?bar=qux') - - expect(getViewCreateCount()).toBe(1) - expect(getViewCreate(0).location.href).toMatch(/\/foo$/) - - const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) - expect(lastUpdate.location.href).toMatch(/\/foo\?bar=qux$/) - expect(lastUpdate.id).toBe(getViewCreate(0).id) - }) - it('should create new view on path change', () => { const { changeLocation } = setupBuilder.build() - const { getViewCreateCount, getViewCreate } = viewTest + const { getViewCreateCount } = viewTest expect(getViewCreateCount()).toBe(1) - expect(getViewCreate(0).location.href).toMatch(/\/foo$/) changeLocation('/bar') expect(getViewCreateCount()).toBe(2) - expect(getViewCreate(1).location.href).toMatch(/\/bar$/) }) it('should create new view on hash change from history', () => { const { changeLocation } = setupBuilder.build() - const { getViewCreateCount, getViewCreate } = viewTest + const { getViewCreateCount } = viewTest expect(getViewCreateCount()).toBe(1) - expect(getViewCreate(0).location.href).toMatch(/\/foo$/) changeLocation('/foo#bar') expect(getViewCreateCount()).toBe(2) - expect(getViewCreate(1).location.href).toMatch(/\/foo#bar$/) }) function mockGetElementById() { @@ -115,124 +97,6 @@ describe('track views automatically', () => { expect(getViewCreateCount()).toBe(2) }) }) - - describe('view referrer', () => { - it('should set the document referrer as referrer for the initial view', () => { - setupBuilder.build() - const { getViewCreate } = viewTest - - expect(getViewCreate(0).referrer).toEqual(document.referrer) - }) - - it('should set the previous view URL as referrer when a route change occurs', () => { - const { changeLocation } = setupBuilder.build() - const { getViewCreate } = viewTest - - changeLocation('/bar') - - expect(getViewCreate(1).referrer).toEqual(jasmine.stringMatching(/\/foo$/)) - }) - - it('should set the previous view URL as referrer when a the session is renewed', () => { - const { lifeCycle } = setupBuilder.build() - const { getViewCreate } = viewTest - - lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) - - expect(getViewCreate(1).referrer).toEqual(jasmine.stringMatching(/\/foo$/)) - }) - - it('should use the most up-to-date URL of the previous view as a referrer', () => { - const { changeLocation } = setupBuilder.build() - const { getViewCreate } = viewTest - - changeLocation('/foo?a=b') - changeLocation('/bar') - - expect(getViewCreate(1).referrer).toEqual(jasmine.stringMatching(/\/foo\?a=b$/)) - }) - }) -}) - -describe('track views manually', () => { - let setupBuilder: TestSetupBuilder - let viewTest: ViewTest - - beforeEach(() => { - setupBuilder = setup() - .withFakeLocation('/foo') - .withConfiguration({ trackViewsManually: true }) - .beforeBuild((buildContext) => { - viewTest = setupViewTest(buildContext) - return viewTest - }) - }) - - afterEach(() => { - setupBuilder.cleanup() - }) - - describe('location changes', () => { - it('should update view location on search change', () => { - const { changeLocation } = setupBuilder.build() - const { getViewCreateCount, getViewCreate, getViewUpdate, getViewUpdateCount } = viewTest - - changeLocation('/foo?bar=qux') - - expect(getViewCreateCount()).toBe(1) - expect(getViewCreate(0).location.href).toMatch(/\/foo$/) - - const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) - expect(lastUpdate.location.href).toMatch(/\/foo\?bar=qux$/) - expect(lastUpdate.id).toBe(getViewCreate(0).id) - }) - - it('should update view location on path change', () => { - const { changeLocation } = setupBuilder.build() - const { getViewCreateCount, getViewCreate, getViewUpdate, getViewUpdateCount } = viewTest - - changeLocation('/bar') - - expect(getViewCreateCount()).toBe(1) - expect(getViewCreate(0).location.href).toMatch(/\/foo$/) - - const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) - expect(lastUpdate.location.href).toMatch(/\/bar$/) - expect(lastUpdate.id).toBe(getViewCreate(0).id) - }) - }) - - describe('view referrer', () => { - it('should set the document referrer as referrer for the initial view', () => { - setupBuilder.build() - const { getViewCreate } = viewTest - - expect(getViewCreate(0).referrer).toEqual(document.referrer) - }) - - it('should set the previous view URL as referrer when starting a new view', () => { - const { changeLocation } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView } = viewTest - - startView() - changeLocation('/bar') - - const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) - expect(lastUpdate.referrer).toEqual(jasmine.stringMatching(/\/foo$/)) - expect(lastUpdate.location.href).toEqual(jasmine.stringMatching(/\/bar$/)) - }) - - it('should use the most up-to-date URL of the previous view as a referrer', () => { - const { changeLocation } = setupBuilder.build() - const { getViewCreate, startView } = viewTest - - changeLocation('/foo?a=b') - changeLocation('/bar') - startView() - - expect(getViewCreate(1).referrer).toEqual(jasmine.stringMatching(/\/bar$/)) - }) - }) }) describe('initial view', () => { diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts index 038fe5e392..fc1a86e995 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts @@ -12,6 +12,7 @@ import { TimeStamp, display, Observable, + Subscription, } from '@datadog/browser-core' import { ViewLoadingType, ViewCustomTimings } from '../../../rawRumEvent.types' @@ -25,7 +26,6 @@ export interface ViewEvent { id: string name?: string location: Readonly - referrer: string timings: Timings customTimings: ViewCustomTimings eventCounts: EventCounts @@ -41,8 +41,6 @@ export interface ViewEvent { export interface ViewCreatedEvent { id: string name?: string - location: Location - referrer: string startClocks: ClocksState } @@ -65,9 +63,11 @@ export function trackViews( let currentView = initialView const { stop: stopViewLifeCycle } = startViewLifeCycle() - const { unsubscribe: stopViewCollectionMode } = areViewsTrackedAutomatically - ? startAutomaticViewCollection(locationChangeObservable) - : startManualViewCollection(locationChangeObservable) + + let locationChangeSubscription: Subscription + if (areViewsTrackedAutomatically) { + locationChangeSubscription = renewViewOnLocationChange(locationChangeObservable) + } function trackInitialView(name?: string) { const initialView = newView( @@ -75,7 +75,6 @@ export function trackViews( domMutationObservable, location, ViewLoadingType.INITIAL_LOAD, - document.referrer, clocksOrigin(), name ) @@ -87,15 +86,7 @@ export function trackViews( } function trackViewChange(startClocks?: ClocksState, name?: string) { - return newView( - lifeCycle, - domMutationObservable, - location, - ViewLoadingType.ROUTE_CHANGE, - currentView.url, - startClocks, - name - ) + return newView(lifeCycle, domMutationObservable, location, ViewLoadingType.ROUTE_CHANGE, startClocks, name) } function startViewLifeCycle() { @@ -127,24 +118,14 @@ export function trackViews( } } - function startAutomaticViewCollection(locationChangeObservable: Observable) { + function renewViewOnLocationChange(locationChangeObservable: Observable) { return locationChangeObservable.subscribe(({ oldLocation, newLocation }) => { if (areDifferentLocation(oldLocation, newLocation)) { - // Renew view on location changes currentView.end() currentView.triggerUpdate() currentView = trackViewChange() return } - currentView.updateLocation(newLocation) - currentView.triggerUpdate() - }) - } - - function startManualViewCollection(locationChangeObservable: Observable) { - return locationChangeObservable.subscribe(({ newLocation }) => { - currentView.updateLocation(newLocation) - currentView.triggerUpdate() }) } @@ -159,7 +140,7 @@ export function trackViews( currentView = trackViewChange(startClocks, name) }, stop: () => { - stopViewCollectionMode() + locationChangeSubscription?.unsubscribe() stopInitialViewTracking() stopViewLifeCycle() currentView.end() @@ -172,7 +153,6 @@ function newView( domMutationObservable: Observable, initialLocation: Location, loadingType: ViewLoadingType, - referrer: string, startClocks: ClocksState = clocksNow(), name?: string ) { @@ -182,9 +162,9 @@ function newView( const customTimings: ViewCustomTimings = {} let documentVersion = 0 let endClocks: ClocksState | undefined - let location = { ...initialLocation } + const location = { ...initialLocation } - lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { id, name, startClocks, location, referrer }) + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { id, name, startClocks }) // Update the view every time the measures are changing const { throttled: scheduleViewUpdate, cancel: cancelScheduleViewUpdate } = throttle( @@ -216,7 +196,6 @@ function newView( name, loadingType, location, - referrer, startClocks, timings, duration: elapsed(startClocks.timeStamp, currentEnd), @@ -232,9 +211,6 @@ function newView( stopViewMetricsTracking() lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, { endClocks }) }, - getLocation() { - return location - }, triggerUpdate() { // cancel any pending view updates execution cancelScheduleViewUpdate() @@ -249,12 +225,6 @@ function newView( addTiming(name: string, time: TimeStamp) { customTimings[sanitizeTiming(name)] = elapsed(startClocks.timeStamp, time) }, - updateLocation(newLocation: Location) { - location = { ...newLocation } - }, - get url() { - return location.href - }, } } diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts index abeab415ea..3b85b05a45 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts @@ -26,7 +26,6 @@ const VIEW: ViewEvent = { loadingTime: 20 as Duration, loadingType: ViewLoadingType.INITIAL_LOAD, location: {} as Location, - referrer: '', startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, timings: { domComplete: 10 as Duration, diff --git a/packages/rum-core/src/domain/urlContexts.spec.ts b/packages/rum-core/src/domain/urlContexts.spec.ts new file mode 100644 index 0000000000..857eb7eec6 --- /dev/null +++ b/packages/rum-core/src/domain/urlContexts.spec.ts @@ -0,0 +1,153 @@ +import { relativeToClocks, RelativeTime } from '@datadog/browser-core' +import { setup, TestSetupBuilder } from '../../test/specHelper' +import { startUrlContexts, UrlContexts } from './urlContexts' +import { ViewCreatedEvent, ViewEndedEvent } from './rumEventsCollection/view/trackViews' +import { LifeCycleEventType } from './lifeCycle' + +describe('urlContexts', () => { + let setupBuilder: TestSetupBuilder + let urlContexts: UrlContexts + + beforeEach(() => { + setupBuilder = setup() + .withFakeLocation('http://fake-url.com') + .withFakeClock() + .beforeBuild(({ lifeCycle, locationChangeObservable, location }) => { + urlContexts = startUrlContexts(lifeCycle, locationChangeObservable, location) + return urlContexts + }) + }) + + afterEach(() => { + setupBuilder.cleanup() + }) + + it('should return undefined before the initial view', () => { + setupBuilder.build() + + expect(urlContexts.findUrl()).toBeUndefined() + }) + + it('should not create url context on location change before the initial view', () => { + const { changeLocation } = setupBuilder.build() + + changeLocation('/foo') + + expect(urlContexts.findUrl()).toBeUndefined() + }) + + it('should return current url and document referrer for initial view', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(0 as RelativeTime), + } as ViewCreatedEvent) + + const urlContext = urlContexts.findUrl()! + expect(urlContext.view.url).toBe('http://fake-url.com/') + expect(urlContext.view.referrer).toBe(document.referrer) + }) + + it('should update url context on location change', () => { + const { lifeCycle, changeLocation } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(0 as RelativeTime), + } as ViewCreatedEvent) + changeLocation('/foo') + + const urlContext = urlContexts.findUrl()! + expect(urlContext.view.url).toContain('http://fake-url.com/foo') + expect(urlContext.view.referrer).toBe(document.referrer) + }) + + it('should update url context on new view', () => { + const { lifeCycle, changeLocation } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(0 as RelativeTime), + } as ViewCreatedEvent) + changeLocation('/foo') + lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, { + endClocks: relativeToClocks(10 as RelativeTime), + } as ViewEndedEvent) + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(10 as RelativeTime), + } as ViewCreatedEvent) + + const urlContext = urlContexts.findUrl()! + expect(urlContext.view.url).toBe('http://fake-url.com/foo') + expect(urlContext.view.referrer).toBe('http://fake-url.com/') + }) + + it('should return the url context corresponding to the start time', () => { + const { lifeCycle, changeLocation, clock } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(0 as RelativeTime), + } as ViewCreatedEvent) + + clock.tick(10) + changeLocation('/foo') + lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, { + endClocks: relativeToClocks(10 as RelativeTime), + } as ViewEndedEvent) + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(10 as RelativeTime), + } as ViewCreatedEvent) + + clock.tick(10) + changeLocation('/foo#bar') + + clock.tick(10) + changeLocation('/qux') + lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, { + endClocks: relativeToClocks(30 as RelativeTime), + } as ViewEndedEvent) + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(30 as RelativeTime), + } as ViewCreatedEvent) + + expect(urlContexts.findUrl(5 as RelativeTime)).toEqual({ + view: { + url: 'http://fake-url.com/', + referrer: document.referrer, + }, + }) + expect(urlContexts.findUrl(15 as RelativeTime)).toEqual({ + view: { + url: 'http://fake-url.com/foo', + referrer: 'http://fake-url.com/', + }, + }) + expect(urlContexts.findUrl(25 as RelativeTime)).toEqual({ + view: { + url: 'http://fake-url.com/foo#bar', + referrer: 'http://fake-url.com/', + }, + }) + expect(urlContexts.findUrl(35 as RelativeTime)).toEqual({ + view: { + url: 'http://fake-url.com/qux', + referrer: 'http://fake-url.com/foo', + }, + }) + }) + + /** + * It could happen if there is an event happening just between view end and view creation + * (which seems unlikely) and this event would anyway be rejected by lack of view id + */ + it('should return undefined when no current view', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: relativeToClocks(0 as RelativeTime), + } as ViewCreatedEvent) + lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, { + endClocks: relativeToClocks(10 as RelativeTime), + } as ViewEndedEvent) + + expect(urlContexts.findUrl()).toBeUndefined() + }) +}) diff --git a/packages/rum-core/src/domain/urlContexts.ts b/packages/rum-core/src/domain/urlContexts.ts new file mode 100644 index 0000000000..2121ab9ef2 --- /dev/null +++ b/packages/rum-core/src/domain/urlContexts.ts @@ -0,0 +1,76 @@ +import { RelativeTime, Observable, SESSION_TIME_OUT_DELAY, relativeNow } from '@datadog/browser-core' +import { UrlContext } from '../rawRumEvent.types' +import { LocationChange } from '../browser/locationChangeObservable' +import { ContextHistory } from './contextHistory' +import { LifeCycle, LifeCycleEventType } from './lifeCycle' + +/** + * We want to attach to an event: + * - the url corresponding to its start + * - the referrer corresponding to the previous view url (or document referrer for initial view) + */ + +export const URL_CONTEXT_TIME_OUT_DELAY = SESSION_TIME_OUT_DELAY + +export interface UrlContexts { + findUrl: (startTime?: RelativeTime) => UrlContext | undefined + stop: () => void +} + +export function startUrlContexts( + lifeCycle: LifeCycle, + locationChangeObservable: Observable, + location: Location +) { + const urlContextHistory = new ContextHistory(URL_CONTEXT_TIME_OUT_DELAY) + + let previousViewUrl: string | undefined + + lifeCycle.subscribe(LifeCycleEventType.VIEW_ENDED, ({ endClocks }) => { + urlContextHistory.closeCurrent(endClocks.relative) + }) + + lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, ({ startClocks }) => { + const viewUrl = location.href + urlContextHistory.setCurrent( + buildUrlContext({ + url: viewUrl, + referrer: !previousViewUrl ? document.referrer : previousViewUrl, + }), + startClocks.relative + ) + previousViewUrl = viewUrl + }) + + const locationChangeSubscription = locationChangeObservable.subscribe(({ newLocation }) => { + const current = urlContextHistory.getCurrent() + if (current) { + const changeTime = relativeNow() + urlContextHistory.closeCurrent(changeTime) + urlContextHistory.setCurrent( + buildUrlContext({ + url: newLocation.href, + referrer: current.view.referrer, + }), + changeTime + ) + } + }) + + function buildUrlContext({ url, referrer }: { url: string; referrer: string }) { + return { + view: { + url, + referrer, + }, + } + } + + return { + findUrl: (startTime?: RelativeTime) => urlContextHistory.find(startTime), + stop: () => { + locationChangeSubscription.unsubscribe() + urlContextHistory.stop() + }, + } +} diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index dd5d8ac88a..24ca0dab97 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -199,8 +199,6 @@ export interface ViewContext extends Context { view: { id: string name?: string - url: string - referrer: string } } @@ -210,6 +208,13 @@ export interface ActionContext extends Context { } } +export interface UrlContext extends Context { + view: { + url: string + referrer: string + } +} + export interface InternalContext { application_id: string session_id: string | undefined diff --git a/packages/rum-core/test/specHelper.ts b/packages/rum-core/test/specHelper.ts index fdba589e06..669bfe922e 100644 --- a/packages/rum-core/test/specHelper.ts +++ b/packages/rum-core/test/specHelper.ts @@ -14,8 +14,9 @@ import { LifeCycle, LifeCycleEventType, RawRumEventCollectedData } from '../src/ import { ParentContexts } from '../src/domain/parentContexts' import { trackViews, ViewEvent } from '../src/domain/rumEventsCollection/view/trackViews' import { RumSession, RumSessionPlan } from '../src/domain/rumSession' -import { RawRumEvent, RumContext, ViewContext } from '../src/rawRumEvent.types' +import { RawRumEvent, RumContext, ViewContext, UrlContext } from '../src/rawRumEvent.types' import { LocationChange } from '../src/browser/locationChangeObservable' +import { UrlContexts } from '../src/domain/urlContexts' import { validateFormat } from './formatValidation' import { createRumSessionMock } from './mockRumSession' @@ -43,6 +44,7 @@ export interface BuildContext { applicationId: string parentContexts: ParentContexts foregroundContexts: ForegroundContexts + urlContexts: UrlContexts } export interface TestIO { @@ -67,6 +69,15 @@ export function setup(): TestSetupBuilder { let clock: Clock let fakeLocation: Partial = location let parentContexts: ParentContexts + const urlContexts: UrlContexts = { + findUrl: () => ({ + view: { + url: fakeLocation.href!, + referrer: document.referrer, + }, + }), + stop: noop, + } let foregroundContexts: ForegroundContexts = { isInForegroundAt: () => undefined, selectInForegroundPeriodsFor: () => undefined, @@ -130,6 +141,7 @@ export function setup(): TestSetupBuilder { domMutationObservable, locationChangeObservable, parentContexts, + urlContexts, foregroundContexts, session, applicationId: FAKE_APP_ID, @@ -162,7 +174,7 @@ export function setup(): TestSetupBuilder { function validateRumEventFormat(rawRumEvent: RawRumEvent) { const fakeId = '00000000-aaaa-0000-aaaa-000000000000' - const fakeContext: RumContext & ViewContext = { + const fakeContext: RumContext & ViewContext & UrlContext = { _dd: { format_version: 2, drift: 0, diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index 522ff5035e..891d779e33 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -45,8 +45,6 @@ describe('startRecording', () => { }, view: { id: viewId, - referrer: '', - url: 'http://example.org', }, } }, diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index d5098665f9..5678d182cb 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -219,7 +219,7 @@ describe('startSegmentCollection', () => { describe('computeSegmentContext', () => { const DEFAULT_VIEW_CONTEXT: ViewContext = { session: { id: '456' }, - view: { id: '123', url: 'http://foo.com', referrer: 'http://bar.com' }, + view: { id: '123' }, } const DEFAULT_SESSION = createRumSessionMock().setId('456')