Skip to content

Commit 0b7f455

Browse files
Integrate thomas.bertet/PROF-13798-profiling-quota-in-sdk (#4514) into staging-20
Integrated commit sha: 1177a58 Co-authored-by: thomasbertet <thomas.bertet@datadoghq.com>
2 parents e419154 + 1177a58 commit 0b7f455

7 files changed

Lines changed: 522 additions & 7 deletions

File tree

packages/core/src/domain/configuration/transportConfiguration.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Site } from '../intakeSites'
22
import { INTAKE_SITE_US1, INTAKE_URL_PARAMETERS } from '../intakeSites'
3-
import type { InitConfiguration, SdkSource } from './configuration'
3+
import type { InitConfiguration, ProxyFn, SdkSource } from './configuration'
44
import type { EndpointBuilder, TransportSource } from './endpointBuilder'
55
import { createEndpointBuilder } from './endpointBuilder'
66

@@ -14,6 +14,8 @@ export interface TransportConfiguration {
1414
debuggerEndpointBuilder: EndpointBuilder
1515
datacenter?: string | undefined
1616
replica?: ReplicaConfiguration
17+
clientToken: string
18+
proxy?: string | ProxyFn | undefined
1719
site: Site
1820
source: SdkSource
1921
}
@@ -55,6 +57,8 @@ export function computeTransportConfiguration(
5557
const replicaConfiguration = computeReplicaConfiguration(resolvedConfiguration)
5658

5759
return {
60+
clientToken: initConfiguration.clientToken,
61+
proxy: initConfiguration.proxy,
5862
replica: replicaConfiguration,
5963
site,
6064
source,

packages/rum/src/domain/profiling/datadogProfiler.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { assembleProfilingPayload } from './transport/assembly'
3131
import { createLongTaskHistory } from './longTaskHistory'
3232
import { createActionHistory } from './actionHistory'
3333
import { createVitalHistory } from './vitalHistory'
34+
import { checkProfilingQuota } from './quotaCheck'
35+
import type { QuotaReason } from './quotaCheck'
3436

3537
export const DEFAULT_RUM_PROFILER_CONFIGURATION: RUMProfilerConfiguration = {
3638
sampleIntervalMs: 10, // Sample stack trace every 10ms
@@ -58,6 +60,7 @@ export function createRumProfiler(
5860
const vitalHistory = mockable(createVitalHistory)(lifeCycle)
5961

6062
let instance: RumProfilerInstance = { state: 'stopped', stateReason: 'initializing' }
63+
let quotaCheckGeneration = 0
6164

6265
// Stops the profiler when session expires
6366
lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => {
@@ -66,7 +69,10 @@ export function createRumProfiler(
6669

6770
// Start the profiler again when session is renewed
6871
lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => {
69-
if (instance.state === 'stopped' && instance.stateReason === 'session-expired') {
72+
if (
73+
instance.state === 'stopped' &&
74+
(instance.stateReason === 'session-expired' || instance.stateReason === 'quota_ko')
75+
) {
7076
start()
7177
}
7278
})
@@ -97,14 +103,35 @@ export function createRumProfiler(
97103

98104
// Start profiler instance
99105
startNextProfilerInstance()
106+
107+
// Quota check — optimistic: profiler already recording, only gates sending.
108+
// Generation counter invalidates results from a prior session (incremented on each start() call).
109+
// State guard handles within-session cancellation (user stop, session expiry, etc.).
110+
const checkGeneration = ++quotaCheckGeneration
111+
const sessionId = session.findTrackedSession()?.id
112+
if (sessionId) {
113+
mockable(checkProfilingQuota)(configuration, sessionId)
114+
.then((result) => {
115+
if (checkGeneration !== quotaCheckGeneration) {
116+
return
117+
}
118+
if (instance.state !== 'running' && instance.state !== 'paused') {
119+
return
120+
}
121+
if (result.decision === 'quota_ko') {
122+
stopProfiling('quota_ko', result.reason)
123+
}
124+
})
125+
.catch(monitorError)
126+
}
100127
}
101128

102129
// Public API to manually stop the profiler.
103130
function stop() {
104131
stopProfiling('stopped-by-user')
105132
}
106133

107-
function stopProfiling(reason: RumProfilerStoppedInstance['stateReason']) {
134+
function stopProfiling(reason: RumProfilerStoppedInstance['stateReason'], quotaReason?: QuotaReason) {
108135
// Stop current profiler instance (data collection happens async in background)
109136
stopProfilerInstance(reason)
110137

@@ -113,7 +140,7 @@ export function createRumProfiler(
113140
globalCleanupTasks.length = 0
114141

115142
// Update Profiling status once the Profiler has been stopped.
116-
profilingContextManager.set({ status: 'stopped', error_reason: undefined })
143+
profilingContextManager.set({ status: 'stopped', error_reason: undefined, quota_reason: quotaReason })
117144
}
118145

119146
/**
@@ -279,8 +306,15 @@ export function createRumProfiler(
279306
// Cleanup instance-specific tasks (e.g., view listener)
280307
runningInstance.cleanupTasks.forEach((cleanupTask) => cleanupTask())
281308

282-
// Collect and send profile data in background - doesn't block state transitions
283-
collectProfilerInstance(runningInstance)
309+
if (stateReason === 'quota_ko') {
310+
// Discard data — quota denied means we should not send anything
311+
clearTimeout(runningInstance.timeoutId)
312+
runningInstance.profiler.removeEventListener('samplebufferfull', handleSampleBufferFull)
313+
void runningInstance.profiler.stop().catch(monitorError)
314+
} else {
315+
// Collect and send profile data in background - doesn't block state transitions
316+
collectProfilerInstance(runningInstance)
317+
}
284318
}
285319

286320
function pauseProfilerInstance() {

packages/rum/src/domain/profiling/profiler.spec.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ import {
2323
waitNextMicrotask,
2424
replaceMockable,
2525
createSessionManagerMock,
26+
replaceMockableWithSpy,
2627
} from '@datadog/browser-core/test'
2728
import { mockRumConfiguration, mockViewHistory } from '../../../../rum-core/test'
2829
import { mockProfiler } from '../../../test'
2930
import type { BrowserProfilerTrace } from '../../types'
31+
import { checkProfilingQuota } from './quotaCheck'
3032
import { mockedTrace } from './test-utils/mockedTrace'
3133
import { createRumProfiler } from './datadogProfiler'
3234
import type { ProfilerTrace, RUMProfilerConfiguration } from './types'
@@ -43,10 +45,14 @@ describe('profiler', () => {
4345
// Store the original pathname
4446
const originalPathname = document.location.pathname
4547
let interceptor: ReturnType<typeof interceptRequests>
48+
let checkProfilingQuotaSpy: jasmine.Spy
4649

4750
beforeEach(() => {
4851
interceptor = interceptRequests()
4952
interceptor.withFetch(DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK)
53+
// Default: quota always ok. Individual quota-check tests can reconfigure via spy.and.callFake(...)
54+
checkProfilingQuotaSpy = replaceMockableWithSpy(checkProfilingQuota)
55+
checkProfilingQuotaSpy.and.returnValue(Promise.resolve({ decision: 'quota_ok', reason: 'quota_ok' }))
5056
})
5157

5258
afterEach(() => {
@@ -1027,6 +1033,246 @@ describe('profiler', () => {
10271033
profiler.stop()
10281034
expect(profiler.isStopped()).toBe(true)
10291035
})
1036+
1037+
describe('quota check', () => {
1038+
it('should stop profiler and set quota_exceeded context when quota check returns quota_exceeded', async () => {
1039+
checkProfilingQuotaSpy.and.returnValue(Promise.resolve({ decision: 'quota_ko', reason: 'quota_exceeded' }))
1040+
const { profiler, profilingContextManager } = setupProfiler()
1041+
1042+
profiler.start()
1043+
await waitForBoolean(() => profiler.isStopped())
1044+
1045+
expect(profilingContextManager.get()).toEqual({
1046+
status: 'stopped',
1047+
error_reason: undefined,
1048+
quota_reason: 'quota_exceeded',
1049+
} as any)
1050+
expect(interceptor.requests.length).toBe(0) // no data sent
1051+
})
1052+
1053+
it('should stop profiler and set org_disabled context when quota check returns org_disabled', async () => {
1054+
checkProfilingQuotaSpy.and.returnValue(Promise.resolve({ decision: 'quota_ko', reason: 'org_disabled' }))
1055+
const { profiler, profilingContextManager } = setupProfiler()
1056+
1057+
profiler.start()
1058+
await waitForBoolean(() => profiler.isStopped())
1059+
1060+
expect(profilingContextManager.get()).toEqual({
1061+
status: 'stopped',
1062+
error_reason: undefined,
1063+
quota_reason: 'org_disabled',
1064+
} as any)
1065+
expect(interceptor.requests.length).toBe(0) // no data sent
1066+
})
1067+
1068+
it('should stop profiler and set unknown_reason context when quota check returns unknown_reason', async () => {
1069+
checkProfilingQuotaSpy.and.returnValue(Promise.resolve({ decision: 'quota_ko', reason: 'unknown_reason' }))
1070+
const { profiler, profilingContextManager } = setupProfiler()
1071+
1072+
profiler.start()
1073+
await waitForBoolean(() => profiler.isStopped())
1074+
1075+
expect(profilingContextManager.get()).toEqual({
1076+
status: 'stopped',
1077+
error_reason: undefined,
1078+
quota_reason: 'unknown_reason',
1079+
} as any)
1080+
expect(interceptor.requests.length).toBe(0) // no data sent
1081+
})
1082+
1083+
it('should keep profiler running when quota check returns quota-ok', async () => {
1084+
// default spy already returns quota-ok
1085+
const { profiler, profilingContextManager } = setupProfiler()
1086+
1087+
profiler.start()
1088+
await waitForBoolean(() => profiler.isRunning())
1089+
1090+
expect(profiler.isRunning()).toBe(true)
1091+
expect(profilingContextManager.get()?.status).toBe('running')
1092+
1093+
profiler.stop()
1094+
})
1095+
1096+
it('should not call quota check and proceed when sessionId is undefined at start', async () => {
1097+
// default spy already returns quota-ok; we just verify it's never called
1098+
mockProfiler(deepClone(mockedTrace))
1099+
const hooks = createHooks()
1100+
const profilingContextManager = startProfilingContext(hooks)
1101+
const noSessionManager = createSessionManagerMock()
1102+
spyOn(noSessionManager, 'findTrackedSession').and.returnValue(undefined)
1103+
const profilerNoSession = createRumProfiler(
1104+
mockRumConfiguration({ profilingSampleRate: 100 }),
1105+
new LifeCycle(),
1106+
noSessionManager,
1107+
profilingContextManager,
1108+
createIdentityEncoder,
1109+
mockViewHistory(),
1110+
{ sampleIntervalMs: 10, collectIntervalMs: 60000, minProfileDurationMs: 0 }
1111+
)
1112+
1113+
profilerNoSession.start()
1114+
await waitForBoolean(() => profilerNoSession.isRunning())
1115+
1116+
expect(checkProfilingQuotaSpy).not.toHaveBeenCalled()
1117+
expect(profilerNoSession.isRunning()).toBe(true)
1118+
1119+
profilerNoSession.stop()
1120+
})
1121+
1122+
it('should discard quota-exceeded result when profiler was already stopped by user', async () => {
1123+
let resolveQuota!: (result: { decision: string; reason: string }) => void
1124+
checkProfilingQuotaSpy.and.callFake(
1125+
() =>
1126+
new Promise((resolve) => {
1127+
resolveQuota = resolve
1128+
})
1129+
)
1130+
const { profiler, profilingContextManager } = setupProfiler()
1131+
1132+
profiler.start()
1133+
await waitForBoolean(() => profiler.isRunning())
1134+
1135+
profiler.stop()
1136+
expect(profiler.isStopped()).toBe(true)
1137+
expect(profilingContextManager.get()?.status).toBe('stopped')
1138+
expect(profilingContextManager.get()?.error_reason).toBeUndefined()
1139+
1140+
resolveQuota({ decision: 'quota_ko', reason: 'quota_exceeded' })
1141+
await waitNextMicrotask()
1142+
1143+
expect(profilingContextManager.get()?.error_reason).toBeUndefined()
1144+
})
1145+
1146+
it('should discard quota-exceeded result when SESSION_EXPIRED fired before quota resolved', async () => {
1147+
let resolveQuota!: (result: { decision: string; reason: string }) => void
1148+
checkProfilingQuotaSpy.and.callFake(
1149+
() =>
1150+
new Promise((resolve) => {
1151+
resolveQuota = resolve
1152+
})
1153+
)
1154+
const { profiler, profilingContextManager } = setupProfiler()
1155+
1156+
profiler.start()
1157+
await waitForBoolean(() => profiler.isRunning())
1158+
1159+
lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED)
1160+
expect(profiler.isStopped()).toBe(true)
1161+
1162+
resolveQuota({ decision: 'quota_ko', reason: 'quota_exceeded' })
1163+
await waitNextMicrotask()
1164+
1165+
expect(profilingContextManager.get()?.error_reason).toBeUndefined()
1166+
1167+
// data IS sent (normal session-expired collection happens)
1168+
await waitForBoolean(() => interceptor.requests.length >= 1)
1169+
expect(interceptor.requests.length).toBeGreaterThanOrEqual(1)
1170+
})
1171+
1172+
it('should stop profiler and not resume when quota-exceeded resolves while paused', async () => {
1173+
let resolveQuota!: (result: { decision: string; reason: string }) => void
1174+
checkProfilingQuotaSpy.and.callFake(
1175+
() =>
1176+
new Promise((resolve) => {
1177+
resolveQuota = resolve
1178+
})
1179+
)
1180+
const { profiler, profilingContextManager } = setupProfiler()
1181+
1182+
profiler.start()
1183+
await waitForBoolean(() => profiler.isRunning())
1184+
1185+
setVisibilityState('hidden')
1186+
await waitForBoolean(() => profiler.isPaused())
1187+
1188+
resolveQuota({ decision: 'quota_ko', reason: 'quota_exceeded' })
1189+
await waitNextMicrotask()
1190+
1191+
expect(profiler.isStopped()).toBe(true)
1192+
expect(profilingContextManager.get()).toEqual({
1193+
status: 'stopped',
1194+
error_reason: undefined,
1195+
quota_reason: 'quota_exceeded',
1196+
} as any)
1197+
1198+
setVisibilityState('visible')
1199+
await waitNextMicrotask()
1200+
1201+
expect(profiler.isStopped()).toBe(true)
1202+
})
1203+
1204+
it('should discard stale quota result when SESSION_RENEWED restarts the profiler', async () => {
1205+
let resolveOldQuota!: (result: { decision: string; reason: string }) => void
1206+
let callCount = 0
1207+
checkProfilingQuotaSpy.and.callFake(() => {
1208+
callCount++
1209+
if (callCount === 1) {
1210+
return new Promise((resolve) => {
1211+
resolveOldQuota = resolve
1212+
})
1213+
}
1214+
return Promise.resolve({ decision: 'quota_ok', reason: 'quota_ok' })
1215+
})
1216+
const { profiler, profilingContextManager } = setupProfiler()
1217+
1218+
profiler.start()
1219+
await waitForBoolean(() => profiler.isRunning())
1220+
1221+
lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED)
1222+
lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED, {} as SessionRenewalEvent)
1223+
await waitForBoolean(() => profiler.isRunning())
1224+
1225+
resolveOldQuota({ decision: 'quota_ko', reason: 'quota_exceeded' })
1226+
await waitNextMicrotask()
1227+
1228+
expect(profiler.isRunning()).toBe(true)
1229+
expect(profilingContextManager.get()?.status).toBe('running')
1230+
1231+
profiler.stop()
1232+
})
1233+
1234+
it('should restart profiler and re-check quota on SESSION_RENEWED after quota_exceeded or org_disabled', async () => {
1235+
let callCount = 0
1236+
checkProfilingQuotaSpy.and.callFake(() => {
1237+
callCount++
1238+
return Promise.resolve(
1239+
callCount === 1
1240+
? { decision: 'quota_ko', reason: 'quota_exceeded' }
1241+
: { decision: 'quota_ok', reason: 'quota_ok' }
1242+
)
1243+
})
1244+
const { profiler } = setupProfiler()
1245+
1246+
profiler.start()
1247+
await waitForBoolean(() => profiler.isStopped())
1248+
1249+
expect(callCount).toBe(1)
1250+
1251+
lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED, {} as SessionRenewalEvent)
1252+
await waitForBoolean(() => profiler.isRunning())
1253+
1254+
expect(callCount).toBe(2)
1255+
expect(profiler.isRunning()).toBe(true)
1256+
1257+
profiler.stop()
1258+
})
1259+
1260+
it('should NOT restart profiler on SESSION_RENEWED after stopped-by-user', async () => {
1261+
// default spy already returns quota-ok
1262+
const { profiler } = setupProfiler()
1263+
1264+
profiler.start()
1265+
await waitForBoolean(() => profiler.isRunning())
1266+
1267+
profiler.stop()
1268+
expect(profiler.isStopped()).toBe(true)
1269+
1270+
lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED, {} as SessionRenewalEvent)
1271+
await waitNextMicrotask()
1272+
1273+
expect(profiler.isStopped()).toBe(true)
1274+
})
1275+
})
10301276
})
10311277

10321278
function waitForBoolean(booleanCallback: () => boolean) {

0 commit comments

Comments
 (0)