diff --git a/packages/core/src/transport/eventBridge.spec.ts b/packages/core/src/transport/eventBridge.spec.ts index a9d3e2bfdb..158cf2ce96 100644 --- a/packages/core/src/transport/eventBridge.spec.ts +++ b/packages/core/src/transport/eventBridge.spec.ts @@ -1,4 +1,5 @@ -import { deleteEventBridgeStub, initEventBridgeStub } from '../../test' +import { PRIVACY_LEVEL_FROM_EVENT_BRIDGE, deleteEventBridgeStub, initEventBridgeStub } from '../../test' +import type { DatadogEventBridge } from './eventBridge' import { getEventBridge, canUseEventBridge } from './eventBridge' describe('canUseEventBridge', () => { @@ -33,7 +34,7 @@ describe('canUseEventBridge', () => { }) }) -describe('getEventBridge', () => { +describe('event bridge send', () => { let sendSpy: jasmine.Spy<(msg: string) => void> beforeEach(() => { @@ -45,11 +46,44 @@ describe('getEventBridge', () => { deleteEventBridgeStub() }) - it('event bridge should serialize sent events', () => { + it('should serialize sent events without view', () => { const eventBridge = getEventBridge()! eventBridge.send('view', { foo: 'bar' }) expect(sendSpy).toHaveBeenCalledOnceWith('{"eventType":"view","event":{"foo":"bar"}}') }) + + it('should serialize sent events with view', () => { + const eventBridge = getEventBridge()! + + eventBridge.send('view', { foo: 'bar' }, '123') + + expect(sendSpy).toHaveBeenCalledOnceWith('{"eventType":"view","event":{"foo":"bar"},"view":{"id":"123"}}') + }) +}) + +describe('event bridge getPrivacyLevel', () => { + let eventBridgeStub: DatadogEventBridge + + beforeEach(() => { + eventBridgeStub = initEventBridgeStub() + }) + + afterEach(() => { + deleteEventBridgeStub() + }) + + it('should return the privacy level', () => { + const eventBridge = getEventBridge()! + + expect(eventBridge.getPrivacyLevel()).toEqual(PRIVACY_LEVEL_FROM_EVENT_BRIDGE) + }) + + it('should return undefined if getPrivacyLevel not present in the bridge', () => { + delete eventBridgeStub.getPrivacyLevel + const eventBridge = getEventBridge()! + + expect(eventBridge.getPrivacyLevel()).toBeUndefined() + }) }) diff --git a/packages/core/src/transport/eventBridge.ts b/packages/core/src/transport/eventBridge.ts index e2fbe0b345..793b1329ef 100644 --- a/packages/core/src/transport/eventBridge.ts +++ b/packages/core/src/transport/eventBridge.ts @@ -6,6 +6,7 @@ export interface BrowserWindowWithEventBridge extends Window { } export interface DatadogEventBridge { + getPrivacyLevel?(): string getAllowedWebViewHosts(): string send(msg: string): void } @@ -18,11 +19,15 @@ export function getEventBridge() { } return { + getPrivacyLevel() { + return eventBridgeGlobal.getPrivacyLevel?.() + }, getAllowedWebViewHosts() { return JSON.parse(eventBridgeGlobal.getAllowedWebViewHosts()) as string[] }, - send(eventType: T, event: E) { - eventBridgeGlobal.send(JSON.stringify({ eventType, event })) + send(eventType: T, event: E, viewId?: string) { + const view = viewId ? { id: viewId } : undefined + eventBridgeGlobal.send(JSON.stringify({ eventType, event, view })) }, } } diff --git a/packages/core/test/emulate/eventBridge.ts b/packages/core/test/emulate/eventBridge.ts index 178d84cc2c..ee58d49806 100644 --- a/packages/core/test/emulate/eventBridge.ts +++ b/packages/core/test/emulate/eventBridge.ts @@ -1,9 +1,11 @@ import type { BrowserWindowWithEventBridge } from '../../src/transport' -export function initEventBridgeStub(allowedWebViewHosts: string[] = [window.location.hostname]) { +export const PRIVACY_LEVEL_FROM_EVENT_BRIDGE = 'allow' +export function initEventBridgeStub(allowedWebViewHosts = [window.location.hostname]) { const eventBridgeStub = { send: (_msg: string) => undefined, getAllowedWebViewHosts: () => JSON.stringify(allowedWebViewHosts), + getPrivacyLevel: () => PRIVACY_LEVEL_FROM_EVENT_BRIDGE, } ;(window as BrowserWindowWithEventBridge).DatadogEventBridge = eventBridgeStub return eventBridgeStub diff --git a/packages/logs/src/transport/startLogsBridge.ts b/packages/logs/src/transport/startLogsBridge.ts index e7775b3a7a..b5cc56a6e2 100644 --- a/packages/logs/src/transport/startLogsBridge.ts +++ b/packages/logs/src/transport/startLogsBridge.ts @@ -8,6 +8,6 @@ export function startLogsBridge(lifeCycle: LifeCycle) { const bridge = getEventBridge<'log', LogsEvent>()! lifeCycle.subscribe(LifeCycleEventType.LOG_COLLECTED, (serverLogsEvent: LogsEvent & Context) => { - bridge.send('log', serverLogsEvent) + bridge.send('log', serverLogsEvent, serverLogsEvent.view.id) }) } diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 95316eb56a..18f63b7aa9 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -23,6 +23,7 @@ import { deleteEventBridgeStub, cleanupSyntheticsWorkerValues, mockSyntheticsWorkerValues, + PRIVACY_LEVEL_FROM_EVENT_BRIDGE, } from '@datadog/browser-core/test' import type { TestSetupBuilder } from '../../test' import { setup, noopRecorderApi } from '../../test' @@ -112,18 +113,34 @@ describe('rum public api', () => { deleteEventBridgeStub() }) - it('init should accept empty application id and client token', () => { + it('should accept empty application id and client token', () => { const hybridInitConfiguration: HybridInitConfiguration = {} rumPublicApi.init(hybridInitConfiguration as RumInitConfiguration) expect(display.error).not.toHaveBeenCalled() }) - it('init should force session sample rate to 100', () => { + it('should force session sample rate to 100', () => { const invalidConfiguration: HybridInitConfiguration = { sessionSampleRate: 50 } rumPublicApi.init(invalidConfiguration as RumInitConfiguration) expect(rumPublicApi.getInitConfiguration()?.sessionSampleRate).toEqual(100) }) + it('should set the default privacy level received from the bridge if the not provided in the init configuration', () => { + const hybridInitConfiguration: HybridInitConfiguration = {} + rumPublicApi.init(hybridInitConfiguration as RumInitConfiguration) + expect((rumPublicApi.getInitConfiguration() as RumInitConfiguration)?.defaultPrivacyLevel).toEqual( + PRIVACY_LEVEL_FROM_EVENT_BRIDGE + ) + }) + + it('should set the default privacy level from the init configuration if provided', () => { + const hybridInitConfiguration: HybridInitConfiguration = { defaultPrivacyLevel: 'mask' } + rumPublicApi.init(hybridInitConfiguration as RumInitConfiguration) + expect((rumPublicApi.getInitConfiguration() as RumInitConfiguration)?.defaultPrivacyLevel).toEqual( + hybridInitConfiguration.defaultPrivacyLevel + ) + }) + it('should initialize even if session cannot be handled', () => { spyOnProperty(document, 'cookie', 'get').and.returnValue('') const rumPublicApi = makeRumPublicApi(startRumSpy, noopRecorderApi, {}) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 04fba460fa..9a78212b24 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -31,6 +31,7 @@ import { CustomerDataCompressionStatus, createCustomerDataTrackerManager, storeContextManager, + getEventBridge, } from '@datadog/browser-core' import type { LifeCycle } from '../domain/lifeCycle' import type { ViewContexts } from '../domain/contexts/viewContexts' @@ -341,11 +342,12 @@ export function makeRumPublicApi( return true } - function overrideInitConfigurationForBridge(initConfiguration: C): C { + function overrideInitConfigurationForBridge(initConfiguration: C): C { return assign({}, initConfiguration, { applicationId: '00000000-aaaa-0000-aaaa-000000000000', clientToken: 'empty', sessionSampleRate: 100, + defaultPrivacyLevel: initConfiguration.defaultPrivacyLevel ?? getEventBridge()?.getPrivacyLevel(), }) } } diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index 5b2c570f1d..7aeb52e50b 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -381,8 +381,13 @@ describe('view events', () => { clock.tick(VIEW_DURATION) window.dispatchEvent(createNewEvent('beforeunload')) - const lastBridgeMessage = JSON.parse(sendSpy.calls.mostRecent().args[0]) as { eventType: 'rum'; event: RumEvent } + const lastBridgeMessage = JSON.parse(sendSpy.calls.mostRecent().args[0]) as { + eventType: 'rum' + event: RumEvent + view: { id: string } + } expect(lastBridgeMessage.event.type).toBe('view') expect(lastBridgeMessage.event.view.time_spent).toBe(toServerDuration(VIEW_DURATION)) + expect(lastBridgeMessage.view.id).toBeDefined() }) }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index c717cdf425..3a07ccfba5 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -58,7 +58,7 @@ export function startRumSessionManager(configuration: RumConfiguration, lifeCycl export function startRumSessionManagerStub(): RumSessionManager { const session: RumSession = { id: '00000000-aaaa-0000-aaaa-000000000000', - sessionReplayAllowed: false, + sessionReplayAllowed: true, } return { findTrackedSession: () => session, diff --git a/packages/rum-core/src/transport/startRumEventBridge.ts b/packages/rum-core/src/transport/startRumEventBridge.ts index 3c4e6dfe4f..1eb85ef95c 100644 --- a/packages/rum-core/src/transport/startRumEventBridge.ts +++ b/packages/rum-core/src/transport/startRumEventBridge.ts @@ -8,6 +8,6 @@ export function startRumEventBridge(lifeCycle: LifeCycle) { const bridge = getEventBridge<'rum', RumEvent>()! lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (serverRumEvent: RumEvent & Context) => { - bridge.send('rum', serverRumEvent) + bridge.send('rum', serverRumEvent, serverRumEvent.view.id) }) } diff --git a/packages/rum/src/boot/recorderApi.ts b/packages/rum/src/boot/recorderApi.ts index 2a6badae47..6e65fb2457 100644 --- a/packages/rum/src/boot/recorderApi.ts +++ b/packages/rum/src/boot/recorderApi.ts @@ -1,5 +1,5 @@ import type { DeflateEncoder } from '@datadog/browser-core' -import { DeflateEncoderStreamId, canUseEventBridge, noop, runOnReadyState } from '@datadog/browser-core' +import { DeflateEncoderStreamId, noop, runOnReadyState } from '@datadog/browser-core' import type { LifeCycle, ViewContexts, @@ -53,7 +53,7 @@ export function makeRecorderApi( startRecordingImpl: StartRecording, createDeflateWorkerImpl?: CreateDeflateWorker ): RecorderApi { - if (canUseEventBridge() || !isBrowserSupported()) { + if (!isBrowserSupported()) { return { start: noop, stop: noop, diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index fd419ed73e..a13ff0c19d 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -3,7 +3,12 @@ import { PageExitReason, DefaultPrivacyLevel, noop, isIE, DeflateEncoderStreamId import type { LifeCycle, ViewCreatedEvent, RumConfiguration } from '@datadog/browser-rum-core' import { LifeCycleEventType } from '@datadog/browser-rum-core' import type { Clock } from '@datadog/browser-core/test' -import { collectAsyncCalls, createNewEvent } from '@datadog/browser-core/test' +import { + collectAsyncCalls, + createNewEvent, + deleteEventBridgeStub, + initEventBridgeStub, +} from '@datadog/browser-core/test' import type { RumSessionManagerMock, TestSetupBuilder } from '../../../rum-core/test' import { appendElement, createRumSessionManagerMock, setup } from '../../../rum-core/test' @@ -81,6 +86,7 @@ describe('startRecording', () => { setupBuilder.cleanup() clock?.cleanup() resetDeflateWorkerState() + deleteEventBridgeStub() }) it('sends recorded segments with valid context', async () => { @@ -209,6 +215,22 @@ describe('startRecording', () => { }) }) + it('should send records through the bridge when it is present', () => { + const eventBridgeStub = initEventBridgeStub() + setupBuilder.build() + const sendSpy = spyOn(eventBridgeStub, 'send') + + document.body.dispatchEvent(createNewEvent('click', { clientX: 1, clientY: 2 })) + + const lastBridgeMessage = JSON.parse(sendSpy.calls.mostRecent().args[0]) + + expect(lastBridgeMessage).toEqual({ + eventType: 'record', + event: jasmine.objectContaining({ type: RecordType.IncrementalSnapshot }), + view: { id: viewId }, + }) + }) + function changeView(lifeCycle: LifeCycle) { lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, {} as any) viewId = 'view-id-2' diff --git a/packages/rum/src/boot/startRecording.ts b/packages/rum/src/boot/startRecording.ts index 0202018d70..f031cb57e2 100644 --- a/packages/rum/src/boot/startRecording.ts +++ b/packages/rum/src/boot/startRecording.ts @@ -1,10 +1,12 @@ import type { RawError, HttpRequest, DeflateEncoder } from '@datadog/browser-core' -import { createHttpRequest, addTelemetryDebug } from '@datadog/browser-core' +import { createHttpRequest, addTelemetryDebug, noop, canUseEventBridge } from '@datadog/browser-core' import type { LifeCycle, ViewContexts, RumConfiguration, RumSessionManager } from '@datadog/browser-rum-core' import { LifeCycleEventType } from '@datadog/browser-rum-core' import { record } from '../domain/record' import { startSegmentCollection, SEGMENT_BYTES_LIMIT } from '../domain/segmentCollection' +import type { BrowserRecord } from '../types' +import { startRecordBridge } from '../domain/startRecordBridge' export function startRecording( lifeCycle: LifeCycle, @@ -23,14 +25,21 @@ export function startRecording( httpRequest || createHttpRequest(configuration, configuration.sessionReplayEndpointBuilder, SEGMENT_BYTES_LIMIT, reportError) - const { addRecord, stop: stopSegmentCollection } = startSegmentCollection( - lifeCycle, - configuration, - sessionManager, - viewContexts, - replayRequest, - encoder - ) + let addRecord = (_record: BrowserRecord) => {} + let stopSegmentCollection = noop + + if (!canUseEventBridge()) { + ;({ addRecord, stop: stopSegmentCollection } = startSegmentCollection( + lifeCycle, + configuration, + sessionManager, + viewContexts, + replayRequest, + encoder + )) + } else { + ;({ addRecord } = startRecordBridge(viewContexts)) + } const { stop: stopRecording } = record({ emit: addRecord, diff --git a/packages/rum/src/domain/startRecordBridge.ts b/packages/rum/src/domain/startRecordBridge.ts new file mode 100644 index 0000000000..81507b5a81 --- /dev/null +++ b/packages/rum/src/domain/startRecordBridge.ts @@ -0,0 +1,17 @@ +import { getEventBridge } from '@datadog/browser-core' +import type { ViewContexts } from '@datadog/browser-rum-core' +import type { BrowserRecord } from '../types' + +export function startRecordBridge(viewContexts: ViewContexts) { + const bridge = getEventBridge<'record', BrowserRecord>()! + + return { + addRecord: (record: BrowserRecord) => { + const view = viewContexts.findView() + if (!view) { + return + } + bridge.send('record', record, view.id) + }, + } +} diff --git a/test/e2e/lib/framework/intakeRegistry.ts b/test/e2e/lib/framework/intakeRegistry.ts index 240b450166..3f1d85c978 100644 --- a/test/e2e/lib/framework/intakeRegistry.ts +++ b/test/e2e/lib/framework/intakeRegistry.ts @@ -121,6 +121,10 @@ export class IntakeRegistry { get replaySegments() { return this.replayRequests.map((request) => request.segment) } + + get replayRecords() { + return this.replayRequests.flatMap((request) => request.segment.records) + } } function isLogsIntakeRequest(request: IntakeRequest): request is LogsIntakeRequest { diff --git a/test/e2e/lib/framework/serverApps/intake.ts b/test/e2e/lib/framework/serverApps/intake.ts index 6108661a88..a1edc442df 100644 --- a/test/e2e/lib/framework/serverApps/intake.ts +++ b/test/e2e/lib/framework/serverApps/intake.ts @@ -58,7 +58,7 @@ function computeIntakeRequestInfos(req: express.Request): IntakeRequestInfos { return { isBridge: true, encoding, - intakeType: eventType === 'log' ? 'logs' : 'rum', + intakeType: eventType === 'log' ? 'logs' : eventType === 'record' ? 'replay' : 'rum', } } @@ -104,6 +104,23 @@ function readReplayIntakeRequest( infos: IntakeRequestInfos & { intakeType: 'replay' } ): Promise { return new Promise((resolve, reject) => { + if (infos.isBridge) { + readStream(req) + .then((rawBody) => { + resolve({ + ...infos, + segment: { + records: rawBody + .toString('utf-8') + .split('\n') + .map((line): any => JSON.parse(line)), + }, + } as ReplayIntakeRequest) + }) + .catch(reject) + return + } + let segmentPromise: Promise<{ encoding: string filename: string diff --git a/test/e2e/scenario/eventBridge.scenario.ts b/test/e2e/scenario/eventBridge.scenario.ts index 30896201d4..9b9b6ea171 100644 --- a/test/e2e/scenario/eventBridge.scenario.ts +++ b/test/e2e/scenario/eventBridge.scenario.ts @@ -91,4 +91,14 @@ describe('bridge present', () => { expect(intakeRegistry.logsEvents.length).toBe(1) expect(intakeRegistry.hasOnlyBridgeRequests).toBe(true) }) + + createTest('send records to the bridge') + .withRum() + .withEventBridge() + .run(async ({ intakeRegistry }) => { + await flushEvents() + + expect(intakeRegistry.replayRecords.length).toBeGreaterThan(0) + expect(intakeRegistry.hasOnlyBridgeRequests).toBe(true) + }) })