Skip to content

Commit 066a0c3

Browse files
⚗️ Add canvas recording init configuration
1 parent 0b06411 commit 066a0c3

6 files changed

Lines changed: 178 additions & 5 deletions

File tree

packages/browser-core/src/tools/experimentalFeatures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { objectHasValue } from './utils/objectUtils'
1414

1515
// eslint-disable-next-line no-restricted-syntax
1616
export enum ExperimentalFeature {
17+
RECORD_CANVAS = 'record_canvas',
1718
TRACK_INTAKE_REQUESTS = 'track_intake_requests',
1819
TRACK_WEBSOCKETS = 'track_websockets',
1920
}

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

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { InitConfiguration } from '@datadog/browser-core'
2-
import { DefaultPrivacyLevel, display, TraceContextInjection } from '@datadog/browser-core'
2+
import {
3+
addExperimentalFeatures,
4+
DefaultPrivacyLevel,
5+
display,
6+
ExperimentalFeature,
7+
TraceContextInjection,
8+
} from '@datadog/browser-core'
39
import type {
410
ExtractTelemetryConfiguration,
511
CamelToSnakeCase,
@@ -322,6 +328,51 @@ describe('validateAndBuildRumConfiguration', () => {
322328
})
323329
})
324330

331+
describe('canvas recording', () => {
332+
it('is disabled by default', () => {
333+
const configuration = validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!
334+
335+
expect(configuration.recordCanvas).toBeFalse()
336+
expect(configuration.canvasMaxFramesPerSecond).toBe(0)
337+
})
338+
339+
it('stays disabled when requested without the experimental feature', () => {
340+
const configuration = validateAndBuildRumConfiguration({
341+
...DEFAULT_INIT_CONFIGURATION,
342+
recordCanvas: true,
343+
})!
344+
345+
expect(configuration.recordCanvas).toBeFalse()
346+
expect(configuration.canvasMaxFramesPerSecond).toBe(0)
347+
})
348+
349+
describe('when the experimental feature is enabled', () => {
350+
beforeEach(() => {
351+
addExperimentalFeatures([ExperimentalFeature.RECORD_CANVAS])
352+
})
353+
354+
it('uses one frame per second by default when enabled', () => {
355+
const configuration = validateAndBuildRumConfiguration({
356+
...DEFAULT_INIT_CONFIGURATION,
357+
recordCanvas: true,
358+
})!
359+
360+
expect(configuration.recordCanvas).toBeTrue()
361+
expect(configuration.canvasMaxFramesPerSecond).toBe(1)
362+
})
363+
364+
it('uses the configured frame rate', () => {
365+
const configuration = validateAndBuildRumConfiguration({
366+
...DEFAULT_INIT_CONFIGURATION,
367+
recordCanvas: true,
368+
canvasMaxFramesPerSecond: 2.5,
369+
})!
370+
371+
expect(configuration.canvasMaxFramesPerSecond).toBe(2.5)
372+
})
373+
})
374+
})
375+
325376
describe('actionNameAttribute', () => {
326377
it('defaults to undefined', () => {
327378
expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.actionNameAttribute).toBeUndefined()
@@ -872,6 +923,8 @@ describe('serializeRumConfiguration', () => {
872923
trackResourceHeaders: true,
873924
betaEnableViewUpdates: true,
874925
betaTrackWebSockets: false,
926+
recordCanvas: true,
927+
canvasMaxFramesPerSecond: 2.5,
875928
}
876929

877930
type MapRumInitConfigurationKey<Key extends string> = Key extends keyof InitConfiguration
@@ -887,7 +940,8 @@ describe('serializeRumConfiguration', () => {
887940
? 'track_long_task' // We forgot the s, keeping this for backward compatibility
888941
: // The following options are not reported as telemetry. Please avoid adding more of them.
889942
// `remoteConfiguration` is covered by the legacy `remote_configuration_id` field.
890-
Key extends 'applicationId' | 'subdomain' | 'remoteConfiguration'
943+
Key extends
944+
'applicationId' | 'subdomain' | 'remoteConfiguration' | 'recordCanvas' | 'canvasMaxFramesPerSecond'
891945
? never
892946
: CamelToSnakeCase<Key>
893947
// By specifying the type here, we can ensure that serializeConfiguration is returning an

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
DefaultPrivacyLevel,
66
TraceContextInjection,
77
display,
8+
ExperimentalFeature,
9+
isExperimentalFeatureEnabled,
810
isNumber,
911
isNonEmptyArray,
1012
BROWSER_CORE_SCHEMA,
@@ -211,6 +213,25 @@ export interface RumInitConfiguration extends InitConfiguration {
211213
*/
212214
startSessionReplayRecordingManually?: boolean | undefined
213215

216+
/**
217+
* Enables recording canvas elements in Session Replay.
218+
*
219+
* @category Session Replay
220+
* @defaultValue false
221+
* @hidden
222+
*/
223+
recordCanvas?: boolean | undefined
224+
225+
/**
226+
* The maximum number of canvas frames recorded per second. Setting this option to `0` disables canvas frame recording.
227+
* This option has no effect unless {@link RumInitConfiguration.recordCanvas | recordCanvas} is enabled.
228+
*
229+
* @category Session Replay
230+
* @defaultValue 1
231+
* @hidden
232+
*/
233+
canvasMaxFramesPerSecond?: number | undefined
234+
214235
/**
215236
* Enables privacy control for action names.
216237
*
@@ -394,6 +415,8 @@ export const RUM_SCHEMA = {
394415
enablePrivacyForActionName: { type: 'boolean', default: true },
395416
propagateTraceBaggage: { type: 'boolean', default: true },
396417
startSessionReplayRecordingManually: { type: 'boolean', default: false, strict: false },
418+
recordCanvas: { type: 'boolean', default: false },
419+
canvasMaxFramesPerSecond: { type: 'number', min: 0, max: 5, default: 1 },
397420

398421
// Enums
399422
defaultPrivacyLevel: {
@@ -486,8 +509,12 @@ export function validateAndBuildRumConfiguration(
486509
return
487510
}
488511

512+
const recordCanvas = config.recordCanvas && isExperimentalFeatureEnabled(ExperimentalFeature.RECORD_CANVAS)
513+
489514
return {
490515
...config,
516+
recordCanvas,
517+
canvasMaxFramesPerSecond: recordCanvas ? config.canvasMaxFramesPerSecond : 0,
491518
allowedTracingUrls,
492519
beforeSend: config.beforeSend
493520
? (catchUserErrors(config.beforeSend, 'beforeSend threw an error:') as typeof config.beforeSend)

packages/js-core/api/configuration.api.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type EnumField = ({
2626
} & Optionality & Multiple & Strict);
2727

2828
// @public
29-
export type FieldDef = StringField | PercentageField | BooleanField | SiteField | MatchOptionField | EnumField | UnionField | SchemaField | FunctionField;
29+
export type FieldDef = StringField | NumberField | PercentageField | BooleanField | SiteField | MatchOptionField | EnumField | UnionField | SchemaField | FunctionField;
3030

3131
// @public
3232
export type FunctionField = {
@@ -54,6 +54,13 @@ export interface Multiple {
5454
multiple?: true;
5555
}
5656

57+
// @public
58+
export type NumberField = {
59+
type: 'number';
60+
min?: number;
61+
max?: number;
62+
} & Optionality & Multiple & Strict;
63+
5764
// @public
5865
export type Optionality = {
5966
required: true;

packages/js-core/src/entries/configuration.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,44 @@ describe('validateAndBuildConfiguration', () => {
6969
})
7070
})
7171

72+
describe('number fields', () => {
73+
it('accepts finite numbers without bounds', () => {
74+
const schema = { value: { type: 'number', required: true } } as const
75+
76+
expect(validateAndBuildConfiguration({ value: -42.5 }, schema, display)).toEqual({ value: -42.5 })
77+
})
78+
79+
it('accepts finite numbers within inclusive bounds', () => {
80+
const schema = { value: { type: 'number', min: 0, max: 5, required: true } } as const
81+
82+
expect(validateAndBuildConfiguration({ value: 0 }, schema, display)).toEqual({ value: 0 })
83+
expect(validateAndBuildConfiguration({ value: 2.5 }, schema, display)).toEqual({ value: 2.5 })
84+
expect(validateAndBuildConfiguration({ value: 5 }, schema, display)).toEqual({ value: 5 })
85+
})
86+
87+
it('rejects non-finite numbers and values outside the bounds', () => {
88+
const schema = { value: { type: 'number', min: 0, max: 5, default: 1 } } as const
89+
90+
;[-1, 6, NaN, Infinity, '1'].forEach((value) => {
91+
expect(validateAndBuildConfiguration({ value }, schema, display)).toBeUndefined()
92+
})
93+
})
94+
95+
it('uses the default when missing', () => {
96+
const schema = { value: { type: 'number', default: 1 } } as const
97+
98+
expect(validateAndBuildConfiguration({}, schema, display)).toEqual({ value: 1 })
99+
})
100+
101+
it('supports one-sided bounds', () => {
102+
const minimumSchema = { value: { type: 'number', min: 0, required: true } } as const
103+
const maximumSchema = { value: { type: 'number', max: 5, required: true } } as const
104+
105+
expect(validateAndBuildConfiguration({ value: -1 }, minimumSchema, display)).toBeUndefined()
106+
expect(validateAndBuildConfiguration({ value: 6 }, maximumSchema, display)).toBeUndefined()
107+
})
108+
})
109+
72110
describe('enum fields with allowAll', () => {
73111
const VALUES = ['a', 'b', 'c'] as const
74112
const schema = {
@@ -489,6 +527,30 @@ describe('validateAndBuildConfiguration', () => {
489527
expect(display.error).toHaveBeenCalledOnceWith('"rate" must be a number between 0 and 100')
490528
})
491529

530+
it('reports the configured bounds for an invalid number', () => {
531+
const schema = { value: { type: 'number' as const, min: 0, max: 5, default: 1 } }
532+
validateAndBuildConfiguration({ value: 6 }, schema, display)
533+
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number between 0 and 5')
534+
})
535+
536+
it('reports a configured minimum for an invalid number', () => {
537+
const schema = { value: { type: 'number' as const, min: 0 } }
538+
validateAndBuildConfiguration({ value: -1 }, schema, display)
539+
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number greater than or equal to 0')
540+
})
541+
542+
it('reports a configured maximum for an invalid number', () => {
543+
const schema = { value: { type: 'number' as const, max: 5 } }
544+
validateAndBuildConfiguration({ value: 6 }, schema, display)
545+
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number less than or equal to 5')
546+
})
547+
548+
it('reports finite-number requirements for an unbounded number', () => {
549+
const schema = { value: { type: 'number' as const } }
550+
validateAndBuildConfiguration({ value: Infinity }, schema, display)
551+
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a finite number')
552+
})
553+
492554
it('reports the right message for an invalid boolean', () => {
493555
const schema = { flag: { type: 'boolean' as const, default: false } }
494556
validateAndBuildConfiguration({ flag: 'yes' }, schema, display)

packages/js-core/src/entries/configuration.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export type StringField = { type: 'string' } & Optionality & Multiple & Strict
4343
/** A numeric field constrained to the 0–100 range, typically used for sample rates. */
4444
export type PercentageField = { type: 'percentage' } & Optionality & Multiple & Strict
4545

46+
/** A finite numeric field, optionally constrained by inclusive minimum and maximum values. */
47+
export type NumberField = { type: 'number'; min?: number; max?: number } & Optionality & Multiple & Strict
48+
4649
/**
4750
* A boolean field. When `strict: false`, a non-boolean value is coerced with `!!value`
4851
* instead of being rejected.
@@ -100,6 +103,7 @@ export type FunctionField = {
100103
/** The union of all field definition types supported by a {@link ConfigurationSchema}. */
101104
export type FieldDef =
102105
| StringField
106+
| NumberField
103107
| PercentageField
104108
| BooleanField
105109
| SiteField
@@ -125,7 +129,7 @@ export interface ConfigurationSchema {
125129

126130
type InferBase<F extends FieldDef> = F extends { type: 'string' }
127131
? string
128-
: F extends { type: 'percentage' }
132+
: F extends { type: 'number' | 'percentage' }
129133
? number
130134
: F extends { type: 'boolean' }
131135
? boolean
@@ -154,7 +158,7 @@ type InferBase<F extends FieldDef> = F extends { type: 'string' }
154158
// Non-recursive variant of InferBase used inside UnionField to avoid infinite type instantiation.
155159
type InferVariant<F> = F extends { type: 'string' }
156160
? string
157-
: F extends { type: 'percentage' }
161+
: F extends { type: 'number' | 'percentage' }
158162
? number
159163
: F extends { type: 'boolean' }
160164
? boolean
@@ -229,6 +233,17 @@ function buildErrorMessage(key: string, field: FieldDef): string {
229233
switch (field.type) {
230234
case 'string':
231235
return `"${key}" must be a non-empty string`
236+
case 'number':
237+
if (field.min !== undefined && field.max !== undefined) {
238+
return `"${key}" must be a number between ${field.min} and ${field.max}`
239+
}
240+
if (field.min !== undefined) {
241+
return `"${key}" must be a number greater than or equal to ${field.min}`
242+
}
243+
if (field.max !== undefined) {
244+
return `"${key}" must be a number less than or equal to ${field.max}`
245+
}
246+
return `"${key}" must be a finite number`
232247
case 'percentage':
233248
return `"${key}" must be a number between 0 and 100`
234249
case 'boolean':
@@ -375,6 +390,13 @@ function validateField(field: FieldDef, value: unknown, display: Display): unkno
375390
return typeof value === 'function' ? value : undefined
376391
case 'string':
377392
return typeof value === 'string' && value.length > 0 ? value : undefined
393+
case 'number':
394+
return typeof value === 'number' &&
395+
Number.isFinite(value) &&
396+
(field.min === undefined || value >= field.min) &&
397+
(field.max === undefined || value <= field.max)
398+
? value
399+
: undefined
378400
case 'percentage':
379401
return isPercentage(value) ? value : undefined
380402
case 'boolean':

0 commit comments

Comments
 (0)