Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions packages/browser-rum-core/src/boot/preStartRum.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,11 @@ describe('preStartRum', () => {
startDeflateWorkerSpy = jasmine.createSpy().and.returnValue(FAKE_WORKER)
;({ strategy, doStartRumSpy } = createPreStartStrategyWithDefaults({
rumPublicApiOptions: {
startDeflateWorker: startDeflateWorkerSpy,
createDeflateEncoder: noop as any,
loadDeflateWorker: () =>
Promise.resolve({
startDeflateWorker: startDeflateWorkerSpy as any,
createDeflateEncoder: noop as any,
}),
},
}))
})
Expand Down Expand Up @@ -242,7 +245,7 @@ describe('preStartRum', () => {
expect(worker).toBeDefined()
})

it('aborts the initialization if it fails to create a deflate worker', () => {
it('aborts the initialization if it fails to create a deflate worker', async () => {
startDeflateWorkerSpy.and.returnValue(undefined)

strategy.init(
Expand All @@ -253,6 +256,10 @@ describe('preStartRum', () => {
PUBLIC_API
)

// Let the deflate worker chunk load (and fail to produce a worker) and the session manager settle.
await new Promise<void>((resolve) => setTimeout(resolve, 10))

expect(startDeflateWorkerSpy).toHaveBeenCalledTimes(1)
expect(doStartRumSpy).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -339,7 +346,7 @@ describe('preStartRum', () => {
strategy.init(MANUAL_CONFIGURATION, PUBLIC_API)
await collectAsyncCalls(doStartRumSpy, 1)
expect(doStartRumSpy).toHaveBeenCalled()
const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3]
const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[4]
expect(initialViewOptions).toEqual({ name: 'foo' })
expect(startViewSpy).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -370,7 +377,7 @@ describe('preStartRum', () => {
await collectAsyncCalls(startViewSpy, 1)

expect(doStartRumSpy).toHaveBeenCalled()
const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3]
const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[4]
expect(initialViewOptions).toEqual({ name: 'foo' })
expect(startViewSpy).toHaveBeenCalledOnceWith({ name: 'bar' }, relativeToClocks(clock.relative(20)))
})
Expand All @@ -383,7 +390,7 @@ describe('preStartRum', () => {
strategy.startView({ name: 'foo' })
await collectAsyncCalls(doStartRumSpy, 1)
expect(doStartRumSpy).toHaveBeenCalled()
const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3]
const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[4]
expect(initialViewOptions).toEqual({ name: 'foo' })
expect(startViewSpy).not.toHaveBeenCalled()
})
Expand Down
55 changes: 40 additions & 15 deletions packages/browser-rum-core/src/boot/preStartRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,20 @@ import type { OperationOptions, FailureReason } from '../domain/vital/vitalColle
import { callPluginsMethod } from '../domain/plugins'
import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext'
import type { StartRumResult } from './startRum'
import type { RumPublicApiOptions, Strategy } from './rumPublicApi'
import type { CreateDeflateEncoder, RumPublicApiOptions, Strategy } from './rumPublicApi'

export type DoStartRum = (
configuration: RumConfiguration,
sessionManager: SessionManager,
deflateWorker: DeflateWorker | undefined,
createDeflateEncoder: CreateDeflateEncoder | undefined,
initialViewOptions: ViewOptions | undefined,
telemetry: Telemetry,
hooks: Hooks
) => StartRumResult

export function createPreStartStrategy(
{ ignoreInitIfSyntheticsWillInjectRum = true, startDeflateWorker }: RumPublicApiOptions,
{ ignoreInitIfSyntheticsWillInjectRum = true, loadDeflateWorker }: RumPublicApiOptions,
trackingConsentState: TrackingConsentState,
doStartRum: DoStartRum
): Strategy {
Expand All @@ -80,6 +81,11 @@ export function createPreStartStrategy(
| { options: ViewOptions | undefined; callback: (startRumResult: StartRumResult) => void }
| undefined
let deflateWorker: DeflateWorker | undefined
let createDeflateEncoder: CreateDeflateEncoder | undefined
// Tracks the on-demand loading of the deflate worker (used for intake request compression):
// 'loading' while its chunk is being fetched, 'failed' when it could not be started. When set,
// RUM start is held back until the worker is ready (or aborted if it failed).
let deflateWorkerLoadingState: 'loading' | 'failed' | undefined

let cachedInitConfiguration: RumInitConfiguration | undefined
let cachedConfiguration: RumConfiguration | undefined
Expand All @@ -98,6 +104,13 @@ export function createPreStartStrategy(
return
}

if (deflateWorkerLoadingState) {
// 'loading': wait for the deflate worker before starting; it will call tryStartRum again once
// ready. 'failed': intake request compression was requested but the worker is unavailable, so
// we abort the start (matching the previous synchronous behavior).
return
}

trackingConsentStateSubscription.unsubscribe()

let initialViewOptions: ViewOptions | undefined
Expand All @@ -121,6 +134,7 @@ export function createPreStartStrategy(
cachedConfiguration,
sessionManager,
deflateWorker,
createDeflateEncoder,
initialViewOptions,
telemetry,
hooks
Expand Down Expand Up @@ -155,19 +169,30 @@ export function createPreStartStrategy(
return
}

if (configuration.compressIntakeRequests && !eventBridgeAvailable && startDeflateWorker) {
deflateWorker = startDeflateWorker(
configuration,
'Datadog RUM',
// Worker initialization can fail asynchronously, especially in Firefox where even CSP
// issues are reported asynchronously. For now, the SDK will continue its execution even if
// data won't be sent to Datadog. We could improve this behavior in the future.
noop
)
if (!deflateWorker) {
// `startDeflateWorker` should have logged an error message explaining the issue
return
}
if (configuration.compressIntakeRequests && !eventBridgeAvailable && loadDeflateWorker) {
// The deflate worker lives in its own chunk, so it is fetched lazily here (in parallel with
// the session manager) and RUM start is held back until it is ready.
deflateWorkerLoadingState = 'loading'
loadDeflateWorker()
.then((deflateModule) => {
deflateWorker = deflateModule?.startDeflateWorker(
configuration,
'Datadog RUM',
// Worker initialization can fail asynchronously, especially in Firefox where even CSP
// issues are reported asynchronously. For now, the SDK will continue its execution even
// if data won't be sent to Datadog. We could improve this behavior in the future.
noop
)
if (!deflateWorker) {
// the failure has already been logged (chunk loading error or `startDeflateWorker`)
deflateWorkerLoadingState = 'failed'
return
}
createDeflateEncoder = deflateModule!.createDeflateEncoder
deflateWorkerLoadingState = undefined
tryStartRum()
})
.catch(monitorError)
}

cachedConfiguration = configuration
Expand Down
6 changes: 5 additions & 1 deletion packages/browser-rum-core/src/boot/rumPublicApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ describe('rum public api', () => {
onRumStart: recorderApiOnRumStartSpy,
},
rumPublicApiOptions: {
startDeflateWorker: () => FAKE_WORKER,
loadDeflateWorker: () =>
Promise.resolve({
startDeflateWorker: () => FAKE_WORKER,
createDeflateEncoder: (() => ({})) as any,
}),
},
}))
})
Expand Down
22 changes: 15 additions & 7 deletions packages/browser-rum-core/src/boot/rumPublicApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,14 +619,22 @@ export interface ProfilerApi {
) => void
}

export interface RumPublicApiOptions {
ignoreInitIfSyntheticsWillInjectRum?: boolean
startDeflateWorker?: (
export interface DeflateWorkerModule {
startDeflateWorker: (
configuration: RumConfiguration,
source: string,
onInitializationFailure: () => void
) => DeflateWorker | undefined
createDeflateEncoder?: (worker: DeflateWorker, streamId: DeflateEncoderStreamId) => DeflateEncoder
createDeflateEncoder: (worker: DeflateWorker, streamId: DeflateEncoderStreamId) => DeflateEncoder
}

export type CreateDeflateEncoder = DeflateWorkerModule['createDeflateEncoder']

export interface RumPublicApiOptions {
ignoreInitIfSyntheticsWillInjectRum?: boolean
// The deflate worker embeds a sizeable inlined worker string, so it is loaded on demand (only when
// session replay or intake request compression is used) to keep it out of the main bundle.
loadDeflateWorker?: () => Promise<DeflateWorkerModule | undefined>
sdkName?: SdkName
}

Expand Down Expand Up @@ -672,10 +680,10 @@ export function makeRumPublicApi(
let strategy = createPreStartStrategy(
options,
trackingConsentState,
(configuration, sessionManager, deflateWorker, initialViewOptions, telemetry, hooks) => {
(configuration, sessionManager, deflateWorker, createDeflateEncoder, initialViewOptions, telemetry, hooks) => {
const createEncoder =
deflateWorker && options.createDeflateEncoder
? (streamId: DeflateEncoderStreamId) => options.createDeflateEncoder!(deflateWorker, streamId)
deflateWorker && createDeflateEncoder
? (streamId: DeflateEncoderStreamId) => createDeflateEncoder(deflateWorker, streamId)
: createIdentityEncoder

const startRumResult = mockable(startRum)(
Expand Down
35 changes: 35 additions & 0 deletions packages/browser-rum/src/boot/lazyLoadDeflateWorker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { display } from '@datadog/browser-core'
import type { MockTelemetry } from '@datadog/browser-core/test'
import { replaceMockable, startMockTelemetry } from '@datadog/browser-core/test'
import { lazyLoadDeflateWorker, importDeflateWorker } from './lazyLoadDeflateWorker'

describe('lazyLoadDeflateWorker', () => {
let displaySpy: jasmine.Spy
let telemetry: MockTelemetry

beforeEach(() => {
telemetry = startMockTelemetry()
displaySpy = spyOn(display, 'error')
})

it('should report a console error but no telemetry error if CSP blocks the module', async () => {
const error = new Error('Dynamic import was blocked due to Content Security Policy')
replaceMockable(importDeflateWorker, () => Promise.reject(error))
const module = await lazyLoadDeflateWorker()

expect(module).toBeUndefined()
expect(displaySpy).toHaveBeenCalledWith(jasmine.stringContaining('Deflate worker failed to start'), error)
expect(displaySpy).toHaveBeenCalledWith(jasmine.stringContaining('Please make sure CSP is correctly configured'))
expect(await telemetry.getEvents()).toEqual([])
})

it('should report a console error but no telemetry error if importing fails for non-CSP reasons', async () => {
const error = new Error('Dynamic import failed')
replaceMockable(importDeflateWorker, () => Promise.reject(error))
const module = await lazyLoadDeflateWorker()

expect(module).toBeUndefined()
expect(displaySpy).toHaveBeenCalledWith(jasmine.stringContaining('Deflate worker failed to start'), error)
expect(await telemetry.getEvents()).toEqual([])
})
})
23 changes: 23 additions & 0 deletions packages/browser-rum/src/boot/lazyLoadDeflateWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { mockable } from '@datadog/browser-core'
// Type-only import: erased at compile time, so it does not pull the deflate worker (and its inlined
// worker string) into the main bundle. The actual module is loaded lazily via `importDeflateWorker`.
import type * as deflate from '../domain/deflate'
import { reportScriptLoadingError } from '../domain/scriptLoadingError'

export type DeflateModule = typeof deflate

export async function lazyLoadDeflateWorker(): Promise<DeflateModule | undefined> {
try {
return await mockable(importDeflateWorker)()
} catch (error: unknown) {
reportScriptLoadingError({
error,
source: 'Deflate worker',
scriptType: 'module',
})
}
}

export function importDeflateWorker(): Promise<DeflateModule> {
return import(/* webpackChunkName: "datadogDeflateWorker" */ '../domain/deflate')
}
4 changes: 2 additions & 2 deletions packages/browser-rum/src/boot/postStartStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function createPostStartStrategy(
lifeCycle: LifeCycle,
sessionManager: SessionManager,
viewHistory: ViewHistory,
getOrCreateDeflateEncoder: () => DeflateEncoder | undefined,
getOrCreateDeflateEncoder: () => Promise<DeflateEncoder | undefined>,
telemetry: Telemetry
): Strategy {
let status = RecorderStatus.Stopped
Expand Down Expand Up @@ -83,7 +83,7 @@ export function createPostStartStrategy(
return
}

const deflateEncoder = getOrCreateDeflateEncoder()
const deflateEncoder = await getOrCreateDeflateEncoder()
if (!deflateEncoder) {
status = RecorderStatus.Stopped
observable.notify({ type: 'deflate-encoder-load-failed' })
Expand Down
5 changes: 5 additions & 0 deletions packages/browser-rum/src/boot/recorderApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ import {
import { mockDocumentReadyState, mockRumConfiguration, mockViewHistory } from '../../../browser-rum-core/test'
import type { CreateDeflateWorker } from '../domain/deflate'
import { resetDeflateWorkerState, createDeflateWorker } from '../domain/deflate'
import * as deflateModule from '../domain/deflate'
import { MockWorker } from '../../test'
import * as replayStats from '../domain/replayStats'
import { type RecorderInitMetrics } from '../domain/startRecorderInitTelemetry'
import { makeRecorderApi } from './recorderApi'
import type { StartRecording } from './postStartStrategy'
import { importRecorder } from './lazyLoadRecorder'
import { importDeflateWorker } from './lazyLoadDeflateWorker'

describe('makeRecorderApi', () => {
let lifeCycle: LifeCycle
Expand All @@ -55,6 +57,9 @@ describe('makeRecorderApi', () => {
telemetry = startMockTelemetry()
mockWorker = new MockWorker()
createDeflateWorkerSpy = replaceMockableWithSpy(createDeflateWorker).and.callFake(() => mockWorker)
// The deflate worker module is loaded on demand; resolve the dynamic import to the real module
// (instead of exercising a real code-split chunk, which the test bundler doesn't support).
replaceMockableWithSpy(importDeflateWorker).and.resolveTo(deflateModule)
spyOn(display, 'error')

lifeCycle = new LifeCycle()
Expand Down
28 changes: 16 additions & 12 deletions packages/browser-rum/src/boot/recorderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,8 @@ import type {
StartRecordingOptions,
} from '@datadog/browser-rum-core'
import { getReplayStats as getReplayStatsImpl } from '../domain/replayStats'
import {
createDeflateEncoder,
DeflateWorkerStatus,
getDeflateWorkerStatus,
startDeflateWorker,
} from '../domain/deflate'
import type { DeflateModule } from './lazyLoadDeflateWorker'
import { lazyLoadDeflateWorker } from './lazyLoadDeflateWorker'
import { createPostStartStrategy } from './postStartStrategy'
import { createPreStartStrategy } from './preStartStrategy'

Expand All @@ -38,6 +34,10 @@ export function makeRecorderApi(): RecorderApi {
// eslint-disable-next-line prefer-const
let { strategy, shouldStartImmediately } = createPreStartStrategy()

// The deflate worker module is loaded on demand (when recording starts) to keep it out of the main
// bundle. It stays undefined until then, which means the worker is not initialized yet.
let deflateModule: DeflateModule | undefined

return {
start: (options?: StartRecordingOptions) => strategy.start(options),
stop: () => strategy.stop(),
Expand Down Expand Up @@ -66,10 +66,9 @@ export function makeRecorderApi(): RecorderApi {
//
// 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 && strategy.isRecording(),
(deflateModule?.isDeflateWorkerInitialized() ?? false) && strategy.isRecording(),

getReplayStats: (viewId) =>
getDeflateWorkerStatus() === DeflateWorkerStatus.Initialized ? getReplayStatsImpl(viewId) : undefined,
getReplayStats: (viewId) => (deflateModule?.isDeflateWorkerInitialized() ? getReplayStatsImpl(viewId) : undefined),
}

function onRumStart(
Expand All @@ -82,12 +81,17 @@ export function makeRecorderApi(): RecorderApi {
) {
let cachedDeflateEncoder: DeflateEncoder | undefined

function getOrCreateDeflateEncoder() {
async function getOrCreateDeflateEncoder() {
if (!cachedDeflateEncoder) {
worker ??= startDeflateWorker(configuration, 'Datadog Session Replay', () => strategy.stop())
deflateModule ??= await lazyLoadDeflateWorker()
if (!deflateModule) {
return
}

worker ??= deflateModule.startDeflateWorker(configuration, 'Datadog Session Replay', () => strategy.stop())

if (worker) {
cachedDeflateEncoder = createDeflateEncoder(worker, DeflateEncoderStreamId.REPLAY)
cachedDeflateEncoder = deflateModule.createDeflateEncoder(worker, DeflateEncoderStreamId.REPLAY)
}
}
return cachedDeflateEncoder
Expand Down
4 changes: 4 additions & 0 deletions packages/browser-rum/src/domain/deflate/deflateWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export function getDeflateWorkerStatus() {
return state.status
}

export function isDeflateWorkerInitialized() {
return state.status === DeflateWorkerStatus.Initialized
}

/**
* Starts the deflate worker and handle messages and errors
*
Expand Down
1 change: 1 addition & 0 deletions packages/browser-rum/src/domain/deflate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
startDeflateWorker,
DeflateWorkerStatus,
getDeflateWorkerStatus,
isDeflateWorkerInitialized,
resetDeflateWorkerState,
createDeflateWorker,
} from './deflateWorker'
Loading
Loading