Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7cb6936
Add init params with defaults
BeltranBulbarellaDD Aug 21, 2026
32e95d5
Fix typecheck
BeltranBulbarellaDD Aug 21, 2026
702d981
add trackCanvasCapture tracker
BeltranBulbarellaDD Aug 25, 2026
af5efab
✨ Add canvas capture stop, node privacy level, and init config minimums
BeltranBulbarellaDD Aug 25, 2026
a209092
Clean canvas on privacy change, add unit tests
BeltranBulbarellaDD Aug 25, 2026
3d772c4
Add max dimensions
BeltranBulbarellaDD Aug 25, 2026
e88d1dd
Add context.imageSmoothingQuality
BeltranBulbarellaDD Aug 25, 2026
04324e8
only capture canvases with the allow privacy level
BeltranBulbarellaDD Aug 26, 2026
fc53067
Address comments
BeltranBulbarellaDD Aug 28, 2026
1eed2ca
Address codex feedback.
BeltranBulbarellaDD Aug 28, 2026
08b9d72
Merge branch 'main' into beltran.bulbarella/canvas_support-3-capture-…
BeltranBulbarellaDD Aug 28, 2026
d830fc9
Fix CI
BeltranBulbarellaDD Aug 28, 2026
6e9b197
Address codex review comments
BeltranBulbarellaDD Aug 28, 2026
b3b3f5c
Fix unit test
BeltranBulbarellaDD Aug 31, 2026
08e1850
Refactor canvas manager
BeltranBulbarellaDD Aug 31, 2026
02f8ab1
Fix tainted canvases test
BeltranBulbarellaDD Aug 31, 2026
15cda8f
Remove redundand nodeId and privacy level checks
BeltranBulbarellaDD Aug 31, 2026
216c3df
Comment out the fallbacks
BeltranBulbarellaDD Aug 31, 2026
0c7efb1
Move hash and snapshot to separate files
BeltranBulbarellaDD Aug 31, 2026
9a0094b
Once tainted, always tainted
BeltranBulbarellaDD Aug 31, 2026
d57b809
Merge branch 'main' into beltran.bulbarella/canvas_support-3-capture-…
BeltranBulbarellaDD Aug 31, 2026
c0703e3
Remove createImageBitmap, and use drawImage.
BeltranBulbarellaDD Aug 31, 2026
7704f96
Update spec
BeltranBulbarellaDD Aug 31, 2026
b900b9f
Fix test
BeltranBulbarellaDD Aug 31, 2026
3115990
Simplify tests
BeltranBulbarellaDD Aug 31, 2026
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 thread
BeltranBulbarellaDD marked this conversation as resolved.
},
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,69 @@ 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('makes a tainted canvas capturable after its bitmap is reset', () => {
const canvasManager = createCanvasManager()
const canvas = appendCanvas()

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

canvasManager.markCanvasBitmapReset(canvas)

expect(canvasManager.getPreviousHash(canvas)).toBeUndefined()
expect(canvasManager.isCanvasCaptureInFlight(canvas, captureId)).toBeFalse()
expect(canvasManager.getCapturableCanvases()).toEqual([canvas])
})

it('forgets capture state when a canvas node is removed without forgetting its taint state', () => {
const canvasManager = createCanvasManager()
const canvas = appendCanvas()

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

canvasManager.forgetCanvasNode(canvas)

expect(canvasManager.isCanvasDirty(canvas)).toBeFalse()
expect(canvasManager.getPreviousHash(canvas)).toBeUndefined()
expect(canvasManager.isCanvasCaptureInFlight(canvas, captureId)).toBeFalse()
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
87 changes: 84 additions & 3 deletions packages/browser-rum/src/domain/record/canvas/canvasManager.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,62 @@
export interface CanvasManager {
clearDirtyCanvases: () => void
forgetCanvasNode: (canvas: HTMLCanvasElement) => void
getCapturableCanvases: () => HTMLCanvasElement[]
getDirtyCanvases: () => HTMLCanvasElement[]
getPreviousHash: (canvas: HTMLCanvasElement) => string | undefined
isCanvasCaptureInFlight: (canvas: HTMLCanvasElement, captureId: number) => boolean
isCanvasDirty: (canvas: HTMLCanvasElement) => boolean
markCanvasBitmapReset: (canvas: HTMLCanvasElement) => void
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
}

function markCanvasDirty(canvas: HTMLCanvasElement) {
if (canvas.isConnected && !taintedCanvases.has(canvas)) {
dirtyCanvases.add(canvas)
dirtyVersions.set(canvas, getDirtyVersion(canvas) + 1)
}
}

return {
clearDirtyCanvases: () => dirtyCanvases.clear(),
forgetCanvasNode: (canvas) => {
dirtyCanvases.delete(canvas)
dirtyVersions.delete(canvas)
previousHashes.delete(canvas)
inFlightCaptures.delete(canvas)
},
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
Outdated
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 +70,47 @@ 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),
markCanvasDirty: (canvas) => {
if (canvas.isConnected) {
dirtyCanvases.add(canvas)
markCanvasCleanIfUnchanged: (canvas, captureId) => {
const capture = inFlightCaptures.get(canvas)
if (capture?.id === captureId && capture.dirtyVersion === getDirtyVersion(canvas)) {
dirtyCanvases.delete(canvas)
}
},
markCanvasBitmapReset: (canvas) => {
taintedCanvases.delete(canvas)
previousHashes.delete(canvas)
inFlightCaptures.delete(canvas)
markCanvasDirty(canvas)
},
markCanvasDirty,
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 @@ -76,7 +76,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 @@ -96,7 +103,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),
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
]

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
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function processRemovedNodes(nodes: Set<Node>, transaction: SerializationTransac

forNodeAndDescendants(node, (node: Node) => {
if (isCanvasElement(node)) {
transaction.scope.canvasManager.markCanvasClean(node)
transaction.scope.canvasManager.forgetCanvasNode(node)
}

if (isNodeShadowHost(node)) {
Expand Down Expand Up @@ -261,12 +261,13 @@ function processAttributeMutations(

const change: RoleAnnotatedAttributeChange = [nodeId]
for (const [domAttributeName, oldValue] of attributeNames) {
if (node.getAttribute(domAttributeName) === oldValue) {
continue // No change since the last snapshot.
if (isCanvasElement(node) && isCanvasSizeAttribute(domAttributeName)) {
// Assigning either dimension resets the bitmap even when the attribute value does not change.
transaction.scope.canvasManager.markCanvasBitmapReset(node)
}

if (isCanvasElement(node) && isCanvasSizeAttribute(domAttributeName)) {
transaction.scope.canvasManager.markCanvasDirty(node)
if (node.getAttribute(domAttributeName) === oldValue) {
continue // No change since the last snapshot.
}

if (domAttributeName === 'value') {
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