diff --git a/packages/browser-core/src/transport/eventBridge.ts b/packages/browser-core/src/transport/eventBridge.ts index 8372efd3a9..d20b092079 100644 --- a/packages/browser-core/src/transport/eventBridge.ts +++ b/packages/browser-core/src/transport/eventBridge.ts @@ -16,6 +16,7 @@ export interface DatadogEventBridge { export const enum BridgeCapability { RECORDS = 'records', + PROFILES = 'profiles', } export function getEventBridge() { diff --git a/packages/browser-rum/src/boot/profilerApi.spec.ts b/packages/browser-rum/src/boot/profilerApi.spec.ts index 6cec74d1da..194afbd506 100644 --- a/packages/browser-rum/src/boot/profilerApi.spec.ts +++ b/packages/browser-rum/src/boot/profilerApi.spec.ts @@ -1,9 +1,17 @@ -import { MID_HASH_UUID, replaceMockableWithSpy, createSessionManagerMock } from '@datadog/browser-core/test' +import { + MID_HASH_UUID, + replaceMockableWithSpy, + createSessionManagerMock, + replaceMockable, + waitNextMicrotask, + mockEventBridge, +} from '@datadog/browser-core/test' import { mockRumConfiguration, mockViewHistory } from '@datadog/browser-rum-core/test' import { createHooks, LifeCycle } from '@datadog/browser-rum-core' -import { createIdentityEncoder } from '@datadog/browser-core' +import { BridgeCapability, createIdentityEncoder } from '@datadog/browser-core' import { isProfilingSupported } from '../domain/profiling/profilingSupported' import { makeProfilerApi } from './profilerApi' +import { lazyLoadProfiler } from './lazyLoadProfiler' describe('profilerApi', () => { describe('deterministic sampling', () => { @@ -26,4 +34,62 @@ describe('profilerApi', () => { expect(isProfilingSupportedSpy).not.toHaveBeenCalled() }) }) + + describe('bridge mode', () => { + let createRumProfilerSpy: jasmine.Spy + + beforeEach(() => { + createRumProfilerSpy = jasmine + .createSpy('createRumProfiler') + .and.returnValue({ start: jasmine.createSpy(), stop: jasmine.createSpy() }) + replaceMockable(isProfilingSupported, () => true) + replaceMockable(lazyLoadProfiler, () => Promise.resolve(createRumProfilerSpy)) + }) + + async function startApi() { + const api = makeProfilerApi() + api.onRumStart( + new LifeCycle(), + createHooks(), + mockRumConfiguration({ profilingSampleRate: 100 }), + createSessionManagerMock().setId('session-id-1'), + mockViewHistory(), + createIdentityEncoder + ) + await waitNextMicrotask() // let lazyLoadProfiler().then() run + return api + } + + it('without bridge, it starts the profiler', async () => { + await startApi() + expect(createRumProfilerSpy).toHaveBeenCalled() + }) + + it('without PROFILES capability, it does not start the profiler', async () => { + mockEventBridge({ capabilities: [BridgeCapability.RECORDS] }) + await startApi() + expect(createRumProfilerSpy).not.toHaveBeenCalled() + }) + + it('with PROFILES capability, it starts the profiler', async () => { + mockEventBridge({ capabilities: [BridgeCapability.RECORDS, BridgeCapability.PROFILES] }) + await startApi() + expect(createRumProfilerSpy).toHaveBeenCalled() + }) + + it('with PROFILES capability, it starts the profiler even when profilingSampleRate is 0', async () => { + mockEventBridge({ capabilities: [BridgeCapability.RECORDS, BridgeCapability.PROFILES] }) + const api = makeProfilerApi() + api.onRumStart( + new LifeCycle(), + createHooks(), + mockRumConfiguration({ profilingSampleRate: 0 }), + createSessionManagerMock().setId('session-id-1'), + mockViewHistory(), + createIdentityEncoder + ) + await waitNextMicrotask() + expect(createRumProfilerSpy).toHaveBeenCalled() + }) + }) }) diff --git a/packages/browser-rum/src/boot/profilerApi.ts b/packages/browser-rum/src/boot/profilerApi.ts index 58b0a64869..4a83fa04ac 100644 --- a/packages/browser-rum/src/boot/profilerApi.ts +++ b/packages/browser-rum/src/boot/profilerApi.ts @@ -1,6 +1,14 @@ -import type { LifeCycle, ViewHistory, RumConfiguration, ProfilerApi, Hooks } from '@datadog/browser-rum-core' -import type { SessionManager, DeflateEncoderStreamId, Encoder } from '@datadog/browser-core' -import { monitorError, correctedChildSampleRate, isSampled, mockable } from '@datadog/browser-core' +import type { Hooks, LifeCycle, ProfilerApi, RumConfiguration, ViewHistory } from '@datadog/browser-rum-core' +import type { DeflateEncoderStreamId, Encoder, SessionContext, SessionManager } from '@datadog/browser-core' +import { + BridgeCapability, + bridgeSupports, + canUseEventBridge, + correctedChildSampleRate, + isSampled, + mockable, + monitorError, +} from '@datadog/browser-core' import type { RUMProfiler } from '../domain/profiling/types' import { isProfilingSupported } from '../domain/profiling/profilingSupported' import { startProfilingContext } from '../domain/profiling/profilingContext' @@ -25,15 +33,7 @@ export function makeProfilerApi(): ProfilerApi { return } - // Sampling (sticky sampling based on session id) - if ( - !isSampled( - session.id, - correctedChildSampleRate(configuration.sessionSampleRate, configuration.profilingSampleRate) - ) - ) { - // No sampling, no profiling. - // Note: No Profiling context is set at this stage. + if (!isProfilingSampled(configuration, session)) { return } @@ -49,7 +49,7 @@ export function makeProfilerApi(): ProfilerApi { return } - lazyLoadProfiler() + mockable(lazyLoadProfiler)() .then((createRumProfiler) => { if (!createRumProfiler) { profilingContextManager.set({ status: 'error', error_reason: 'failed-to-lazy-load' }) @@ -77,3 +77,14 @@ export function makeProfilerApi(): ProfilerApi { }, } } + +function isProfilingSampled(configuration: RumConfiguration, session: SessionContext) { + if (canUseEventBridge()) { + // In bridge mode, native SDK owns the sampling decision, skip the rate check + return bridgeSupports(BridgeCapability.PROFILES) + } + return isSampled( + session.id, + correctedChildSampleRate(configuration.sessionSampleRate, configuration.profilingSampleRate) + ) +} diff --git a/packages/browser-rum/src/domain/profiling/profiler.spec.ts b/packages/browser-rum/src/domain/profiling/datadogProfiler.spec.ts similarity index 87% rename from packages/browser-rum/src/domain/profiling/profiler.spec.ts rename to packages/browser-rum/src/domain/profiling/datadogProfiler.spec.ts index 6a670c5931..ee1326c54d 100644 --- a/packages/browser-rum/src/domain/profiling/profiler.spec.ts +++ b/packages/browser-rum/src/domain/profiling/datadogProfiler.spec.ts @@ -9,6 +9,7 @@ import { } from '@datadog/js-core/time' import type { Duration } from '@datadog/js-core/time' import type { ProfilerTrace } from '@datadog/browser-core' +import { BridgeCapability, createIdentityEncoder, createValueHistory, deepClone } from '@datadog/browser-core' import type { ViewHistoryEntry } from '@datadog/browser-rum-core' import { LifeCycle, @@ -17,15 +18,12 @@ import { VitalType, createHooks, } from '@datadog/browser-rum-core' -import { createIdentityEncoder, createValueHistory, deepClone } from '@datadog/browser-core' import { setPageVisibility, restorePageVisibility, createNewEvent, - interceptRequests, - DEFAULT_FETCH_MOCK, - readFormDataRequest, mockClock, + mockEventBridge, waitNextMicrotask, replaceMockable, createSessionManagerMock, @@ -39,10 +37,11 @@ import type { BrowserProfilerTrace } from '../../types' import { checkProfilingQuota } from './quotaCheck' import { mockedTrace } from './test-utils/mockedTrace' import { createRumProfiler } from './datadogProfiler' +import { createFormDataEmitter } from './transport/formDataEmitter' +import { createBridgeEmitter } from './transport/profilingBridge' import type { RUMProfilerConfiguration } from './types' import type { ProfilingContextManager } from './profilingContext' import { startProfilingContext } from './profilingContext' -import type { ProfileEventPayload } from './transport/assembly' import { createLongTaskHistory, type LongTaskContext } from './longTaskHistory' import type { ActionContext } from './actionHistory' import { createActionHistory } from './actionHistory' @@ -52,12 +51,10 @@ import { createVitalHistory } from './vitalHistory' describe('profiler', () => { // Store the original pathname const originalPathname = document.location.pathname - let interceptor: ReturnType + let emitPayloadSpy: jasmine.Spy let checkProfilingQuotaSpy: jasmine.Spy beforeEach(() => { - interceptor = interceptRequests() - interceptor.withFetch(DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK) // Default: quota always ok. Individual quota-check tests can reconfigure via spy.and.callFake(...) checkProfilingQuotaSpy = replaceMockableWithSpy(checkProfilingQuota) checkProfilingQuotaSpy.and.returnValue(Promise.resolve({ decision: 'quota_ok', reason: 'quota_ok' })) @@ -125,6 +122,9 @@ describe('profiler', () => { }) replaceMockable(createVitalHistory, () => vitalHistory) + emitPayloadSpy = jasmine.createSpy('emitPayload') + replaceMockable(createFormDataEmitter, () => emitPayloadSpy) + // Start collection of profile. const profiler = createRumProfiler( mockRumConfiguration({ trackLongTasks: true, profilingSampleRate: 100 }), @@ -189,13 +189,13 @@ describe('profiler', () => { expect(profilingContextManager.get()?.status).toBe('stopped') // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 1) - - expect(interceptor.requests.length).toBe(1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) - const request = await readFormDataRequest(interceptor.requests[0]) - expect(request.event.session?.id).toBe('session-id-1') - expect(request['wall-time.json']).toEqual(mockedRumProfilerTrace) + expect(emitPayloadSpy.calls.count()).toBe(1) + const payload = emitPayloadSpy.calls.argsFor(0)[0] + expect(payload.profile.session).toEqual({ id: 'session-id-1' }) + expect(payload.trace.stacks).toEqual(mockedRumProfilerTrace.stacks) + expect(payload.trace.samples).toEqual(mockedRumProfilerTrace.samples) }) it('should pause profiling collection on hidden visibility and restart on visible visibility', async () => { @@ -217,10 +217,10 @@ describe('profiler', () => { expect(profilingContextManager.get()?.status).toBe('running') // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) // Assert that the profiler has collected data on pause. - expect(interceptor.requests.length).toBe(1) + expect(emitPayloadSpy.calls.count()).toBe(1) // Change back to visible setVisibilityState('visible') @@ -237,15 +237,15 @@ describe('profiler', () => { expect(profilingContextManager.get()?.status).toBe('stopped') // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 2) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 2) - expect(interceptor.requests.length).toBe(2) + expect(emitPayloadSpy.calls.count()).toBe(2) - // Check the the sendProfilesSpy was called with the mocked trace - const request = await readFormDataRequest(interceptor.requests[1]) - - expect(request.event.session?.id).toBe('session-id-1') - expect(request['wall-time.json']).toEqual(mockedRumProfilerTrace) + // Check the emitPayloadSpy was called with the mocked trace + const payload = emitPayloadSpy.calls.argsFor(1)[0] + expect(payload.profile.session).toEqual({ id: 'session-id-1' }) + expect(payload.trace.stacks).toEqual(mockedRumProfilerTrace.stacks) + expect(payload.trace.samples).toEqual(mockedRumProfilerTrace.samples) }) it('should collect long task happening during a profiling session', async () => { @@ -304,16 +304,13 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(2) - - const requestOne = await readFormDataRequest(interceptor.requests[0]) - const requestTwo = await readFormDataRequest(interceptor.requests[1]) + expect(emitPayloadSpy.calls.count()).toBe(2) - const traceOne = requestOne['wall-time.json'] - const traceTwo = requestTwo['wall-time.json'] + const payloadOne = emitPayloadSpy.calls.argsFor(0)[0] + const payloadTwo = emitPayloadSpy.calls.argsFor(1)[0] - expect(requestOne.event.long_task?.id.length).toBe(2) - expect(traceOne.longTasks).toEqual([ + expect(payloadOne.profile.long_task?.id.length).toBe(2) + expect(payloadOne.trace.longTasks).toEqual([ { id: 'long-task-id-2', startClocks: jasmine.any(Object), @@ -328,8 +325,8 @@ describe('profiler', () => { }, ]) - expect(requestTwo.event.long_task?.id.length).toBe(1) - expect(traceTwo.longTasks).toEqual([ + expect(payloadTwo.profile.long_task?.id.length).toBe(1) + expect(payloadTwo.trace.longTasks).toEqual([ { id: 'long-task-id-3', startClocks: jasmine.any(Object), @@ -395,16 +392,13 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(2) + expect(emitPayloadSpy.calls.count()).toBe(2) - const requestOne = await readFormDataRequest(interceptor.requests[0]) - const requestTwo = await readFormDataRequest(interceptor.requests[1]) + const payloadOne = emitPayloadSpy.calls.argsFor(0)[0] + const payloadTwo = emitPayloadSpy.calls.argsFor(1)[0] - const traceOne = requestOne['wall-time.json'] - const traceTwo = requestTwo['wall-time.json'] - - expect(requestOne.event.action?.id.length).toBe(2) - expect(traceOne.actions).toEqual([ + expect(payloadOne.profile.action?.id.length).toBe(2) + expect(payloadOne.trace.actions).toEqual([ { id: 'action-id-2', startClocks: jasmine.any(Object), @@ -419,8 +413,8 @@ describe('profiler', () => { }, ]) - expect(requestTwo.event.action?.id.length).toBe(1) - expect(traceTwo.actions).toEqual([ + expect(payloadTwo.profile.action?.id.length).toBe(1) + expect(payloadTwo.trace.actions).toEqual([ { id: 'action-id-3', startClocks: jasmine.any(Object), @@ -489,16 +483,13 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(2) - - const requestOne = await readFormDataRequest(interceptor.requests[0]) - const requestTwo = await readFormDataRequest(interceptor.requests[1]) + expect(emitPayloadSpy.calls.count()).toBe(2) - const traceOne = requestOne['wall-time.json'] - const traceTwo = requestTwo['wall-time.json'] + const payloadOne = emitPayloadSpy.calls.argsFor(0)[0] + const payloadTwo = emitPayloadSpy.calls.argsFor(1)[0] - expect(requestOne.event.vital?.id.length).toBe(2) - expect(traceOne.vitals).toEqual([ + expect(payloadOne.profile.vital?.id.length).toBe(2) + expect(payloadOne.trace.vitals).toEqual([ { id: 'vital-id-2', startClocks: jasmine.any(Object), @@ -513,8 +504,8 @@ describe('profiler', () => { }, ]) - expect(requestTwo.event.vital?.id.length).toBe(1) - expect(traceTwo.vitals).toEqual([ + expect(payloadTwo.profile.vital?.id.length).toBe(1) + expect(payloadTwo.trace.vitals).toEqual([ { id: 'vital-id-3', startClocks: jasmine.any(Object), @@ -567,15 +558,11 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(3) - - const req1 = await readFormDataRequest(interceptor.requests[0]) - const req2 = await readFormDataRequest(interceptor.requests[1]) - const req3 = await readFormDataRequest(interceptor.requests[2]) + expect(emitPayloadSpy.calls.count()).toBe(3) - const vitals1 = req1['wall-time.json'].vitals - const vitals2 = req2['wall-time.json'].vitals - const vitals3 = req3['wall-time.json'].vitals + const vitals1 = emitPayloadSpy.calls.argsFor(0)[0].trace.vitals as BrowserProfilerTrace['vitals'] + const vitals2 = emitPayloadSpy.calls.argsFor(1)[0].trace.vitals as BrowserProfilerTrace['vitals'] + const vitals3 = emitPayloadSpy.calls.argsFor(2)[0].trace.vitals as BrowserProfilerTrace['vitals'] // Profile 1: all three operations present, only op1 has a duration expect(vitals1?.map((v) => v.id)).toEqual(jasmine.arrayContaining(['op-id-1', 'op-id-2', 'op-id-3'])) @@ -637,10 +624,9 @@ describe('profiler', () => { expect(profilingContextManager.get()?.status).toBe('stopped') // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) - const request = await readFormDataRequest(interceptor.requests[0]) - const views = request['wall-time.json'].views + const views = emitPayloadSpy.calls.argsFor(0)[0].trace.views expect(views.length).toBe(2) expect(views[0].viewId).toBe('view-user') @@ -691,14 +677,14 @@ describe('profiler', () => { await waitForBoolean(() => profiler.isPaused()) // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) // Assert that the profiler has collected data on pause. - expect(interceptor.requests.length).toBe(1) + expect(emitPayloadSpy.calls.count()).toBe(1) - const request = await readFormDataRequest(interceptor.requests[0]) - expect(request.event.session?.id).toBe('session-id-1') - expect(request['wall-time.json'].views).toEqual([ + const payload = emitPayloadSpy.calls.argsFor(0)[0] + expect(payload.profile.session).toEqual({ id: 'session-id-1' }) + expect(payload.trace.views).toEqual([ { viewId: initialViewEntry.id, viewName: initialViewEntry.name, @@ -726,14 +712,14 @@ describe('profiler', () => { expect(profilingContextManager.get()?.status).toBe('stopped') // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 2) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 2) - expect(interceptor.requests.length).toBe(2) + expect(emitPayloadSpy.calls.count()).toBe(2) - // Check the the sendProfilesSpy was called with the mocked trace - const request2 = await readFormDataRequest(interceptor.requests[1]) - expect(request2.event.session?.id).toBe('session-id-1') - expect(request2['wall-time.json']).toEqual(mockedRumProfilerTrace) + const payload2 = emitPayloadSpy.calls.argsFor(1)[0] + expect(payload2.profile.session).toEqual({ id: 'session-id-1' }) + expect(payload2.trace.stacks).toEqual(mockedRumProfilerTrace.stacks) + expect(payload2.trace.samples).toEqual(mockedRumProfilerTrace.samples) }) it('should stop profiling when session expires', async () => { @@ -753,10 +739,10 @@ describe('profiler', () => { expect(profilingContextManager.get()?.status).toBe('stopped') // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) // Verify that profiler collected data before stopping - expect(interceptor.requests.length).toBe(1) + expect(emitPayloadSpy.calls.count()).toBe(1) }) it('should not restart profiling after session expiration when visibility changes', async () => { @@ -805,10 +791,10 @@ describe('profiler', () => { expect(profilingContextManager.get()?.status).toBe('stopped') // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) // Verify that profiler collected data before stopping - expect(interceptor.requests.length).toBe(1) + expect(emitPayloadSpy.calls.count()).toBe(1) // Notify that the session has been renewed lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) @@ -824,10 +810,10 @@ describe('profiler', () => { expect(profiler.isStopped()).toBe(true) // Wait for data collection to complete (async fire-and-forget) - await waitForBoolean(() => interceptor.requests.length >= 2) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 2) // Should have collected data from both sessions (before expiration and after renewal) - expect(interceptor.requests.length).toBe(2) + expect(emitPayloadSpy.calls.count()).toBe(2) }) it('should handle multiple session expiration and renewal cycles', async () => { @@ -845,8 +831,8 @@ describe('profiler', () => { expect(profiler.isStopped()).toBe(true) expect(profilingContextManager.get()?.status).toBe('stopped') - await waitForBoolean(() => interceptor.requests.length >= 1) - expect(interceptor.requests.length).toBe(1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) + expect(emitPayloadSpy.calls.count()).toBe(1) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) await waitForBoolean(() => profiler.isRunning()) @@ -857,8 +843,8 @@ describe('profiler', () => { expect(profiler.isStopped()).toBe(true) expect(profilingContextManager.get()?.status).toBe('stopped') - await waitForBoolean(() => interceptor.requests.length >= 2) - expect(interceptor.requests.length).toBe(2) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 2) + expect(emitPayloadSpy.calls.count()).toBe(2) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) await waitForBoolean(() => profiler.isRunning()) @@ -869,8 +855,8 @@ describe('profiler', () => { expect(profiler.isStopped()).toBe(true) // Should have collected data from: initial session + first renewal + second renewal = 3 profiles - await waitForBoolean(() => interceptor.requests.length >= 3) - expect(interceptor.requests.length).toBe(3) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 3) + expect(emitPayloadSpy.calls.count()).toBe(3) }) it('should not restart profiling on session renewal if profiler was manually stopped', async () => { @@ -1042,9 +1028,8 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(1) - const request = await readFormDataRequest(interceptor.requests[0]) - const trace = request['wall-time.json'] + expect(emitPayloadSpy.calls.count()).toBe(1) + const trace = emitPayloadSpy.calls.argsFor(0)[0].trace // Should only include the long task that occurred during the actual profiling window. // Without the fix (using timeStamp for duration), both long tasks would be included @@ -1064,10 +1049,9 @@ describe('profiler', () => { profiler.stop() - await waitForBoolean(() => interceptor.requests.length >= 1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) - const request = await readFormDataRequest(interceptor.requests[0]) - expect(request.event.session?.id).toBe('session-id-1') + expect(emitPayloadSpy.calls.argsFor(0)[0].profile.session).toEqual({ id: 'session-id-1' }) }) describe('discard logic', () => { @@ -1085,7 +1069,7 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(0) + expect(emitPayloadSpy.calls.count()).toBe(0) }) it('should send profile when below duration threshold if a long task is present', async () => { @@ -1109,7 +1093,7 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(1) + expect(emitPayloadSpy.calls.count()).toBe(1) }) it('should send profile when duration threshold is met', async () => { @@ -1126,7 +1110,7 @@ describe('profiler', () => { await waitNextMicrotask() await waitNextMicrotask() - expect(interceptor.requests.length).toBe(1) + expect(emitPayloadSpy.calls.count()).toBe(1) }) }) @@ -1177,7 +1161,7 @@ describe('profiler', () => { error_reason: undefined, quota_reason: 'quota_exceeded', } as any) - expect(interceptor.requests.length).toBe(0) // no data sent + expect(emitPayloadSpy.calls.count()).toBe(0) // no data sent }) it('should stop profiler and set org_disabled context when quota check returns org_disabled', async () => { @@ -1192,7 +1176,7 @@ describe('profiler', () => { error_reason: undefined, quota_reason: 'org_disabled', } as any) - expect(interceptor.requests.length).toBe(0) // no data sent + expect(emitPayloadSpy.calls.count()).toBe(0) // no data sent }) it('should stop profiler and set unknown_reason context when quota check returns unknown_reason', async () => { @@ -1207,7 +1191,7 @@ describe('profiler', () => { error_reason: undefined, quota_reason: 'unknown_reason', } as any) - expect(interceptor.requests.length).toBe(0) // no data sent + expect(emitPayloadSpy.calls.count()).toBe(0) // no data sent }) it('should keep profiler running when quota check returns quota-ok', async () => { @@ -1295,8 +1279,8 @@ describe('profiler', () => { expect(profilingContextManager.get()?.error_reason).toBeUndefined() // data IS sent (normal session-expired collection happens) - await waitForBoolean(() => interceptor.requests.length >= 1) - expect(interceptor.requests.length).toBeGreaterThanOrEqual(1) + await waitForBoolean(() => emitPayloadSpy.calls.count() >= 1) + expect(emitPayloadSpy.calls.count()).toBeGreaterThanOrEqual(1) }) it('should stop profiler and not resume when quota-exceeded resolves while paused', async () => { @@ -1402,6 +1386,44 @@ describe('profiler', () => { expect(profiler.isStopped()).toBe(true) }) + + it('should not call quota check in bridge mode', async () => { + mockEventBridge({ capabilities: [BridgeCapability.PROFILES] }) + const { profiler } = setupProfiler() + + profiler.start() + await waitNextMicrotask() + profiler.stop() + + expect(checkProfilingQuotaSpy).not.toHaveBeenCalled() + }) + }) + + describe('transport selection', () => { + function buildProfiler() { + const hooks = createHooks() + return createRumProfiler( + mockRumConfiguration({ profilingSampleRate: 100 }), + new LifeCycle(), + createSessionManagerMock(), + startProfilingContext(hooks), + createIdentityEncoder, + mockViewHistory() + ) + } + + it('uses bridge emitter when event bridge is active', () => { + mockEventBridge({ capabilities: [BridgeCapability.PROFILES] }) + const createBridgeEmitterSpy = replaceMockableWithSpy(createBridgeEmitter) + buildProfiler() + expect(createBridgeEmitterSpy).toHaveBeenCalled() + }) + + it('uses form data emitter when no event bridge', () => { + const createFormDataEmitterSpy = replaceMockableWithSpy(createFormDataEmitter) + buildProfiler() + expect(createFormDataEmitterSpy).toHaveBeenCalled() + }) }) }) diff --git a/packages/browser-rum/src/domain/profiling/datadogProfiler.ts b/packages/browser-rum/src/domain/profiling/datadogProfiler.ts index 4323d1bcb0..5b19f2e22c 100644 --- a/packages/browser-rum/src/domain/profiling/datadogProfiler.ts +++ b/packages/browser-rum/src/domain/profiling/datadogProfiler.ts @@ -1,21 +1,21 @@ import { elapsed, clocksOrigin, clocksNow } from '@datadog/js-core/time' -import type { Encoder, SessionManager, Profiler } from '@datadog/browser-core' +import type { SessionManager, Profiler, DeflateEncoderStreamId, Encoder } from '@datadog/browser-core' import { addEventListener, + canUseEventBridge, clearTimeout, setTimeout, DOM_EVENT, monitorError, display, globalObject, - DeflateEncoderStreamId, mockable, isSampled, correctedChildSampleRate, } from '@datadog/browser-core' -import type { LifeCycle, RumConfiguration, TransportPayload, ViewHistory } from '@datadog/browser-rum-core' -import { createFormDataTransport, LifeCycleEventType } from '@datadog/browser-rum-core' +import type { LifeCycle, RumConfiguration, ViewHistory } from '@datadog/browser-rum-core' +import { LifeCycleEventType } from '@datadog/browser-rum-core' import type { BrowserProfilerTrace, RumViewEntry } from '../../types' import type { RumProfilerInstance, @@ -23,10 +23,13 @@ import type { RUMProfiler, RUMProfilerConfiguration, RumProfilerStoppedInstance, + ProfilingPayload, } from './types' import type { ProfilingContextManager } from './profilingContext' +import { createBridgeEmitter } from './transport/profilingBridge' +import { createFormDataEmitter } from './transport/formDataEmitter' import { getCustomOrDefaultViewName } from './utils/getCustomOrDefaultViewName' -import { assembleProfilingPayload } from './transport/assembly' +import { buildProfileEvent } from './transport/buildProfileEvent' import { createLongTaskHistory } from './longTaskHistory' import { createActionHistory } from './actionHistory' import { createVitalHistory } from './vitalHistory' @@ -48,7 +51,9 @@ export function createRumProfiler( viewHistory: ViewHistory, profilerConfiguration: RUMProfilerConfiguration = DEFAULT_RUM_PROFILER_CONFIGURATION ): RUMProfiler { - const transport = createFormDataTransport(configuration, lifeCycle, createEncoder, DeflateEncoderStreamId.PROFILING) + const emitPayload = canUseEventBridge() + ? mockable(createBridgeEmitter)() + : mockable(createFormDataEmitter)(configuration, lifeCycle, createEncoder) let lastViewEntry: RumViewEntry | undefined @@ -112,8 +117,15 @@ export function createRumProfiler( // Start profiler instance startNextProfilerInstance() + triggerQuotaCheck() + } - // Quota check — optimistic: profiler already recording, only gates sending. + function triggerQuotaCheck() { + if (canUseEventBridge()) { + // Quota check only in non-bridge mode. + return + } + // Optimistic: profiler already recording, only gates sending. // Generation counter invalidates results from a prior session (incremented on each start() call). // State guard handles within-session cancellation (user stop, session expiry, etc.). const checkGeneration = ++quotaCheckGeneration @@ -280,8 +292,8 @@ export function createRumProfiler( } handleProfilerTrace( - // Enrich trace with time and instance data - Object.assign(trace, { + // Enrich trace with time and instance data — create new object to avoid mutating the raw trace + Object.assign({} as BrowserProfilerTrace, trace, { startClocks, endClocks, clocksOrigin: clocksOrigin(), @@ -360,9 +372,11 @@ export function createRumProfiler( } function handleProfilerTrace(trace: BrowserProfilerTrace, sessionId: string | undefined): void { - const payload = assembleProfilingPayload(trace, configuration, sessionId) - - void transport.send(payload as unknown as TransportPayload) + const payload: ProfilingPayload = { + profile: buildProfileEvent(trace, configuration, sessionId), + trace, + } + emitPayload(payload) } function handleSampleBufferFull(): void { diff --git a/packages/browser-rum/src/domain/profiling/transport/assembly.ts b/packages/browser-rum/src/domain/profiling/transport/assembly.ts deleted file mode 100644 index a75d4312f9..0000000000 --- a/packages/browser-rum/src/domain/profiling/transport/assembly.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { clockDrift } from '@datadog/js-core/time' -import { buildTags } from '@datadog/browser-core' -import type { RumConfiguration } from '@datadog/browser-rum-core' -import type { BrowserProfileEvent, BrowserProfilerTrace } from '../../../types' -import { buildProfileEventAttributes } from './buildProfileEventAttributes' - -export interface ProfileEventPayload { - event: BrowserProfileEvent - 'wall-time.json': BrowserProfilerTrace -} - -export function assembleProfilingPayload( - profilerTrace: BrowserProfilerTrace, - configuration: RumConfiguration, - sessionId: string | undefined -): ProfileEventPayload { - const event = buildProfileEvent(profilerTrace, configuration, sessionId) - - return { - event, - 'wall-time.json': profilerTrace, - } -} - -function buildProfileEvent( - profilerTrace: BrowserProfilerTrace, - configuration: RumConfiguration, - sessionId: string | undefined -): ProfileEventPayload['event'] { - const tags = buildTags(configuration) // TODO: get that from the tagContext hook - const profileAttributes = buildProfileEventAttributes(profilerTrace, configuration.applicationId, sessionId) - const profileEventTags = buildProfileEventTags(tags) - - const profileEvent: ProfileEventPayload['event'] = { - ...profileAttributes, - attachments: ['wall-time.json'], - start: new Date(profilerTrace.startClocks.timeStamp).toISOString(), - end: new Date(profilerTrace.endClocks.timeStamp).toISOString(), - family: 'chrome', - runtime: 'chrome', - format: 'json', - version: 4, // Ingestion event version (not the version application tag) - tags_profiler: profileEventTags.join(','), - _dd: { - clock_drift: clockDrift(), - }, - } - - return profileEvent -} - -/** - * Builds tags for the Profile Event. - * - * @param tags - RUM tags - * @returns Combined tags for the Profile Event. - */ -function buildProfileEventTags(tags: string[]): string[] { - // Tags already contains the common tags for all events. (service, env, version, etc.) - // Here we are adding some specific-to-profiling tags. - const profileEventTags = tags.concat(['language:javascript', 'runtime:chrome', 'family:chrome', 'host:browser']) - - return profileEventTags -} diff --git a/packages/browser-rum/src/domain/profiling/transport/buildProfileEventAttributes.spec.ts b/packages/browser-rum/src/domain/profiling/transport/buildProfileEvent.spec.ts similarity index 99% rename from packages/browser-rum/src/domain/profiling/transport/buildProfileEventAttributes.spec.ts rename to packages/browser-rum/src/domain/profiling/transport/buildProfileEvent.spec.ts index 589a2ff57f..128351c1e5 100644 --- a/packages/browser-rum/src/domain/profiling/transport/buildProfileEventAttributes.spec.ts +++ b/packages/browser-rum/src/domain/profiling/transport/buildProfileEvent.spec.ts @@ -2,7 +2,7 @@ import { clocksOrigin } from '@datadog/js-core/time' import { RumPerformanceEntryType } from '@datadog/browser-rum-core' import type { BrowserProfilerTrace, RumViewEntry } from '../../../types' import type { LongTaskContext } from '../longTaskHistory' -import { buildProfileEventAttributes, type ProfileEventAttributes } from './buildProfileEventAttributes' +import { buildProfileEventAttributes, type ProfileEventAttributes } from './buildProfileEvent' describe('buildProfileEventAttributes', () => { const applicationId = 'test-app-id' diff --git a/packages/browser-rum/src/domain/profiling/transport/buildProfileEventAttributes.ts b/packages/browser-rum/src/domain/profiling/transport/buildProfileEvent.ts similarity index 63% rename from packages/browser-rum/src/domain/profiling/transport/buildProfileEventAttributes.ts rename to packages/browser-rum/src/domain/profiling/transport/buildProfileEvent.ts index 3954855d70..5ce2eb36d8 100644 --- a/packages/browser-rum/src/domain/profiling/transport/buildProfileEventAttributes.ts +++ b/packages/browser-rum/src/domain/profiling/transport/buildProfileEvent.ts @@ -1,4 +1,7 @@ -import type { BrowserProfilerTrace, RumProfilerVitalEntry, RumViewEntry } from '../../../types' +import { clockDrift } from '@datadog/js-core/time' +import { buildTags } from '@datadog/browser-core' +import type { RumConfiguration } from '@datadog/browser-rum-core' +import type { BrowserProfileEvent, BrowserProfilerTrace, RumProfilerVitalEntry, RumViewEntry } from '../../../types' export interface ProfileEventAttributes { application: { @@ -24,6 +27,34 @@ export interface ProfileEventAttributes { } } +/** + * Builds a BrowserProfileEvent from a trace. + */ +export function buildProfileEvent( + profilerTrace: BrowserProfilerTrace, + configuration: RumConfiguration, + sessionId: string | undefined +): BrowserProfileEvent { + const tags = buildTags(configuration) // TODO: get that from the tagContext hook + const profileAttributes = buildProfileEventAttributes(profilerTrace, configuration.applicationId, sessionId) + const profileEventTags = buildProfileEventTags(tags) + + return { + ...profileAttributes, + attachments: ['wall-time.json'], + start: new Date(profilerTrace.startClocks.timeStamp).toISOString(), + end: new Date(profilerTrace.endClocks.timeStamp).toISOString(), + family: 'chrome', + runtime: 'chrome', + format: 'json', + version: 4, // Ingestion event version (not the version application tag) + tags_profiler: profileEventTags.join(','), + _dd: { + clock_drift: clockDrift(), + }, + } +} + /** * Builds attributes for the Profile Event. * @@ -104,3 +135,9 @@ function extractVitalIdsAndLabels(vitals?: RumProfilerVitalEntry[]): { return result } + +function buildProfileEventTags(tags: string[]): string[] { + // Tags already contains the common tags for all events. (service, env, version, etc.) + // Here we are adding some specific-to-profiling tags. + return tags.concat(['language:javascript', 'runtime:chrome', 'family:chrome', 'host:browser']) +} diff --git a/packages/browser-rum/src/domain/profiling/transport/formDataEmitter.spec.ts b/packages/browser-rum/src/domain/profiling/transport/formDataEmitter.spec.ts new file mode 100644 index 0000000000..15c0b5b508 --- /dev/null +++ b/packages/browser-rum/src/domain/profiling/transport/formDataEmitter.spec.ts @@ -0,0 +1,52 @@ +import { relativeNow, timeStampNow } from '@datadog/js-core/time' +import { createIdentityEncoder } from '@datadog/browser-core' +import { interceptRequests, DEFAULT_FETCH_MOCK, readFormDataRequest } from '@datadog/browser-core/test' +import { LifeCycle } from '@datadog/browser-rum-core' +import { mockRumConfiguration } from '@datadog/browser-rum-core/test' +import { mockedTrace } from '../test-utils/mockedTrace' +import { createFormDataEmitter } from './formDataEmitter' + +describe('createFormDataEmitter', () => { + let interceptor: ReturnType + + beforeEach(() => { + interceptor = interceptRequests() + interceptor.withFetch(DEFAULT_FETCH_MOCK) + }) + + it('sends a FormData request with event and wall-time.json parts', async () => { + const lifeCycle = new LifeCycle() + const emit = createFormDataEmitter(mockRumConfiguration(), lifeCycle, createIdentityEncoder) + + emit({ + profile: { + application: { id: 'app-id' }, + attachments: ['wall-time.json'], + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-01T00:01:00.000Z', + family: 'chrome', + runtime: 'chrome', + format: 'json', + version: 4, + tags_profiler: 'sdk_version:1.0.0', + _dd: { clock_drift: 0 }, + }, + trace: { + ...mockedTrace, + startClocks: { relative: relativeNow(), timeStamp: timeStampNow() }, + endClocks: { relative: relativeNow(), timeStamp: timeStampNow() }, + clocksOrigin: { relative: 0, timeStamp: 0 }, + sampleInterval: 10, + longTasks: [], + views: [], + actions: [], + vitals: [], + }, + }) + await Promise.resolve() // wait for encode() to complete + expect(interceptor.requests.length).toBe(1) + const formData = await readFormDataRequest<{ event: any; 'wall-time.json': any }>(interceptor.requests[0]) + expect(formData.event).toBeDefined() + expect(formData['wall-time.json']).toBeDefined() + }) +}) diff --git a/packages/browser-rum/src/domain/profiling/transport/formDataEmitter.ts b/packages/browser-rum/src/domain/profiling/transport/formDataEmitter.ts new file mode 100644 index 0000000000..40d8ae220b --- /dev/null +++ b/packages/browser-rum/src/domain/profiling/transport/formDataEmitter.ts @@ -0,0 +1,17 @@ +import type { Encoder } from '@datadog/browser-core' +import { DeflateEncoderStreamId } from '@datadog/browser-core' +import type { LifeCycle, RumConfiguration, TransportPayload } from '@datadog/browser-rum-core' +import { createFormDataTransport } from '@datadog/browser-rum-core' +import type { ProfilingPayload } from '../types' + +export function createFormDataEmitter( + configuration: RumConfiguration, + lifeCycle: LifeCycle, + createEncoder: (streamId: DeflateEncoderStreamId) => Encoder +): (payload: ProfilingPayload) => void { + const transport = createFormDataTransport(configuration, lifeCycle, createEncoder, DeflateEncoderStreamId.PROFILING) + return ({ profile, trace }: ProfilingPayload) => { + const formPayload = { event: profile, 'wall-time.json': trace } + void transport.send(formPayload as unknown as TransportPayload) + } +} diff --git a/packages/browser-rum/src/domain/profiling/transport/profilingBridge.spec.ts b/packages/browser-rum/src/domain/profiling/transport/profilingBridge.spec.ts new file mode 100644 index 0000000000..8fb64bb444 --- /dev/null +++ b/packages/browser-rum/src/domain/profiling/transport/profilingBridge.spec.ts @@ -0,0 +1,38 @@ +import { BridgeCapability } from '@datadog/browser-core' +import { mockEventBridge } from '@datadog/browser-core/test' +import { mockedTrace } from '../test-utils/mockedTrace' +import type { ProfilingPayload } from '../types' +import { createBridgeEmitter } from './profilingBridge' + +describe('createBridgeEmitter', () => { + it('sends the payload through the bridge as a profile event', () => { + const bridge = mockEventBridge({ capabilities: [BridgeCapability.RECORDS, BridgeCapability.PROFILES] }) + const sendSpy = spyOn(bridge, 'send') + const emit = createBridgeEmitter() + + const payload: ProfilingPayload = { + profile: { + application: { id: 'app-id' }, + attachments: ['wall-time.json'], + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-01T00:01:00.000Z', + family: 'chrome', + runtime: 'chrome', + format: 'json', + version: 4, + tags_profiler: 'sdk_version:1.0.0', + _dd: { clock_drift: 0 }, + }, + trace: mockedTrace as any, + } + + emit(payload) + + expect(sendSpy).toHaveBeenCalledOnceWith(jasmine.stringContaining('"eventType":"profile"')) + const sent = JSON.parse(sendSpy.calls.first().args[0]) + expect(sent.eventType).toBe('profile') + expect(sent.event.profile).toEqual(payload.profile) + expect(sent.event.trace).toEqual(payload.trace) + expect(sent.view).toBeUndefined() + }) +}) diff --git a/packages/browser-rum/src/domain/profiling/transport/profilingBridge.ts b/packages/browser-rum/src/domain/profiling/transport/profilingBridge.ts new file mode 100644 index 0000000000..410c06acbc --- /dev/null +++ b/packages/browser-rum/src/domain/profiling/transport/profilingBridge.ts @@ -0,0 +1,9 @@ +import { getEventBridge } from '@datadog/browser-core' +import type { ProfilingPayload } from '../types' + +export function createBridgeEmitter(): (payload: ProfilingPayload) => void { + const bridge = getEventBridge<'profile', ProfilingPayload>()! + return (payload: ProfilingPayload) => { + bridge.send('profile', payload) + } +} diff --git a/packages/browser-rum/src/domain/profiling/types.ts b/packages/browser-rum/src/domain/profiling/types.ts index 6a3957c080..3b9b7353e1 100644 --- a/packages/browser-rum/src/domain/profiling/types.ts +++ b/packages/browser-rum/src/domain/profiling/types.ts @@ -1,6 +1,6 @@ import type { ClocksState } from '@datadog/js-core/time' import type { TimeoutId, Profiler } from '@datadog/browser-core' -import type { RumViewEntry } from '../../types' +import type { BrowserProfileEvent, BrowserProfilerTrace, RumViewEntry } from '../../types' import type { LongTaskContext } from './longTaskHistory' /** @@ -56,6 +56,11 @@ export interface RUMProfiler { isPaused: () => boolean } +export interface ProfilingPayload { + profile: BrowserProfileEvent + trace: BrowserProfilerTrace +} + export interface RUMProfilerConfiguration { sampleIntervalMs: number // Sample stack trace every x milliseconds (defaults to 10ms for Unix, 16ms on Windows) collectIntervalMs: number // Interval for collecting RUM Profiles (defaults to 1min) diff --git a/test/e2e/lib/framework/intakeProxyMiddleware.ts b/test/e2e/lib/framework/intakeProxyMiddleware.ts index 06b1e08929..96cd7a7344 100644 --- a/test/e2e/lib/framework/intakeProxyMiddleware.ts +++ b/test/e2e/lib/framework/intakeProxyMiddleware.ts @@ -45,7 +45,7 @@ export type ProfileIntakeRequest = { intakeType: 'profile' event: BrowserProfileEvent trace: BrowserProfilerTrace - traceFile: { + traceFile?: { filename: string encoding: string | null mimetype: string @@ -112,7 +112,8 @@ function computeIntakeRequestInfos(req: express.Request): IntakeRequestInfos { encoding, transport, batchTime, - intakeType: eventType === 'log' ? 'logs' : eventType === 'record' ? 'replay' : 'rum', + intakeType: + eventType === 'log' ? 'logs' : eventType === 'record' ? 'replay' : eventType === 'profile' ? 'profile' : 'rum', } } @@ -233,6 +234,23 @@ function readProfileIntakeRequest( infos: IntakeRequestInfos & { intakeType: 'profile' } ): Promise { return new Promise((resolve, reject) => { + if (infos.isBridge) { + readStream(req) + .then((rawBody) => { + const payload = JSON.parse(rawBody.toString('utf-8')) as { + profile: BrowserProfileEvent + trace: BrowserProfilerTrace + } + resolve({ + ...infos, + event: payload.profile, + trace: payload.trace, + }) + }) + .catch(reject) + return + } + let eventPromise: Promise let tracePromise: Promise<{ trace: BrowserProfilerTrace diff --git a/test/e2e/lib/framework/pageSetups.ts b/test/e2e/lib/framework/pageSetups.ts index e517c746c8..ea7c667660 100644 --- a/test/e2e/lib/framework/pageSetups.ts +++ b/test/e2e/lib/framework/pageSetups.ts @@ -46,6 +46,7 @@ export interface WorkerOptions { export interface EventBridgeOptions { isTraceSampled?: boolean + capabilities?: string[] } export type SetupFactory = (options: SetupOptions, servers: Servers) => string @@ -328,10 +329,12 @@ function setupEventBridge(servers: Servers, options: EventBridgeOptions = {}) { },` : '' + const capabilities = options.capabilities ?? ['records'] + return html`