From 17b3cbbaaca295d5702fbe8590f1ee23b4bee775 Mon Sep 17 00:00:00 2001 From: Aymeric Date: Fri, 6 Dec 2024 10:54:37 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[RUM-6813]=20Split=20the?= =?UTF-8?q?=20recorder=20API=20module=20(#3181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: BenoƮt Zugmeyer --- packages/rum/src/boot/postStartStrategy.ts | 134 ++++++++++++ packages/rum/src/boot/preStartStrategy.ts | 34 ++++ packages/rum/src/boot/recorderApi.spec.ts | 2 +- packages/rum/src/boot/recorderApi.ts | 225 +++++---------------- 4 files changed, 223 insertions(+), 172 deletions(-) create mode 100644 packages/rum/src/boot/postStartStrategy.ts create mode 100644 packages/rum/src/boot/preStartStrategy.ts diff --git a/packages/rum/src/boot/postStartStrategy.ts b/packages/rum/src/boot/postStartStrategy.ts new file mode 100644 index 0000000000..a8948f7f30 --- /dev/null +++ b/packages/rum/src/boot/postStartStrategy.ts @@ -0,0 +1,134 @@ +import type { + LifeCycle, + RumConfiguration, + RumSessionManager, + StartRecordingOptions, + ViewHistory, + RumSession, +} from '@datadog/browser-rum-core' +import { LifeCycleEventType, SessionReplayState } from '@datadog/browser-rum-core' +import { PageExitReason, runOnReadyState, type DeflateEncoder } from '@datadog/browser-core' +import { getSessionReplayLink } from '../domain/getSessionReplayLink' +import type { startRecording } from './startRecording' + +export type StartRecording = typeof startRecording + +export const enum RecorderStatus { + // The recorder is stopped. + Stopped, + // The user started the recording while it wasn't possible yet. The recorder should start as soon + // as possible. + IntentToStart, + // The recorder is starting. It does not record anything yet. + Starting, + // The recorder is started, it records the session. + Started, +} + +export interface Strategy { + start: (options?: StartRecordingOptions) => void + stop: () => void + isRecording: () => boolean + getSessionReplayLink: () => string | undefined +} + +export function createPostStartStrategy( + configuration: RumConfiguration, + lifeCycle: LifeCycle, + sessionManager: RumSessionManager, + viewHistory: ViewHistory, + startRecordingImpl: StartRecording, + getOrCreateDeflateEncoder: () => DeflateEncoder | undefined +): Strategy { + let status = RecorderStatus.Stopped + + lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => { + if (status === RecorderStatus.Starting || status === RecorderStatus.Started) { + stop() + status = RecorderStatus.IntentToStart + } + }) + + // Stop the recorder on page unload to avoid sending records after the page is ended. + lifeCycle.subscribe(LifeCycleEventType.PAGE_EXITED, (pageExitEvent) => { + if (pageExitEvent.reason === PageExitReason.UNLOADING) { + stop() + } + }) + + lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + if (status === RecorderStatus.IntentToStart) { + start() + } + }) + + function start(options?: StartRecordingOptions) { + const session = sessionManager.findTrackedSession() + if (canStartRecording(session, options)) { + status = RecorderStatus.IntentToStart + return + } + + if (isRecordingInProgress(status)) { + return + } + + status = RecorderStatus.Starting + + runOnReadyState(configuration, 'interactive', () => { + if (status !== RecorderStatus.Starting) { + return + } + + const deflateEncoder = getOrCreateDeflateEncoder() + if (!deflateEncoder) { + status = RecorderStatus.Stopped + return + } + + ;({ stop: stopRecording } = startRecordingImpl( + lifeCycle, + configuration, + sessionManager, + viewHistory, + deflateEncoder + )) + + status = RecorderStatus.Started + }) + + if (shouldForceReplay(session!, options)) { + sessionManager.setForcedReplay() + } + } + + function stop() { + if (status !== RecorderStatus.Stopped && status === RecorderStatus.Started) { + stopRecording?.() + } + + status = RecorderStatus.Stopped + } + + let stopRecording: () => void + return { + start, + stop, + getSessionReplayLink() { + return getSessionReplayLink(configuration, sessionManager, viewHistory, status !== RecorderStatus.Stopped) + }, + isRecording: () => status === RecorderStatus.Started, + } +} + +function canStartRecording(session: RumSession | undefined, options?: StartRecordingOptions) { + return !session || (session.sessionReplay === SessionReplayState.OFF && (!options || !options.force)) +} + +function isRecordingInProgress(status: RecorderStatus) { + return status === RecorderStatus.Starting || status === RecorderStatus.Started +} + +function shouldForceReplay(session: RumSession, options?: StartRecordingOptions) { + return options && options.force && session.sessionReplay === SessionReplayState.OFF +} diff --git a/packages/rum/src/boot/preStartStrategy.ts b/packages/rum/src/boot/preStartStrategy.ts new file mode 100644 index 0000000000..bc728523bf --- /dev/null +++ b/packages/rum/src/boot/preStartStrategy.ts @@ -0,0 +1,34 @@ +import { noop } from '@datadog/browser-core' +import type { RumConfiguration } from '@datadog/browser-rum-core' +import type { Strategy } from './postStartStrategy' + +const enum PreStartRecorderStatus { + None, + HadManualStart, + HadManualStop, +} + +export function createPreStartStrategy(): { + strategy: Strategy + shouldStartImmediately: (configuration: RumConfiguration) => boolean +} { + let status = PreStartRecorderStatus.None + return { + strategy: { + start() { + status = PreStartRecorderStatus.HadManualStart + }, + stop() { + status = PreStartRecorderStatus.HadManualStop + }, + isRecording: () => false, + getSessionReplayLink: noop as () => string | undefined, + }, + shouldStartImmediately(configuration) { + return ( + status === PreStartRecorderStatus.HadManualStart || + (status === PreStartRecorderStatus.None && !configuration.startSessionReplayRecordingManually) + ) + }, + } +} diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index 5a0cd972bb..28c218bd9a 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -14,8 +14,8 @@ import type { CreateDeflateWorker } from '../domain/deflate' import { MockWorker } from '../../test' import { resetDeflateWorkerState } from '../domain/deflate' import * as replayStats from '../domain/replayStats' -import type { StartRecording } from './recorderApi' import { makeRecorderApi } from './recorderApi' +import type { StartRecording } from './postStartStrategy' describe('makeRecorderApi', () => { let lifeCycle: LifeCycle diff --git a/packages/rum/src/boot/recorderApi.ts b/packages/rum/src/boot/recorderApi.ts index 87e02f7ef7..ca0d7133b9 100644 --- a/packages/rum/src/boot/recorderApi.ts +++ b/packages/rum/src/boot/recorderApi.ts @@ -1,12 +1,10 @@ -import type { DeflateEncoder } from '@datadog/browser-core' +import type { DeflateEncoder, DeflateWorker } from '@datadog/browser-core' import { - DeflateEncoderStreamId, canUseEventBridge, noop, - runOnReadyState, - PageExitReason, BridgeCapability, bridgeSupports, + DeflateEncoderStreamId, } from '@datadog/browser-core' import type { LifeCycle, @@ -16,49 +14,18 @@ import type { RumConfiguration, StartRecordingOptions, } from '@datadog/browser-rum-core' -import { LifeCycleEventType, SessionReplayState } from '@datadog/browser-rum-core' import { getReplayStats as getReplayStatsImpl } from '../domain/replayStats' -import { getSessionReplayLink } from '../domain/getSessionReplayLink' import type { CreateDeflateWorker } from '../domain/deflate' import { createDeflateEncoder, - startDeflateWorker, DeflateWorkerStatus, getDeflateWorkerStatus, + startDeflateWorker, } from '../domain/deflate' - -import type { startRecording } from './startRecording' import { isBrowserSupported } from './isBrowserSupported' - -export type StartRecording = typeof startRecording - -const enum RecorderStatus { - // The recorder is stopped. - Stopped, - // The user started the recording while it wasn't possible yet. The recorder should start as soon - // as possible. - IntentToStart, - // The recorder is starting. It does not record anything yet. - Starting, - // The recorder is started, it records the session. - Started, -} -type RecorderState = - | { - status: RecorderStatus.Stopped - } - | { - status: RecorderStatus.IntentToStart - } - | { - status: RecorderStatus.Starting - } - | { - status: RecorderStatus.Started - stopRecording: () => void - } - -type StartStrategyFn = (options?: StartRecordingOptions) => void +import type { StartRecording } from './postStartStrategy' +import { createPostStartStrategy } from './postStartStrategy' +import { createPreStartStrategy } from './preStartStrategy' export function makeRecorderApi( startRecordingImpl: StartRecording, @@ -75,139 +42,14 @@ export function makeRecorderApi( } } - let state: RecorderState = { - status: RecorderStatus.IntentToStart, - } - - let startStrategy: StartStrategyFn = () => { - state = { status: RecorderStatus.IntentToStart } - } - let stopStrategy = () => { - state = { status: RecorderStatus.Stopped } - } - let getSessionReplayLinkStrategy = noop as () => string | undefined + // eslint-disable-next-line prefer-const + let { strategy, shouldStartImmediately } = createPreStartStrategy() return { - start: (options?: StartRecordingOptions) => startStrategy(options), - stop: () => stopStrategy(), - getSessionReplayLink: () => getSessionReplayLinkStrategy(), - onRumStart: ( - lifeCycle: LifeCycle, - configuration: RumConfiguration, - sessionManager: RumSessionManager, - viewHistory: ViewHistory, - worker - ) => { - if (configuration.startSessionReplayRecordingManually) { - state = { status: RecorderStatus.Stopped } - } - lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => { - if (state.status === RecorderStatus.Starting || state.status === RecorderStatus.Started) { - stopStrategy() - state = { status: RecorderStatus.IntentToStart } - } - }) - - // Stop the recorder on page unload to avoid sending records after the page is ended. - lifeCycle.subscribe(LifeCycleEventType.PAGE_EXITED, (pageExitEvent) => { - if (pageExitEvent.reason === PageExitReason.UNLOADING) { - stopStrategy() - } - }) - - lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { - if (state.status === RecorderStatus.IntentToStart) { - startStrategy() - } - }) - - let cachedDeflateEncoder: DeflateEncoder | undefined - - function getOrCreateDeflateEncoder() { - if (!cachedDeflateEncoder) { - if (!worker) { - worker = startDeflateWorker( - configuration, - 'Datadog Session Replay', - () => { - stopStrategy() - }, - createDeflateWorkerImpl - ) - } - if (worker) { - cachedDeflateEncoder = createDeflateEncoder(configuration, worker, DeflateEncoderStreamId.REPLAY) - } - } - return cachedDeflateEncoder - } - - startStrategy = (options?: StartRecordingOptions) => { - const session = sessionManager.findTrackedSession() - if (!session || (session.sessionReplay === SessionReplayState.OFF && (!options || !options.force))) { - state = { status: RecorderStatus.IntentToStart } - return - } - - if (state.status === RecorderStatus.Starting || state.status === RecorderStatus.Started) { - return - } - - state = { status: RecorderStatus.Starting } - - runOnReadyState(configuration, 'interactive', () => { - if (state.status !== RecorderStatus.Starting) { - return - } - - const deflateEncoder = getOrCreateDeflateEncoder() - if (!deflateEncoder) { - state = { - status: RecorderStatus.Stopped, - } - return - } - - const { stop: stopRecording } = startRecordingImpl( - lifeCycle, - configuration, - sessionManager, - viewHistory, - deflateEncoder - ) - state = { - status: RecorderStatus.Started, - stopRecording, - } - }) - - if (options && options.force && session.sessionReplay === SessionReplayState.OFF) { - sessionManager.setForcedReplay() - } - } - - stopStrategy = () => { - if (state.status === RecorderStatus.Stopped) { - return - } - - if (state.status === RecorderStatus.Started) { - state.stopRecording() - } - - state = { - status: RecorderStatus.Stopped, - } - } - - getSessionReplayLinkStrategy = () => - getSessionReplayLink(configuration, sessionManager, viewHistory, state.status !== RecorderStatus.Stopped) - - if (state.status === RecorderStatus.IntentToStart) { - startStrategy() - } - }, - + start: (options?: StartRecordingOptions) => strategy.start(options), + stop: () => strategy.stop(), + getSessionReplayLink: () => strategy.getSessionReplayLink(), + onRumStart, isRecording: () => // The worker is started optimistically, meaning we could have started to record but its // initialization fails a bit later. This could happen when: @@ -231,9 +73,50 @@ export function makeRecorderApi( // // In the future, when the compression worker will also be used for RUM data, this will be // less important since no RUM event will be sent when the worker fails to initialize. - getDeflateWorkerStatus() === DeflateWorkerStatus.Initialized && state.status === RecorderStatus.Started, + getDeflateWorkerStatus() === DeflateWorkerStatus.Initialized && strategy.isRecording(), getReplayStats: (viewId) => getDeflateWorkerStatus() === DeflateWorkerStatus.Initialized ? getReplayStatsImpl(viewId) : undefined, } + + function onRumStart( + lifeCycle: LifeCycle, + configuration: RumConfiguration, + sessionManager: RumSessionManager, + viewHistory: ViewHistory, + worker: DeflateWorker | undefined + ) { + let cachedDeflateEncoder: DeflateEncoder | undefined + + function getOrCreateDeflateEncoder() { + if (!cachedDeflateEncoder) { + worker ??= startDeflateWorker( + configuration, + 'Datadog Session Replay', + () => { + strategy.stop() + }, + createDeflateWorkerImpl + ) + + if (worker) { + cachedDeflateEncoder = createDeflateEncoder(configuration, worker, DeflateEncoderStreamId.REPLAY) + } + } + return cachedDeflateEncoder + } + + strategy = createPostStartStrategy( + configuration, + lifeCycle, + sessionManager, + viewHistory, + startRecordingImpl, + getOrCreateDeflateEncoder + ) + + if (shouldStartImmediately(configuration)) { + strategy.start() + } + } }