From db2c5bde728bbbbd6499f85f49509127d0423b53 Mon Sep 17 00:00:00 2001 From: Aymeric Date: Wed, 18 Dec 2024 17:45:44 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20[RUM-6813]=20Lazy=20load?= =?UTF-8?q?=20session=20replay=20(#3152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 1 + packages/core/src/browser/runOnReadyState.ts | 9 + packages/core/src/index.ts | 4 +- packages/core/src/tools/monitor.ts | 18 +- packages/core/tsconfig.esm.json | 1 - packages/rum-core/tsconfig.esm.json | 1 - packages/rum-react/tsconfig.esm.json | 1 - packages/rum-slim/tsconfig.esm.json | 1 - packages/rum/src/boot/lazyLoadRecorder.ts | 3 + packages/rum/src/boot/postStartStrategy.ts | 55 +++--- packages/rum/src/boot/recorderApi.spec.ts | 159 +++++++++++++----- packages/rum/src/boot/recorderApi.ts | 4 +- .../rum/src/entries/internalSynthetics.ts | 5 +- packages/rum/src/entries/main.ts | 7 +- packages/rum/tsconfig.esm.json | 1 - scripts/deploy/deploy.js | 59 ++++--- scripts/deploy/deploy.spec.js | 46 +++-- scripts/deploy/lib/deploymentUtils.js | 28 +-- scripts/deploy/lib/testHelpers.js | 11 ++ scripts/deploy/upload-source-maps.js | 79 ++++----- scripts/deploy/upload-source-maps.spec.js | 100 +++++++++-- scripts/lib/filesUtils.js | 12 ++ test/app/package.json | 4 +- .../{webpack.config.js => webpack.base.js} | 14 +- test/app/webpack.ssr.js | 10 ++ test/app/webpack.web.js | 8 + test/e2e/lib/framework/sdkBuilds.ts | 4 + test/e2e/lib/framework/serverApps/mock.ts | 10 ++ tsconfig.base.json | 1 + webpack.base.js | 4 +- 30 files changed, 467 insertions(+), 193 deletions(-) create mode 100644 packages/rum/src/boot/lazyLoadRecorder.ts rename test/app/{webpack.config.js => webpack.base.js} (57%) create mode 100644 test/app/webpack.ssr.js create mode 100644 test/app/webpack.web.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 18130fd9e9..0ff3cec04c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -308,6 +308,7 @@ script-tests: interruptible: true script: - yarn + - yarn build:bundle - yarn test:script ######################################################################################################################## # Deploy diff --git a/packages/core/src/browser/runOnReadyState.ts b/packages/core/src/browser/runOnReadyState.ts index 02fcbb6203..bbc2740068 100644 --- a/packages/core/src/browser/runOnReadyState.ts +++ b/packages/core/src/browser/runOnReadyState.ts @@ -14,3 +14,12 @@ export function runOnReadyState( const eventName = expectedReadyState === 'complete' ? DOM_EVENT.LOAD : DOM_EVENT.DOM_CONTENT_LOADED return addEventListener(configuration, window, eventName, callback, { once: true }) } + +export function asyncRunOnReadyState( + configuration: Configuration, + expectedReadyState: 'complete' | 'interactive' +): Promise { + return new Promise((resolve) => { + runOnReadyState(configuration, expectedReadyState, resolve) + }) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 957b86a17e..c4440b6ba1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,7 +47,7 @@ export { addTelemetryUsage, drainPreStartTelemetry, } from './domain/telemetry' -export { monitored, monitor, callMonitored, setDebugMode } from './tools/monitor' +export { monitored, monitor, callMonitored, setDebugMode, monitorError } from './tools/monitor' export { Observable, Subscription } from './tools/observable' export { startSessionManager, @@ -82,7 +82,7 @@ export { AbstractLifeCycle } from './tools/abstractLifeCycle' export * from './domain/eventRateLimiter/createEventRateLimiter' export * from './tools/utils/browserDetection' export { sendToExtension } from './tools/sendToExtension' -export { runOnReadyState } from './browser/runOnReadyState' +export { runOnReadyState, asyncRunOnReadyState } from './browser/runOnReadyState' export { getZoneJsOriginalValue } from './tools/getZoneJsOriginalValue' export { instrumentMethod, instrumentSetter, InstrumentedMethodCall } from './tools/instrumentMethod' export { diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index b34acda7c3..6b0c8b0b7c 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -50,13 +50,17 @@ export function callMonitored any>( // eslint-disable-next-line @typescript-eslint/no-unsafe-return return fn.apply(context, args) } catch (e) { - displayIfDebugEnabled(e) - if (onMonitorErrorCollected) { - try { - onMonitorErrorCollected(e) - } catch (e) { - displayIfDebugEnabled(e) - } + monitorError(e) + } +} + +export function monitorError(e: unknown) { + displayIfDebugEnabled(e) + if (onMonitorErrorCollected) { + try { + onMonitorErrorCollected(e) + } catch (e) { + displayIfDebugEnabled(e) } } } diff --git a/packages/core/tsconfig.esm.json b/packages/core/tsconfig.esm.json index fee4f61227..9d1d447af3 100644 --- a/packages/core/tsconfig.esm.json +++ b/packages/core/tsconfig.esm.json @@ -3,7 +3,6 @@ "compilerOptions": { "baseUrl": ".", "declaration": true, - "module": "es6", "rootDir": "./src/", "outDir": "./esm/" }, diff --git a/packages/rum-core/tsconfig.esm.json b/packages/rum-core/tsconfig.esm.json index fee4f61227..9d1d447af3 100644 --- a/packages/rum-core/tsconfig.esm.json +++ b/packages/rum-core/tsconfig.esm.json @@ -3,7 +3,6 @@ "compilerOptions": { "baseUrl": ".", "declaration": true, - "module": "es6", "rootDir": "./src/", "outDir": "./esm/" }, diff --git a/packages/rum-react/tsconfig.esm.json b/packages/rum-react/tsconfig.esm.json index fee4f61227..9d1d447af3 100644 --- a/packages/rum-react/tsconfig.esm.json +++ b/packages/rum-react/tsconfig.esm.json @@ -3,7 +3,6 @@ "compilerOptions": { "baseUrl": ".", "declaration": true, - "module": "es6", "rootDir": "./src/", "outDir": "./esm/" }, diff --git a/packages/rum-slim/tsconfig.esm.json b/packages/rum-slim/tsconfig.esm.json index fee4f61227..9d1d447af3 100644 --- a/packages/rum-slim/tsconfig.esm.json +++ b/packages/rum-slim/tsconfig.esm.json @@ -3,7 +3,6 @@ "compilerOptions": { "baseUrl": ".", "declaration": true, - "module": "es6", "rootDir": "./src/", "outDir": "./esm/" }, diff --git a/packages/rum/src/boot/lazyLoadRecorder.ts b/packages/rum/src/boot/lazyLoadRecorder.ts new file mode 100644 index 0000000000..35935abe28 --- /dev/null +++ b/packages/rum/src/boot/lazyLoadRecorder.ts @@ -0,0 +1,3 @@ +export function lazyLoadRecorder() { + return import(/* webpackChunkName: "recorder" */ './startRecording').then((module) => module.startRecording) +} diff --git a/packages/rum/src/boot/postStartStrategy.ts b/packages/rum/src/boot/postStartStrategy.ts index a8948f7f30..292ca37d7f 100644 --- a/packages/rum/src/boot/postStartStrategy.ts +++ b/packages/rum/src/boot/postStartStrategy.ts @@ -7,7 +7,7 @@ import type { RumSession, } from '@datadog/browser-rum-core' import { LifeCycleEventType, SessionReplayState } from '@datadog/browser-rum-core' -import { PageExitReason, runOnReadyState, type DeflateEncoder } from '@datadog/browser-core' +import { asyncRunOnReadyState, monitorError, PageExitReason, type DeflateEncoder } from '@datadog/browser-core' import { getSessionReplayLink } from '../domain/getSessionReplayLink' import type { startRecording } from './startRecording' @@ -37,10 +37,11 @@ export function createPostStartStrategy( lifeCycle: LifeCycle, sessionManager: RumSessionManager, viewHistory: ViewHistory, - startRecordingImpl: StartRecording, + loadRecorder: () => Promise, getOrCreateDeflateEncoder: () => DeflateEncoder | undefined ): Strategy { let status = RecorderStatus.Stopped + let stopRecording: () => void lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => { if (status === RecorderStatus.Starting || status === RecorderStatus.Started) { @@ -62,6 +63,30 @@ export function createPostStartStrategy( } }) + const doStart = async () => { + const [startRecordingImpl] = await Promise.all([loadRecorder(), asyncRunOnReadyState(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 + } + function start(options?: StartRecordingOptions) { const session = sessionManager.findTrackedSession() if (canStartRecording(session, options)) { @@ -75,27 +100,8 @@ export function createPostStartStrategy( 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 - }) + // Intentionally not awaiting doStart() to keep it asynchronous + doStart().catch(monitorError) if (shouldForceReplay(session!, options)) { sessionManager.setForcedReplay() @@ -103,14 +109,13 @@ export function createPostStartStrategy( } function stop() { - if (status !== RecorderStatus.Stopped && status === RecorderStatus.Started) { + if (status === RecorderStatus.Started) { stopRecording?.() } status = RecorderStatus.Stopped } - let stopRecording: () => void return { start, stop, diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index 68d91be491..2802ae9111 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -2,7 +2,7 @@ import type { DeflateEncoder, DeflateWorker, DeflateWorkerAction } from '@datado import { BridgeCapability, PageExitReason, display } from '@datadog/browser-core' import type { RecorderApi, RumSessionManager } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' -import { mockEventBridge, registerCleanupTask } from '@datadog/browser-core/test' +import { collectAsyncCalls, mockEventBridge, registerCleanupTask } from '@datadog/browser-core/test' import type { RumSessionManagerMock } from '../../../rum-core/test' import { createRumSessionManagerMock, @@ -20,11 +20,11 @@ import type { StartRecording } from './postStartStrategy' describe('makeRecorderApi', () => { let lifeCycle: LifeCycle let recorderApi: RecorderApi - let startRecordingSpy: jasmine.Spy + let startRecordingSpy: jasmine.Spy + let loadRecorderSpy: jasmine.Spy<() => Promise> let stopRecordingSpy: jasmine.Spy<() => void> let mockWorker: MockWorker let createDeflateWorkerSpy: jasmine.Spy - let rumInit: (options?: { worker?: DeflateWorker }) => void function setupRecorderApi({ @@ -37,11 +37,17 @@ describe('makeRecorderApi', () => { lifeCycle = new LifeCycle() stopRecordingSpy = jasmine.createSpy('stopRecording') - startRecordingSpy = jasmine.createSpy('startRecording').and.callFake(() => ({ - stop: stopRecordingSpy, - })) + startRecordingSpy = jasmine.createSpy('startRecording') + + // Workaround because using resolveTo(startRecordingSpy) was not working + loadRecorderSpy = jasmine.createSpy('loadRecorder').and.resolveTo((...args: any) => { + startRecordingSpy(...args) + return { + stop: stopRecordingSpy, + } + }) - recorderApi = makeRecorderApi(startRecordingSpy, createDeflateWorkerSpy) + recorderApi = makeRecorderApi(loadRecorderSpy, createDeflateWorkerSpy) rumInit = ({ worker } = {}) => { recorderApi.onRumStart( lifeCycle, @@ -60,19 +66,25 @@ describe('makeRecorderApi', () => { describe('recorder boot', () => { describe('with automatic start', () => { - it('starts recording when init() is called', () => { + it('starts recording when init() is called', async () => { setupRecorderApi() + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) expect(startRecordingSpy).toHaveBeenCalled() }) - it('starts recording after the DOM is loaded', () => { + it('starts recording after the DOM is loaded', async () => { setupRecorderApi() const { triggerOnDomLoaded } = mockDocumentReadyState() rumInit() + + expect(loadRecorderSpy).toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() triggerOnDomLoaded() + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalled() }) }) @@ -80,8 +92,10 @@ describe('makeRecorderApi', () => { describe('with manual start', () => { it('does not start recording when init() is called', () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() rumInit() + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) @@ -89,30 +103,36 @@ describe('makeRecorderApi', () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) const { triggerOnDomLoaded } = mockDocumentReadyState() rumInit() + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() triggerOnDomLoaded() + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) }) }) describe('recorder start', () => { - it('ignores additional start calls while recording is already started', () => { + it('ignores additional start calls while recording is already started', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit() recorderApi.start() recorderApi.start() recorderApi.start() + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalledTimes(1) }) - it('ignores restart before the DOM is loaded', () => { + it('ignores restart before the DOM is loaded', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) const { triggerOnDomLoaded } = mockDocumentReadyState() rumInit() recorderApi.stop() recorderApi.start() triggerOnDomLoaded() + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalledTimes(1) }) @@ -123,6 +143,8 @@ describe('makeRecorderApi', () => { }) rumInit() recorderApi.start() + + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) @@ -133,10 +155,11 @@ describe('makeRecorderApi', () => { }) rumInit() recorderApi.start() + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) - it('should start recording if session is tracked without session replay when forced', () => { + it('should start recording if session is tracked without session replay when forced', async () => { const setForcedReplaySpy = jasmine.createSpy() setupRecorderApi({ @@ -149,46 +172,60 @@ describe('makeRecorderApi', () => { rumInit() recorderApi.start({ force: true }) + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalledTimes(1) expect(setForcedReplaySpy).toHaveBeenCalledTimes(1) }) - it('uses the previously created worker if available', () => { + it('uses the previously created worker if available', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit({ worker: mockWorker }) recorderApi.start() + + await collectAsyncCalls(startRecordingSpy, 1) expect(createDeflateWorkerSpy).not.toHaveBeenCalled() expect(startRecordingSpy).toHaveBeenCalled() }) - it('does not start recording if worker creation fails', () => { + it('does not start recording if worker creation fails', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit() + createDeflateWorkerSpy.and.throwError('Crash') recorderApi.start() + + expect(loadRecorderSpy).toHaveBeenCalled() + await collectAsyncCalls(createDeflateWorkerSpy, 1) + expect(startRecordingSpy).not.toHaveBeenCalled() }) - it('stops recording if worker initialization fails', () => { + it('stops recording if worker initialization fails', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit() recorderApi.start() + expect(loadRecorderSpy).toHaveBeenCalled() + await collectAsyncCalls(startRecordingSpy, 1) mockWorker.dispatchErrorEvent() expect(stopRecordingSpy).toHaveBeenCalled() }) - it('restarting the recording should not reset the worker action id', () => { + it('restarting the recording should not reset the worker action id', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit() recorderApi.start() + await collectAsyncCalls(startRecordingSpy, 1) + const firstCallDeflateEncoder: DeflateEncoder = startRecordingSpy.calls.mostRecent().args[4] firstCallDeflateEncoder.write('foo') recorderApi.stop() recorderApi.start() + await collectAsyncCalls(startRecordingSpy, 2) const secondCallDeflateEncoder: DeflateEncoder = startRecordingSpy.calls.mostRecent().args[4] secondCallDeflateEncoder.write('foo') @@ -201,12 +238,14 @@ describe('makeRecorderApi', () => { }) describe('if event bridge present', () => { - it('should start recording when the bridge supports records', () => { + it('should start recording when the bridge supports records', async () => { mockEventBridge({ capabilities: [BridgeCapability.RECORDS] }) setupRecorderApi() rumInit() recorderApi.start() + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalled() }) @@ -216,6 +255,8 @@ describe('makeRecorderApi', () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit() recorderApi.start() + + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) }) @@ -236,27 +277,33 @@ describe('makeRecorderApi', () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) recorderApi.start() rumInit() + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) }) }) describe('recorder stop', () => { - it('ignores calls while recording is already stopped', () => { + it('ignores calls while recording is already stopped', async () => { setupRecorderApi() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) + recorderApi.stop() recorderApi.stop() recorderApi.stop() expect(stopRecordingSpy).toHaveBeenCalledTimes(1) }) - it('prevents recording to start when the DOM is loaded', () => { + it('prevents recording to start when the DOM is loaded', async () => { setupRecorderApi() const { triggerOnDomLoaded } = mockDocumentReadyState() rumInit() recorderApi.stop() triggerOnDomLoaded() + + await collectAsyncCalls(loadRecorderSpy, 1) + expect(startRecordingSpy).not.toHaveBeenCalled() }) }) @@ -269,9 +316,11 @@ describe('makeRecorderApi', () => { }) // prevent getting records after the before_unload event has been triggered. - it('stop recording when the page unloads', () => { + it('stop recording when the page unloads', async () => { sessionManager.setTrackedWithSessionReplay() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalledTimes(1) lifeCycle.notify(LifeCycleEventType.PAGE_EXITED, { reason: PageExitReason.UNLOADING }) @@ -284,13 +333,15 @@ describe('makeRecorderApi', () => { sessionManager.setTrackedWithoutSessionReplay() }) - it('starts recording if startSessionReplayRecording was called', () => { + it('starts recording if startSessionReplayRecording was called', async () => { rumInit() sessionManager.setTrackedWithSessionReplay() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) expect(startRecordingSpy).not.toHaveBeenCalled() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) - expect(startRecordingSpy).toHaveBeenCalled() + await collectAsyncCalls(startRecordingSpy, 1) + + expect(startRecordingSpy).toHaveBeenCalledTimes(1) expect(stopRecordingSpy).not.toHaveBeenCalled() }) @@ -300,6 +351,8 @@ describe('makeRecorderApi', () => { sessionManager.setTrackedWithSessionReplay() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) }) @@ -314,6 +367,8 @@ describe('makeRecorderApi', () => { sessionManager.setNotTracked() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() expect(stopRecordingSpy).not.toHaveBeenCalled() }) @@ -328,6 +383,8 @@ describe('makeRecorderApi', () => { rumInit() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() expect(stopRecordingSpy).not.toHaveBeenCalled() }) @@ -338,16 +395,20 @@ describe('makeRecorderApi', () => { sessionManager.setTrackedWithSessionReplay() }) - it('stops recording if startSessionReplayRecording was called', () => { + it('stops recording if startSessionReplayRecording was called', async () => { rumInit() + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalledTimes(1) sessionManager.setTrackedWithoutSessionReplay() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) expect(stopRecordingSpy).toHaveBeenCalled() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).toHaveBeenCalledTimes(1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) }) + // reassess this test it('prevents session recording to start if the session is renewed before the DOM is loaded', () => { const { triggerOnDomLoaded } = mockDocumentReadyState() rumInit() @@ -355,6 +416,7 @@ describe('makeRecorderApi', () => { lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) triggerOnDomLoaded() + expect(loadRecorderSpy).toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() }) }) @@ -364,13 +426,15 @@ describe('makeRecorderApi', () => { sessionManager.setTrackedWithSessionReplay() }) - it('stops recording if startSessionReplayRecording was called', () => { + it('stops recording if startSessionReplayRecording was called', async () => { rumInit() + await collectAsyncCalls(startRecordingSpy, 1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) sessionManager.setNotTracked() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) expect(stopRecordingSpy).toHaveBeenCalled() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).toHaveBeenCalledTimes(1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) }) }) @@ -380,22 +444,28 @@ describe('makeRecorderApi', () => { sessionManager.setTrackedWithSessionReplay() }) - it('keeps recording if startSessionReplayRecording was called', () => { + it('keeps recording if startSessionReplayRecording was called', async () => { rumInit() + await collectAsyncCalls(startRecordingSpy, 1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) expect(stopRecordingSpy).toHaveBeenCalled() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + await collectAsyncCalls(startRecordingSpy, 2) + + expect(loadRecorderSpy).toHaveBeenCalledTimes(2) expect(startRecordingSpy).toHaveBeenCalledTimes(2) }) - it('does not starts recording if stopSessionReplayRecording was called', () => { + it('does not starts recording if stopSessionReplayRecording was called', async () => { rumInit() + await collectAsyncCalls(startRecordingSpy, 1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) recorderApi.stop() expect(stopRecordingSpy).toHaveBeenCalledTimes(1) lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).toHaveBeenCalledTimes(1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) expect(stopRecordingSpy).toHaveBeenCalledTimes(1) }) @@ -406,12 +476,15 @@ describe('makeRecorderApi', () => { sessionManager.setNotTracked() }) - it('starts recording if startSessionReplayRecording was called', () => { + it('starts recording if startSessionReplayRecording was called', async () => { rumInit() sessionManager.setTrackedWithSessionReplay() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + await collectAsyncCalls(startRecordingSpy, 1) + expect(startRecordingSpy).toHaveBeenCalled() + expect(stopRecordingSpy).not.toHaveBeenCalled() }) @@ -421,6 +494,7 @@ describe('makeRecorderApi', () => { sessionManager.setTrackedWithSessionReplay() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() expect(stopRecordingSpy).not.toHaveBeenCalled() }) @@ -436,6 +510,7 @@ describe('makeRecorderApi', () => { sessionManager.setTrackedWithoutSessionReplay() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() expect(stopRecordingSpy).not.toHaveBeenCalled() }) @@ -450,6 +525,7 @@ describe('makeRecorderApi', () => { rumInit() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).not.toHaveBeenCalled() expect(startRecordingSpy).not.toHaveBeenCalled() expect(stopRecordingSpy).not.toHaveBeenCalled() }) @@ -467,45 +543,48 @@ describe('makeRecorderApi', () => { expect(recorderApi.isRecording()).toBeFalse() }) - it('is false when the worker is not yet initialized', () => { + it('is false when the worker is not yet initialized', async () => { setupRecorderApi() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) - recorderApi.start() expect(recorderApi.isRecording()).toBeFalse() }) - it('is false when the worker failed to initialize', () => { + it('is false when the worker failed to initialize', async () => { setupRecorderApi() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) - recorderApi.start() mockWorker.dispatchErrorEvent() expect(recorderApi.isRecording()).toBeFalse() }) - it('is true when recording is started and the worker is initialized', () => { + it('is true when recording is started and the worker is initialized', async () => { setupRecorderApi() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) - recorderApi.start() mockWorker.processAllMessages() expect(recorderApi.isRecording()).toBeTrue() }) - it('is false before the DOM is loaded', () => { + it('is false before the DOM is loaded', async () => { setupRecorderApi() const { triggerOnDomLoaded } = mockDocumentReadyState() rumInit() recorderApi.start() + mockWorker.processAllMessages() expect(recorderApi.isRecording()).toBeFalse() triggerOnDomLoaded() + await collectAsyncCalls(startRecordingSpy, 1) + mockWorker.processAllMessages() expect(recorderApi.isRecording()).toBeTrue() @@ -525,31 +604,31 @@ describe('makeRecorderApi', () => { expect(recorderApi.getReplayStats(VIEW_ID)).toBeUndefined() }) - it('is undefined when the worker is not yet initialized', () => { + it('is undefined when the worker is not yet initialized', async () => { setupRecorderApi() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) - recorderApi.start() replayStats.addSegment(VIEW_ID) expect(recorderApi.getReplayStats(VIEW_ID)).toBeUndefined() }) - it('is undefined when the worker failed to initialize', () => { + it('is undefined when the worker failed to initialize', async () => { setupRecorderApi() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) - recorderApi.start() replayStats.addSegment(VIEW_ID) mockWorker.dispatchErrorEvent() expect(recorderApi.getReplayStats(VIEW_ID)).toBeUndefined() }) - it('is defined when recording is started and the worker is initialized', () => { + it('is defined when recording is started and the worker is initialized', async () => { setupRecorderApi() rumInit() + await collectAsyncCalls(startRecordingSpy, 1) - recorderApi.start() replayStats.addSegment(VIEW_ID) mockWorker.processAllMessages() diff --git a/packages/rum/src/boot/recorderApi.ts b/packages/rum/src/boot/recorderApi.ts index ca0d7133b9..7af9743744 100644 --- a/packages/rum/src/boot/recorderApi.ts +++ b/packages/rum/src/boot/recorderApi.ts @@ -28,7 +28,7 @@ import { createPostStartStrategy } from './postStartStrategy' import { createPreStartStrategy } from './preStartStrategy' export function makeRecorderApi( - startRecordingImpl: StartRecording, + loadRecorder: () => Promise, createDeflateWorkerImpl?: CreateDeflateWorker ): RecorderApi { if ((canUseEventBridge() && !bridgeSupports(BridgeCapability.RECORDS)) || !isBrowserSupported()) { @@ -111,7 +111,7 @@ export function makeRecorderApi( lifeCycle, sessionManager, viewHistory, - startRecordingImpl, + loadRecorder, getOrCreateDeflateEncoder ) diff --git a/packages/rum/src/entries/internalSynthetics.ts b/packages/rum/src/entries/internalSynthetics.ts index 7890cac0fe..92ef1069e7 100644 --- a/packages/rum/src/entries/internalSynthetics.ts +++ b/packages/rum/src/entries/internalSynthetics.ts @@ -6,15 +6,14 @@ * changes. */ import { makeRumPublicApi, startRum } from '@datadog/browser-rum-core' - -import { startRecording } from '../boot/startRecording' import { makeRecorderApi } from '../boot/recorderApi' +import { lazyLoadRecorder } from '../boot/lazyLoadRecorder' export { DefaultPrivacyLevel } from '@datadog/browser-core' // Disable the rule that forbids potential side effects, because we know that those functions don't // have side effects. /* eslint-disable local-rules/disallow-side-effects */ -const recorderApi = makeRecorderApi(startRecording) +const recorderApi = makeRecorderApi(lazyLoadRecorder) export const datadogRum = makeRumPublicApi(startRum, recorderApi, { ignoreInitIfSyntheticsWillInjectRum: false }) /* eslint-enable local-rules/disallow-side-effects */ diff --git a/packages/rum/src/entries/main.ts b/packages/rum/src/entries/main.ts index 895cc7509e..3bf61e728f 100644 --- a/packages/rum/src/entries/main.ts +++ b/packages/rum/src/entries/main.ts @@ -2,11 +2,9 @@ import { defineGlobal, getGlobalObject } from '@datadog/browser-core' import type { RumPublicApi } from '@datadog/browser-rum-core' import { makeRumPublicApi, startRum } from '@datadog/browser-rum-core' - -import { startRecording } from '../boot/startRecording' import { makeRecorderApi } from '../boot/recorderApi' import { createDeflateEncoder, startDeflateWorker } from '../domain/deflate' - +import { lazyLoadRecorder } from '../boot/lazyLoadRecorder' export { CommonProperties, RumPublicApi as RumGlobal, @@ -31,7 +29,8 @@ export { } from '@datadog/browser-rum-core' export { DefaultPrivacyLevel } from '@datadog/browser-core' -const recorderApi = makeRecorderApi(startRecording) +const recorderApi = makeRecorderApi(lazyLoadRecorder) + export const datadogRum = makeRumPublicApi(startRum, recorderApi, { startDeflateWorker, createDeflateEncoder }) interface BrowserWindow extends Window { diff --git a/packages/rum/tsconfig.esm.json b/packages/rum/tsconfig.esm.json index 270d42a788..815687627a 100644 --- a/packages/rum/tsconfig.esm.json +++ b/packages/rum/tsconfig.esm.json @@ -3,7 +3,6 @@ "compilerOptions": { "baseUrl": ".", "declaration": true, - "module": "es6", "allowJs": true, "rootDir": "./src/", "outDir": "./esm/" diff --git a/scripts/deploy/deploy.js b/scripts/deploy/deploy.js index 29fcc01fdb..a8422afb1a 100644 --- a/scripts/deploy/deploy.js +++ b/scripts/deploy/deploy.js @@ -1,14 +1,14 @@ 'use strict' const { printLog, runMain } = require('../lib/executionUtils') -const { fetchPR, getLocalBranch } = require('../lib/gitUtils') +const { fetchPR, LOCAL_BRANCH } = require('../lib/gitUtils') const { command } = require('../lib/command') +const { forEachFile } = require('../lib/filesUtils') const { buildRootUploadPath, buildDatacenterUploadPath, buildBundleFolder, - buildBundleFileName, buildPullRequestUploadPath, packages, } = require('./lib/deploymentUtils') @@ -36,7 +36,6 @@ if (require.main === module) { const env = process.argv[2] const version = process.argv[3] const uploadPathTypes = process.argv[4].split(',') - runMain(async () => { await main(env, version, uploadPathTypes) }) @@ -46,28 +45,50 @@ async function main(env, version, uploadPathTypes) { const awsConfig = AWS_CONFIG[env] let cloudfrontPathsToInvalidate = [] for (const { packageName } of packages) { - const bundleFolder = buildBundleFolder(packageName) - for (const uploadPathType of uploadPathTypes) { - let uploadPath - if (uploadPathType === 'pull-request') { - const pr = await fetchPR(getLocalBranch()) - if (!pr) { - console.log('No pull requests found for the branch') - return - } - uploadPath = buildPullRequestUploadPath(packageName, pr.number) - } else if (uploadPathType === 'root') { - uploadPath = buildRootUploadPath(packageName, version) - } else { - uploadPath = buildDatacenterUploadPath(uploadPathType, packageName, version) + const pathsToInvalidate = await uploadPackage(awsConfig, packageName, version, uploadPathTypes) + cloudfrontPathsToInvalidate.push(...pathsToInvalidate) + } + invalidateCloudfront(awsConfig, cloudfrontPathsToInvalidate) +} + +async function uploadPackage(awsConfig, packageName, version, uploadPathTypes) { + const cloudfrontPathsToInvalidate = [] + const bundleFolder = buildBundleFolder(packageName) + + for (const uploadPathType of uploadPathTypes) { + await forEachFile(bundleFolder, async (bundlePath) => { + if (!bundlePath.endsWith('.js')) { + return } - const bundlePath = `${bundleFolder}/${buildBundleFileName(packageName)}` + + const relativeBundlePath = bundlePath.replace(`${bundleFolder}/`, '') + const uploadPath = await generateUploadPath(uploadPathType, relativeBundlePath, version) uploadToS3(awsConfig, bundlePath, uploadPath, version) cloudfrontPathsToInvalidate.push(`/${uploadPath}`) + }) + } + + return cloudfrontPathsToInvalidate +} + +async function generateUploadPath(uploadPathType, filePath, version) { + let uploadPath + + if (uploadPathType === 'pull-request') { + const pr = await fetchPR(LOCAL_BRANCH) + if (!pr) { + console.log('No pull requests found for the branch') + process.exit(0) } + uploadPath = buildPullRequestUploadPath(filePath, pr.number) + } else if (uploadPathType === 'root') { + uploadPath = buildRootUploadPath(filePath, version) + } else { + uploadPath = buildDatacenterUploadPath(uploadPathType, filePath, version) } - invalidateCloudfront(awsConfig, cloudfrontPathsToInvalidate) + + return uploadPath } function uploadToS3(awsConfig, bundlePath, uploadPath, version) { diff --git a/scripts/deploy/deploy.spec.js b/scripts/deploy/deploy.spec.js index 468e23b9b9..a953378c8a 100644 --- a/scripts/deploy/deploy.spec.js +++ b/scripts/deploy/deploy.spec.js @@ -1,7 +1,13 @@ const assert = require('node:assert/strict') const { beforeEach, before, describe, it, mock } = require('node:test') const path = require('node:path') -const { mockModule, mockCommandImplementation, FAKE_AWS_ENV_CREDENTIALS } = require('./lib/testHelpers.js') +const { + mockModule, + mockCommandImplementation, + replaceChunkHashes, + FAKE_AWS_ENV_CREDENTIALS, + FAKE_CHUNK_HASH, +} = require('./lib/testHelpers.js') void describe('deploy', () => { let commandMock = mock.fn() @@ -12,11 +18,13 @@ void describe('deploy', () => { let commands function getS3Commands() { - return commands.filter(({ command }) => command.includes('aws s3 cp')) + return commands.filter(({ command }) => command.includes('aws s3 cp')).map(replaceChunkHashes) } function getCloudfrontCommands() { - return commands.filter(({ command }) => command.includes('aws cloudfront create-invalidation')) + return commands + .filter(({ command }) => command.includes('aws cloudfront create-invalidation')) + .map(replaceChunkHashes) } before(async () => { @@ -37,15 +45,23 @@ void describe('deploy', () => { assert.deepEqual(getS3Commands(), [ { + // Logs bundle command: 'aws s3 cp --cache-control max-age=14400, s-maxage=60 packages/logs/bundle/datadog-logs.js s3://browser-agent-artifacts-prod/datadog-logs-v6.js', env, }, + { + // RUM chunks: We don't suffix chunk names as they are referenced by the main bundle. Renaming them would require updates via Webpack, adding unnecessary complexity for minimal value. + command: `aws s3 cp --cache-control max-age=14400, s-maxage=60 packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js s3://browser-agent-artifacts-prod/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js`, + env, + }, + // RUM bundle { command: 'aws s3 cp --cache-control max-age=14400, s-maxage=60 packages/rum/bundle/datadog-rum.js s3://browser-agent-artifacts-prod/datadog-rum-v6.js', env, }, + // RUM slim bundle { command: 'aws s3 cp --cache-control max-age=14400, s-maxage=60 packages/rum-slim/bundle/datadog-rum-slim.js s3://browser-agent-artifacts-prod/datadog-rum-slim-v6.js', @@ -55,8 +71,7 @@ void describe('deploy', () => { assert.deepEqual(getCloudfrontCommands(), [ { - command: - 'aws cloudfront create-invalidation --distribution-id EGB08BYCT1DD9 --paths /datadog-logs-v6.js,/datadog-rum-v6.js,/datadog-rum-slim-v6.js', + command: `aws cloudfront create-invalidation --distribution-id EGB08BYCT1DD9 --paths /datadog-logs-v6.js,/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js,/datadog-rum-v6.js,/datadog-rum-slim-v6.js`, env, }, ]) @@ -71,6 +86,10 @@ void describe('deploy', () => { 'aws s3 cp --cache-control max-age=14400, s-maxage=60 packages/logs/bundle/datadog-logs.js s3://browser-agent-artifacts-prod/us1/v6/datadog-logs.js', env, }, + { + command: `aws s3 cp --cache-control max-age=14400, s-maxage=60 packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js s3://browser-agent-artifacts-prod/us1/v6/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js`, + env, + }, { command: 'aws s3 cp --cache-control max-age=14400, s-maxage=60 packages/rum/bundle/datadog-rum.js s3://browser-agent-artifacts-prod/us1/v6/datadog-rum.js', @@ -84,8 +103,7 @@ void describe('deploy', () => { ]) assert.deepEqual(getCloudfrontCommands(), [ { - command: - 'aws cloudfront create-invalidation --distribution-id EGB08BYCT1DD9 --paths /us1/v6/datadog-logs.js,/us1/v6/datadog-rum.js,/us1/v6/datadog-rum-slim.js', + command: `aws cloudfront create-invalidation --distribution-id EGB08BYCT1DD9 --paths /us1/v6/datadog-logs.js,/us1/v6/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js,/us1/v6/datadog-rum.js,/us1/v6/datadog-rum-slim.js`, env, }, ]) @@ -100,6 +118,10 @@ void describe('deploy', () => { 'aws s3 cp --cache-control max-age=900, s-maxage=60 packages/logs/bundle/datadog-logs.js s3://browser-agent-artifacts-staging/datadog-logs-staging.js', env, }, + { + command: `aws s3 cp --cache-control max-age=900, s-maxage=60 packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js s3://browser-agent-artifacts-staging/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js`, + env, + }, { command: 'aws s3 cp --cache-control max-age=900, s-maxage=60 packages/rum/bundle/datadog-rum.js s3://browser-agent-artifacts-staging/datadog-rum-staging.js', @@ -114,8 +136,7 @@ void describe('deploy', () => { assert.deepEqual(getCloudfrontCommands(), [ { - command: - 'aws cloudfront create-invalidation --distribution-id E2FP11ZSCFD3EU --paths /datadog-logs-staging.js,/datadog-rum-staging.js,/datadog-rum-slim-staging.js', + command: `aws cloudfront create-invalidation --distribution-id E2FP11ZSCFD3EU --paths /datadog-logs-staging.js,/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js,/datadog-rum-staging.js,/datadog-rum-slim-staging.js`, env, }, ]) @@ -133,6 +154,10 @@ void describe('deploy', () => { 'aws s3 cp --cache-control max-age=900, s-maxage=60 packages/logs/bundle/datadog-logs.js s3://browser-agent-artifacts-staging/pull-request/123/datadog-logs.js', env, }, + { + command: `aws s3 cp --cache-control max-age=900, s-maxage=60 packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js s3://browser-agent-artifacts-staging/pull-request/123/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js`, + env, + }, { command: 'aws s3 cp --cache-control max-age=900, s-maxage=60 packages/rum/bundle/datadog-rum.js s3://browser-agent-artifacts-staging/pull-request/123/datadog-rum.js', @@ -147,8 +172,7 @@ void describe('deploy', () => { assert.deepEqual(getCloudfrontCommands(), [ { - command: - 'aws cloudfront create-invalidation --distribution-id E2FP11ZSCFD3EU --paths /pull-request/123/datadog-logs.js,/pull-request/123/datadog-rum.js,/pull-request/123/datadog-rum-slim.js', + command: `aws cloudfront create-invalidation --distribution-id E2FP11ZSCFD3EU --paths /pull-request/123/datadog-logs.js,/pull-request/123/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js,/pull-request/123/datadog-rum.js,/pull-request/123/datadog-rum-slim.js`, env, }, ]) diff --git a/scripts/deploy/lib/deploymentUtils.js b/scripts/deploy/lib/deploymentUtils.js index c26acca3f5..992aee6d46 100644 --- a/scripts/deploy/lib/deploymentUtils.js +++ b/scripts/deploy/lib/deploymentUtils.js @@ -4,20 +4,25 @@ const packages = [ { packageName: 'rum-slim', service: 'browser-rum-sdk' }, ] -// ex: datadog-rum-v4.js -const buildRootUploadPath = (packageName, version, extension = 'js') => `datadog-${packageName}-${version}.${extension}` +// ex: datadog-rum-v4.js, chunks/recorder-8d8a8dfab6958424038f-datadog-rum.js +const buildRootUploadPath = (filePath, version) => { + // We don't suffix chunk names as they are referenced by the main bundle. Renaming them would require updates via Webpack, adding unnecessary complexity for minimal value. + if (filePath.includes('chunks')) { + return filePath + } -// ex: us1/v4/datadog-rum.js -const buildDatacenterUploadPath = (datacenter, packageName, version, extension = 'js') => - `${datacenter}/${version}/datadog-${packageName}.${extension}` + const [basePath, ...extensions] = filePath.split('.') + const ext = extensions.join('.') // allow to handle multiple extensions like `.js.map` -// ex: datadog-rum.js -const buildBundleFileName = (packageName, extension = 'js') => `datadog-${packageName}.${extension}` - -// ex: pull-request/2781/datadog-rum.js -function buildPullRequestUploadPath(packageName, version, extension = 'js') { - return `pull-request/${version}/datadog-${packageName}.${extension}` + return `${basePath}-${version}.${ext}` } + +// ex: us1/v4/datadog-rum.js, eu1/v4/chunks/recorder-8d8a8dfab6958424038f-datadog-rum.js +const buildDatacenterUploadPath = (datacenter, filePath, version) => `${datacenter}/${version}/${filePath}` + +// ex: pull-request/2781/datadog-rum.js, pull-request/2781/chunks/recorder-8d8a8dfab6958424038f-datadog-rum.js +const buildPullRequestUploadPath = (filePath, version) => `pull-request/${version}/${filePath}` + // ex: packages/rum/bundle const buildBundleFolder = (packageName) => `packages/${packageName}/bundle` @@ -25,7 +30,6 @@ module.exports = { packages, buildRootUploadPath, buildDatacenterUploadPath, - buildBundleFileName, buildBundleFolder, buildPullRequestUploadPath, } diff --git a/scripts/deploy/lib/testHelpers.js b/scripts/deploy/lib/testHelpers.js index edcd8b922e..df9a49a018 100644 --- a/scripts/deploy/lib/testHelpers.js +++ b/scripts/deploy/lib/testHelpers.js @@ -18,6 +18,8 @@ const FAKE_AWS_ENV_CREDENTIALS = { AWS_SESSION_TOKEN: 'FAKESESSIONTOKEN123456', } +const FAKE_CHUNK_HASH = 'FAKEHASHd7628536637b074ddc3b' + function mockCommandImplementation(mock) { const commands = [] @@ -58,8 +60,17 @@ function rebuildStringTemplate(template, ...values) { return normalizedString } +function replaceChunkHashes(commandDetail) { + return { + ...commandDetail, + command: commandDetail.command.replace(/-[a-f0-9]+-datadog-rum/g, `-${FAKE_CHUNK_HASH}-datadog-rum`), + } +} + module.exports = { mockModule, mockCommandImplementation, + replaceChunkHashes, FAKE_AWS_ENV_CREDENTIALS, + FAKE_CHUNK_HASH, } diff --git a/scripts/deploy/upload-source-maps.js b/scripts/deploy/upload-source-maps.js index ca7241662e..ec1af055fa 100644 --- a/scripts/deploy/upload-source-maps.js +++ b/scripts/deploy/upload-source-maps.js @@ -6,13 +6,8 @@ const { command } = require('../lib/command') const { getBuildEnvValue } = require('../lib/buildEnv') const { getTelemetryOrgApiKey } = require('../lib/secrets') const { siteByDatacenter } = require('../lib/datadogSites') -const { - buildRootUploadPath, - buildDatacenterUploadPath, - buildBundleFolder, - buildBundleFileName, - packages, -} = require('./lib/deploymentUtils') +const { forEachFile } = require('../lib/filesUtils') +const { buildRootUploadPath, buildDatacenterUploadPath, buildBundleFolder, packages } = require('./lib/deploymentUtils') /** * Upload source maps to datadog @@ -20,59 +15,65 @@ const { * BUILD_MODE=canary|release node upload-source-maps.js staging|canary|vXXX root,us1,eu1,... */ +function getSitesByVersion(version) { + switch (version) { + case 'staging': + return ['datad0g.com', 'datadoghq.com'] + case 'canary': + return ['datadoghq.com'] + default: + return Object.values(siteByDatacenter) + } +} + if (require.main === module) { const version = process.argv[2] let uploadPathTypes = process.argv[3].split(',') - runMain(() => { - main(version, uploadPathTypes) + runMain(async () => { + await main(version, uploadPathTypes) }) } -function main(version, uploadPathTypes) { +async function main(version, uploadPathTypes) { for (const { packageName, service } of packages) { - const bundleFolder = buildBundleFolder(packageName) - for (const uploadPathType of uploadPathTypes) { - let sites - let uploadPath - if (uploadPathType === 'root') { - sites = getSitesByVersion(version) - uploadPath = buildRootUploadPath(packageName, version) - renameFilesWithVersionSuffix(packageName, bundleFolder, version) - } else { - sites = [siteByDatacenter[uploadPathType]] - uploadPath = buildDatacenterUploadPath(uploadPathType, packageName, version) - } - const prefix = path.dirname(`/${uploadPath}`) - uploadSourceMaps(packageName, service, prefix, bundleFolder, sites) - } + await uploadSourceMaps(packageName, service, version, uploadPathTypes) } printLog('Source maps upload done.') } -function getSitesByVersion(version) { - switch (version) { - case 'staging': - return ['datad0g.com', 'datadoghq.com'] - case 'canary': - return ['datadoghq.com'] - default: - return Object.values(siteByDatacenter) +async function uploadSourceMaps(packageName, service, version, uploadPathTypes) { + const bundleFolder = buildBundleFolder(packageName) + + for (const uploadPathType of uploadPathTypes) { + let sites + let uploadPath + if (uploadPathType === 'root') { + sites = getSitesByVersion(version) + uploadPath = buildRootUploadPath(packageName, version) + await renameFilesWithVersionSuffix(bundleFolder, version) + } else { + sites = [siteByDatacenter[uploadPathType]] + uploadPath = buildDatacenterUploadPath(uploadPathType, packageName, version) + } + const prefix = path.dirname(`/${uploadPath}`) + uploadToDatadog(packageName, service, prefix, bundleFolder, sites) } } -function renameFilesWithVersionSuffix(packageName, bundleFolder, version) { +async function renameFilesWithVersionSuffix(bundleFolder, version) { // The datadog-ci CLI is taking a directory as an argument. It will scan every source map files in // it and upload those along with the minified bundle. The file names must match the one from the // CDN, thus we need to rename the bundles with the right suffix. - for (const extension of ['js', 'js.map']) { - const bundlePath = `${bundleFolder}/${buildBundleFileName(packageName, extension)}` - const uploadPath = `${bundleFolder}/${buildRootUploadPath(packageName, version, extension)}` + await forEachFile(bundleFolder, (bundlePath) => { + const uploadPath = buildRootUploadPath(bundlePath, version) + + console.log(`Renaming ${bundlePath} to ${uploadPath}`) command`mv ${bundlePath} ${uploadPath}`.run() - } + }) } -function uploadSourceMaps(packageName, service, prefix, bundleFolder, sites) { +function uploadToDatadog(packageName, service, prefix, bundleFolder, sites) { for (const site of sites) { printLog(`Uploading ${packageName} source maps with prefix ${prefix} for ${site}...`) diff --git a/scripts/deploy/upload-source-maps.spec.js b/scripts/deploy/upload-source-maps.spec.js index 261b7dccdc..39896cba12 100644 --- a/scripts/deploy/upload-source-maps.spec.js +++ b/scripts/deploy/upload-source-maps.spec.js @@ -2,7 +2,7 @@ const assert = require('node:assert/strict') const path = require('node:path') const { beforeEach, before, describe, it, mock } = require('node:test') const { siteByDatacenter } = require('../lib/datadogSites') -const { mockModule, mockCommandImplementation } = require('./lib/testHelpers.js') +const { mockModule, mockCommandImplementation, replaceChunkHashes, FAKE_CHUNK_HASH } = require('./lib/testHelpers.js') const FAKE_API_KEY = 'FAKE_API_KEY' const ENV_STAGING = { @@ -22,6 +22,10 @@ void describe('upload-source-maps', () => { return commands.filter(({ command }) => command.includes('datadog-ci sourcemaps')) } + function getFileRenamingCommands() { + return commands.filter(({ command }) => command.includes('mv')).map(replaceChunkHashes) + } + before(async () => { await mockModule(path.resolve(__dirname, '../lib/command.js'), { command: commandMock }) await mockModule(path.resolve(__dirname, '../lib/secrets.js'), { getTelemetryOrgApiKey: () => FAKE_API_KEY }) @@ -48,6 +52,36 @@ void describe('upload-source-maps', () => { const commandsByDatacenter = commands.filter(({ env }) => env?.DATADOG_SITE === site) const env = { DATADOG_API_KEY: FAKE_API_KEY, DATADOG_SITE: site } + // rename the files with the version suffix + assert.deepEqual(getFileRenamingCommands(), [ + { + command: 'mv packages/logs/bundle/datadog-logs.js packages/logs/bundle/datadog-logs-v6.js', + }, + { + command: 'mv packages/logs/bundle/datadog-logs.js.map packages/logs/bundle/datadog-logs-v6.js.map', + }, + { + command: `mv packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js`, + }, + { + command: `mv packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js.map packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js.map`, + }, + { + command: 'mv packages/rum/bundle/datadog-rum.js packages/rum/bundle/datadog-rum-v6.js', + }, + { + command: 'mv packages/rum/bundle/datadog-rum.js.map packages/rum/bundle/datadog-rum-v6.js.map', + }, + { + command: 'mv packages/rum-slim/bundle/datadog-rum-slim.js packages/rum-slim/bundle/datadog-rum-slim-v6.js', + }, + { + command: + 'mv packages/rum-slim/bundle/datadog-rum-slim.js.map packages/rum-slim/bundle/datadog-rum-slim-v6.js.map', + }, + ]) + + // upload the source maps assert.deepEqual(commandsByDatacenter, [ { command: @@ -93,6 +127,36 @@ void describe('upload-source-maps', () => { void it('should upload staging packages source maps', async () => { await uploadSourceMaps('staging', ['root']) + // rename the files with the version suffix + assert.deepEqual(getFileRenamingCommands(), [ + { + command: 'mv packages/logs/bundle/datadog-logs.js packages/logs/bundle/datadog-logs-staging.js', + }, + { + command: 'mv packages/logs/bundle/datadog-logs.js.map packages/logs/bundle/datadog-logs-staging.js.map', + }, + { + command: `mv packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js`, + }, + { + command: `mv packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js.map packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js.map`, + }, + { + command: 'mv packages/rum/bundle/datadog-rum.js packages/rum/bundle/datadog-rum-staging.js', + }, + { + command: 'mv packages/rum/bundle/datadog-rum.js.map packages/rum/bundle/datadog-rum-staging.js.map', + }, + { + command: 'mv packages/rum-slim/bundle/datadog-rum-slim.js packages/rum-slim/bundle/datadog-rum-slim-staging.js', + }, + { + command: + 'mv packages/rum-slim/bundle/datadog-rum-slim.js.map packages/rum-slim/bundle/datadog-rum-slim-staging.js.map', + }, + ]) + + // upload the source maps assert.deepEqual(getSourceMapCommands(), [ { command: @@ -130,28 +194,36 @@ void describe('upload-source-maps', () => { void it('should upload canary packages source maps', async () => { await uploadSourceMaps('canary', ['root']) - assert.deepEqual(getSourceMapCommands(), [ + // rename the files with the version suffix + assert.deepEqual(getFileRenamingCommands(), [ { - command: - 'datadog-ci sourcemaps upload packages/logs/bundle --service browser-logs-sdk --release-version dev --minified-path-prefix / --project-path @datadog/browser-logs/ --repository-url https://www.github.com/datadog/browser-sdk', - env: ENV_PROD, + command: 'mv packages/logs/bundle/datadog-logs.js packages/logs/bundle/datadog-logs-canary.js', }, { - command: - 'datadog-ci sourcemaps upload packages/rum/bundle --service browser-rum-sdk --release-version dev --minified-path-prefix / --project-path @datadog/browser-rum/ --repository-url https://www.github.com/datadog/browser-sdk', - env: ENV_PROD, + command: 'mv packages/logs/bundle/datadog-logs.js.map packages/logs/bundle/datadog-logs-canary.js.map', + }, + { + command: `mv packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js`, + }, + { + command: `mv packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js.map packages/rum/bundle/chunks/recorder-${FAKE_CHUNK_HASH}-datadog-rum.js.map`, + }, + { + command: 'mv packages/rum/bundle/datadog-rum.js packages/rum/bundle/datadog-rum-canary.js', + }, + { + command: 'mv packages/rum/bundle/datadog-rum.js.map packages/rum/bundle/datadog-rum-canary.js.map', + }, + { + command: 'mv packages/rum-slim/bundle/datadog-rum-slim.js packages/rum-slim/bundle/datadog-rum-slim-canary.js', }, { command: - 'datadog-ci sourcemaps upload packages/rum-slim/bundle --service browser-rum-sdk --release-version dev --minified-path-prefix / --project-path @datadog/browser-rum-slim/ --repository-url https://www.github.com/datadog/browser-sdk', - env: ENV_PROD, + 'mv packages/rum-slim/bundle/datadog-rum-slim.js.map packages/rum-slim/bundle/datadog-rum-slim-canary.js.map', }, ]) - }) - - void it('should upload canary packages source maps', async () => { - await uploadSourceMaps('canary', ['root']) + // upload the source maps assert.deepEqual(getSourceMapCommands(), [ { command: diff --git a/scripts/lib/filesUtils.js b/scripts/lib/filesUtils.js index a6b56a813d..57b9f7ba1e 100644 --- a/scripts/lib/filesUtils.js +++ b/scripts/lib/filesUtils.js @@ -12,6 +12,17 @@ function readCiFileVariable(variableName) { return regexp.exec(ciFileContent)?.[1] } +async function forEachFile(directoryPath, callback) { + for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) { + const entryPath = `${directoryPath}/${entry.name}` + if (entry.isFile()) { + await callback(entryPath) + } else if (entry.isDirectory()) { + await forEachFile(entryPath, callback) + } + } +} + async function replaceCiFileVariable(variableName, value) { await modifyFile(CI_FILE, (content) => content.replace(new RegExp(`${variableName}: .*`), `${variableName}: ${value}`) @@ -53,4 +64,5 @@ module.exports = { replaceCiFileVariable, modifyFile, findBrowserSdkPackageJsonFiles, + forEachFile, } diff --git a/test/app/package.json b/test/app/package.json index 5ca98df878..fa6225a366 100644 --- a/test/app/package.json +++ b/test/app/package.json @@ -2,9 +2,9 @@ "name": "app", "version": "0.0.0", "scripts": { - "build": "webpack --mode=production", + "build": "webpack --config ./webpack.web.js", "compat:tsc": "tsc -p tsconfig.json", - "compat:ssr": "webpack --mode=development && node dist/app.js" + "compat:ssr": "webpack --config ./webpack.ssr.js && node dist/app.js" }, "dependencies": { "@datadog/browser-core": "portal:../../packages/core", diff --git a/test/app/webpack.config.js b/test/app/webpack.base.js similarity index 57% rename from test/app/webpack.config.js rename to test/app/webpack.base.js index f7f5af9ae1..028da0b6bc 100644 --- a/test/app/webpack.config.js +++ b/test/app/webpack.base.js @@ -1,8 +1,10 @@ const path = require('path') -module.exports = (_env, argv) => ({ +const filename = 'app.js' +module.exports = ({ target, optimization, mode }) => ({ + mode, entry: './app.ts', - target: ['web', 'es5'], + target, module: { rules: [ { @@ -14,12 +16,10 @@ module.exports = (_env, argv) => ({ resolve: { extensions: ['.ts', '.js'], }, - optimization: { - // Display stack trace when SSR test fail - minimize: argv.mode === 'development', - }, + optimization, output: { path: path.resolve(__dirname, 'dist'), - filename: 'app.js', + filename, + chunkFilename: `chunks/[name]-[contenthash]-${filename}`, }, }) diff --git a/test/app/webpack.ssr.js b/test/app/webpack.ssr.js new file mode 100644 index 0000000000..a97db94a15 --- /dev/null +++ b/test/app/webpack.ssr.js @@ -0,0 +1,10 @@ +const webpackBase = require('./webpack.base') + +module.exports = () => + webpackBase({ + mode: 'development', + target: ['node', 'es2018'], + optimization: { + minimize: true, + }, + }) diff --git a/test/app/webpack.web.js b/test/app/webpack.web.js new file mode 100644 index 0000000000..6c6a40a9da --- /dev/null +++ b/test/app/webpack.web.js @@ -0,0 +1,8 @@ +const webpackBase = require('./webpack.base') + +module.exports = () => + webpackBase({ + mode: 'production', + target: ['web', 'es2018'], + optimization: { chunkIds: 'named' }, + }) diff --git a/test/e2e/lib/framework/sdkBuilds.ts b/test/e2e/lib/framework/sdkBuilds.ts index 048e4b019d..9382652992 100644 --- a/test/e2e/lib/framework/sdkBuilds.ts +++ b/test/e2e/lib/framework/sdkBuilds.ts @@ -2,7 +2,11 @@ import * as path from 'path' const ROOT = path.join(__dirname, '../../../..') export const RUM_BUNDLE = path.join(ROOT, 'packages/rum/bundle/datadog-rum.js') +export const rumBundleRecorderChunk = (name: string, hash: string) => + path.join(ROOT, `packages/rum/bundle/chunks/${name}-${hash}-datadog-rum.js`) export const RUM_SLIM_BUNDLE = path.join(ROOT, 'packages/rum-slim/bundle/datadog-rum-slim.js') export const LOGS_BUNDLE = path.join(ROOT, 'packages/logs/bundle/datadog-logs.js') export const WORKER_BUNDLE = path.join(ROOT, 'packages/worker/bundle/worker.js') export const NPM_BUNDLE = path.join(ROOT, 'test/app/dist/app.js') +export const npmBundleChunks = (name: string, hash: string) => + path.join(ROOT, `test/app/dist/chunks/${name}-${hash}-app.js`) diff --git a/test/e2e/lib/framework/serverApps/mock.ts b/test/e2e/lib/framework/serverApps/mock.ts index ff04c33198..6e748345c4 100644 --- a/test/e2e/lib/framework/serverApps/mock.ts +++ b/test/e2e/lib/framework/serverApps/mock.ts @@ -123,6 +123,11 @@ export function createMockServerApp(servers: Servers, setup: string): MockServer res.sendFile(sdkBuilds.RUM_BUNDLE) }) + app.get('/chunks/:name-:hash-datadog-rum.js', (req, res) => { + const { name, hash } = req.params + res.sendFile(sdkBuilds.rumBundleRecorderChunk(name, hash)) + }) + app.get('/datadog-rum-slim.js', (_req, res) => { res.sendFile(sdkBuilds.RUM_SLIM_BUNDLE) }) @@ -135,6 +140,11 @@ export function createMockServerApp(servers: Servers, setup: string): MockServer res.sendFile(sdkBuilds.NPM_BUNDLE) }) + app.get('/chunks/:name-:hash-app.js', (req, res) => { + const { name, hash } = req.params + res.sendFile(sdkBuilds.npmBundleChunks(name, hash)) + }) + return Object.assign(app, { getLargeResponseWroteSize() { return largeResponseBytesWritten diff --git a/tsconfig.base.json b/tsconfig.base.json index 309c0ed6f8..8230854f3a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,6 +5,7 @@ "esModuleInterop": true, "importHelpers": false, "moduleResolution": "node", + "module": "ES2020", "resolveJsonModule": true, "strict": true, "target": "ES2018", diff --git a/webpack.base.js b/webpack.base.js index 2267426c8c..be8744cf74 100644 --- a/webpack.base.js +++ b/webpack.base.js @@ -11,6 +11,7 @@ module.exports = ({ entry, mode, filename, types, keepBuildEnvVariables, plugins mode, output: { filename, + chunkFilename: `chunks/[name]-[contenthash]-${filename}`, path: path.resolve('./bundle'), }, target: ['web', 'es2018'], @@ -25,7 +26,7 @@ module.exports = ({ entry, mode, filename, types, keepBuildEnvVariables, plugins configFile: tsconfigPath, onlyCompileBundledFiles: true, compilerOptions: { - module: 'es6', + module: 'es2020', allowJs: true, types: types || [], }, @@ -44,6 +45,7 @@ module.exports = ({ entry, mode, filename, types, keepBuildEnvVariables, plugins }, optimization: { + chunkIds: 'named', minimizer: [ new TerserPlugin({ extractComments: false,