Skip to content
Closed
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 {
RECORD_CANVAS = '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,51 @@ describe('validateAndBuildRumConfiguration', () => {
})
})

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

expect(configuration.recordCanvas).toBeFalse()
expect(configuration.canvasMaxFramesPerSecond).toBe(0)
})

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

expect(configuration.recordCanvas).toBeFalse()
expect(configuration.canvasMaxFramesPerSecond).toBe(0)
})

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

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

expect(configuration.recordCanvas).toBeTrue()
expect(configuration.canvasMaxFramesPerSecond).toBe(1)
})

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

expect(configuration.canvasMaxFramesPerSecond).toBe(2.5)
})
})
})

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

type MapRumInitConfigurationKey<Key extends string> = Key extends keyof InitConfiguration
Expand All @@ -887,7 +940,8 @@ 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' | 'recordCanvas' | 'canvasMaxFramesPerSecond'
? 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,25 @@ export interface RumInitConfiguration extends InitConfiguration {
*/
startSessionReplayRecordingManually?: boolean | undefined

/**
* Enables recording canvas elements in Session Replay.
*
* @category Session Replay
* @defaultValue false
* @hidden
*/
recordCanvas?: boolean | undefined

/**
* The maximum number of canvas frames recorded per second. Setting this option to `0` disables canvas frame recording.
* This option has no effect unless {@link RumInitConfiguration.recordCanvas | recordCanvas} is enabled.
*
* @category Session Replay
* @defaultValue 1
* @hidden
*/
canvasMaxFramesPerSecond?: number | undefined

/**
* Enables privacy control for action names.
*
Expand Down Expand Up @@ -394,6 +415,8 @@ export const RUM_SCHEMA = {
enablePrivacyForActionName: { type: 'boolean', default: true },
propagateTraceBaggage: { type: 'boolean', default: true },
startSessionReplayRecordingManually: { type: 'boolean', default: false, strict: false },
recordCanvas: { type: 'boolean', default: false },
canvasMaxFramesPerSecond: { type: 'number', min: 0, max: 5, default: 1 },

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

const recordCanvas = config.recordCanvas && isExperimentalFeatureEnabled(ExperimentalFeature.RECORD_CANVAS)

return {
...config,
recordCanvas,
canvasMaxFramesPerSecond: recordCanvas ? config.canvasMaxFramesPerSecond : 0,
allowedTracingUrls,
beforeSend: config.beforeSend
? (catchUserErrors(config.beforeSend, 'beforeSend threw an error:') as typeof config.beforeSend)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createCanvasManager } from './canvasManager'

describe('createCanvasManager', () => {
it('tracks whether a canvas is dirty', () => {
const canvasManager = createCanvasManager()
const canvas = document.createElement('canvas')

expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()

canvasManager.markCanvasDirty(canvas)
expect(canvasManager.isCanvasDirty(canvas)).toBeTrue()

canvasManager.markCanvasClean(canvas)
expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()
})

it('tracks canvases independently', () => {
const canvasManager = createCanvasManager()
const dirtyCanvas = document.createElement('canvas')
const cleanCanvas = document.createElement('canvas')

canvasManager.markCanvasDirty(dirtyCanvas)

expect(canvasManager.isCanvasDirty(dirtyCanvas)).toBeTrue()
expect(canvasManager.isCanvasDirty(cleanCanvas)).toBeFalse()
})
})
15 changes: 15 additions & 0 deletions packages/browser-rum/src/domain/record/canvas/canvasManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface CanvasManager {
isCanvasDirty: (canvas: HTMLCanvasElement) => boolean
markCanvasClean: (canvas: HTMLCanvasElement) => void
markCanvasDirty: (canvas: HTMLCanvasElement) => void
}

export function createCanvasManager(): CanvasManager {
const dirtyCanvases = new WeakSet<HTMLCanvasElement>()

return {
isCanvasDirty: (canvas) => dirtyCanvases.has(canvas),
markCanvasClean: (canvas) => dirtyCanvases.delete(canvas),
markCanvasDirty: (canvas) => dirtyCanvases.add(canvas),
}
}
36 changes: 34 additions & 2 deletions packages/browser-rum/src/domain/record/record.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ describe('record', () => {
])
})

describe('canvas mutation tracking', () => {
it('instruments canvas drawing when canvas recording is enabled', () => {
const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value

startRecording({ recordCanvas: true, canvasMaxFramesPerSecond: 1 })

expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).not.toBe(
originalFillRect
)
})

it('does not instrument canvas drawing when canvas recording is disabled', () => {
const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value

startRecording({ recordCanvas: false, canvasMaxFramesPerSecond: 1 })

expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe(
originalFillRect
)
})

it('does not instrument canvas drawing when the maximum frame rate is zero', () => {
const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value

startRecording({ recordCanvas: true, canvasMaxFramesPerSecond: 0 })

expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe(
originalFillRect
)
})
})

it('flushes pending mutation records before taking a full snapshot', async () => {
startRecording()

Expand Down Expand Up @@ -374,12 +406,12 @@ describe('record', () => {
})
})

function startRecording() {
function startRecording(configuration: Partial<RumConfiguration> = {}) {
lifeCycle = new LifeCycle()
recordApi = record({
emitRecord: emitSpy,
emitStats: noop,
configuration: { defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW } as RumConfiguration,
configuration: { defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, ...configuration } as RumConfiguration,
lifeCycle,
viewHistory: {
findView: () => ({ id: FAKE_VIEW_ID, startClocks: {} }),
Expand Down
7 changes: 7 additions & 0 deletions packages/browser-rum/src/domain/record/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import {
trackViewEnd,
trackViewportResize,
trackVisualViewportResize,
trackCanvas2DMutations,
} from './trackers'
import { createElementsScrollPositions } from './elementsScrollPositions'
import type { ShadowRootsController } from './shadowRootsController'
import { initShadowRootsController } from './shadowRootsController'
import { startFullSnapshots } from './startFullSnapshots'
import type { EmitRecordCallback, EmitStatsCallback } from './record.types'
import { createRecordingScope } from './recordingScope'
import { createCanvasManager } from './canvas/canvasManager'

export interface RecordOptions {
emitRecord: EmitRecordCallback
Expand Down Expand Up @@ -76,6 +78,11 @@ export function record(options: RecordOptions): RecordAPI {
trackViewEnd(lifeCycle, processRecord, flushMutations),
]

if (configuration.recordCanvas && configuration.canvasMaxFramesPerSecond > 0) {
const canvasManager = createCanvasManager()
trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty))
}

return {
stop: () => {
shadowRootsController.stop()
Expand Down
1 change: 1 addition & 0 deletions packages/browser-rum/src/domain/record/trackers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export { trackFocus } from './trackFocus'
export { trackViewEnd } from './trackViewEnd'
export { trackInput } from './trackInput'
export { trackMutation } from './trackMutation'
export { trackCanvas2DMutations } from './trackCanvas'
export type { Tracker } from './tracker.types'
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { mockClock, registerCleanupTask } from '@datadog/browser-core/test'
import type { Clock } from '@datadog/browser-core/test'
import type { Tracker } from './tracker.types'
import { trackCanvas2DMutations } from './trackCanvas'

describe('trackCanvas2DMutations', () => {
let canvas: HTMLCanvasElement
let context: CanvasRenderingContext2D
let markCanvasDirtySpy: jasmine.Spy<(canvas: HTMLCanvasElement) => void>
let tracker: Tracker | undefined

beforeEach(() => {
canvas = document.createElement('canvas')
context = canvas.getContext('2d')!
markCanvasDirtySpy = jasmine.createSpy()

registerCleanupTask(() => tracker?.stop())
})

it('marks the canvas dirty after drawing operations', () => {
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
const imageData = context.createImageData(1, 1)
const drawingOperations = [
() => context.clearRect(0, 0, 1, 1),
() => context.fillRect(0, 0, 1, 1),
() => context.strokeRect(0, 0, 1, 1),
() => context.fill(),
() => context.stroke(),
() => context.fillText('foo', 0, 0),
() => context.strokeText('foo', 0, 0),
() => context.drawImage(canvas, 0, 0),
() => context.putImageData(imageData, 0, 0),
() => context.drawFocusIfNeeded(canvas),
() => context.reset(),
]

drawingOperations.forEach((draw) => {
markCanvasDirtySpy.calls.reset()
draw()
expect(markCanvasDirtySpy).toHaveBeenCalledOnceWith(canvas)
})
})

it('does not mark the canvas dirty for non-drawing operations', () => {
tracker = trackCanvas2DMutations(markCanvasDirtySpy)

context.beginPath()
context.moveTo(0, 0)
context.lineTo(1, 1)

expect(markCanvasDirtySpy).not.toHaveBeenCalled()
})

it('does not mark the canvas dirty when a drawing operation throws', () => {
tracker = trackCanvas2DMutations(markCanvasDirtySpy)

expect(() => context.putImageData(null as unknown as ImageData, 0, 0)).toThrow()
expect(markCanvasDirtySpy).not.toHaveBeenCalled()
})

it('marks the canvas dirty when it is resized', () => {
const clock: Clock = mockClock()
tracker = trackCanvas2DMutations(markCanvasDirtySpy)

canvas.width = 100
canvas.height = 50
clock.tick(0)

expect(markCanvasDirtySpy).toHaveBeenCalledTimes(2)
expect(markCanvasDirtySpy.calls.argsFor(0)[0]).toBe(canvas)
expect(markCanvasDirtySpy.calls.argsFor(1)[0]).toBe(canvas)
})

it('stops tracking drawing operations', () => {
const clock: Clock = mockClock()
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
tracker.stop()

context.fillRect(0, 0, 1, 1)
canvas.width = 100
clock.tick(0)

expect(markCanvasDirtySpy).not.toHaveBeenCalled()
})
})
Loading
Loading