Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/browser-core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { objectHasValue } from './utils/objectUtils'

// eslint-disable-next-line no-restricted-syntax
export enum ExperimentalFeature {
SESSION_REPLAY_RECORD_CANVAS = 'session_replay_record_canvas',
TRACK_INTAKE_REQUESTS = 'track_intake_requests',
TRACK_WEBSOCKETS = 'track_websockets',
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { InitConfiguration } from '@datadog/browser-core'
import { DefaultPrivacyLevel, display, TraceContextInjection } from '@datadog/browser-core'
import {
addExperimentalFeatures,
DefaultPrivacyLevel,
display,
ExperimentalFeature,
TraceContextInjection,
} from '@datadog/browser-core'
import type {
ExtractTelemetryConfiguration,
CamelToSnakeCase,
Expand Down Expand Up @@ -322,6 +328,57 @@ describe('validateAndBuildRumConfiguration', () => {
})
})

describe('canvas recording', () => {
it('is disabled by default', () => {
const configuration = validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!

expect(configuration.enableSessionReplayCanvasRecording).toBeUndefined()
})

it('stays disabled when requested without the experimental feature', () => {
const configuration = validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
enableSessionReplayCanvasRecording: {},
})!

expect(configuration.enableSessionReplayCanvasRecording).toBeUndefined()
})

describe('when the experimental feature is enabled', () => {
beforeEach(() => {
addExperimentalFeatures([ExperimentalFeature.SESSION_REPLAY_RECORD_CANVAS])
})

it('uses one frame per second by default when enabled', () => {
const configuration = validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
enableSessionReplayCanvasRecording: {},
})!

expect(configuration.enableSessionReplayCanvasRecording).toEqual({ maxFramesPerSecond: 1 })
})

it('uses the configured frame rate', () => {
const configuration = validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
enableSessionReplayCanvasRecording: { maxFramesPerSecond: 2.5 },
})!

expect(configuration.enableSessionReplayCanvasRecording).toEqual({ maxFramesPerSecond: 2.5 })
})

it('rejects invalid canvas recording options', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
enableSessionReplayCanvasRecording: true as any,
})
).toBeUndefined()
expect(displayErrorSpy).toHaveBeenCalledOnceWith('"enableSessionReplayCanvasRecording" is not a valid object')
})
})
})

describe('actionNameAttribute', () => {
it('defaults to undefined', () => {
expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.actionNameAttribute).toBeUndefined()
Expand Down Expand Up @@ -872,6 +929,7 @@ describe('serializeRumConfiguration', () => {
trackResourceHeaders: true,
betaEnableViewUpdates: true,
betaTrackWebSockets: false,
enableSessionReplayCanvasRecording: { maxFramesPerSecond: 2.5 },
}

type MapRumInitConfigurationKey<Key extends string> = Key extends keyof InitConfiguration
Expand All @@ -887,7 +945,7 @@ describe('serializeRumConfiguration', () => {
? 'track_long_task' // We forgot the s, keeping this for backward compatibility
: // The following options are not reported as telemetry. Please avoid adding more of them.
// `remoteConfiguration` is covered by the legacy `remote_configuration_id` field.
Key extends 'applicationId' | 'subdomain' | 'remoteConfiguration'
Key extends 'applicationId' | 'subdomain' | 'remoteConfiguration' | 'enableSessionReplayCanvasRecording'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
DefaultPrivacyLevel,
TraceContextInjection,
display,
ExperimentalFeature,
isExperimentalFeatureEnabled,
isNumber,
isNonEmptyArray,
BROWSER_CORE_SCHEMA,
Expand Down Expand Up @@ -211,6 +213,23 @@ export interface RumInitConfiguration extends InitConfiguration {
*/
startSessionReplayRecordingManually?: boolean | undefined

/**
* Configures recording canvas elements in Session Replay. Canvas recording is disabled when this option is omitted.
*
* @category Session Replay
* @hidden
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
*/
enableSessionReplayCanvasRecording?:
| {
/**
* The maximum number of canvas frames recorded per second. Setting this option to `0` disables canvas frame recording.
*
* @defaultValue 1
*/
maxFramesPerSecond?: number | undefined
}
| undefined

/**
* Enables privacy control for action names.
*
Expand Down Expand Up @@ -394,6 +413,12 @@ export const RUM_SCHEMA = {
enablePrivacyForActionName: { type: 'boolean', default: true },
propagateTraceBaggage: { type: 'boolean', default: true },
startSessionReplayRecordingManually: { type: 'boolean', default: false, strict: false },
enableSessionReplayCanvasRecording: {
type: 'schema',
schema: {
maxFramesPerSecond: { type: 'number', min: 0, max: 5, default: 1 },
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
},
},

// Enums
defaultPrivacyLevel: {
Expand Down Expand Up @@ -486,8 +511,15 @@ export function validateAndBuildRumConfiguration(
return
}

const enableSessionReplayCanvasRecording = isExperimentalFeatureEnabled(
ExperimentalFeature.SESSION_REPLAY_RECORD_CANVAS
)
? config.enableSessionReplayCanvasRecording
: undefined

return {
...config,
enableSessionReplayCanvasRecording,
allowedTracingUrls,
beforeSend: config.beforeSend
? (catchUserErrors(config.beforeSend, 'beforeSend threw an error:') as typeof config.beforeSend)
Expand Down
9 changes: 8 additions & 1 deletion packages/js-core/api/configuration.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type EnumField = ({
} & Optionality & Multiple & Strict);

// @public
export type FieldDef = StringField | PercentageField | BooleanField | SiteField | MatchOptionField | EnumField | UnionField | SchemaField | FunctionField;
export type FieldDef = StringField | NumberField | PercentageField | BooleanField | SiteField | MatchOptionField | EnumField | UnionField | SchemaField | FunctionField;

// @public
export type FunctionField = {
Expand Down Expand Up @@ -54,6 +54,13 @@ export interface Multiple {
multiple?: true;
}

// @public
export type NumberField = {
type: 'number';
min?: number;
max?: number;
} & Optionality & Multiple & Strict;

// @public
export type Optionality = {
required: true;
Expand Down
62 changes: 62 additions & 0 deletions packages/js-core/src/entries/configuration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,44 @@ describe('validateAndBuildConfiguration', () => {
})
})

describe('number fields', () => {
it('accepts finite numbers without bounds', () => {
const schema = { value: { type: 'number', required: true } } as const

expect(validateAndBuildConfiguration({ value: -42.5 }, schema, display)).toEqual({ value: -42.5 })
})

it('accepts finite numbers within inclusive bounds', () => {
const schema = { value: { type: 'number', min: 0, max: 5, required: true } } as const

expect(validateAndBuildConfiguration({ value: 0 }, schema, display)).toEqual({ value: 0 })
expect(validateAndBuildConfiguration({ value: 2.5 }, schema, display)).toEqual({ value: 2.5 })
expect(validateAndBuildConfiguration({ value: 5 }, schema, display)).toEqual({ value: 5 })
})

it('rejects non-finite numbers and values outside the bounds', () => {
const schema = { value: { type: 'number', min: 0, max: 5, default: 1 } } as const

;[-1, 6, NaN, Infinity, '1'].forEach((value) => {
expect(validateAndBuildConfiguration({ value }, schema, display)).toBeUndefined()
})
})

it('uses the default when missing', () => {
const schema = { value: { type: 'number', default: 1 } } as const

expect(validateAndBuildConfiguration({}, schema, display)).toEqual({ value: 1 })
})

it('supports one-sided bounds', () => {
const minimumSchema = { value: { type: 'number', min: 0, required: true } } as const
const maximumSchema = { value: { type: 'number', max: 5, required: true } } as const

expect(validateAndBuildConfiguration({ value: -1 }, minimumSchema, display)).toBeUndefined()
expect(validateAndBuildConfiguration({ value: 6 }, maximumSchema, display)).toBeUndefined()
})
})

describe('enum fields with allowAll', () => {
const VALUES = ['a', 'b', 'c'] as const
const schema = {
Expand Down Expand Up @@ -489,6 +527,30 @@ describe('validateAndBuildConfiguration', () => {
expect(display.error).toHaveBeenCalledOnceWith('"rate" must be a number between 0 and 100')
})

it('reports the configured bounds for an invalid number', () => {
const schema = { value: { type: 'number' as const, min: 0, max: 5, default: 1 } }
validateAndBuildConfiguration({ value: 6 }, schema, display)
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number between 0 and 5')
})

it('reports a configured minimum for an invalid number', () => {
const schema = { value: { type: 'number' as const, min: 0 } }
validateAndBuildConfiguration({ value: -1 }, schema, display)
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number greater than or equal to 0')
})

it('reports a configured maximum for an invalid number', () => {
const schema = { value: { type: 'number' as const, max: 5 } }
validateAndBuildConfiguration({ value: 6 }, schema, display)
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a number less than or equal to 5')
})

it('reports finite-number requirements for an unbounded number', () => {
const schema = { value: { type: 'number' as const } }
validateAndBuildConfiguration({ value: Infinity }, schema, display)
expect(display.error).toHaveBeenCalledOnceWith('"value" must be a finite number')
})

it('reports the right message for an invalid boolean', () => {
const schema = { flag: { type: 'boolean' as const, default: false } }
validateAndBuildConfiguration({ flag: 'yes' }, schema, display)
Expand Down
26 changes: 24 additions & 2 deletions packages/js-core/src/entries/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export type StringField = { type: 'string' } & Optionality & Multiple & Strict
/** A numeric field constrained to the 0–100 range, typically used for sample rates. */
export type PercentageField = { type: 'percentage' } & Optionality & Multiple & Strict

/** A finite numeric field, optionally constrained by inclusive minimum and maximum values. */
export type NumberField = { type: 'number'; min?: number; max?: number } & Optionality & Multiple & Strict

/**
* A boolean field. When `strict: false`, a non-boolean value is coerced with `!!value`
* instead of being rejected.
Expand Down Expand Up @@ -100,6 +103,7 @@ export type FunctionField = {
/** The union of all field definition types supported by a {@link ConfigurationSchema}. */
export type FieldDef =
| StringField
| NumberField
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
| PercentageField
| BooleanField
| SiteField
Expand All @@ -125,7 +129,7 @@ export interface ConfigurationSchema {

type InferBase<F extends FieldDef> = F extends { type: 'string' }
? string
: F extends { type: 'percentage' }
: F extends { type: 'number' | 'percentage' }
? number
: F extends { type: 'boolean' }
? boolean
Expand Down Expand Up @@ -154,7 +158,7 @@ type InferBase<F extends FieldDef> = F extends { type: 'string' }
// Non-recursive variant of InferBase used inside UnionField to avoid infinite type instantiation.
type InferVariant<F> = F extends { type: 'string' }
? string
: F extends { type: 'percentage' }
: F extends { type: 'number' | 'percentage' }
? number
: F extends { type: 'boolean' }
? boolean
Expand Down Expand Up @@ -229,6 +233,17 @@ function buildErrorMessage(key: string, field: FieldDef): string {
switch (field.type) {
case 'string':
return `"${key}" must be a non-empty string`
case 'number':
if (field.min !== undefined && field.max !== undefined) {
return `"${key}" must be a number between ${field.min} and ${field.max}`
}
if (field.min !== undefined) {
return `"${key}" must be a number greater than or equal to ${field.min}`
}
if (field.max !== undefined) {
return `"${key}" must be a number less than or equal to ${field.max}`
}
return `"${key}" must be a finite number`
case 'percentage':
return `"${key}" must be a number between 0 and 100`
case 'boolean':
Expand Down Expand Up @@ -375,6 +390,13 @@ function validateField(field: FieldDef, value: unknown, display: Display): unkno
return typeof value === 'function' ? value : undefined
case 'string':
return typeof value === 'string' && value.length > 0 ? value : undefined
case 'number':
return typeof value === 'number' &&
Number.isFinite(value) &&
(field.min === undefined || value >= field.min) &&
(field.max === undefined || value <= field.max)
? value
: undefined
case 'percentage':
return isPercentage(value) ? value : undefined
case 'boolean':
Expand Down
Loading