diff --git a/packages/browser-core/src/tools/experimentalFeatures.ts b/packages/browser-core/src/tools/experimentalFeatures.ts index c28e399ad5..51603af01a 100644 --- a/packages/browser-core/src/tools/experimentalFeatures.ts +++ b/packages/browser-core/src/tools/experimentalFeatures.ts @@ -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', } diff --git a/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts b/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts index 539c940cc4..2e3708b1b7 100644 --- a/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts @@ -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, @@ -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() @@ -872,6 +929,7 @@ describe('serializeRumConfiguration', () => { trackResourceHeaders: true, betaEnableViewUpdates: true, betaTrackWebSockets: false, + enableSessionReplayCanvasRecording: { maxFramesPerSecond: 2.5 }, } type MapRumInitConfigurationKey = Key extends keyof InitConfiguration @@ -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 // By specifying the type here, we can ensure that serializeConfiguration is returning an diff --git a/packages/browser-rum-core/src/domain/configuration/configuration.ts b/packages/browser-rum-core/src/domain/configuration/configuration.ts index cd6dca186a..466a930ce9 100644 --- a/packages/browser-rum-core/src/domain/configuration/configuration.ts +++ b/packages/browser-rum-core/src/domain/configuration/configuration.ts @@ -5,6 +5,8 @@ import { DefaultPrivacyLevel, TraceContextInjection, display, + ExperimentalFeature, + isExperimentalFeatureEnabled, isNumber, isNonEmptyArray, BROWSER_CORE_SCHEMA, @@ -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 + */ + 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. * @@ -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 }, + }, + }, // Enums defaultPrivacyLevel: { @@ -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) diff --git a/packages/js-core/api/configuration.api.md b/packages/js-core/api/configuration.api.md index d0713cb64a..d3e5b4a3e8 100644 --- a/packages/js-core/api/configuration.api.md +++ b/packages/js-core/api/configuration.api.md @@ -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 = { @@ -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; diff --git a/packages/js-core/src/entries/configuration.spec.ts b/packages/js-core/src/entries/configuration.spec.ts index ac1721c923..08c6a0d737 100644 --- a/packages/js-core/src/entries/configuration.spec.ts +++ b/packages/js-core/src/entries/configuration.spec.ts @@ -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 = { @@ -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) diff --git a/packages/js-core/src/entries/configuration.ts b/packages/js-core/src/entries/configuration.ts index 6ddae22395..f3772d87df 100644 --- a/packages/js-core/src/entries/configuration.ts +++ b/packages/js-core/src/entries/configuration.ts @@ -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. @@ -100,6 +103,7 @@ export type FunctionField = { /** The union of all field definition types supported by a {@link ConfigurationSchema}. */ export type FieldDef = | StringField + | NumberField | PercentageField | BooleanField | SiteField @@ -125,7 +129,7 @@ export interface ConfigurationSchema { type InferBase = F extends { type: 'string' } ? string - : F extends { type: 'percentage' } + : F extends { type: 'number' | 'percentage' } ? number : F extends { type: 'boolean' } ? boolean @@ -154,7 +158,7 @@ type InferBase = F extends { type: 'string' } // Non-recursive variant of InferBase used inside UnionField to avoid infinite type instantiation. type InferVariant = F extends { type: 'string' } ? string - : F extends { type: 'percentage' } + : F extends { type: 'number' | 'percentage' } ? number : F extends { type: 'boolean' } ? boolean @@ -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': @@ -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':