diff --git a/packages/core/src/domain/internalMonitoring.ts b/packages/core/src/domain/internalMonitoring.ts index 1f4b5f4d4a..13ec231202 100644 --- a/packages/core/src/domain/internalMonitoring.ts +++ b/packages/core/src/domain/internalMonitoring.ts @@ -93,6 +93,20 @@ function startMonitoringBatch(configuration: Configuration) { } } +export function startFakeInternalMonitoring() { + const messages: MonitoringMessage[] = [] + assign(monitoringConfiguration, { + batch: { + add(message: MonitoringMessage) { + messages.push(message) + }, + }, + maxMessagesPerPage: Infinity, + sentMessageCount: 0, + }) + return messages +} + export function resetInternalMonitoring() { monitoringConfiguration.batch = undefined } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index afa317b593..f24a1fc062 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,8 @@ export { callMonitored, addMonitoringMessage, addErrorToMonitoringBatch, + startFakeInternalMonitoring, + resetInternalMonitoring, setDebugMode, } from './domain/internalMonitoring' export { Observable, Subscription } from './tools/observable' diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index 74b8252a90..e427030d03 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -1,4 +1,4 @@ -import { Configuration } from '@datadog/browser-core' +import { Configuration, noop } from '@datadog/browser-core' import { RecorderApi, ParentContexts, @@ -10,6 +10,7 @@ import { createNewEvent } from '@datadog/browser-core/test/specHelper' import { createRumSessionMock, RumSessionMock } from '../../../rum-core/test/mockRumSession' import { setup, TestSetupBuilder } from '../../../rum-core/test/specHelper' import { DeflateWorker } from '../domain/segmentCollection/deflateWorker' +import { startDeflateWorker } from '../domain/segmentCollection/startDeflateWorker' import { makeRecorderApi, StartRecording } from './recorderApi' const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } @@ -19,7 +20,23 @@ describe('makeRecorderApi', () => { let recorderApi: RecorderApi let startRecordingSpy: jasmine.Spy let stopRecordingSpy: jasmine.Spy<() => void> - let getDeflateWorkerSingletonSpy: jasmine.Spy<() => DeflateWorker | undefined> + let startDeflateWorkerSpy: jasmine.Spy + const FAKE_WORKER = {} as DeflateWorker + + function startDeflateWorkerWith(worker?: DeflateWorker) { + if (!startDeflateWorkerSpy) { + startDeflateWorkerSpy = jasmine.createSpy('startDeflateWorker') + } + startDeflateWorkerSpy.and.callFake((callback) => callback(worker)) + } + + function callLastRegisteredInitialisationCallback() { + startDeflateWorkerSpy.calls.mostRecent().args[0](FAKE_WORKER) + } + + function stopDeflateWorker() { + startDeflateWorkerSpy.and.callFake(noop) + } let rumInit: (initConfiguration: RumInitConfiguration) => void @@ -29,8 +46,9 @@ describe('makeRecorderApi', () => { startRecordingSpy = jasmine.createSpy('startRecording').and.callFake(() => ({ stop: stopRecordingSpy, })) - getDeflateWorkerSingletonSpy = jasmine.createSpy('getDeflateWorkerSingleton').and.returnValue({}) - recorderApi = makeRecorderApi(startRecordingSpy, getDeflateWorkerSingletonSpy) + + startDeflateWorkerWith(FAKE_WORKER) + recorderApi = makeRecorderApi(startRecordingSpy, startDeflateWorkerSpy) rumInit = (initConfiguration) => { recorderApi.onRumStart(lifeCycle, initConfiguration, {} as Configuration, session, {} as ParentContexts) } @@ -104,10 +122,23 @@ describe('makeRecorderApi', () => { it('do not start recording if worker fails to be instantiated', () => { setupBuilder.build() rumInit(DEFAULT_INIT_CONFIGURATION) - getDeflateWorkerSingletonSpy.and.returnValue(undefined) + startDeflateWorkerWith(undefined) recorderApi.start() expect(startRecordingSpy).not.toHaveBeenCalled() }) + + it('does not start recording multiple times if restarted before worker is initialized', () => { + setupBuilder.build() + rumInit(DEFAULT_INIT_CONFIGURATION) + stopDeflateWorker() + recorderApi.start() + recorderApi.stop() + + callLastRegisteredInitialisationCallback() + recorderApi.start() + callLastRegisteredInitialisationCallback() + expect(startRecordingSpy).toHaveBeenCalledTimes(1) + }) }) describe('stopSessionReplayRecording()', () => { diff --git a/packages/rum/src/boot/recorderApi.ts b/packages/rum/src/boot/recorderApi.ts index ccaf5f82b1..23953c5f0f 100644 --- a/packages/rum/src/boot/recorderApi.ts +++ b/packages/rum/src/boot/recorderApi.ts @@ -8,7 +8,7 @@ import { RecorderApi, } from '@datadog/browser-rum-core' import { getReplayStats } from '../domain/replayStats' -import { getDeflateWorkerSingleton } from '../domain/segmentCollection/deflateWorkerSingleton' +import { startDeflateWorker } from '../domain/segmentCollection/startDeflateWorker' import { startRecording } from './startRecording' @@ -42,7 +42,7 @@ type RecorderState = export function makeRecorderApi( startRecordingImpl: StartRecording, - getDeflateWorkerSingletonImpl = getDeflateWorkerSingleton + startDeflateWorkerImpl = startDeflateWorker ): RecorderApi { let state: RecorderState = { status: RecorderStatus.Stopped, @@ -96,26 +96,31 @@ export function makeRecorderApi( return } - const worker = getDeflateWorkerSingletonImpl() - if (!worker) { - state = { - status: RecorderStatus.Stopped, + startDeflateWorkerImpl((worker) => { + if (state.status !== RecorderStatus.Starting) { + return } - return - } - const { stop: stopRecording } = startRecordingImpl( - lifeCycle, - initConfiguration.applicationId, - configuration, - session, - parentContexts, - worker - ) - state = { - status: RecorderStatus.Started, - stopRecording, - } + if (!worker) { + state = { + status: RecorderStatus.Stopped, + } + return + } + + const { stop: stopRecording } = startRecordingImpl( + lifeCycle, + initConfiguration.applicationId, + configuration, + session, + parentContexts, + worker + ) + state = { + status: RecorderStatus.Started, + stopRecording, + } + }) }) } diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index 609449a8f2..9cc6ad6ba9 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -9,7 +9,7 @@ import { collectAsyncCalls } from '../../test/utils' import { setMaxSegmentSize } from '../domain/segmentCollection/segmentCollection' import { Segment, RecordType } from '../types' -import { getDeflateWorkerSingleton } from '../domain/segmentCollection/deflateWorkerSingleton' +import { doStartDeflateWorker } from '../domain/segmentCollection/startDeflateWorker' import { startRecording } from './startRecording' describe('startRecording', () => { @@ -37,6 +37,12 @@ describe('startRecording', () => { textField = document.createElement('input') sandbox.appendChild(textField) + const requestSendSpy = spyOn(HttpRequest.prototype, 'send') + ;({ + waitAsyncCalls: waitRequestSendCalls, + expectNoExtraAsyncCall: expectNoExtraRequestSendCalls, + } = collectAsyncCalls(requestSendSpy)) + setupBuilder = setup() .withParentContexts({ findView() { @@ -61,17 +67,11 @@ describe('startRecording', () => { configuration, session, parentContexts, - getDeflateWorkerSingleton()! + doStartDeflateWorker()! ) stopRecording = recording ? recording.stop : noop return { stop: stopRecording } }) - - const requestSendSpy = spyOn(HttpRequest.prototype, 'send') - ;({ - waitAsyncCalls: waitRequestSendCalls, - expectNoExtraAsyncCall: expectNoExtraRequestSendCalls, - } = collectAsyncCalls(requestSendSpy)) }) afterEach(() => { diff --git a/packages/rum/src/domain/segmentCollection/deflateWorker.d.ts b/packages/rum/src/domain/segmentCollection/deflateWorker.d.ts index fb25583b2b..988c4c50f1 100644 --- a/packages/rum/src/domain/segmentCollection/deflateWorker.d.ts +++ b/packages/rum/src/domain/segmentCollection/deflateWorker.d.ts @@ -2,7 +2,10 @@ export function createDeflateWorker(): DeflateWorker export interface DeflateWorker { addEventListener(name: 'message', listener: DeflateWorkerListener): void + addEventListener(name: 'error', listener: (error: ErrorEvent) => void): void removeEventListener(name: 'message', listener: DeflateWorkerListener): void + removeEventListener(name: 'error', listener: (error: ErrorEvent) => void): void + postMessage(message: DeflateWorkerAction): void terminate(): void } @@ -10,27 +13,49 @@ export interface DeflateWorker { export type DeflateWorkerListener = (event: { data: DeflateWorkerResponse }) => void export type DeflateWorkerAction = + // Action to send when creating the worker to check if the communication is working correctly. + // The worker should respond with a 'initialized' response. + | { + action: 'init' + } + // Action to send when writing some unfinished data. The worker will respond with a 'wrote' + // response, with the same id and measurements of the wrote data size. | { - id: number action: 'write' + id: number data: string } + // Action to send when finishing to write some data. The worker will respond with a 'flushed' + // response, with the same id, measurements of the wrote data size and the complete deflate + // data. | { - id: number action: 'flush' + id: number data?: string } export type DeflateWorkerResponse = + // Response to 'init' action + | { + type: 'initialized' + } + // Response to 'write' action | { + type: 'wrote' id: number compressedSize: number additionalRawSize: number } + // Response to 'flush' action | { + type: 'flushed' id: number result: Uint8Array additionalRawSize: number rawSize: number } - | { error: Error | string } + // Could happen at any time when something goes wrong in the worker + | { + type: 'errored' + error: Error | string + } diff --git a/packages/rum/src/domain/segmentCollection/deflateWorker.js b/packages/rum/src/domain/segmentCollection/deflateWorker.js index fdb8c16586..7ff6850546 100644 --- a/packages/rum/src/domain/segmentCollection/deflateWorker.js +++ b/packages/rum/src/domain/segmentCollection/deflateWorker.js @@ -20,9 +20,15 @@ function workerCodeFn() { monitor((event) => { const data = event.data switch (data.action) { + case 'init': + self.postMessage({ + type: 'initialized', + }) + break case 'write': const additionalRawSize = pushData(data.data) self.postMessage({ + type: 'wrote', id: data.id, compressedSize: deflate.chunks.reduce((total, chunk) => total + chunk.length, 0), additionalRawSize, @@ -32,6 +38,7 @@ function workerCodeFn() { const additionalRawSize = data.data ? pushData(data.data) : 0 deflate.push('', constants.Z_FINISH) self.postMessage({ + type: 'flushed', id: data.id, result: deflate.result, additionalRawSize, @@ -58,10 +65,16 @@ function workerCodeFn() { return fn.apply(this, arguments) } catch (e) { try { - self.postMessage({ error: e }) + self.postMessage({ + type: 'errored', + error: e, + }) } catch (_) { // DATA_CLONE_ERR, cf https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm - self.postMessage({ error: '' + e }) + self.postMessage({ + type: 'errored', + error: '' + e, + }) } } } diff --git a/packages/rum/src/domain/segmentCollection/deflateWorker.spec.ts b/packages/rum/src/domain/segmentCollection/deflateWorker.spec.ts index 377f9724d5..efb61c51a5 100644 --- a/packages/rum/src/domain/segmentCollection/deflateWorker.spec.ts +++ b/packages/rum/src/domain/segmentCollection/deflateWorker.spec.ts @@ -11,9 +11,9 @@ describe('deflateWorker', () => { const deflateWorker = createDeflateWorker() listen(deflateWorker, 3, (events) => { expect(events).toEqual([ - { id: 0, compressedSize: 11, additionalRawSize: 3 }, - { id: 1, compressedSize: 20, additionalRawSize: 3 }, - { id: 2, compressedSize: 29, additionalRawSize: 3 }, + { type: 'wrote', id: 0, compressedSize: 11, additionalRawSize: 3 }, + { type: 'wrote', id: 1, compressedSize: 20, additionalRawSize: 3 }, + { type: 'wrote', id: 2, compressedSize: 29, additionalRawSize: 3 }, ]) done() }) @@ -26,8 +26,9 @@ describe('deflateWorker', () => { const deflateWorker = createDeflateWorker() listen(deflateWorker, 2, (events) => { expect(events).toEqual([ - { id: 0, compressedSize: 11, additionalRawSize: 3 }, + { type: 'wrote', id: 0, compressedSize: 11, additionalRawSize: 3 }, { + type: 'flushed', id: 1, result: new Uint8Array([120, 156, 74, 203, 207, 7, 0, 0, 0, 255, 255, 3, 0, 2, 130, 1, 69]), additionalRawSize: 0, @@ -45,6 +46,7 @@ describe('deflateWorker', () => { listen(deflateWorker, 1, (events) => { expect(events).toEqual([ { + type: 'flushed', id: 0, result: new Uint8Array([120, 156, 74, 203, 207, 7, 0, 0, 0, 255, 255, 3, 0, 2, 130, 1, 69]), additionalRawSize: 3, @@ -61,22 +63,26 @@ describe('deflateWorker', () => { listen(deflateWorker, 4, (events) => { expect(events).toEqual([ { + type: 'wrote', id: 0, compressedSize: 11, additionalRawSize: 3, }, { + type: 'flushed', id: 1, result: new Uint8Array([120, 156, 74, 203, 207, 7, 0, 0, 0, 255, 255, 3, 0, 2, 130, 1, 69]), additionalRawSize: 0, rawSize: 3, }, { + type: 'wrote', id: 2, compressedSize: 11, additionalRawSize: 3, }, { + type: 'flushed', id: 3, result: new Uint8Array([120, 156, 74, 74, 44, 2, 0, 0, 0, 255, 255, 3, 0, 2, 93, 1, 54]), additionalRawSize: 0, diff --git a/packages/rum/src/domain/segmentCollection/deflateWorkerSingleton.ts b/packages/rum/src/domain/segmentCollection/deflateWorkerSingleton.ts deleted file mode 100644 index 562b6dd829..0000000000 --- a/packages/rum/src/domain/segmentCollection/deflateWorkerSingleton.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { addErrorToMonitoringBatch, display, includes, monitor } from '@datadog/browser-core' -import { createDeflateWorker, DeflateWorker } from './deflateWorker' - -let workerSingleton: DeflateWorker - -export function getDeflateWorkerSingleton() { - if (!workerSingleton) { - try { - workerSingleton = createDeflateWorker() - } catch (error) { - display.error('Session Replay recording failed to start: an error occurred while creating the Worker:', error) - if (includes(error.message, 'Content Security Policy')) { - display.error( - 'Please make sure CSP is correctly configured ' + - 'https://docs.datadoghq.com/real_user_monitoring/faq/content_security_policy' - ) - } else { - addErrorToMonitoringBatch(error) - } - return - } - workerSingleton.addEventListener( - 'message', - monitor(({ data }) => { - if ('error' in data) { - addErrorToMonitoringBatch(data.error) - } - }) - ) - } - return workerSingleton -} diff --git a/packages/rum/src/domain/segmentCollection/segment.spec.ts b/packages/rum/src/domain/segmentCollection/segment.spec.ts index 5e6877dafb..3f9af287c9 100644 --- a/packages/rum/src/domain/segmentCollection/segment.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segment.spec.ts @@ -129,7 +129,7 @@ describe('Segment', () => { worker.processNextMessage() // process the segment1 initial record worker.dropNextMessage() // drop the segment1 flush worker.processAllMessages() - expect(worker.listenersCount).toBe(1) + expect(worker.messageListenersCount).toBe(1) expect(displaySpy).toHaveBeenCalledWith( '[MONITORING MESSAGE]', "Segment did not receive a 'flush' response before being replaced.", diff --git a/packages/rum/src/domain/segmentCollection/segment.ts b/packages/rum/src/domain/segmentCollection/segment.ts index 8cb5c4af1f..ea8c45e57a 100644 --- a/packages/rum/src/domain/segmentCollection/segment.ts +++ b/packages/rum/src/domain/segmentCollection/segment.ts @@ -33,13 +33,13 @@ export class Segment { replayStats.addRecord(viewId) const listener: DeflateWorkerListener = monitor(({ data }) => { - if ('error' in data) { + if (data.type === 'errored' || data.type === 'initialized') { return } if (data.id === this.id) { replayStats.addWroteData(viewId, data.additionalRawSize) - if ('result' in data) { + if (data.type === 'flushed') { onFlushed(data.result, data.rawSize) worker.removeEventListener('message', listener) } else { diff --git a/packages/rum/src/domain/segmentCollection/startDeflateWorker.spec.ts b/packages/rum/src/domain/segmentCollection/startDeflateWorker.spec.ts new file mode 100644 index 0000000000..2907da496f --- /dev/null +++ b/packages/rum/src/domain/segmentCollection/startDeflateWorker.spec.ts @@ -0,0 +1,173 @@ +import { + display, + isIE, + MonitoringMessage, + noop, + resetInternalMonitoring, + startFakeInternalMonitoring, +} from '@datadog/browser-core' +import { MockWorker } from '../../../test/utils' +import { createDeflateWorker } from './deflateWorker' +import { startDeflateWorker, resetDeflateWorkerState } from './startDeflateWorker' + +describe('startDeflateWorker', () => { + let deflateWorker: MockWorker + let createDeflateWorkerSpy: jasmine.Spy + let callbackSpy: jasmine.Spy + + beforeEach(() => { + deflateWorker = new MockWorker() + callbackSpy = jasmine.createSpy('callbackSpy') + createDeflateWorkerSpy = jasmine.createSpy('createDeflateWorkerSpy').and.callFake(() => deflateWorker) + }) + + afterEach(() => { + resetDeflateWorkerState() + }) + + it('creates a deflate worker and call callback when initialized', () => { + startDeflateWorker(callbackSpy, createDeflateWorkerSpy) + expect(createDeflateWorkerSpy).toHaveBeenCalledTimes(1) + deflateWorker.processAllMessages() + expect(callbackSpy).toHaveBeenCalledOnceWith(deflateWorker) + }) + + it('uses the previously created worker', () => { + startDeflateWorker(noop, createDeflateWorkerSpy) + deflateWorker.processAllMessages() + + startDeflateWorker(callbackSpy, createDeflateWorkerSpy) + expect(createDeflateWorkerSpy).toHaveBeenCalledTimes(1) + deflateWorker.processAllMessages() + expect(callbackSpy).toHaveBeenCalledOnceWith(deflateWorker) + }) + + describe('loading state', () => { + it('does not create multiple workers when called multiple times while the worker is loading', () => { + startDeflateWorker(noop, createDeflateWorkerSpy) + startDeflateWorker(noop, createDeflateWorkerSpy) + expect(createDeflateWorkerSpy).toHaveBeenCalledTimes(1) + }) + + it('calls all registered callbacks when the worker is initialized', () => { + const callbackSpy1 = jasmine.createSpy() + const callbackSpy2 = jasmine.createSpy() + startDeflateWorker(callbackSpy1, createDeflateWorkerSpy) + startDeflateWorker(callbackSpy2, createDeflateWorkerSpy) + deflateWorker.processAllMessages() + expect(callbackSpy1).toHaveBeenCalledOnceWith(deflateWorker) + expect(callbackSpy2).toHaveBeenCalledOnceWith(deflateWorker) + }) + }) + + describe('worker CSP error', () => { + let internalMonitoringMessages: MonitoringMessage[] + // mimic Chrome behavior + let CSP_ERROR: DOMException + let displaySpy: jasmine.Spy + + beforeEach(() => { + if (isIE()) { + pending('IE does not support CSP blocking worker creation') + } + displaySpy = spyOn(display, 'error') + internalMonitoringMessages = startFakeInternalMonitoring() + CSP_ERROR = new DOMException( + "Failed to construct 'Worker': Access to the script at 'blob:https://example.org/9aadbb61-effe-41ee-aa76-fc607053d642' is denied by the document's Content Security Policy." + ) + }) + + afterEach(() => { + resetInternalMonitoring() + }) + + it('displays CSP instructions when the worker creation throws a CSP error', () => { + startDeflateWorker(noop, () => { + throw CSP_ERROR + }) + expect(displaySpy).toHaveBeenCalledWith( + 'Please make sure CSP is correctly configured https://docs.datadoghq.com/real_user_monitoring/faq/content_security_policy' + ) + }) + + it('does not report CSP errors to internal monitoring', () => { + startDeflateWorker(noop, () => { + throw CSP_ERROR + }) + expect(internalMonitoringMessages).toEqual([]) + }) + + it('displays ErrorEvent as CSP error', () => { + startDeflateWorker(noop, createDeflateWorkerSpy) + deflateWorker.dispatchErrorEvent() + expect(displaySpy).toHaveBeenCalledWith( + 'Please make sure CSP is correctly configured https://docs.datadoghq.com/real_user_monitoring/faq/content_security_policy' + ) + }) + it('calls the callback without argument in case of an error occurs during loading', () => { + startDeflateWorker(callbackSpy, createDeflateWorkerSpy) + deflateWorker.dispatchErrorEvent() + expect(callbackSpy).toHaveBeenCalledOnceWith() + }) + + it('calls the callback without argument in case of an error occurred in a previous loading', () => { + startDeflateWorker(noop, createDeflateWorkerSpy) + deflateWorker.dispatchErrorEvent() + + startDeflateWorker(callbackSpy, createDeflateWorkerSpy) + expect(callbackSpy).toHaveBeenCalledOnceWith() + }) + }) + + describe('worker unknown error', () => { + let internalMonitoringMessages: MonitoringMessage[] + const UNKNOWN_ERROR = new Error('boom') + let displaySpy: jasmine.Spy + + beforeEach(() => { + displaySpy = spyOn(display, 'error') + internalMonitoringMessages = startFakeInternalMonitoring() + }) + + afterEach(() => { + resetInternalMonitoring() + }) + + it('displays an error message when the worker creation throws an unknown error', () => { + startDeflateWorker(noop, () => { + throw UNKNOWN_ERROR + }) + expect(displaySpy).toHaveBeenCalledOnceWith( + 'Session Replay recording failed to start: an error occurred while creating the Worker:', + UNKNOWN_ERROR + ) + }) + + it('reports unknown errors to internal monitoring', () => { + startDeflateWorker(noop, () => { + throw UNKNOWN_ERROR + }) + expect(internalMonitoringMessages).toEqual([ + { status: 'error' as any, message: 'boom', error: { kind: 'Error', stack: jasmine.any(String) } }, + ]) + }) + + it('does not display error messages as CSP error', () => { + startDeflateWorker(noop, createDeflateWorkerSpy) + deflateWorker.dispatchErrorMessage('foo') + expect(displaySpy).not.toHaveBeenCalledWith( + 'Please make sure CSP is correctly configured https://docs.datadoghq.com/real_user_monitoring/faq/content_security_policy' + ) + }) + + it('reports errors occurring after loading to internal monitoring', () => { + startDeflateWorker(noop, createDeflateWorkerSpy) + deflateWorker.processAllMessages() + + deflateWorker.dispatchErrorMessage('boom') + expect(internalMonitoringMessages).toEqual([ + { status: 'error' as any, message: 'Uncaught "boom"', error: { stack: jasmine.any(String) } }, + ]) + }) + }) +}) diff --git a/packages/rum/src/domain/segmentCollection/startDeflateWorker.ts b/packages/rum/src/domain/segmentCollection/startDeflateWorker.ts new file mode 100644 index 0000000000..274844c9b9 --- /dev/null +++ b/packages/rum/src/domain/segmentCollection/startDeflateWorker.ts @@ -0,0 +1,112 @@ +import { addErrorToMonitoringBatch, display, includes, monitor } from '@datadog/browser-core' +import { createDeflateWorker, DeflateWorker } from './deflateWorker' + +/** + * In order to be sure that the worker is correctly working, we need a round trip of + * initialization messages, making the creation asynchronous. + * These worker lifecycle states handle this case. + */ +const enum DeflateWorkerStatus { + Nil, + Loading, + Error, + Initialized, +} + +type DeflateWorkerState = + | { + status: DeflateWorkerStatus.Nil + } + | { + status: DeflateWorkerStatus.Loading + callbacks: Array<(worker?: DeflateWorker) => void> + } + | { + status: DeflateWorkerStatus.Error + } + | { + status: DeflateWorkerStatus.Initialized + worker: DeflateWorker + } + +let state: DeflateWorkerState = { status: DeflateWorkerStatus.Nil } + +export function startDeflateWorker( + callback: (worker?: DeflateWorker) => void, + createDeflateWorkerImpl = createDeflateWorker +) { + switch (state.status) { + case DeflateWorkerStatus.Nil: + state = { status: DeflateWorkerStatus.Loading, callbacks: [callback] } + doStartDeflateWorker(createDeflateWorkerImpl) + break + case DeflateWorkerStatus.Loading: + state.callbacks.push(callback) + break + case DeflateWorkerStatus.Error: + callback() + break + case DeflateWorkerStatus.Initialized: + callback(state.worker) + break + } +} + +export function resetDeflateWorkerState() { + state = { status: DeflateWorkerStatus.Nil } +} + +/** + * Starts the deflate worker and handle messages and errors + * + * The spec allow browsers to handle worker errors differently: + * - Chromium throws an exception + * - Firefox fires an error event + * + * more details: https://bugzilla.mozilla.org/show_bug.cgi?id=1736865#c2 + */ +export function doStartDeflateWorker(createDeflateWorkerImpl = createDeflateWorker) { + try { + const worker = createDeflateWorkerImpl() + worker.addEventListener('error', monitor(onError)) + worker.addEventListener( + 'message', + monitor(({ data }) => { + if (data.type === 'errored') { + onError(data.error) + } else if (data.type === 'initialized') { + onInitialized(worker) + } + }) + ) + worker.postMessage({ action: 'init' }) + return worker + } catch (error) { + onError(error) + } +} + +function onInitialized(worker: DeflateWorker) { + if (state.status === DeflateWorkerStatus.Loading) { + state.callbacks.forEach((callback) => callback(worker)) + state = { status: DeflateWorkerStatus.Initialized, worker } + } +} + +function onError(error: ErrorEvent | Error | string) { + if (state.status === DeflateWorkerStatus.Loading) { + display.error('Session Replay recording failed to start: an error occurred while creating the Worker:', error) + if (error instanceof Event || (error instanceof Error && includes(error.message, 'Content Security Policy'))) { + display.error( + 'Please make sure CSP is correctly configured ' + + 'https://docs.datadoghq.com/real_user_monitoring/faq/content_security_policy' + ) + } else { + addErrorToMonitoringBatch(error) + } + state.callbacks.forEach((callback) => callback()) + state = { status: DeflateWorkerStatus.Error } + } else { + addErrorToMonitoringBatch(error) + } +} diff --git a/packages/rum/test/utils.ts b/packages/rum/test/utils.ts index eb37dc1594..2933f7671d 100644 --- a/packages/rum/test/utils.ts +++ b/packages/rum/test/utils.ts @@ -19,19 +19,26 @@ export class MockWorker implements DeflateWorker { readonly pendingMessages: DeflateWorkerAction[] = [] private rawSize = 0 private deflatedData: Uint8Array[] = [] - private listeners: DeflateWorkerListener[] = [] - - addEventListener(_: 'message', listener: DeflateWorkerListener): void { - const index = this.listeners.indexOf(listener) + private listeners: { + message: DeflateWorkerListener[] + error: Array<(error: unknown) => void> + } = { message: [], error: [] } + + addEventListener(eventName: 'message', listener: DeflateWorkerListener): void + addEventListener(eventName: 'error', listener: (error: ErrorEvent) => void): void + addEventListener(eventName: 'message' | 'error', listener: any): void { + const index = this.listeners[eventName].indexOf(listener) if (index < 0) { - this.listeners.push(listener) + this.listeners[eventName].push(listener) } } - removeEventListener(_: 'message', listener: DeflateWorkerListener): void { - const index = this.listeners.indexOf(listener) + removeEventListener(eventName: 'message', listener: DeflateWorkerListener): void + removeEventListener(eventName: 'error', listener: (error: ErrorEvent) => void): void + removeEventListener(eventName: 'message' | 'error', listener: any): void { + const index = this.listeners[eventName].indexOf(listener) if (index >= 0) { - this.listeners.splice(index, 1) + this.listeners[eventName].splice(index, 1) } } @@ -44,11 +51,11 @@ export class MockWorker implements DeflateWorker { } get pendingData() { - return this.pendingMessages.map((message) => message.data || '').join('') + return this.pendingMessages.map((message) => ('data' in message ? message.data : '')).join('') } - get listenersCount() { - return this.listeners.length + get messageListenersCount() { + return this.listeners.message.length } processAllMessages(): void { @@ -64,40 +71,69 @@ export class MockWorker implements DeflateWorker { processNextMessage(): void { const message = this.pendingMessages.shift() if (message) { - const encodedData = new TextEncoder().encode(message.data) - this.rawSize += encodedData.length - // In the mock worker, for simplicity, we'll just use the UTF-8 encoded string instead of deflating it. - this.deflatedData.push(encodedData) - switch (message.action) { - case 'write': - this.listeners.forEach((listener) => + case 'init': + this.listeners.message.forEach((listener) => listener({ data: { - id: message.id, - compressedSize: uint8ArraysSize(this.deflatedData), - rawSize: this.rawSize, - additionalRawSize: encodedData.length, + type: 'initialized', }, }) ) break + case 'write': + { + const additionalRawSize = this.pushData(message.data) + this.listeners.message.forEach((listener) => + listener({ + data: { + type: 'wrote', + id: message.id, + compressedSize: uint8ArraysSize(this.deflatedData), + additionalRawSize, + }, + }) + ) + } + break case 'flush': - this.listeners.forEach((listener) => - listener({ - data: { - id: message.id, - result: mergeUint8Arrays(this.deflatedData), - rawSize: this.rawSize, - additionalRawSize: encodedData.length, - }, - }) - ) - this.deflatedData.length = 0 - this.rawSize = 0 + { + const additionalRawSize = this.pushData(message.data) + this.listeners.message.forEach((listener) => + listener({ + data: { + type: 'flushed', + id: message.id, + result: mergeUint8Arrays(this.deflatedData), + rawSize: this.rawSize, + additionalRawSize, + }, + }) + ) + this.deflatedData.length = 0 + this.rawSize = 0 + } + break } } } + + dispatchErrorEvent() { + const error = new ErrorEvent('worker') + this.listeners.error.forEach((listener) => listener(error)) + } + + dispatchErrorMessage(error: Error | string) { + this.listeners.message.forEach((listener) => listener({ data: { type: 'errored', error } })) + } + + private pushData(data?: string) { + const encodedData = new TextEncoder().encode(data) + this.rawSize += encodedData.length + // In the mock worker, for simplicity, we'll just use the UTF-8 encoded string instead of deflating it. + this.deflatedData.push(encodedData) + return encodedData.length + } } function uint8ArraysSize(arrays: Uint8Array[]) {