diff --git a/packages/rum-core/src/domain/assembly.spec.ts b/packages/rum-core/src/domain/assembly.spec.ts index 4870e45cb6..00bab64a41 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -367,10 +367,20 @@ describe('rum assembly', () => { commonContext.hasReplay = true lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { - rawRumEvent: createRawRumEvent(RumEventType.VIEW), + rawRumEvent: createRawRumEvent(RumEventType.ERROR), startTime: 0 as RelativeTime, }) expect(serverRumEvents[0].session.has_replay).toBe(true) }) + + it('should not use commonContext.hasReplay on view events', () => { + commonContext.hasReplay = true + + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW), + startTime: 0 as RelativeTime, + }) + expect(serverRumEvents[0].session.has_replay).toBe(undefined) + }) }) }) diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index 9f8c075fb4..8be81c142e 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -58,7 +58,6 @@ export function startRumAssembly( date: timeStampNow(), service: configuration.service, session: { - has_replay: commonContext.hasReplay, // must be computed on each event because synthetics instrumentation can be done after sdk execution // cf https://github.com/puppeteer/puppeteer/issues/3667 type: getSessionType(), @@ -73,6 +72,10 @@ export function startRumAssembly( serverRumEvent.context = context } + if (!('has_replay' in serverRumEvent.session)) { + ;(serverRumEvent.session as { has_replay?: boolean }).has_replay = commonContext.hasReplay + } + if (!isEmptyObject(commonContext.user)) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion ;(serverRumEvent.usr as RumEvent['usr']) = commonContext.user as User & Context diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index 9a865dd88b..651768750e 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -21,6 +21,8 @@ export enum LifeCycleEventType { BEFORE_UNLOAD, RAW_RUM_EVENT_COLLECTED, RUM_EVENT_COLLECTED, + RECORD_STARTED, + RECORD_STOPPED, } export interface Subscription { @@ -44,6 +46,8 @@ export class LifeCycle { | LifeCycleEventType.BEFORE_UNLOAD | LifeCycleEventType.AUTO_ACTION_DISCARDED | LifeCycleEventType.VIEW_ENDED + | LifeCycleEventType.RECORD_STARTED + | LifeCycleEventType.RECORD_STOPPED ): void notify( eventType: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, @@ -84,7 +88,9 @@ export class LifeCycle { | LifeCycleEventType.DOM_MUTATED | LifeCycleEventType.BEFORE_UNLOAD | LifeCycleEventType.AUTO_ACTION_DISCARDED - | LifeCycleEventType.VIEW_ENDED, + | LifeCycleEventType.VIEW_ENDED + | LifeCycleEventType.RECORD_STARTED + | LifeCycleEventType.RECORD_STOPPED, callback: () => void ): Subscription subscribe( 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 93e62d1b7e..d24113234d 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts @@ -1061,3 +1061,72 @@ describe('rum track custom timings', () => { expect(warnSpy).toHaveBeenCalled() }) }) + +describe('track hasReplay', () => { + let setupBuilder: TestSetupBuilder + let handler: jasmine.Spy + let getViewEvent: (index: number) => View + + beforeEach(() => { + ;({ handler, getViewEvent } = spyOnViews()) + + setupBuilder = setup() + .withFakeLocation('/foo') + .withFakeClock() + .beforeBuild(({ location, lifeCycle }) => { + lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, handler) + return trackViews(location, lifeCycle) + }) + }) + + afterEach(() => { + setupBuilder.cleanup() + }) + + it('sets hasReplay to false by default', () => { + setupBuilder.build() + expect(getViewEvent(0).hasReplay).toBe(false) + }) + + it('sets hasReplay to true when the recording starts', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.RECORD_STARTED) + + history.pushState({}, '', '/bar') + + expect(getViewEvent(1).hasReplay).toBe(true) + }) + + it('keeps hasReplay to true when the recording stops', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.RECORD_STARTED) + lifeCycle.notify(LifeCycleEventType.RECORD_STOPPED) + + history.pushState({}, '', '/bar') + + expect(getViewEvent(1).hasReplay).toBe(true) + }) + + it('sets hasReplay to true when a new view is created after the recording starts', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.RECORD_STARTED) + + history.pushState({}, '', '/bar') + + expect(getViewEvent(2).hasReplay).toBe(true) + }) + + it('sets hasReplay to false when a new view is created after the recording stops', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.RECORD_STARTED) + lifeCycle.notify(LifeCycleEventType.RECORD_STOPPED) + + history.pushState({}, '', '/bar') + + expect(getViewEvent(2).hasReplay).toBe(false) + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts index 652e784db3..58f8c24967 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts @@ -36,6 +36,7 @@ export interface View { loadingTime?: Duration loadingType: ViewLoadingType cumulativeLayoutShift?: number + hasReplay: boolean } export interface ViewCreatedEvent { @@ -55,9 +56,11 @@ export function trackViews( onNewLocation: NewLocationListener = () => undefined ) { const startOrigin = 0 as RelativeTime + let hasReplay = false const initialView = newView( lifeCycle, location, + hasReplay, ViewLoadingType.INITIAL_LOAD, document.referrer, startOrigin, @@ -79,7 +82,15 @@ export function trackViews( // Renew view on location changes currentView.end() currentView.triggerUpdate() - currentView = newView(lifeCycle, location, ViewLoadingType.ROUTE_CHANGE, currentView.url, undefined, viewName) + currentView = newView( + lifeCycle, + location, + hasReplay, + ViewLoadingType.ROUTE_CHANGE, + currentView.url, + undefined, + viewName + ) return } currentView.updateLocation(location) @@ -90,7 +101,7 @@ export function trackViews( lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { // do not trigger view update to avoid wrong data currentView.end() - currentView = newView(lifeCycle, location, ViewLoadingType.ROUTE_CHANGE, currentView.url) + currentView = newView(lifeCycle, location, hasReplay, ViewLoadingType.ROUTE_CHANGE, currentView.url) }) // End the current view on page unload @@ -99,6 +110,15 @@ export function trackViews( currentView.triggerUpdate() }) + lifeCycle.subscribe(LifeCycleEventType.RECORD_STARTED, () => { + hasReplay = true + currentView.updateHasReplay(true) + }) + + lifeCycle.subscribe(LifeCycleEventType.RECORD_STOPPED, () => { + hasReplay = false + }) + // Session keep alive const keepAliveInterval = window.setInterval( monitor(() => { @@ -125,6 +145,7 @@ export function trackViews( function newView( lifeCycle: LifeCycle, initialLocation: Location, + initialHasReplay: boolean, loadingType: ViewLoadingType, referrer: string, startTime = relativeNow(), @@ -145,6 +166,7 @@ function newView( let loadingTime: Duration | undefined let endTime: RelativeTime | undefined let location: Location = { ...initialLocation } + let hasReplay = initialHasReplay lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { id, startTime, location, referrer }) @@ -195,6 +217,7 @@ function newView( loadingTime, loadingType, location, + hasReplay, referrer, startTime, timings, @@ -238,6 +261,9 @@ function newView( updateLocation(newLocation: Location) { location = { ...newLocation } }, + updateHasReplay(newHasReplay: boolean) { + hasReplay = newHasReplay + }, 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 04416e0078..8b10ff5126 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts @@ -2,6 +2,7 @@ import { Duration, RelativeTime, ServerDuration } from '@datadog/browser-core' import { setup, TestSetupBuilder } from '../../../../test/specHelper' import { RumEventType, ViewLoadingType } from '../../../rawRumEvent.types' import { LifeCycleEventType } from '../../lifeCycle' +import { View } from './trackViews' import { startViewCollection } from './viewCollection' describe('viewCollection', () => { @@ -24,7 +25,7 @@ describe('viewCollection', () => { it('should create view from view update', () => { const { lifeCycle, rawRumEvents } = setupBuilder.build() const location: Partial = {} - const view = { + const view: View = { cumulativeLayoutShift: 1, customTimings: { bar: 20 as Duration, @@ -41,6 +42,7 @@ describe('viewCollection', () => { id: 'xxx', name: undefined, isActive: false, + hasReplay: false, loadingTime: 20 as Duration, loadingType: ViewLoadingType.INITIAL_LOAD, location: location as Location, @@ -98,6 +100,9 @@ describe('viewCollection', () => { }, time_spent: (100 * 1e6) as ServerDuration, }, + session: { + has_replay: undefined, + }, }) }) }) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts index 1a4b0596bf..7f5881a29c 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts @@ -54,6 +54,9 @@ function processViewUpdate(view: View) { }, time_spent: toServerDuration(view.duration), }, + session: { + has_replay: view.hasReplay || undefined, + }, } if (!isEmptyObject(view.customTimings)) { viewEvent.view.custom_timings = mapValues( diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index b2669b5c6a..df306d5c19 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -79,6 +79,9 @@ export interface RawRumViewEvent { long_task: Count resource: Count } + session: { + has_replay: true | undefined + } _dd: { document_version: number } diff --git a/packages/rum-core/test/fixtures.ts b/packages/rum-core/test/fixtures.ts index 2d4451e5f5..fc14631216 100644 --- a/packages/rum-core/test/fixtures.ts +++ b/packages/rum-core/test/fixtures.ts @@ -80,6 +80,9 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR resource: { count: 0 }, time_spent: 0 as ServerDuration, }, + session: { + has_replay: undefined, + }, }, overrides ) diff --git a/packages/rum-recorder/src/boot/recorder.ts b/packages/rum-recorder/src/boot/recorder.ts index 4696e94524..fc38041eea 100644 --- a/packages/rum-recorder/src/boot/recorder.ts +++ b/packages/rum-recorder/src/boot/recorder.ts @@ -34,7 +34,7 @@ export function startRecording( trackViewEndRecord(lifeCycle, (record) => addRawRecord(record)) return { - stop() { + stop: () => { stopRecording() stopSegmentCollection() stopTrackingFocusRecords() diff --git a/packages/rum-recorder/src/boot/rumRecorderPublicApi.spec.ts b/packages/rum-recorder/src/boot/rumRecorderPublicApi.spec.ts index d966696dde..87e4e867b2 100644 --- a/packages/rum-recorder/src/boot/rumRecorderPublicApi.spec.ts +++ b/packages/rum-recorder/src/boot/rumRecorderPublicApi.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import { Configuration } from '@datadog/browser-core' +import { Configuration, noop } from '@datadog/browser-core' import { RumPublicApi, RumUserConfiguration, StartRum } from '@datadog/browser-rum-core' import { makeRumRecorderPublicApi, StartRecording } from './rumRecorderPublicApi' @@ -18,7 +18,9 @@ describe('makeRumRecorderPublicApi', () => { beforeEach(() => { enabledFlags = [] - startRecordingSpy = jasmine.createSpy() + startRecordingSpy = jasmine.createSpy().and.callFake(() => ({ + stop: noop, + })) startRumSpy = jasmine.createSpy().and.callFake(() => { const configuration: Partial = { isEnabled(flag: string) { diff --git a/packages/rum-recorder/src/boot/rumRecorderPublicApi.ts b/packages/rum-recorder/src/boot/rumRecorderPublicApi.ts index acfb7b15e4..a42edc10d6 100644 --- a/packages/rum-recorder/src/boot/rumRecorderPublicApi.ts +++ b/packages/rum-recorder/src/boot/rumRecorderPublicApi.ts @@ -1,23 +1,39 @@ import { monitor } from '@datadog/browser-core' -import { makeRumPublicApi, StartRum } from '@datadog/browser-rum-core' +import { LifeCycleEventType, makeRumPublicApi, StartRum } from '@datadog/browser-rum-core' import { startRecording } from './recorder' export type StartRecording = typeof startRecording +const enum RecorderStatus { + Stopped, + Started, +} +type RecorderState = + | { + status: RecorderStatus.Stopped + } + | { + status: RecorderStatus.Started + stopRecording: () => void + } + export function makeRumRecorderPublicApi(startRumImpl: StartRum, startRecordingImpl: StartRecording) { const rumRecorderGlobal = makeRumPublicApi((userConfiguration, getCommonContext) => { - let isRecording: true | undefined + let state: RecorderState = { + status: RecorderStatus.Stopped, + } const startRumResult = startRumImpl(userConfiguration, () => ({ ...getCommonContext(), - hasReplay: isRecording, + hasReplay: state.status === RecorderStatus.Started ? true : undefined, })) const { lifeCycle, parentContexts, configuration, session } = startRumResult if (configuration.isEnabled('postpone_start_recording')) { ;(rumRecorderGlobal as any).startSessionReplayRecording = monitor(startSessionReplayRecording) + ;(rumRecorderGlobal as any).stopSessionReplayRecording = monitor(stopSessionReplayRecording) if (!(userConfiguration as any).manualSessionReplayRecordingStart) { startSessionReplayRecording() } @@ -26,12 +42,34 @@ export function makeRumRecorderPublicApi(startRumImpl: StartRum, startRecordingI } function startSessionReplayRecording() { - if (isRecording) { + if (state.status === RecorderStatus.Started) { + return + } + + const { stop: stopRecording } = startRecordingImpl( + lifeCycle, + userConfiguration.applicationId, + configuration, + session, + parentContexts + ) + state = { + status: RecorderStatus.Started, + stopRecording, + } + lifeCycle.notify(LifeCycleEventType.RECORD_STARTED) + } + + function stopSessionReplayRecording() { + if (state.status !== RecorderStatus.Started) { return } - isRecording = true - startRecordingImpl(lifeCycle, userConfiguration.applicationId, configuration, session, parentContexts) + state.stopRecording() + state = { + status: RecorderStatus.Stopped, + } + lifeCycle.notify(LifeCycleEventType.RECORD_STOPPED) } return startRumResult diff --git a/packages/rum-recorder/src/domain/segmentCollection.spec.ts b/packages/rum-recorder/src/domain/segmentCollection.spec.ts index 3c059d4f35..d87b0e915e 100644 --- a/packages/rum-recorder/src/domain/segmentCollection.spec.ts +++ b/packages/rum-recorder/src/domain/segmentCollection.spec.ts @@ -12,7 +12,7 @@ const RECORD: Record = { type: RecordType.ViewEnd, timestamp: 10 } const BEFORE_MAX_SEGMENT_DURATION = MAX_SEGMENT_DURATION * 0.9 describe('startSegmentCollection', () => { - let stopErrorCollection: () => void + let stopSegmentCollection: () => void function startSegmentCollection(context: SegmentContext | undefined) { const lifeCycle = new LifeCycle() @@ -21,7 +21,7 @@ describe('startSegmentCollection', () => { const sendSpy = jasmine.createSpy<(data: Uint8Array, meta: SegmentMeta) => void>() const { stop, addRecord } = doStartSegmentCollection(lifeCycle, () => context, sendSpy, worker, eventEmitter) - stopErrorCollection = stop + stopSegmentCollection = stop const segmentFlushSpy = spyOn(Segment.prototype, 'flush').and.callThrough() return { addRecord, @@ -42,7 +42,7 @@ describe('startSegmentCollection', () => { afterEach(() => { jasmine.clock().uninstall() - stopErrorCollection() + stopSegmentCollection() }) it('immediately starts a new segment', () => { @@ -133,6 +133,13 @@ describe('startSegmentCollection', () => { expect(segmentFlushSpy).toHaveBeenCalledTimes(1) expect(sendCurrentSegment().creation_reason).not.toBe('max_duration') }) + + it('flushes a segment when calling stop()', () => { + const { segmentFlushSpy, addRecord } = startSegmentCollection(CONTEXT) + addRecord(RECORD) + stopSegmentCollection() + expect(segmentFlushSpy).toHaveBeenCalled() + }) }) }) diff --git a/packages/rum-recorder/src/domain/segmentCollection.ts b/packages/rum-recorder/src/domain/segmentCollection.ts index b9cc63d932..0f313ea031 100644 --- a/packages/rum-recorder/src/domain/segmentCollection.ts +++ b/packages/rum-recorder/src/domain/segmentCollection.ts @@ -33,6 +33,8 @@ export const MAX_SEGMENT_DURATION = 30_000 // To help investigate session replays issues, each segment is created with a "creation reason", // indicating why the session has been created. +let workerSingleton: DeflateWorker + export function startSegmentCollection( lifeCycle: LifeCycle, applicationId: string, @@ -40,15 +42,36 @@ export function startSegmentCollection( parentContexts: ParentContexts, send: (data: Uint8Array, meta: SegmentMeta) => void ) { - const worker = createDeflateWorker() + if (!workerSingleton) { + workerSingleton = createDeflateWorker() + } return doStartSegmentCollection( lifeCycle, () => computeSegmentContext(applicationId, session, parentContexts), send, - worker + workerSingleton ) } +const enum SegmentCollectionStatus { + WaitingForInitialRecord, + SegmentPending, + Stopped, +} +type SegmentCollectionState = + | { + status: SegmentCollectionStatus.WaitingForInitialRecord + nextSegmentCreationReason: CreationReason + } + | { + status: SegmentCollectionStatus.SegmentPending + segment: Segment + expirationTimeoutId: number + } + | { + status: SegmentCollectionStatus.Stopped + } + export function doStartSegmentCollection( lifeCycle: LifeCycle, getSegmentContext: () => SegmentContext | undefined, @@ -56,9 +79,10 @@ export function doStartSegmentCollection( worker: DeflateWorker, emitter: EventEmitter = window ) { - let currentSegment: Segment | undefined - let currentSegmentExpirationTimeoutId: number - let nextSegmentCreationReason: CreationReason = 'init' + let state: SegmentCollectionState = { + status: SegmentCollectionStatus.WaitingForInitialRecord, + nextSegmentCreationReason: 'init', + } const writer = new DeflateSegmentWriter( worker, @@ -91,40 +115,55 @@ export function doStartSegmentCollection( { capture: true } ) - function flushSegment(creationReason: CreationReason) { - if (currentSegment) { - currentSegment.flush() - currentSegment = undefined - clearTimeout(currentSegmentExpirationTimeoutId) + function flushSegment(nextSegmentCreationReason?: CreationReason) { + if (state.status === SegmentCollectionStatus.SegmentPending) { + state.segment.flush() + clearTimeout(state.expirationTimeoutId) } - nextSegmentCreationReason = creationReason + if (nextSegmentCreationReason) { + state = { + status: SegmentCollectionStatus.WaitingForInitialRecord, + nextSegmentCreationReason, + } + } else { + state = { + status: SegmentCollectionStatus.Stopped, + } + } } return { addRecord: (record: Record) => { - if (!currentSegment) { - const context = getSegmentContext() - if (!context) { - return - } - - currentSegment = new Segment(writer, context, nextSegmentCreationReason, record) - currentSegmentExpirationTimeoutId = setTimeout( - monitor(() => { - flushSegment('max_duration') - }), - MAX_SEGMENT_DURATION - ) - } else { - currentSegment.addRecord(record) + switch (state.status) { + case SegmentCollectionStatus.WaitingForInitialRecord: + const context = getSegmentContext() + if (!context) { + return + } + state = { + status: SegmentCollectionStatus.SegmentPending, + segment: new Segment(writer, context, state.nextSegmentCreationReason, record), + expirationTimeoutId: setTimeout( + monitor(() => { + flushSegment('max_duration') + }), + MAX_SEGMENT_DURATION + ), + } + break + + case SegmentCollectionStatus.SegmentPending: + state.segment.addRecord(record) + break } }, + stop: () => { + flushSegment() unsubscribeViewCreated() unsubscribeBeforeUnload() unsubscribeVisibilityChange() - worker.terminate() }, } }