Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
Original file line number Diff line number Diff line change
Expand Up @@ -355,16 +355,31 @@ describe('validateAndBuildRumConfiguration', () => {
sessionReplayCanvasRecording: { enable: true },
})!

expect(configuration.sessionReplayCanvasRecording).toEqual({ enable: true, maxFramesPerSecond: 1 })
expect(configuration.sessionReplayCanvasRecording).toEqual({
enable: true,
maxFramesPerSecond: 1,
hashingMaxDimension: 100,
maxImageDimension: 1000,
})
})

it('uses the configured frame rate', () => {
const configuration = validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 2.5 },
sessionReplayCanvasRecording: {
enable: true,
maxFramesPerSecond: 2.5,
hashingMaxDimension: 50,
maxImageDimension: 500,
},
})!

expect(configuration.sessionReplayCanvasRecording).toEqual({ enable: true, maxFramesPerSecond: 2.5 })
expect(configuration.sessionReplayCanvasRecording).toEqual({
enable: true,
maxFramesPerSecond: 2.5,
hashingMaxDimension: 50,
maxImageDimension: 500,
})
})

it('preserves the configured frame rate when disabled', () => {
Expand All @@ -373,7 +388,30 @@ describe('validateAndBuildRumConfiguration', () => {
sessionReplayCanvasRecording: { enable: false, maxFramesPerSecond: 2.5 },
})!

expect(configuration.sessionReplayCanvasRecording).toEqual({ enable: false, maxFramesPerSecond: 2.5 })
expect(configuration.sessionReplayCanvasRecording).toEqual({
enable: false,
maxFramesPerSecond: 2.5,
hashingMaxDimension: 100,
maxImageDimension: 1000,
})
})

it('rejects a hashing dimension above 100 pixels', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplayCanvasRecording: { enable: true, hashingMaxDimension: 101 },
})
).toBeUndefined()
})

it('rejects an image dimension above 1000 pixels', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplayCanvasRecording: { enable: true, maxImageDimension: 1001 },
})
).toBeUndefined()
})

it('rejects invalid canvas recording options', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,22 @@ export interface RumInitConfiguration extends InitConfiguration {
* @defaultValue 1
*/
maxFramesPerSecond?: number | undefined

/**
* The maximum width or height, in pixels, of the image used for canvas change detection.
* Images are downscaled proportionally to fit within this bound and smaller images are not upscaled.
*
* @defaultValue 100
*/
hashingMaxDimension?: number | undefined

/**
* The maximum width or height, in pixels, of recorded canvas images. Images are downscaled proportionally
* to fit within this bound and smaller images are not upscaled.
*
* @defaultValue 1000
*/
maxImageDimension?: number | undefined
}
| undefined

Expand Down Expand Up @@ -423,6 +439,8 @@ export const RUM_SCHEMA = {
schema: {
enable: { type: 'boolean', required: true },
maxFramesPerSecond: { type: 'number', min: 0, max: 5, default: 1 },
hashingMaxDimension: { type: 'number', min: 1, max: 100, default: 100 },
maxImageDimension: { type: 'number', min: 1, max: 1000, default: 1000 },
Comment on lines +442 to +443

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the enforced canvas dimension limits

issue: These schema entries reject hashingMaxDimension > 100 and maxImageDimension > 1000, but the newly exposed configuration fields only describe them as maximum dimensions and do not state either permitted range. A customer can therefore reasonably configure values such as maxImageDimension: 1200 and cause validateAndBuildRumConfiguration() to reject the entire RUM initialization rather than merely getting a larger replay image; document the 1–100 and 1–1000 ranges alongside the options, as is already done for maxFramesPerSecond.

Useful? React with 👍 / 👎.

},
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ describe('CanvasManager', () => {
expect(canvasManager.getDirtyCanvases()).toEqual([])
expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()
})

it('does not return tainted canvases for capture', () => {
const canvasManager = createCanvasManager()
const canvas = appendCanvas()

canvasManager.markCanvasDirty(canvas)
canvasManager.markCanvasTainted(canvas)

expect(canvasManager.getCapturableCanvases()).toEqual([])
canvasManager.markCanvasDirty(canvas)
expect(canvasManager.getCapturableCanvases()).toEqual([])
})

it('resets capture hashes without forgetting tainted canvases', () => {
const canvasManager = createCanvasManager()
const canvas = appendCanvas()

canvasManager.markCanvasDirty(canvas)
const captureId = canvasManager.markCanvasCaptureStarted(canvas)!
canvasManager.setPreviousHash(canvas, 'hash')
canvasManager.markCanvasCaptureFinished(canvas, captureId)
canvasManager.markCanvasTainted(canvas)

canvasManager.reset()

expect(canvasManager.getPreviousHash(canvas)).toBeUndefined()
canvasManager.markCanvasDirty(canvas)
expect(canvasManager.getCapturableCanvases()).toEqual([])
})
})

function appendCanvas(): HTMLCanvasElement {
Expand Down
67 changes: 66 additions & 1 deletion packages/browser-rum/src/domain/record/canvas/canvasManager.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
export interface CanvasManager {
clearDirtyCanvases: () => void
getCapturableCanvases: () => HTMLCanvasElement[]
getDirtyCanvases: () => HTMLCanvasElement[]
getPreviousHash: (canvas: HTMLCanvasElement) => string | undefined
isCanvasCaptureInFlight: (canvas: HTMLCanvasElement, captureId: number) => boolean
isCanvasDirty: (canvas: HTMLCanvasElement) => boolean
markCanvasCaptureFinished: (canvas: HTMLCanvasElement, captureId: number) => void
markCanvasCaptureStarted: (canvas: HTMLCanvasElement) => number | undefined
markCanvasClean: (canvas: HTMLCanvasElement) => void
markCanvasCleanIfUnchanged: (canvas: HTMLCanvasElement, captureId: number) => void
markCanvasDirty: (canvas: HTMLCanvasElement) => void
markCanvasTainted: (canvas: HTMLCanvasElement) => void
reset: () => void
setPreviousHash: (canvas: HTMLCanvasElement, hash: string) => void
}

export function createCanvasManager(): CanvasManager {
const dirtyCanvases = new Set<HTMLCanvasElement>()
const taintedCanvases = new WeakSet<HTMLCanvasElement>()
let dirtyVersions = new WeakMap<HTMLCanvasElement, number>()
let previousHashes = new WeakMap<HTMLCanvasElement, string>()
let inFlightCaptures = new WeakMap<HTMLCanvasElement, { id: number; dirtyVersion: number }>()
let nextCaptureId = 0

function getDirtyVersion(canvas: HTMLCanvasElement) {
return dirtyVersions.get(canvas) ?? 0
}

return {
clearDirtyCanvases: () => dirtyCanvases.clear(),
getCapturableCanvases: () => {
const capturableCanvases: HTMLCanvasElement[] = []

dirtyCanvases.forEach((canvas) => {
if (!canvas.isConnected) {
dirtyCanvases.delete(canvas)
} else if (!taintedCanvases.has(canvas) && !inFlightCaptures.has(canvas)) {
capturableCanvases.push(canvas)
}
})

return capturableCanvases
},
getDirtyCanvases: () => {
const connectedCanvases: HTMLCanvasElement[] = []

Expand All @@ -24,12 +55,46 @@ export function createCanvasManager(): CanvasManager {

return connectedCanvases
},
getPreviousHash: (canvas) => previousHashes.get(canvas),
isCanvasCaptureInFlight: (canvas, captureId) => inFlightCaptures.get(canvas)?.id === captureId,
isCanvasDirty: (canvas) => dirtyCanvases.has(canvas),
markCanvasCaptureFinished: (canvas, captureId) => {
if (inFlightCaptures.get(canvas)?.id === captureId) {
inFlightCaptures.delete(canvas)
}
},
markCanvasCaptureStarted: (canvas) => {
if (taintedCanvases.has(canvas) || inFlightCaptures.has(canvas)) {
return undefined
}

const captureId = nextCaptureId++
inFlightCaptures.set(canvas, { id: captureId, dirtyVersion: getDirtyVersion(canvas) })
return captureId
},
markCanvasClean: (canvas) => dirtyCanvases.delete(canvas),
markCanvasCleanIfUnchanged: (canvas, captureId) => {
const capture = inFlightCaptures.get(canvas)
if (capture?.id === captureId && capture.dirtyVersion === getDirtyVersion(canvas)) {
dirtyCanvases.delete(canvas)
}
},
markCanvasDirty: (canvas) => {
if (canvas.isConnected) {
if (canvas.isConnected && !taintedCanvases.has(canvas)) {
dirtyCanvases.add(canvas)
dirtyVersions.set(canvas, getDirtyVersion(canvas) + 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear taint state after the canvas bitmap is reset

issue: When an origin-unclean canvas is restored by assigning width or height and then redrawn with capturable content, this guard ignores both the size mutation and every later 2D draw, even though resetting the bitmap restores its origin-clean state. Fresh evidence is the newly added taintedCanvases weak set, which is never cleared—even by reset()—so the attempted retry fix now leaves that canvas uncapturable for the rest of the recording; clear or retry the taint state after a bitmap reset or subsequent drawing operation.

Useful? React with 👍 / 👎.

}
},
markCanvasTainted: (canvas) => {
taintedCanvases.add(canvas)
dirtyCanvases.delete(canvas)
},
reset: () => {
dirtyCanvases.clear()
dirtyVersions = new WeakMap()
previousHashes = new WeakMap()
inFlightCaptures = new WeakMap()
},
setPreviousHash: (canvas, hash) => previousHashes.set(canvas, hash),
}
}
18 changes: 16 additions & 2 deletions packages/browser-rum/src/domain/record/record.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,14 @@ describe('record', () => {
it('instruments canvas drawing when canvas recording is enabled', () => {
const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value

startRecording({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 } })
startRecording({
sessionReplayCanvasRecording: {
enable: true,
maxFramesPerSecond: 1,
hashingMaxDimension: 100,
maxImageDimension: 1000,
},
})

expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).not.toBe(
originalFillRect
Expand All @@ -97,7 +104,14 @@ describe('record', () => {
it('does not instrument canvas drawing when the maximum frame rate is zero', () => {
const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value

startRecording({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 0 } })
startRecording({
sessionReplayCanvasRecording: {
enable: true,
maxFramesPerSecond: 0,
hashingMaxDimension: 100,
maxImageDimension: 1000,
},
})

expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe(
originalFillRect
Expand Down
2 changes: 2 additions & 0 deletions packages/browser-rum/src/domain/record/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
trackViewportResize,
trackVisualViewportResize,
trackCanvasContent,
trackCanvasCapture,
} from './trackers'
import { createElementsScrollPositions } from './elementsScrollPositions'
import type { ShadowRootsController } from './shadowRootsController'
Expand Down Expand Up @@ -83,6 +84,7 @@ export function record(options: RecordOptions): RecordAPI {
trackVisualViewportResize(processRecord),
trackViewEnd(lifeCycle, processRecord, flushMutations),
trackCanvasContent(scope),
trackCanvasCapture(scope),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer capture until a callback consumes the images

issue: When the experimental canvas option is enabled, this production call omits onCanvasCapture, so the tracker uses its noop default; a repo-wide search at this commit finds no other production consumer of CanvasCapture. Every dirty canvas therefore still performs thumbnail reads and potentially PNG encoding up to maxFramesPerSecond, but every resulting blob is discarded, adding substantial main-thread and memory work without producing replay data; avoid starting this tracker until the delivery callback is wired.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not an issue.

]

return {
Expand Down
1 change: 1 addition & 0 deletions packages/browser-rum/src/domain/record/recordingScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function createRecordingScope(
scope.nodeIds.clear()
scope.stringIds.clear()
scope.styleSheetIds.clear()
scope.canvasManager.reset()
},

canvasManager,
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 @@ -9,4 +9,5 @@ export { trackViewEnd } from './trackViewEnd'
export { trackInput } from './trackInput'
export { trackMutation } from './trackMutation'
export { trackCanvasContent } from './trackCanvasContent'
export { trackCanvasCapture } from './trackCanvasCapture'
export type { Tracker } from './tracker.types'
Loading
Loading