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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface TransportConfiguration {
flagEvaluationEndpointBuilder: EndpointBuilder
datacenter?: string | undefined
replica?: ReplicaConfiguration
clientToken: string
site: Site
source: 'browser' | 'flutter' | 'unity'
}
Expand All @@ -30,6 +31,7 @@ export function computeTransportConfiguration(initConfiguration: InitConfigurati
const replicaConfiguration = computeReplicaConfiguration({ ...initConfiguration, site, source })

return {
clientToken: initConfiguration.clientToken,
replica: replicaConfiguration,
site,
source,
Expand Down
204 changes: 204 additions & 0 deletions packages/rum/src/domain/profiling/profiler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import {
mockClock,
waitNextMicrotask,
replaceMockable,
replaceMockableWithSpy,
} from '@datadog/browser-core/test'
import { createRumSessionManagerMock, mockRumConfiguration, mockViewHistory } from '../../../../rum-core/test'
import { mockProfiler } from '../../../test'
import type { BrowserProfilerTrace } from '../../types'
import { checkProfilingQuota } from './quotaCheck'
import { mockedTrace } from './test-utils/mockedTrace'
import { createRumProfiler } from './profiler'
import type { ProfilerTrace } from './types'
Expand All @@ -42,10 +44,14 @@ describe('profiler', () => {
// Store the original pathname
const originalPathname = document.location.pathname
let interceptor: ReturnType<typeof interceptRequests>
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('quota-ok'))
})

afterEach(() => {
Expand Down Expand Up @@ -945,6 +951,204 @@ describe('profiler', () => {
profiler.stop()
expect(profiler.isStopped()).toBe(true)
})

describe('quota check', () => {
it('should stop profiler and set quota-exceeded context when quota check returns quota-exceeded', async () => {
checkProfilingQuotaSpy.and.returnValue(Promise.resolve('quota-exceeded'))
const { profiler, profilingContextManager } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isStopped())

expect(profilingContextManager.get()).toEqual({ status: 'stopped', error_reason: 'quota-exceeded' } as any)
expect(interceptor.requests.length).toBe(0) // no data sent
})

it('should keep profiler running when quota check returns quota-ok', async () => {
// default spy already returns quota-ok
const { profiler, profilingContextManager } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isRunning())

expect(profiler.isRunning()).toBe(true)
expect(profilingContextManager.get()?.status).toBe('running')

profiler.stop()
})

it('should not call quota check and proceed when sessionId is undefined at start', async () => {
// default spy already returns quota-ok; we just verify it's never called
mockProfiler(deepClone(mockedTrace) as any)
const hooks = createHooks()
const profilingContextManager = startProfilingContext(hooks)
const noSessionManager = createRumSessionManagerMock()
spyOn(noSessionManager, 'findTrackedSession').and.returnValue(undefined)
const profilerNoSession = createRumProfiler(
mockRumConfiguration({ profilingSampleRate: 100 }),
new LifeCycle(),
noSessionManager,
profilingContextManager,
createIdentityEncoder,
mockViewHistory(),
{ sampleIntervalMs: 10, collectIntervalMs: 60000, minNumberOfSamples: 0, minProfileDurationMs: 0 }
)

profilerNoSession.start()
await waitForBoolean(() => profilerNoSession.isRunning())

expect(checkProfilingQuotaSpy).not.toHaveBeenCalled()
expect(profilerNoSession.isRunning()).toBe(true)

profilerNoSession.stop()
})

it('should discard quota-exceeded result when profiler was already stopped by user', async () => {
let resolveQuota!: (result: 'quota-ok' | 'quota-exceeded') => void
checkProfilingQuotaSpy.and.callFake(
() =>
new Promise((resolve) => {
resolveQuota = resolve
})
)
const { profiler, profilingContextManager } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isRunning())

profiler.stop()
expect(profiler.isStopped()).toBe(true)
expect(profilingContextManager.get()?.status).toBe('stopped')
expect(profilingContextManager.get()?.error_reason).toBeUndefined()

resolveQuota('quota-exceeded')
await waitNextMicrotask()

expect(profilingContextManager.get()?.error_reason).toBeUndefined()
})

it('should discard quota-exceeded result when SESSION_EXPIRED fired before quota resolved', async () => {
let resolveQuota!: (result: 'quota-ok' | 'quota-exceeded') => void
checkProfilingQuotaSpy.and.callFake(
() =>
new Promise((resolve) => {
resolveQuota = resolve
})
)
const { profiler, profilingContextManager } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isRunning())

lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED)
expect(profiler.isStopped()).toBe(true)

resolveQuota('quota-exceeded')
await waitNextMicrotask()

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)
})

it('should stop profiler and not resume when quota-exceeded resolves while paused', async () => {
let resolveQuota!: (result: 'quota-ok' | 'quota-exceeded') => void
checkProfilingQuotaSpy.and.callFake(
() =>
new Promise((resolve) => {
resolveQuota = resolve
})
)
const { profiler, profilingContextManager } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isRunning())

setVisibilityState('hidden')
await waitForBoolean(() => profiler.isPaused())

resolveQuota('quota-exceeded')
await waitNextMicrotask()

expect(profiler.isStopped()).toBe(true)
expect(profilingContextManager.get()).toEqual({ status: 'stopped', error_reason: 'quota-exceeded' } as any)

setVisibilityState('visible')
await waitNextMicrotask()

expect(profiler.isStopped()).toBe(true)
})

it('should discard stale quota result when SESSION_RENEWED restarts the profiler', async () => {
let resolveOldQuota!: (result: 'quota-ok' | 'quota-exceeded') => void
let callCount = 0
checkProfilingQuotaSpy.and.callFake(() => {
callCount++
if (callCount === 1) {
return new Promise((resolve) => {
resolveOldQuota = resolve
})
}
return Promise.resolve('quota-ok')
})
const { profiler, profilingContextManager } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isRunning())

lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED)
lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED)
await waitForBoolean(() => profiler.isRunning())

resolveOldQuota('quota-exceeded')
await waitNextMicrotask()

expect(profiler.isRunning()).toBe(true)
expect(profilingContextManager.get()?.status).toBe('running')

profiler.stop()
})

it('should restart profiler and re-check quota on SESSION_RENEWED after quota-exceeded', async () => {
let callCount = 0
checkProfilingQuotaSpy.and.callFake(() => {
callCount++
return Promise.resolve(callCount === 1 ? 'quota-exceeded' : 'quota-ok')
})
const { profiler } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isStopped())

expect(callCount).toBe(1)

lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED)
await waitForBoolean(() => profiler.isRunning())

expect(callCount).toBe(2)
expect(profiler.isRunning()).toBe(true)

profiler.stop()
})

it('should NOT restart profiler on SESSION_RENEWED after stopped-by-user', async () => {
// default spy already returns quota-ok
const { profiler } = setupProfiler()

profiler.start()
await waitForBoolean(() => profiler.isRunning())

profiler.stop()
expect(profiler.isStopped()).toBe(true)

lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED)
await waitNextMicrotask()

expect(profiler.isStopped()).toBe(true)
})
})
})

function waitForBoolean(booleanCallback: () => boolean) {
Expand Down
46 changes: 42 additions & 4 deletions packages/rum/src/domain/profiling/profiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { assembleProfilingPayload } from './transport/assembly'
import { createLongTaskHistory } from './longTaskHistory'
import { createActionHistory } from './actionHistory'
import { createVitalHistory } from './vitalHistory'
import { checkProfilingQuota } from './quotaCheck'

export const DEFAULT_RUM_PROFILER_CONFIGURATION: RUMProfilerConfiguration = {
sampleIntervalMs: 10, // Sample stack trace every 10ms
Expand Down Expand Up @@ -66,6 +67,7 @@ export function createRumProfiler(
const vitalHistory = mockable(createVitalHistory)(lifeCycle)

let instance: RumProfilerInstance = { state: 'stopped', stateReason: 'initializing' }
let quotaCheckGeneration = 0

// Stops the profiler when session expires
lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => {
Expand All @@ -74,7 +76,10 @@ export function createRumProfiler(

// Start the profiler again when session is renewed
lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => {
if (instance.state === 'stopped' && instance.stateReason === 'session-expired') {
if (
instance.state === 'stopped' &&
(instance.stateReason === 'session-expired' || instance.stateReason === 'quota-exceeded')
) {
start()
}
})
Expand Down Expand Up @@ -105,6 +110,27 @@ export function createRumProfiler(

// Start profiler instance
startNextProfilerInstance()

// Quota check — 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
const sessionId = session.findTrackedSession()?.id
if (sessionId) {
mockable(checkProfilingQuota)(configuration, sessionId)
.then((result) => {
if (checkGeneration !== quotaCheckGeneration) {
return
}
if (instance.state !== 'running' && instance.state !== 'paused') {
return
}
if (result === 'quota-exceeded') {
stopProfiling('quota-exceeded')
}
})
.catch(monitorError)
}
}

// Public API to manually stop the profiler.
Expand All @@ -121,7 +147,12 @@ export function createRumProfiler(
globalCleanupTasks.length = 0

// Update Profiling status once the Profiler has been stopped.
profilingContextManager.set({ status: 'stopped', error_reason: undefined })
if (reason === 'quota-exceeded') {
// TODO(PROF-13798): remove `as any` once rum-events-format schema adds 'quota-exceeded' to error_reason
profilingContextManager.set({ status: 'stopped', error_reason: 'quota-exceeded' as any })
} else {
profilingContextManager.set({ status: 'stopped', error_reason: undefined })
}
}

/**
Expand Down Expand Up @@ -287,8 +318,15 @@ export function createRumProfiler(
// Cleanup instance-specific tasks (e.g., view listener)
runningInstance.cleanupTasks.forEach((cleanupTask) => cleanupTask())

// Collect and send profile data in background - doesn't block state transitions
collectProfilerInstance(runningInstance)
if (stateReason === 'quota-exceeded') {
// Discard data — quota exceeded means we should not send anything
clearTimeout(runningInstance.timeoutId)
runningInstance.profiler.removeEventListener('samplebufferfull', handleSampleBufferFull)
void runningInstance.profiler.stop().catch(monitorError)
} else {
// Collect and send profile data in background - doesn't block state transitions
collectProfilerInstance(runningInstance)
}
}

function pauseProfilerInstance() {
Expand Down
Loading
Loading