Skip to content

Commit 5f38e90

Browse files
✨[PANA-3819] Add telemetry for recorder initialization (#3793)
1 parent 036a26e commit 5f38e90

File tree

6 files changed

+402
-25
lines changed

6 files changed

+402
-25
lines changed

packages/rum-core/src/domain/configuration/configuration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export interface RumConfiguration extends Configuration {
248248
subdomain?: string
249249
customerDataTelemetrySampleRate: number
250250
initialViewMetricsTelemetrySampleRate: number
251+
recorderInitTelemetrySampleRate: number
251252
segmentTelemetrySampleRate: number
252253
traceContextInjection: TraceContextInjection
253254
plugins: RumPlugin[]
@@ -321,6 +322,7 @@ export function validateAndBuildRumConfiguration(
321322
enablePrivacyForActionName: !!initConfiguration.enablePrivacyForActionName,
322323
customerDataTelemetrySampleRate: 1,
323324
initialViewMetricsTelemetrySampleRate: 1,
325+
recorderInitTelemetrySampleRate: 1,
324326
segmentTelemetrySampleRate: 1,
325327
traceContextInjection: objectHasValue(TraceContextInjection, initConfiguration.traceContextInjection)
326328
? initConfiguration.traceContextInjection

packages/rum/src/boot/lazyLoadRecorder.spec.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { DeflateWorker, Telemetry } from '@datadog/browser-core'
2-
import { display, resetTelemetry } from '@datadog/browser-core'
1+
import type { DeflateWorker, RawTelemetryEvent, Telemetry } from '@datadog/browser-core'
2+
import { display } from '@datadog/browser-core'
33
import type { RecorderApi, RumSessionManager } from '@datadog/browser-rum-core'
44
import { LifeCycle } from '@datadog/browser-rum-core'
55
import type { MockTelemetry } from '@datadog/browser-core/test'
@@ -13,6 +13,19 @@ import { makeRecorderApi } from './recorderApi'
1313
import type { StartRecording } from './postStartStrategy'
1414
import { lazyLoadRecorder } from './lazyLoadRecorder'
1515

16+
const RECORDER_INIT_TELEMETRY: RawTelemetryEvent = {
17+
type: 'log',
18+
status: 'debug',
19+
message: 'Recorder init metrics',
20+
metrics: {
21+
forced: false,
22+
loadRecorderModuleDuration: jasmine.any(Number),
23+
recorderInitDuration: jasmine.any(Number),
24+
result: 'recorder-load-failed',
25+
waitForDocReadyDuration: jasmine.any(Number),
26+
},
27+
}
28+
1629
describe('lazyLoadRecorder', () => {
1730
let displaySpy: jasmine.Spy
1831
let telemetry: MockTelemetry
@@ -43,22 +56,27 @@ describe('lazyLoadRecorder', () => {
4356
stopRecordingSpy = jasmine.createSpy('stopRecording')
4457
startRecordingSpy = jasmine.createSpy('startRecording')
4558

46-
// Workaround because using resolveTo(startRecordingSpy) was not working
47-
loadRecorderSpy = jasmine.createSpy('loadRecorder').and.resolveTo((...args: any) => {
59+
loadRecorderSpy = jasmine.createSpy('loadRecorder').and.callFake((...args) => {
4860
if (loadRecorderError) {
4961
return lazyLoadRecorder(() => Promise.reject(loadRecorderError))
5062
}
5163
startRecordingSpy(...args)
52-
return {
64+
return Promise.resolve({
5365
stop: stopRecordingSpy,
54-
}
66+
})
67+
})
68+
69+
const configuration = mockRumConfiguration({
70+
startSessionReplayRecordingManually: startSessionReplayRecordingManually ?? false,
71+
recorderInitTelemetrySampleRate: 100,
72+
telemetrySampleRate: 100,
5573
})
5674

5775
recorderApi = makeRecorderApi(loadRecorderSpy, createDeflateWorkerSpy)
5876
rumInit = ({ worker } = {}) => {
5977
recorderApi.onRumStart(
6078
lifeCycle,
61-
mockRumConfiguration({ startSessionReplayRecordingManually: startSessionReplayRecordingManually ?? false }),
79+
configuration,
6280
sessionManager ?? createRumSessionManagerMock().setId('1234'),
6381
mockViewHistory(),
6482
worker,
@@ -69,11 +87,10 @@ describe('lazyLoadRecorder', () => {
6987
registerCleanupTask(() => {
7088
resetDeflateWorkerState()
7189
replayStats.resetReplayStats()
72-
resetTelemetry()
7390
})
7491
}
7592

76-
it('should report an error but no telemetry if CSP blocks the module', async () => {
93+
it('should report a console error and metrics but no telemetry error if CSP blocks the module', async () => {
7794
const loadRecorderError = new Error('Dynamic import was blocked due to Content Security Policy')
7895
setupRecorderApi({
7996
loadRecorderError,
@@ -87,10 +104,12 @@ describe('lazyLoadRecorder', () => {
87104

88105
expect(displaySpy).toHaveBeenCalledWith(jasmine.stringContaining('Recorder failed to start'), loadRecorderError)
89106
expect(displaySpy).toHaveBeenCalledWith(jasmine.stringContaining('Please make sure CSP is correctly configured'))
90-
expect(await telemetry.hasEvents()).toBe(false)
107+
108+
// There should be no actual telemetry error, but we should see the failure in the metrics.
109+
expect(await telemetry.getEvents()).toEqual([RECORDER_INIT_TELEMETRY])
91110
})
92111

93-
it('should report an error but no telemetry if importing fails for non-CSP reasons', async () => {
112+
it('should report a console error and metrics but no telemetry error if importing fails for non-CSP reasons', async () => {
94113
const loadRecorderError = new Error('Dynamic import failed')
95114
setupRecorderApi({
96115
loadRecorderError,
@@ -103,6 +122,8 @@ describe('lazyLoadRecorder', () => {
103122
await wait(0)
104123

105124
expect(displaySpy).toHaveBeenCalledWith(jasmine.stringContaining('Recorder failed to start'), loadRecorderError)
106-
expect(await telemetry.hasEvents()).toBe(false)
125+
126+
// There should be no actual telemetry error, but we should see the failure in the metrics.
127+
expect(await telemetry.getEvents()).toEqual([RECORDER_INIT_TELEMETRY])
107128
})
108129
})

packages/rum/src/boot/postStartStrategy.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import type {
77
RumSession,
88
} from '@datadog/browser-rum-core'
99
import { LifeCycleEventType, SessionReplayState } from '@datadog/browser-rum-core'
10-
import { asyncRunOnReadyState, monitorError } from '@datadog/browser-core'
10+
import { asyncRunOnReadyState, monitorError, Observable } from '@datadog/browser-core'
1111
import type { Telemetry, DeflateEncoder } from '@datadog/browser-core'
1212
import { getSessionReplayLink } from '../domain/getSessionReplayLink'
13+
import { startRecorderInitTelemetry } from '../domain/startRecorderInitTelemetry'
1314
import type { startRecording } from './startRecording'
1415

1516
export type StartRecording = typeof startRecording
@@ -26,6 +27,15 @@ export const enum RecorderStatus {
2627
Started,
2728
}
2829

30+
export type RecorderInitEvent =
31+
| { type: 'start'; forced: boolean }
32+
| { type: 'document-ready' }
33+
| { type: 'recorder-settled' }
34+
| { type: 'aborted' }
35+
| { type: 'deflate-encoder-load-failed' }
36+
| { type: 'recorder-load-failed' }
37+
| { type: 'succeeded' }
38+
2939
export interface Strategy {
3040
start: (options?: StartRecordingOptions) => void
3141
stop: () => void
@@ -58,16 +68,32 @@ export function createPostStartStrategy(
5868
}
5969
})
6070

61-
const doStart = async () => {
62-
const [startRecordingImpl] = await Promise.all([loadRecorder(), asyncRunOnReadyState(configuration, 'interactive')])
71+
const observable = new Observable<RecorderInitEvent>()
72+
startRecorderInitTelemetry(configuration, telemetry, observable)
73+
74+
const doStart = async (forced: boolean) => {
75+
observable.notify({ type: 'start', forced })
76+
77+
const [startRecordingImpl] = await Promise.all([
78+
notifyWhenSettled(observable, { type: 'recorder-settled' }, loadRecorder()),
79+
notifyWhenSettled(observable, { type: 'document-ready' }, asyncRunOnReadyState(configuration, 'interactive')),
80+
])
6381

6482
if (status !== RecorderStatus.Starting) {
83+
observable.notify({ type: 'aborted' })
84+
return
85+
}
86+
87+
if (!startRecordingImpl) {
88+
status = RecorderStatus.Stopped
89+
observable.notify({ type: 'recorder-load-failed' })
6590
return
6691
}
6792

6893
const deflateEncoder = getOrCreateDeflateEncoder()
69-
if (!deflateEncoder || !startRecordingImpl) {
94+
if (!deflateEncoder) {
7095
status = RecorderStatus.Stopped
96+
observable.notify({ type: 'deflate-encoder-load-failed' })
7197
return
7298
}
7399

@@ -81,6 +107,7 @@ export function createPostStartStrategy(
81107
))
82108

83109
status = RecorderStatus.Started
110+
observable.notify({ type: 'succeeded' })
84111
}
85112

86113
function start(options?: StartRecordingOptions) {
@@ -96,10 +123,12 @@ export function createPostStartStrategy(
96123

97124
status = RecorderStatus.Starting
98125

126+
const forced = shouldForceReplay(session!, options) || false
127+
99128
// Intentionally not awaiting doStart() to keep it asynchronous
100-
doStart().catch(monitorError)
129+
doStart(forced).catch(monitorError)
101130

102-
if (shouldForceReplay(session!, options)) {
131+
if (forced) {
103132
sessionManager.setForcedReplay()
104133
}
105134
}
@@ -133,3 +162,15 @@ function isRecordingInProgress(status: RecorderStatus) {
133162
function shouldForceReplay(session: RumSession, options?: StartRecordingOptions) {
134163
return options && options.force && session.sessionReplay === SessionReplayState.OFF
135164
}
165+
166+
async function notifyWhenSettled<Event, Result>(
167+
observable: Observable<Event>,
168+
event: Event,
169+
promise: Promise<Result>
170+
): Promise<Result> {
171+
try {
172+
return await promise
173+
} finally {
174+
observable.notify(event)
175+
}
176+
}

packages/rum/src/boot/recorderApi.spec.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import type { DeflateEncoder, DeflateWorker, DeflateWorkerAction, Telemetry } from '@datadog/browser-core'
1+
import type {
2+
DeflateEncoder,
3+
DeflateWorker,
4+
DeflateWorkerAction,
5+
RawTelemetryEvent,
6+
Telemetry,
7+
} from '@datadog/browser-core'
28
import { BridgeCapability, display } from '@datadog/browser-core'
39
import type { RecorderApi, RumSessionManager } from '@datadog/browser-rum-core'
410
import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core'
5-
import { collectAsyncCalls, mockEventBridge, registerCleanupTask } from '@datadog/browser-core/test'
11+
import type { MockTelemetry } from '@datadog/browser-core/test'
12+
import { collectAsyncCalls, mockEventBridge, registerCleanupTask, startMockTelemetry } from '@datadog/browser-core/test'
613
import type { RumSessionManagerMock } from '../../../rum-core/test'
714
import {
815
createRumSessionManagerMock,
@@ -14,6 +21,7 @@ import type { CreateDeflateWorker } from '../domain/deflate'
1421
import { MockWorker } from '../../test'
1522
import { resetDeflateWorkerState } from '../domain/deflate'
1623
import * as replayStats from '../domain/replayStats'
24+
import type { RecorderInitMetrics } from '../domain/startRecorderInitTelemetry'
1725
import { makeRecorderApi } from './recorderApi'
1826
import type { StartRecording } from './postStartStrategy'
1927

@@ -26,12 +34,13 @@ describe('makeRecorderApi', () => {
2634
let mockWorker: MockWorker
2735
let createDeflateWorkerSpy: jasmine.Spy<CreateDeflateWorker>
2836
let rumInit: (options?: { worker?: DeflateWorker }) => void
37+
let telemetry: MockTelemetry
2938

3039
function setupRecorderApi({
3140
sessionManager,
3241
startSessionReplayRecordingManually,
3342
}: { sessionManager?: RumSessionManager; startSessionReplayRecordingManually?: boolean } = {}) {
34-
const mockTelemetry = { enabled: true } as Telemetry
43+
telemetry = startMockTelemetry()
3544
mockWorker = new MockWorker()
3645
createDeflateWorkerSpy = jasmine.createSpy('createDeflateWorkerSpy').and.callFake(() => mockWorker)
3746
spyOn(display, 'error')
@@ -48,15 +57,21 @@ describe('makeRecorderApi', () => {
4857
}
4958
})
5059

60+
const configuration = mockRumConfiguration({
61+
startSessionReplayRecordingManually: startSessionReplayRecordingManually ?? false,
62+
recorderInitTelemetrySampleRate: 100,
63+
telemetrySampleRate: 100,
64+
})
65+
5166
recorderApi = makeRecorderApi(loadRecorderSpy, createDeflateWorkerSpy)
5267
rumInit = ({ worker } = {}) => {
5368
recorderApi.onRumStart(
5469
lifeCycle,
55-
mockRumConfiguration({ startSessionReplayRecordingManually: startSessionReplayRecordingManually ?? false }),
70+
configuration,
5671
sessionManager ?? createRumSessionManagerMock().setId('1234'),
5772
mockViewHistory(),
5873
worker,
59-
mockTelemetry
74+
{ enabled: true } as Telemetry
6075
)
6176
}
6277

@@ -72,9 +87,11 @@ describe('makeRecorderApi', () => {
7287
setupRecorderApi()
7388
expect(loadRecorderSpy).not.toHaveBeenCalled()
7489
expect(startRecordingSpy).not.toHaveBeenCalled()
90+
expect(await telemetry.hasEvents()).toEqual(false)
7591
rumInit()
7692
await collectAsyncCalls(startRecordingSpy, 1)
7793
expect(startRecordingSpy).toHaveBeenCalled()
94+
expect(await telemetry.getEvents()).toEqual([expectedRecorderInitTelemetry()])
7895
})
7996

8097
it('starts recording after the DOM is loaded', async () => {
@@ -84,32 +101,39 @@ describe('makeRecorderApi', () => {
84101

85102
expect(loadRecorderSpy).toHaveBeenCalled()
86103
expect(startRecordingSpy).not.toHaveBeenCalled()
104+
expect(await telemetry.hasEvents()).toEqual(false)
105+
87106
triggerOnDomLoaded()
88107
await collectAsyncCalls(startRecordingSpy, 1)
89108

90109
expect(startRecordingSpy).toHaveBeenCalled()
110+
expect(await telemetry.getEvents()).toEqual([expectedRecorderInitTelemetry()])
91111
})
92112
})
93113

94114
describe('with manual start', () => {
95-
it('does not start recording when init() is called', () => {
115+
it('does not start recording when init() is called', async () => {
96116
setupRecorderApi({ startSessionReplayRecordingManually: true })
97117
expect(loadRecorderSpy).not.toHaveBeenCalled()
98118
expect(startRecordingSpy).not.toHaveBeenCalled()
119+
expect(await telemetry.hasEvents()).toEqual(false)
99120
rumInit()
100121
expect(loadRecorderSpy).not.toHaveBeenCalled()
101122
expect(startRecordingSpy).not.toHaveBeenCalled()
123+
expect(await telemetry.hasEvents()).toEqual(false)
102124
})
103125

104-
it('does not start recording after the DOM is loaded', () => {
126+
it('does not start recording after the DOM is loaded', async () => {
105127
setupRecorderApi({ startSessionReplayRecordingManually: true })
106128
const { triggerOnDomLoaded } = mockDocumentReadyState()
107129
rumInit()
108130
expect(loadRecorderSpy).not.toHaveBeenCalled()
109131
expect(startRecordingSpy).not.toHaveBeenCalled()
132+
expect(await telemetry.hasEvents()).toEqual(false)
110133
triggerOnDomLoaded()
111134
expect(loadRecorderSpy).not.toHaveBeenCalled()
112135
expect(startRecordingSpy).not.toHaveBeenCalled()
136+
expect(await telemetry.hasEvents()).toEqual(false)
113137
})
114138
})
115139
})
@@ -178,6 +202,7 @@ describe('makeRecorderApi', () => {
178202

179203
expect(startRecordingSpy).toHaveBeenCalledTimes(1)
180204
expect(setForcedReplaySpy).toHaveBeenCalledTimes(1)
205+
expect(await telemetry.getEvents()).toEqual([expectedRecorderInitTelemetry({ forced: true })])
181206
})
182207

183208
it('uses the previously created worker if available', async () => {
@@ -201,6 +226,13 @@ describe('makeRecorderApi', () => {
201226
await collectAsyncCalls(createDeflateWorkerSpy, 1)
202227

203228
expect(startRecordingSpy).not.toHaveBeenCalled()
229+
const events = await telemetry.getEvents()
230+
expect(events).toEqual([
231+
jasmine.objectContaining({
232+
error: jasmine.anything(),
233+
}),
234+
expectedRecorderInitTelemetry({ result: 'deflate-encoder-load-failed' }),
235+
])
204236
})
205237

206238
it('stops recording if worker initialization fails', async () => {
@@ -630,3 +662,19 @@ describe('makeRecorderApi', () => {
630662
})
631663
})
632664
})
665+
666+
function expectedRecorderInitTelemetry(overrides: Partial<RecorderInitMetrics> = {}): RawTelemetryEvent {
667+
return {
668+
type: 'log',
669+
status: 'debug',
670+
message: 'Recorder init metrics',
671+
metrics: {
672+
forced: false,
673+
loadRecorderModuleDuration: jasmine.any(Number),
674+
recorderInitDuration: jasmine.any(Number),
675+
result: 'succeeded',
676+
waitForDocReadyDuration: jasmine.any(Number),
677+
...overrides,
678+
},
679+
}
680+
}

0 commit comments

Comments
 (0)