From c0c05240e4c47329870acd8eca42fa2b55d4a2b0 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 14 Aug 2026 13:53:12 +0200 Subject: [PATCH 01/21] Add canvas change detetion --- .../src/domain/record/record.spec.ts | 36 +++++++++++++++++-- .../browser-rum/src/domain/record/record.ts | 7 ++++ .../src/domain/record/trackers/index.ts | 1 + 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/browser-rum/src/domain/record/record.spec.ts b/packages/browser-rum/src/domain/record/record.spec.ts index 3c30e044ba..7045b1b629 100644 --- a/packages/browser-rum/src/domain/record/record.spec.ts +++ b/packages/browser-rum/src/domain/record/record.spec.ts @@ -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() @@ -374,12 +406,12 @@ describe('record', () => { }) }) - function startRecording() { + function startRecording(configuration: Partial = {}) { 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: {} }), diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index f550a9128b..1317062a1e 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -15,6 +15,7 @@ import { trackViewEnd, trackViewportResize, trackVisualViewportResize, + trackCanvas2DMutations, } from './trackers' import { createElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' @@ -22,6 +23,7 @@ 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 @@ -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() diff --git a/packages/browser-rum/src/domain/record/trackers/index.ts b/packages/browser-rum/src/domain/record/trackers/index.ts index 7ccc9fcbab..1845381c81 100644 --- a/packages/browser-rum/src/domain/record/trackers/index.ts +++ b/packages/browser-rum/src/domain/record/trackers/index.ts @@ -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' From 3b026470409a0c1ec3bcd6f929bcd9c11d9e3908 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 14 Aug 2026 13:53:24 +0200 Subject: [PATCH 02/21] Add canvas change detetion --- .../record/canvas/canvasManager.spec.ts | 27 ++++++ .../src/domain/record/canvas/canvasManager.ts | 15 ++++ .../record/trackers/trackCanvas.spec.ts | 85 +++++++++++++++++++ .../src/domain/record/trackers/trackCanvas.ts | 56 ++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts create mode 100644 packages/browser-rum/src/domain/record/canvas/canvasManager.ts create mode 100644 packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts create mode 100644 packages/browser-rum/src/domain/record/trackers/trackCanvas.ts diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts new file mode 100644 index 0000000000..fbd3057924 --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts @@ -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() + }) +}) diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts new file mode 100644 index 0000000000..031bdcea9d --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts @@ -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() + + return { + isCanvasDirty: (canvas) => dirtyCanvases.has(canvas), + markCanvasClean: (canvas) => dirtyCanvases.delete(canvas), + markCanvasDirty: (canvas) => dirtyCanvases.add(canvas), + } +} diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts new file mode 100644 index 0000000000..d62035d866 --- /dev/null +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -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() + }) +}) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts new file mode 100644 index 0000000000..e1f210fc2f --- /dev/null +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -0,0 +1,56 @@ +import { instrumentMethod, instrumentSetter } from '@datadog/browser-core' +import type { Tracker } from './tracker.types' + +export type MarkCanvasDirty = (canvas: HTMLCanvasElement) => void + +type Canvas2DDrawingMethod = + | 'clearRect' + | 'fillRect' + | 'strokeRect' + | 'fill' + | 'stroke' + | 'fillText' + | 'strokeText' + | 'drawImage' + | 'putImageData' + | 'drawFocusIfNeeded' + | 'reset' + +const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [ + 'clearRect', + 'fillRect', + 'strokeRect', + 'fill', + 'stroke', + 'fillText', + 'strokeText', + 'drawImage', + 'putImageData', + 'drawFocusIfNeeded', + 'reset', +] + +export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracker { + const instrumentationStoppers: Tracker[] = [] + + if (typeof CanvasRenderingContext2D !== 'undefined') { + CANVAS_2D_DRAWING_METHODS.forEach((method) => { + instrumentationStoppers.push( + instrumentMethod(CanvasRenderingContext2D.prototype, method, ({ target: context, onPostCall }) => { + onPostCall(() => markCanvasDirty(context.canvas)) + }) + ) + }) + } + + if (typeof HTMLCanvasElement !== 'undefined') { + instrumentationStoppers.push( + instrumentSetter(HTMLCanvasElement.prototype, 'width', markCanvasDirty), + instrumentSetter(HTMLCanvasElement.prototype, 'height', markCanvasDirty) + ) + } + + return { + stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()), + } +} From b76c1ca241a742a08993246e3f956959991b5bb9 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 14 Aug 2026 14:25:36 +0200 Subject: [PATCH 03/21] filter out unnsupported versions for canvas APIs --- .../record/trackers/trackCanvas.spec.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts index d62035d866..cdd085c4e4 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -20,25 +20,31 @@ describe('trackCanvas2DMutations', () => { 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(), + const drawingOperations: Array<{ method: string; draw: () => void }> = [ + { method: 'clearRect', draw: () => context.clearRect(0, 0, 1, 1) }, + { method: 'fillRect', draw: () => context.fillRect(0, 0, 1, 1) }, + { method: 'strokeRect', draw: () => context.strokeRect(0, 0, 1, 1) }, + { method: 'fill', draw: () => context.fill() }, + { method: 'stroke', draw: () => context.stroke() }, + { method: 'fillText', draw: () => context.fillText('foo', 0, 0) }, + { method: 'strokeText', draw: () => context.strokeText('foo', 0, 0) }, + { method: 'drawImage', draw: () => context.drawImage(canvas, 0, 0) }, + { method: 'putImageData', draw: () => context.putImageData(imageData, 0, 0) }, + { method: 'drawFocusIfNeeded', draw: () => context.drawFocusIfNeeded(canvas) }, + { method: 'reset', draw: () => context.reset() }, ] - drawingOperations.forEach((draw) => { - markCanvasDirtySpy.calls.reset() - draw() - expect(markCanvasDirtySpy).toHaveBeenCalledOnceWith(canvas) - }) + // Skip unsuported APIs per browser version. + drawingOperations + .filter( + ({ method }) => + typeof CanvasRenderingContext2D.prototype[method as keyof CanvasRenderingContext2D] === 'function' + ) + .forEach(({ draw }) => { + markCanvasDirtySpy.calls.reset() + draw() + expect(markCanvasDirtySpy).toHaveBeenCalledOnceWith(canvas) + }) }) it('does not mark the canvas dirty for non-drawing operations', () => { From e013ae789a8ef33597623a3b0ae7b223daa460aa Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 14 Aug 2026 14:53:09 +0200 Subject: [PATCH 04/21] Track canvas resizes performed through attributes --- .../record/trackers/trackCanvas.spec.ts | 28 ++++++++++++++++++- .../src/domain/record/trackers/trackCanvas.ts | 22 ++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts index cdd085c4e4..f2008df36e 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -77,13 +77,39 @@ describe('trackCanvas2DMutations', () => { expect(markCanvasDirtySpy.calls.argsFor(1)[0]).toBe(canvas) }) - it('stops tracking drawing operations', () => { + it('marks the canvas dirty when it is resized through attributes', () => { + tracker = trackCanvas2DMutations(markCanvasDirtySpy) + + canvas.setAttribute('width', '100') + canvas.setAttribute('HEIGHT', '50') + canvas.removeAttribute('width') + canvas.removeAttribute('height') + + expect(markCanvasDirtySpy).toHaveBeenCalledTimes(4) + markCanvasDirtySpy.calls.allArgs().forEach(([dirtyCanvas]) => expect(dirtyCanvas).toBe(canvas)) + }) + + it('does not mark the canvas dirty for unrelated attribute mutations', () => { + tracker = trackCanvas2DMutations(markCanvasDirtySpy) + const div = document.createElement('div') + + canvas.setAttribute('class', 'foo') + canvas.removeAttribute('class') + canvas.removeAttribute('width') + div.setAttribute('width', '100') + div.removeAttribute('width') + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('stops tracking canvas mutations', () => { const clock: Clock = mockClock() tracker = trackCanvas2DMutations(markCanvasDirtySpy) tracker.stop() context.fillRect(0, 0, 1, 1) canvas.width = 100 + canvas.setAttribute('height', '50') clock.tick(0) expect(markCanvasDirtySpy).not.toHaveBeenCalled() diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts index e1f210fc2f..8a62e741ff 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -30,6 +30,8 @@ const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [ 'reset', ] +const CANVAS_SIZE_ATTRIBUTES = ['width', 'height'] + export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracker { const instrumentationStoppers: Tracker[] = [] @@ -46,7 +48,21 @@ export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracke if (typeof HTMLCanvasElement !== 'undefined') { instrumentationStoppers.push( instrumentSetter(HTMLCanvasElement.prototype, 'width', markCanvasDirty), - instrumentSetter(HTMLCanvasElement.prototype, 'height', markCanvasDirty) + instrumentSetter(HTMLCanvasElement.prototype, 'height', markCanvasDirty), + instrumentMethod(Element.prototype, 'setAttribute', ({ target, parameters, onPostCall }) => { + if (target instanceof HTMLCanvasElement && isCanvasSizeAttribute(parameters[0])) { + onPostCall(() => markCanvasDirty(target)) + } + }), + instrumentMethod(Element.prototype, 'removeAttribute', ({ target, parameters, onPostCall }) => { + if ( + target instanceof HTMLCanvasElement && + isCanvasSizeAttribute(parameters[0]) && + target.hasAttribute(parameters[0]) + ) { + onPostCall(() => markCanvasDirty(target)) + } + }) ) } @@ -54,3 +70,7 @@ export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracke stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()), } } + +function isCanvasSizeAttribute(attributeName: string): boolean { + return CANVAS_SIZE_ATTRIBUTES.includes(attributeName.toLowerCase()) +} From 0e9b67a899cb7cb8c5c4fe68fd68ec301a75b56f Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 14 Aug 2026 16:47:34 +0200 Subject: [PATCH 05/21] adapt for init config changes --- packages/browser-rum/src/domain/record/record.spec.ts | 6 +++--- packages/browser-rum/src/domain/record/record.ts | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/browser-rum/src/domain/record/record.spec.ts b/packages/browser-rum/src/domain/record/record.spec.ts index 7045b1b629..9abb43af7f 100644 --- a/packages/browser-rum/src/domain/record/record.spec.ts +++ b/packages/browser-rum/src/domain/record/record.spec.ts @@ -77,7 +77,7 @@ describe('record', () => { it('instruments canvas drawing when canvas recording is enabled', () => { const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value - startRecording({ recordCanvas: true, canvasMaxFramesPerSecond: 1 }) + startRecording({ enableSessionReplayCanvasRecording: { maxFramesPerSecond: 1 } }) expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).not.toBe( originalFillRect @@ -87,7 +87,7 @@ describe('record', () => { it('does not instrument canvas drawing when canvas recording is disabled', () => { const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value - startRecording({ recordCanvas: false, canvasMaxFramesPerSecond: 1 }) + startRecording() expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe( originalFillRect @@ -97,7 +97,7 @@ describe('record', () => { 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 }) + startRecording({ enableSessionReplayCanvasRecording: { maxFramesPerSecond: 0 } }) expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe( originalFillRect diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index 1317062a1e..9b32c52d06 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -78,7 +78,10 @@ export function record(options: RecordOptions): RecordAPI { trackViewEnd(lifeCycle, processRecord, flushMutations), ] - if (configuration.recordCanvas && configuration.canvasMaxFramesPerSecond > 0) { + if ( + configuration.enableSessionReplayCanvasRecording && + configuration.enableSessionReplayCanvasRecording.maxFramesPerSecond > 0 + ) { const canvasManager = createCanvasManager() trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty)) } From dc6b3d5c72a8b344b8c1d8666e9e3f27ed7ace9e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 17 Aug 2026 11:26:36 +0200 Subject: [PATCH 06/21] Update config structure --- packages/browser-rum/src/domain/record/record.spec.ts | 4 ++-- packages/browser-rum/src/domain/record/record.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser-rum/src/domain/record/record.spec.ts b/packages/browser-rum/src/domain/record/record.spec.ts index 9abb43af7f..8fc38fc4a9 100644 --- a/packages/browser-rum/src/domain/record/record.spec.ts +++ b/packages/browser-rum/src/domain/record/record.spec.ts @@ -77,7 +77,7 @@ describe('record', () => { it('instruments canvas drawing when canvas recording is enabled', () => { const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value - startRecording({ enableSessionReplayCanvasRecording: { maxFramesPerSecond: 1 } }) + startRecording({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 } }) expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).not.toBe( originalFillRect @@ -97,7 +97,7 @@ describe('record', () => { it('does not instrument canvas drawing when the maximum frame rate is zero', () => { const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value - startRecording({ enableSessionReplayCanvasRecording: { maxFramesPerSecond: 0 } }) + startRecording({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 0 } }) expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe( originalFillRect diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index 9b32c52d06..ed047e9e6e 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -79,8 +79,8 @@ export function record(options: RecordOptions): RecordAPI { ] if ( - configuration.enableSessionReplayCanvasRecording && - configuration.enableSessionReplayCanvasRecording.maxFramesPerSecond > 0 + configuration.sessionReplayCanvasRecording?.enable && + configuration.sessionReplayCanvasRecording.maxFramesPerSecond > 0 ) { const canvasManager = createCanvasManager() trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty)) From b056d28805f1c7a2c1b388a158eecb922aa6140f Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 17 Aug 2026 13:48:57 +0200 Subject: [PATCH 07/21] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20Add=20canvas=20dirty?= =?UTF-8?q?-state=20prefilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/browser/domMutationObservable.ts | 2 + .../record/canvas/canvasManager.spec.ts | 8 +- .../src/domain/record/canvas/canvasManager.ts | 14 +- .../browser-rum/src/domain/record/record.ts | 18 ++- .../src/domain/record/recordingScope.ts | 6 +- .../record/test/recordingScope.specHelper.ts | 6 +- .../record/trackers/trackCanvas.spec.ts | 45 +----- .../src/domain/record/trackers/trackCanvas.ts | 68 +++++--- .../record/trackers/trackMutation.spec.ts | 148 +++++++++++++++++- .../domain/record/trackers/trackMutation.ts | 20 ++- test/e2e/scenario/recorder/canvas.scenario.ts | 113 +++++++++++++ 11 files changed, 356 insertions(+), 92 deletions(-) create mode 100644 test/e2e/scenario/recorder/canvas.scenario.ts diff --git a/packages/browser-rum-core/src/browser/domMutationObservable.ts b/packages/browser-rum-core/src/browser/domMutationObservable.ts index 769ce0538e..b35077955b 100644 --- a/packages/browser-rum-core/src/browser/domMutationObservable.ts +++ b/packages/browser-rum-core/src/browser/domMutationObservable.ts @@ -12,6 +12,8 @@ export interface RumAttributesMutationRecord { target: Element oldValue: string | null attributeName: string + // Distinguishes canvas content attributes from namespaced attributes with the same local name. + attributeNamespace: string | null } export interface RumChildListMutationRecord { diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts index fbd3057924..a7368a54b2 100644 --- a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts @@ -5,13 +5,13 @@ describe('createCanvasManager', () => { 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() + + canvasManager.markCanvasDirty(canvas) + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() }) it('tracks canvases independently', () => { @@ -19,7 +19,7 @@ describe('createCanvasManager', () => { const dirtyCanvas = document.createElement('canvas') const cleanCanvas = document.createElement('canvas') - canvasManager.markCanvasDirty(dirtyCanvas) + canvasManager.markCanvasClean(cleanCanvas) expect(canvasManager.isCanvasDirty(dirtyCanvas)).toBeTrue() expect(canvasManager.isCanvasDirty(cleanCanvas)).toBeFalse() diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts index 031bdcea9d..c7cc7dfa18 100644 --- a/packages/browser-rum/src/domain/record/canvas/canvasManager.ts +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts @@ -4,12 +4,18 @@ export interface CanvasManager { markCanvasDirty: (canvas: HTMLCanvasElement) => void } +/** + * Tracks canvases that have already been captured and found to be clean. + * Canvases are dirty by default so that newly discovered canvases are captured. After a capture, + * callers can mark a canvas as clean and avoid sending it again until a drawing or DOM mutation + * marks it dirty. + */ export function createCanvasManager(): CanvasManager { - const dirtyCanvases = new WeakSet() + const cleanCanvases = new WeakSet() return { - isCanvasDirty: (canvas) => dirtyCanvases.has(canvas), - markCanvasClean: (canvas) => dirtyCanvases.delete(canvas), - markCanvasDirty: (canvas) => dirtyCanvases.add(canvas), + isCanvasDirty: (canvas) => !cleanCanvases.has(canvas), + markCanvasClean: (canvas) => cleanCanvases.add(canvas), + markCanvasDirty: (canvas) => cleanCanvases.delete(canvas), } } diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index ed047e9e6e..422ec7f28a 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -53,8 +53,18 @@ export function record(options: RecordOptions): RecordAPI { replayStats.addRecord(view.id) } + const canvasManager = + configuration.sessionReplayCanvasRecording?.enable && + configuration.sessionReplayCanvasRecording.maxFramesPerSecond > 0 + ? createCanvasManager() + : undefined const shadowRootsController = initShadowRootsController(processRecord, emitStats) - const scope = createRecordingScope(configuration, createElementsScrollPositions(), shadowRootsController) + const scope = createRecordingScope( + configuration, + createElementsScrollPositions(), + shadowRootsController, + canvasManager + ) const { stop: stopFullSnapshots } = startFullSnapshots(lifeCycle, processRecord, emitStats, flushMutations, scope) @@ -78,11 +88,7 @@ export function record(options: RecordOptions): RecordAPI { trackViewEnd(lifeCycle, processRecord, flushMutations), ] - if ( - configuration.sessionReplayCanvasRecording?.enable && - configuration.sessionReplayCanvasRecording.maxFramesPerSecond > 0 - ) { - const canvasManager = createCanvasManager() + if (canvasManager) { trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty)) } diff --git a/packages/browser-rum/src/domain/record/recordingScope.ts b/packages/browser-rum/src/domain/record/recordingScope.ts index 14cbce729b..9993f7611f 100644 --- a/packages/browser-rum/src/domain/record/recordingScope.ts +++ b/packages/browser-rum/src/domain/record/recordingScope.ts @@ -4,6 +4,7 @@ import type { ElementsScrollPositions } from './elementsScrollPositions' import { createEventIds, createNodeIds, createStringIds, createStyleSheetIds } from './itemIds' import type { EventIds, NodeIds, StringIds, StyleSheetIds } from './itemIds' import type { ShadowRootsController } from './shadowRootsController' +import type { CanvasManager } from './canvas/canvasManager' /** * State associated with a stream of session replay records. When a new stream of records @@ -21,12 +22,14 @@ export interface RecordingScope { shadowRootsController: ShadowRootsController stringIds: StringIds styleSheetIds: StyleSheetIds + canvasManager: CanvasManager | undefined } export function createRecordingScope( configuration: RumConfiguration, elementsScrollPositions: ElementsScrollPositions, - shadowRootsController: ShadowRootsController + shadowRootsController: ShadowRootsController, + canvasManager?: CanvasManager ): RecordingScope { const eventIds = createEventIds() const nodeIds = createNodeIds() @@ -41,6 +44,7 @@ export function createRecordingScope( scope.styleSheetIds.clear() }, + canvasManager, configuration, elementsScrollPositions, eventIds, diff --git a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts index 91b39c9c2c..3131bbc2ef 100644 --- a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts +++ b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts @@ -3,6 +3,7 @@ import { noop } from '@datadog/browser-core' import { createElementsScrollPositions } from '../elementsScrollPositions' import type { RecordingScope } from '../recordingScope' import { createRecordingScope } from '../recordingScope' +import type { CanvasManager } from '../canvas/canvasManager' import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowRootsController' import { DEFAULT_CONFIGURATION } from './rumConfiguration.specHelper' import { DEFAULT_SHADOW_ROOT_CONTROLLER } from './shadowRootsController.specHelper' @@ -11,10 +12,12 @@ export function createRecordingScopeForTesting({ configuration, addShadowRoot, removeShadowRoot, + canvasManager, }: { configuration?: Partial addShadowRoot?: AddShadowRootCallBack removeShadowRoot?: RemoveShadowRootCallBack + canvasManager?: CanvasManager } = {}): RecordingScope { return createRecordingScope( { @@ -26,6 +29,7 @@ export function createRecordingScopeForTesting({ ...DEFAULT_SHADOW_ROOT_CONTROLLER, addShadowRoot: addShadowRoot || noop, removeShadowRoot: removeShadowRoot || noop, - } + }, + canvasManager ) } diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts index f2008df36e..e2bcc9b6df 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -1,5 +1,4 @@ -import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' -import type { Clock } from '@datadog/browser-core/test' +import { registerCleanupTask } from '@datadog/browser-core/test' import type { Tracker } from './tracker.types' import { trackCanvas2DMutations } from './trackCanvas' @@ -64,53 +63,11 @@ describe('trackCanvas2DMutations', () => { 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('marks the canvas dirty when it is resized through attributes', () => { - tracker = trackCanvas2DMutations(markCanvasDirtySpy) - - canvas.setAttribute('width', '100') - canvas.setAttribute('HEIGHT', '50') - canvas.removeAttribute('width') - canvas.removeAttribute('height') - - expect(markCanvasDirtySpy).toHaveBeenCalledTimes(4) - markCanvasDirtySpy.calls.allArgs().forEach(([dirtyCanvas]) => expect(dirtyCanvas).toBe(canvas)) - }) - - it('does not mark the canvas dirty for unrelated attribute mutations', () => { - tracker = trackCanvas2DMutations(markCanvasDirtySpy) - const div = document.createElement('div') - - canvas.setAttribute('class', 'foo') - canvas.removeAttribute('class') - canvas.removeAttribute('width') - div.setAttribute('width', '100') - div.removeAttribute('width') - - expect(markCanvasDirtySpy).not.toHaveBeenCalled() - }) - it('stops tracking canvas mutations', () => { - const clock: Clock = mockClock() tracker = trackCanvas2DMutations(markCanvasDirtySpy) tracker.stop() context.fillRect(0, 0, 1, 1) - canvas.width = 100 - canvas.setAttribute('height', '50') - clock.tick(0) expect(markCanvasDirtySpy).not.toHaveBeenCalled() }) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts index 8a62e741ff..d871b6e1eb 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -1,4 +1,7 @@ -import { instrumentMethod, instrumentSetter } from '@datadog/browser-core' +import { instrumentMethod } from '@datadog/browser-core' +import type { RumMutationRecord } from '@datadog/browser-rum-core' +import { forEachChildNodes, isElementNode } from '@datadog/browser-rum-core' +import type { CanvasManager } from '../canvas/canvasManager' import type { Tracker } from './tracker.types' export type MarkCanvasDirty = (canvas: HTMLCanvasElement) => void @@ -45,32 +48,53 @@ export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracke }) } - if (typeof HTMLCanvasElement !== 'undefined') { - instrumentationStoppers.push( - instrumentSetter(HTMLCanvasElement.prototype, 'width', markCanvasDirty), - instrumentSetter(HTMLCanvasElement.prototype, 'height', markCanvasDirty), - instrumentMethod(Element.prototype, 'setAttribute', ({ target, parameters, onPostCall }) => { - if (target instanceof HTMLCanvasElement && isCanvasSizeAttribute(parameters[0])) { - onPostCall(() => markCanvasDirty(target)) - } - }), - instrumentMethod(Element.prototype, 'removeAttribute', ({ target, parameters, onPostCall }) => { - if ( - target instanceof HTMLCanvasElement && - isCanvasSizeAttribute(parameters[0]) && - target.hasAttribute(parameters[0]) - ) { - onPostCall(() => markCanvasDirty(target)) - } - }) - ) - } - return { stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()), } } +/** + * Canvas dimensions can be changed through several APIs, including the `width` and `height` + * properties and their corresponding attributes. The mutation observer consolidates these paths + * into attribute mutation records so that the canvas can be marked dirty consistently. + */ +export function markCanvasDirtyFromMutationRecords( + mutations: RumMutationRecord[], + canvasManager: CanvasManager | undefined +): void { + if (!canvasManager) { + return + } + + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + isCanvasElement(mutation.target) && + mutation.attributeNamespace === null && + isCanvasSizeAttribute(mutation.attributeName) + ) { + canvasManager.markCanvasDirty(mutation.target) + } + + if (mutation.type === 'childList') { + for (let index = 0; index < mutation.addedNodes.length; index += 1) { + markCanvasAndDescendantsDirty(mutation.addedNodes[index], canvasManager) + } + } + } +} + +function markCanvasAndDescendantsDirty(node: Node, canvasManager: CanvasManager): void { + if (isCanvasElement(node)) { + canvasManager.markCanvasDirty(node) + } + forEachChildNodes(node, (childNode) => markCanvasAndDescendantsDirty(childNode, canvasManager)) +} + +function isCanvasElement(node: Node): node is HTMLCanvasElement { + return isElementNode(node) && node.tagName === 'CANVAS' +} + function isCanvasSizeAttribute(attributeName: string): boolean { return CANVAS_SIZE_ATTRIBUTES.includes(attributeName.toLowerCase()) } diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts index 0184f10218..f2ff2d2e35 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts @@ -1,5 +1,5 @@ -import { DefaultPrivacyLevel } from '@datadog/browser-core' -import { registerCleanupTask } from '@datadog/browser-core/test' +import { DefaultPrivacyLevel, noop } from '@datadog/browser-core' +import { registerCleanupTask, waitNextMicrotask } from '@datadog/browser-core/test' import type { RumConfiguration } from '@datadog/browser-rum-core' import { PRIVACY_ATTR_NAME, @@ -15,6 +15,7 @@ import type { ChangeDecoder, SerializationStats } from '../serialization' import { aggregateSerializationStats, createSerializationStats } from '../serialization' import { serializeHtml } from '../test/serializeHtml.specHelper' import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' +import { createCanvasManager } from '../canvas/canvasManager' import { trackMutation } from './trackMutation' describe('trackMutation', () => { @@ -550,6 +551,149 @@ describe('trackMutation', () => { }) }) + describe('canvas mutations', () => { + const canvasSizeMutations: Array<{ + name: string + mutate: (canvas: HTMLCanvasElement) => void + }> = [ + { name: 'the width property', mutate: (canvas) => (canvas.width = 101) }, + { name: 'setAttribute', mutate: (canvas) => canvas.setAttribute('width', '101') }, + { name: 'removeAttribute', mutate: (canvas) => canvas.removeAttribute('width') }, + { name: 'toggleAttribute', mutate: (canvas) => canvas.toggleAttribute('width') }, + { name: 'setAttributeNS', mutate: (canvas) => canvas.setAttributeNS(null, 'width', '101') }, + { name: 'removeAttributeNS', mutate: (canvas) => canvas.removeAttributeNS(null, 'width') }, + { + name: 'Attr.value', + mutate: (canvas) => { + canvas.attributes.getNamedItem('width')!.value = '101' + }, + }, + { + name: 'NamedNodeMap.setNamedItem', + mutate: (canvas) => { + const attribute = canvas.ownerDocument.createAttribute('width') + attribute.value = '101' + canvas.attributes.setNamedItem(attribute) + }, + }, + ] + + canvasSizeMutations.forEach(({ name, mutate }) => { + it(`marks a canvas dirty when its size is changed through ${name}`, async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '
', + (sandbox) => { + canvas = sandbox.querySelector('canvas')! + canvasManager.markCanvasClean(canvas) + mutate(canvas) + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) + }) + + it('does not mark a canvas dirty for unrelated attributes', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '
', + (sandbox) => { + canvas = sandbox.querySelector('canvas')! + canvasManager.markCanvasClean(canvas) + canvas.setAttribute('class', 'foo') + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) + + it('does not mark a canvas dirty for namespaced size attributes', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '
', + (sandbox) => { + canvas = sandbox.querySelector('canvas')! + canvasManager.markCanvasClean(canvas) + canvas.setAttributeNS('urn:example', 'example:width', '101') + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) + + it('marks a canvas dirty when it is reinserted', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '
', + (sandbox) => { + canvas = sandbox.querySelector('canvas')! + canvasManager.markCanvasClean(canvas) + canvas.remove() + sandbox.appendChild(canvas) + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) + + it('marks canvases in an inserted subtree dirty', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '
', + (sandbox) => { + const container = sandbox.ownerDocument.createElement('div') + canvas = sandbox.ownerDocument.createElement('canvas') + container.appendChild(canvas) + canvasManager.markCanvasClean(canvas) + sandbox.appendChild(container) + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) + + it('marks a canvas dirty when its size changes in a shadow root', async () => { + const host = document.createElement('div') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const canvas = document.createElement('canvas') + canvas.setAttribute('width', '100') + shadowRoot.appendChild(canvas) + document.body.appendChild(host) + registerCleanupTask(() => host.remove()) + + const canvasManager = createCanvasManager() + canvasManager.markCanvasClean(canvas) + const mutationTracker = trackMutation(shadowRoot, noop, noop, createRecordingScopeForTesting({ canvasManager })) + registerCleanupTask(() => mutationTracker.stop()) + + canvas.toggleAttribute('width') + await waitNextMicrotask() + + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) + }) + describe('ignored nodes', () => { it('skips ignored nodes when serializing', async () => { const { mutation } = await recordMutationOf('
', (sandbox: HTMLElement): void => { diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.ts index 1cb11d3c44..57900cb294 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.ts @@ -7,6 +7,7 @@ import { createMutationBatch } from '../mutationBatch' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import { serializeMutations } from '../serialization' import type { Tracker } from './tracker.types' +import { markCanvasDirtyFromMutationRecords } from './trackCanvas' export type MutationTracker = Tracker & { flush: () => void } @@ -25,16 +26,19 @@ export function trackMutation( } const mutationBatch = createMutationBatch((mutations) => { - serializeMutations( - timeStampNow(), - mutations.concat(observer.takeRecords() as RumMutationRecord[]), - emitRecord, - emitStats, - scope - ) + // Process mutations still queued in the observer so canvas changes are tracked before flushing. + const queuedMutations = observer.takeRecords() as RumMutationRecord[] + markCanvasDirtyFromMutationRecords(queuedMutations, scope.canvasManager) + serializeMutations(timeStampNow(), mutations.concat(queuedMutations), emitRecord, emitStats, scope) }) - const observer = new MutationObserver(monitor(mutationBatch.addMutations)) + const observer = new MutationObserver( + monitor((mutations) => { + // Keep the canvas manager in sync with DOM mutations so unchanged canvases can be skipped. + markCanvasDirtyFromMutationRecords(mutations, scope.canvasManager) + mutationBatch.addMutations(mutations) + }) + ) observer.observe(target, { attributeOldValue: true, diff --git a/test/e2e/scenario/recorder/canvas.scenario.ts b/test/e2e/scenario/recorder/canvas.scenario.ts new file mode 100644 index 0000000000..0e7301b008 --- /dev/null +++ b/test/e2e/scenario/recorder/canvas.scenario.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test' +import { createTest, html } from '../../lib/framework' + +// Since there is no requests being send we just mock by using a probe. +// This will be removed in future PRs. + +declare global { + interface Window { + canvasDirtyProbe: { + cleanCanvases?: WeakSet + dirtyCalls: string[] + originalFillRect: CanvasRenderingContext2D['fillRect'] + } + } +} + +const CANVAS_DIRTY_PROBE = html` + +` + +test.describe('canvas recording', () => { + createTest('tracks dirty canvases') + .withHead(CANVAS_DIRTY_PROBE) + .withRum({ + enableExperimentalFeatures: ['session_replay_record_canvas'], + sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 }, + }) + .run(async ({ page }) => { + // Wait until the recorder instruments the 2D drawing API. + await expect + .poll(() => + page.evaluate(() => CanvasRenderingContext2D.prototype.fillRect !== window.canvasDirtyProbe.originalFillRect) + ) + .toBe(true) + + // Newly inserted canvases are dirty and must be considered for capture. + await page.evaluate(() => { + for (const id of ['drawn', 'resized', 'unchanged']) { + const canvas = document.createElement('canvas') + canvas.id = id + canvas.width = 100 + canvas.height = 100 + document.body.appendChild(canvas) + } + }) + + await expect + .poll(() => + page.evaluate(() => { + const cleanCanvases = window.canvasDirtyProbe.cleanCanvases + return { + dirtyCalls: [...window.canvasDirtyProbe.dirtyCalls].sort(), + cleanStates: cleanCanvases + ? ['drawn', 'resized', 'unchanged'].map((id) => + cleanCanvases.has(document.querySelector(`#${id}`)!) + ) + : undefined, + } + }) + ) + .toEqual({ + dirtyCalls: ['drawn', 'resized', 'unchanged'], + cleanStates: [false, false, false], + }) + + // Establish the post-capture clean state, then exercise the real canvas drawing and DOM APIs. + await page.evaluate(() => { + const probe = window.canvasDirtyProbe + const canvases = document.querySelectorAll('canvas') + + canvases.forEach((canvas) => probe.cleanCanvases!.add(canvas)) + probe.dirtyCalls.length = 0 + + document.querySelector('#drawn')!.getContext('2d')!.fillRect(0, 0, 10, 10) + document.querySelector('#resized')!.toggleAttribute('width') + document.querySelector('#unchanged')!.getContext('2d')!.beginPath() + }) + + // Drawing and resizing make canvases dirty; beginPath() does not change the canvas bitmap. + await expect + .poll(() => page.evaluate(() => [...window.canvasDirtyProbe.dirtyCalls].sort())) + .toEqual(['drawn', 'resized']) + + expect( + await page.evaluate(() => { + const cleanCanvases = window.canvasDirtyProbe.cleanCanvases! + return ['drawn', 'resized', 'unchanged'].map((id) => + cleanCanvases.has(document.querySelector(`#${id}`)!) + ) + }) + ).toEqual([false, false, true]) + }) +}) From 07aed3287f3e30ca7748ce293b74de120fa358e8 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 17 Aug 2026 14:06:38 +0200 Subject: [PATCH 08/21] Make attributeNamespace optional --- packages/browser-rum-core/src/browser/domMutationObservable.ts | 2 +- packages/browser-rum/src/domain/record/trackers/trackCanvas.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-rum-core/src/browser/domMutationObservable.ts b/packages/browser-rum-core/src/browser/domMutationObservable.ts index b35077955b..0ab6502104 100644 --- a/packages/browser-rum-core/src/browser/domMutationObservable.ts +++ b/packages/browser-rum-core/src/browser/domMutationObservable.ts @@ -13,7 +13,7 @@ export interface RumAttributesMutationRecord { oldValue: string | null attributeName: string // Distinguishes canvas content attributes from namespaced attributes with the same local name. - attributeNamespace: string | null + attributeNamespace?: string | null } export interface RumChildListMutationRecord { diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts index d871b6e1eb..baf4fdf445 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -70,7 +70,7 @@ export function markCanvasDirtyFromMutationRecords( if ( mutation.type === 'attributes' && isCanvasElement(mutation.target) && - mutation.attributeNamespace === null && + (mutation.attributeNamespace === null || mutation.attributeNamespace === undefined) && isCanvasSizeAttribute(mutation.attributeName) ) { canvasManager.markCanvasDirty(mutation.target) From b97a7350179befe1d90ae4d8f27a1b8ad32c361c Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 09:27:47 +0200 Subject: [PATCH 09/21] track dirty canvases --- .../record/canvas/canvasManager.spec.ts | 55 +++++++++++++++++-- .../src/domain/record/canvas/canvasManager.ts | 34 ++++++++---- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts index a7368a54b2..8c1546ed20 100644 --- a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts @@ -1,10 +1,14 @@ +import { registerCleanupTask } from '@datadog/browser-core/test' import { createCanvasManager } from './canvasManager' describe('createCanvasManager', () => { it('tracks whether a canvas is dirty', () => { const canvasManager = createCanvasManager() - const canvas = document.createElement('canvas') + const canvas = appendCanvas() + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + canvasManager.markCanvasDirty(canvas) expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() canvasManager.markCanvasClean(canvas) @@ -16,12 +20,55 @@ describe('createCanvasManager', () => { it('tracks canvases independently', () => { const canvasManager = createCanvasManager() - const dirtyCanvas = document.createElement('canvas') - const cleanCanvas = document.createElement('canvas') + const dirtyCanvas = appendCanvas() + const cleanCanvas = appendCanvas() - canvasManager.markCanvasClean(cleanCanvas) + canvasManager.markCanvasDirty(dirtyCanvas) expect(canvasManager.isCanvasDirty(dirtyCanvas)).toBeTrue() expect(canvasManager.isCanvasDirty(cleanCanvas)).toBeFalse() }) + + it('returns connected dirty canvases', () => { + const canvasManager = createCanvasManager() + const canvas = appendCanvas() + + canvasManager.markCanvasDirty(canvas) + + expect(canvasManager.getDirtyCanvases()).toEqual([canvas]) + + canvasManager.markCanvasClean(canvas) + expect(canvasManager.getDirtyCanvases()).toEqual([]) + }) + + it('does not retain detached canvases', () => { + const canvasManager = createCanvasManager() + const canvas = document.createElement('canvas') + + canvasManager.markCanvasDirty(canvas) + + expect(canvasManager.getDirtyCanvases()).toEqual([]) + + document.body.appendChild(canvas) + registerCleanupTask(() => canvas.remove()) + expect(canvasManager.getDirtyCanvases()).toEqual([]) + }) + + it('clears dirty canvases', () => { + const canvasManager = createCanvasManager() + const canvas = appendCanvas() + canvasManager.markCanvasDirty(canvas) + + canvasManager.clearDirtyCanvases() + + expect(canvasManager.getDirtyCanvases()).toEqual([]) + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) }) + +function appendCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas') + document.body.appendChild(canvas) + registerCleanupTask(() => canvas.remove()) + return canvas +} diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts index c7cc7dfa18..da21bd0c02 100644 --- a/packages/browser-rum/src/domain/record/canvas/canvasManager.ts +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts @@ -1,21 +1,35 @@ export interface CanvasManager { + clearDirtyCanvases: () => void + getDirtyCanvases: () => HTMLCanvasElement[] isCanvasDirty: (canvas: HTMLCanvasElement) => boolean markCanvasClean: (canvas: HTMLCanvasElement) => void markCanvasDirty: (canvas: HTMLCanvasElement) => void } -/** - * Tracks canvases that have already been captured and found to be clean. - * Canvases are dirty by default so that newly discovered canvases are captured. After a capture, - * callers can mark a canvas as clean and avoid sending it again until a drawing or DOM mutation - * marks it dirty. - */ export function createCanvasManager(): CanvasManager { - const cleanCanvases = new WeakSet() + const dirtyCanvases = new Set() return { - isCanvasDirty: (canvas) => !cleanCanvases.has(canvas), - markCanvasClean: (canvas) => cleanCanvases.add(canvas), - markCanvasDirty: (canvas) => cleanCanvases.delete(canvas), + clearDirtyCanvases: () => dirtyCanvases.clear(), + getDirtyCanvases: () => { + const connectedCanvases: HTMLCanvasElement[] = [] + + dirtyCanvases.forEach((canvas) => { + if (canvas.isConnected) { + connectedCanvases.push(canvas) + } else { + dirtyCanvases.delete(canvas) + } + }) + + return connectedCanvases + }, + isCanvasDirty: (canvas) => dirtyCanvases.has(canvas), + markCanvasClean: (canvas) => dirtyCanvases.delete(canvas), + markCanvasDirty: (canvas) => { + if (canvas.isConnected) { + dirtyCanvases.add(canvas) + } + }, } } From faf302e93bcb03f12ef04d8c64d0a697e26e855d Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 10:16:09 +0200 Subject: [PATCH 10/21] Seed existing canvases before full snapshots --- .../browser-rum/src/domain/record/record.ts | 6 ++++++ .../src/domain/record/trackers/index.ts | 2 +- .../record/trackers/trackCanvas.spec.ts | 19 ++++++++++++++++++- .../src/domain/record/trackers/trackCanvas.ts | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index 422ec7f28a..1f556cd05d 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -16,6 +16,7 @@ import { trackViewportResize, trackVisualViewportResize, trackCanvas2DMutations, + markCanvasAndDescendantsDirty, } from './trackers' import { createElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' @@ -68,6 +69,11 @@ export function record(options: RecordOptions): RecordAPI { const { stop: stopFullSnapshots } = startFullSnapshots(lifeCycle, processRecord, emitStats, flushMutations, scope) + // Seed all connected canvases after the initial full snapshot. + if (canvasManager) { + markCanvasAndDescendantsDirty(document, canvasManager) + } + function flushMutations() { shadowRootsController.flush() mutationTracker.flush() diff --git a/packages/browser-rum/src/domain/record/trackers/index.ts b/packages/browser-rum/src/domain/record/trackers/index.ts index 1845381c81..d22405f139 100644 --- a/packages/browser-rum/src/domain/record/trackers/index.ts +++ b/packages/browser-rum/src/domain/record/trackers/index.ts @@ -8,5 +8,5 @@ export { trackFocus } from './trackFocus' export { trackViewEnd } from './trackViewEnd' export { trackInput } from './trackInput' export { trackMutation } from './trackMutation' -export { trackCanvas2DMutations } from './trackCanvas' +export { markCanvasAndDescendantsDirty, trackCanvas2DMutations } from './trackCanvas' export type { Tracker } from './tracker.types' diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts index e2bcc9b6df..27264cdab4 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -1,6 +1,7 @@ import { registerCleanupTask } from '@datadog/browser-core/test' +import { createCanvasManager } from '../canvas/canvasManager' import type { Tracker } from './tracker.types' -import { trackCanvas2DMutations } from './trackCanvas' +import { markCanvasAndDescendantsDirty, trackCanvas2DMutations } from './trackCanvas' describe('trackCanvas2DMutations', () => { let canvas: HTMLCanvasElement @@ -72,3 +73,19 @@ describe('trackCanvas2DMutations', () => { expect(markCanvasDirtySpy).not.toHaveBeenCalled() }) }) + +describe('markCanvasAndDescendantsDirty', () => { + it('marks connected canvases in a subtree dirty', () => { + const canvasManager = createCanvasManager() + const container = document.createElement('div') + const canvas = document.createElement('canvas') + const nestedCanvas = document.createElement('canvas') + container.append(canvas, nestedCanvas) + document.body.appendChild(container) + registerCleanupTask(() => container.remove()) + + markCanvasAndDescendantsDirty(container, canvasManager) + + expect(canvasManager.getDirtyCanvases()).toEqual([canvas, nestedCanvas]) + }) +}) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts index baf4fdf445..64c3c07151 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -84,7 +84,7 @@ export function markCanvasDirtyFromMutationRecords( } } -function markCanvasAndDescendantsDirty(node: Node, canvasManager: CanvasManager): void { +export function markCanvasAndDescendantsDirty(node: Node, canvasManager: CanvasManager): void { if (isCanvasElement(node)) { canvasManager.markCanvasDirty(node) } From aab401f68f75ebfebba56b0ac132cf647e06b6a9 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 10:19:06 +0200 Subject: [PATCH 11/21] Recognize canvas elements in XHTML documents --- test/e2e/scenario/recorder/canvas.scenario.ts | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/test/e2e/scenario/recorder/canvas.scenario.ts b/test/e2e/scenario/recorder/canvas.scenario.ts index 0e7301b008..5789603e63 100644 --- a/test/e2e/scenario/recorder/canvas.scenario.ts +++ b/test/e2e/scenario/recorder/canvas.scenario.ts @@ -7,7 +7,7 @@ import { createTest, html } from '../../lib/framework' declare global { interface Window { canvasDirtyProbe: { - cleanCanvases?: WeakSet + dirtyCanvases?: Set dirtyCalls: string[] originalFillRect: CanvasRenderingContext2D['fillRect'] } @@ -17,8 +17,8 @@ declare global { const CANVAS_DIRTY_PROBE = html` @@ -67,12 +67,12 @@ test.describe('canvas recording', () => { await expect .poll(() => page.evaluate(() => { - const cleanCanvases = window.canvasDirtyProbe.cleanCanvases + const dirtyCanvases = window.canvasDirtyProbe.dirtyCanvases return { dirtyCalls: [...window.canvasDirtyProbe.dirtyCalls].sort(), - cleanStates: cleanCanvases + dirtyStates: dirtyCanvases ? ['drawn', 'resized', 'unchanged'].map((id) => - cleanCanvases.has(document.querySelector(`#${id}`)!) + dirtyCanvases.has(document.querySelector(`#${id}`)!) ) : undefined, } @@ -80,15 +80,15 @@ test.describe('canvas recording', () => { ) .toEqual({ dirtyCalls: ['drawn', 'resized', 'unchanged'], - cleanStates: [false, false, false], + dirtyStates: [true, true, true], }) - // Establish the post-capture clean state, then exercise the real canvas drawing and DOM APIs. + // Establish a clean state, then exercise the real canvas drawing and DOM APIs. await page.evaluate(() => { const probe = window.canvasDirtyProbe const canvases = document.querySelectorAll('canvas') - canvases.forEach((canvas) => probe.cleanCanvases!.add(canvas)) + canvases.forEach((canvas) => probe.dirtyCanvases!.delete(canvas)) probe.dirtyCalls.length = 0 document.querySelector('#drawn')!.getContext('2d')!.fillRect(0, 0, 10, 10) @@ -103,11 +103,33 @@ test.describe('canvas recording', () => { expect( await page.evaluate(() => { - const cleanCanvases = window.canvasDirtyProbe.cleanCanvases! + const dirtyCanvases = window.canvasDirtyProbe.dirtyCanvases! return ['drawn', 'resized', 'unchanged'].map((id) => - cleanCanvases.has(document.querySelector(`#${id}`)!) + dirtyCanvases.has(document.querySelector(`#${id}`)!) ) }) - ).toEqual([false, false, true]) + ).toEqual([true, true, false]) + }) + + createTest('seeds canvases rendered before recording starts') + .withHead(CANVAS_DIRTY_PROBE) + .withBody(html` + + + `) + .withRum({ + enableExperimentalFeatures: ['session_replay_record_canvas'], + sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 }, + startSessionReplayRecordingManually: true, + }) + .run(async ({ page }) => { + await page.evaluate(() => window.DD_RUM!.startSessionReplayRecording()) + + await expect + .poll(() => page.evaluate(() => window.canvasDirtyProbe.dirtyCalls)) + .toContain('pre-rendered') }) }) From 5b3a6adb9b6a47c1100ae2736569beedcd70137f Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 10:19:13 +0200 Subject: [PATCH 12/21] Recognize canvas elements in XHTML documents --- packages/browser-rum/src/domain/record/trackers/trackCanvas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts index 64c3c07151..4fca9cbe40 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -92,7 +92,7 @@ export function markCanvasAndDescendantsDirty(node: Node, canvasManager: CanvasM } function isCanvasElement(node: Node): node is HTMLCanvasElement { - return isElementNode(node) && node.tagName === 'CANVAS' + return isElementNode(node) && node.tagName.toLowerCase() === 'canvas' } function isCanvasSizeAttribute(attributeName: string): boolean { From bb0700ad6186d12877f41e8eefaeea0555be86d1 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 10:25:11 +0200 Subject: [PATCH 13/21] format --- test/e2e/scenario/recorder/canvas.scenario.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/e2e/scenario/recorder/canvas.scenario.ts b/test/e2e/scenario/recorder/canvas.scenario.ts index 5789603e63..599e861076 100644 --- a/test/e2e/scenario/recorder/canvas.scenario.ts +++ b/test/e2e/scenario/recorder/canvas.scenario.ts @@ -128,8 +128,6 @@ test.describe('canvas recording', () => { .run(async ({ page }) => { await page.evaluate(() => window.DD_RUM!.startSessionReplayRecording()) - await expect - .poll(() => page.evaluate(() => window.canvasDirtyProbe.dirtyCalls)) - .toContain('pre-rendered') + await expect.poll(() => page.evaluate(() => window.canvasDirtyProbe.dirtyCalls)).toContain('pre-rendered') }) }) From 4c657280f82890f2681eaf40bfbdb74676797785 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 11:18:29 +0200 Subject: [PATCH 14/21] fix nits --- .../browser-rum-core/src/browser/domMutationObservable.ts | 1 - .../src/domain/record/canvas/canvasManager.spec.ts | 2 +- .../browser-rum/src/domain/record/trackers/trackCanvas.ts | 6 ------ 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/browser-rum-core/src/browser/domMutationObservable.ts b/packages/browser-rum-core/src/browser/domMutationObservable.ts index 0ab6502104..770f5610f1 100644 --- a/packages/browser-rum-core/src/browser/domMutationObservable.ts +++ b/packages/browser-rum-core/src/browser/domMutationObservable.ts @@ -12,7 +12,6 @@ export interface RumAttributesMutationRecord { target: Element oldValue: string | null attributeName: string - // Distinguishes canvas content attributes from namespaced attributes with the same local name. attributeNamespace?: string | null } diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts index 8c1546ed20..2d49271abd 100644 --- a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts @@ -1,7 +1,7 @@ import { registerCleanupTask } from '@datadog/browser-core/test' import { createCanvasManager } from './canvasManager' -describe('createCanvasManager', () => { +describe('CanvasManager', () => { it('tracks whether a canvas is dirty', () => { const canvasManager = createCanvasManager() const canvas = appendCanvas() diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts index 4fca9cbe40..c85f41ae16 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -75,12 +75,6 @@ export function markCanvasDirtyFromMutationRecords( ) { canvasManager.markCanvasDirty(mutation.target) } - - if (mutation.type === 'childList') { - for (let index = 0; index < mutation.addedNodes.length; index += 1) { - markCanvasAndDescendantsDirty(mutation.addedNodes[index], canvasManager) - } - } } } From 733632251b2272ca51c550859a3da8c10f4e3a0e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 15:23:45 +0200 Subject: [PATCH 15/21] refactor: move canvas detection to serialization files --- .../src/browser/domMutationObservable.ts | 2 +- .../src/domain/record/canvas/canvasUtils.ts | 31 ++++ .../browser-rum/src/domain/record/record.ts | 18 +-- .../src/domain/record/recordingScope.ts | 4 +- .../serialization/serializeMutations.ts | 2 + .../record/serialization/serializeNode.ts | 5 + .../record/test/recordingScope.specHelper.ts | 4 +- .../src/domain/record/trackers/index.ts | 2 +- .../record/trackers/trackCanvas.spec.ts | 13 +- .../src/domain/record/trackers/trackCanvas.ts | 57 ++----- .../record/trackers/trackMutation.spec.ts | 148 +----------------- .../domain/record/trackers/trackMutation.ts | 20 +-- 12 files changed, 75 insertions(+), 231 deletions(-) create mode 100644 packages/browser-rum/src/domain/record/canvas/canvasUtils.ts diff --git a/packages/browser-rum-core/src/browser/domMutationObservable.ts b/packages/browser-rum-core/src/browser/domMutationObservable.ts index 770f5610f1..e91ae395e3 100644 --- a/packages/browser-rum-core/src/browser/domMutationObservable.ts +++ b/packages/browser-rum-core/src/browser/domMutationObservable.ts @@ -12,7 +12,7 @@ export interface RumAttributesMutationRecord { target: Element oldValue: string | null attributeName: string - attributeNamespace?: string | null + attributeNamespace: string | null } export interface RumChildListMutationRecord { diff --git a/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts b/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts new file mode 100644 index 0000000000..aa33fa3145 --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts @@ -0,0 +1,31 @@ +import type { RumMutationRecord } from '@datadog/browser-rum-core' +import { isElementNode } from '@datadog/browser-rum-core' +import type { CanvasManager } from './canvasManager' + +const CANVAS_SIZE_ATTRIBUTES = ['width', 'height'] + +/** + * Canvas dimensions can be changed through several APIs, including the `width` and `height` + * properties and their corresponding attributes. The mutation observer consolidates these paths + * into attribute mutation records so that the canvas can be marked dirty consistently. + */ +export function markCanvasDirtyFromMutationRecords(mutations: RumMutationRecord[], canvasManager: CanvasManager): void { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + isCanvasElement(mutation.target) && + !mutation.attributeNamespace && + isCanvasSizeAttribute(mutation.attributeName) + ) { + canvasManager.markCanvasDirty(mutation.target) + } + } +} + +export function isCanvasElement(node: Node): node is HTMLCanvasElement { + return isElementNode(node) && node.tagName.toLowerCase() === 'canvas' +} + +function isCanvasSizeAttribute(attributeName: string): boolean { + return CANVAS_SIZE_ATTRIBUTES.includes(attributeName.toLowerCase()) +} diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index 1f556cd05d..5218a44d70 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -15,8 +15,7 @@ import { trackViewEnd, trackViewportResize, trackVisualViewportResize, - trackCanvas2DMutations, - markCanvasAndDescendantsDirty, + trackCanvasContent, } from './trackers' import { createElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' @@ -54,11 +53,7 @@ export function record(options: RecordOptions): RecordAPI { replayStats.addRecord(view.id) } - const canvasManager = - configuration.sessionReplayCanvasRecording?.enable && - configuration.sessionReplayCanvasRecording.maxFramesPerSecond > 0 - ? createCanvasManager() - : undefined + const canvasManager = createCanvasManager() const shadowRootsController = initShadowRootsController(processRecord, emitStats) const scope = createRecordingScope( configuration, @@ -69,11 +64,6 @@ export function record(options: RecordOptions): RecordAPI { const { stop: stopFullSnapshots } = startFullSnapshots(lifeCycle, processRecord, emitStats, flushMutations, scope) - // Seed all connected canvases after the initial full snapshot. - if (canvasManager) { - markCanvasAndDescendantsDirty(document, canvasManager) - } - function flushMutations() { shadowRootsController.flush() mutationTracker.flush() @@ -94,9 +84,7 @@ export function record(options: RecordOptions): RecordAPI { trackViewEnd(lifeCycle, processRecord, flushMutations), ] - if (canvasManager) { - trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty)) - } + trackers.push(trackCanvasContent(scope)) return { stop: () => { diff --git a/packages/browser-rum/src/domain/record/recordingScope.ts b/packages/browser-rum/src/domain/record/recordingScope.ts index 9993f7611f..a8253b3021 100644 --- a/packages/browser-rum/src/domain/record/recordingScope.ts +++ b/packages/browser-rum/src/domain/record/recordingScope.ts @@ -15,6 +15,7 @@ import type { CanvasManager } from './canvas/canvasManager' export interface RecordingScope { resetIds(): void + canvasManager: CanvasManager configuration: RumConfiguration elementsScrollPositions: ElementsScrollPositions eventIds: EventIds @@ -22,14 +23,13 @@ export interface RecordingScope { shadowRootsController: ShadowRootsController stringIds: StringIds styleSheetIds: StyleSheetIds - canvasManager: CanvasManager | undefined } export function createRecordingScope( configuration: RumConfiguration, elementsScrollPositions: ElementsScrollPositions, shadowRootsController: ShadowRootsController, - canvasManager?: CanvasManager + canvasManager: CanvasManager ): RecordingScope { const eventIds = createEventIds() const nodeIds = createNodeIds() diff --git a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts index d430e14c33..c4ecfd5f91 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts @@ -12,6 +12,7 @@ import type { AttributeChange } from '../../../types' import type { RecordingScope } from '../recordingScope' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import type { NodeId, NodeIds } from '../itemIds' +import { markCanvasDirtyFromMutationRecords } from '../canvas/canvasUtils' import type { SerializationTransaction } from './serializationTransaction' import { SerializationKind, serializeInTransaction } from './serializationTransaction' import { serializeNode } from './serializeNode' @@ -26,6 +27,7 @@ export function serializeMutations( emitStats: EmitStatsCallback, scope: RecordingScope ): void { + markCanvasDirtyFromMutationRecords(mutations, scope.canvasManager) serializeInTransaction( SerializationKind.INCREMENTAL_SNAPSHOT, emitRecord, diff --git a/packages/browser-rum/src/domain/record/serialization/serializeNode.ts b/packages/browser-rum/src/domain/record/serialization/serializeNode.ts index 66cd43c4df..c0d91c82bf 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeNode.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeNode.ts @@ -13,6 +13,7 @@ import { } from '@datadog/browser-rum-core' import { MediaInteractionType } from '../../../types' import type { NodeId, StyleSheetId } from '../itemIds' +import { isCanvasElement } from '../canvas/canvasUtils' import type { InsertionCursor } from './insertionCursor' import type { SerializationTransaction } from './serializationTransaction' import { serializeDOMAttributes, serializeVirtualAttributes } from './serializeAttributes' @@ -138,6 +139,10 @@ function serializeElementNode( const domAttributes = Object.entries(serializeDOMAttributes(element, privacyLevel, transaction)) transaction.addNode(insertionPoint, encodedElementName(element), ...domAttributes) + if (isCanvasElement(element)) { + transaction.scope.canvasManager.markCanvasDirty(element) + } + const { _cssText: cssText, rr_mediaState: mediaState, diff --git a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts index 3131bbc2ef..dd3d24529a 100644 --- a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts +++ b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts @@ -5,6 +5,7 @@ import type { RecordingScope } from '../recordingScope' import { createRecordingScope } from '../recordingScope' import type { CanvasManager } from '../canvas/canvasManager' import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowRootsController' +import { createCanvasManager } from '../canvas/canvasManager' import { DEFAULT_CONFIGURATION } from './rumConfiguration.specHelper' import { DEFAULT_SHADOW_ROOT_CONTROLLER } from './shadowRootsController.specHelper' @@ -12,7 +13,6 @@ export function createRecordingScopeForTesting({ configuration, addShadowRoot, removeShadowRoot, - canvasManager, }: { configuration?: Partial addShadowRoot?: AddShadowRootCallBack @@ -30,6 +30,6 @@ export function createRecordingScopeForTesting({ addShadowRoot: addShadowRoot || noop, removeShadowRoot: removeShadowRoot || noop, }, - canvasManager + createCanvasManager() ) } diff --git a/packages/browser-rum/src/domain/record/trackers/index.ts b/packages/browser-rum/src/domain/record/trackers/index.ts index d22405f139..06d531d6e8 100644 --- a/packages/browser-rum/src/domain/record/trackers/index.ts +++ b/packages/browser-rum/src/domain/record/trackers/index.ts @@ -8,5 +8,5 @@ export { trackFocus } from './trackFocus' export { trackViewEnd } from './trackViewEnd' export { trackInput } from './trackInput' export { trackMutation } from './trackMutation' -export { markCanvasAndDescendantsDirty, trackCanvas2DMutations } from './trackCanvas' +export { trackCanvasContent } from './trackCanvas' export type { Tracker } from './tracker.types' diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts index 27264cdab4..ece5f5378a 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -1,7 +1,8 @@ import { registerCleanupTask } from '@datadog/browser-core/test' import { createCanvasManager } from '../canvas/canvasManager' +import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' import type { Tracker } from './tracker.types' -import { markCanvasAndDescendantsDirty, trackCanvas2DMutations } from './trackCanvas' +import { trackCanvasContent } from './trackCanvas' describe('trackCanvas2DMutations', () => { let canvas: HTMLCanvasElement @@ -18,7 +19,7 @@ describe('trackCanvas2DMutations', () => { }) it('marks the canvas dirty after drawing operations', () => { - tracker = trackCanvas2DMutations(markCanvasDirtySpy) + tracker = trackCanvasContent(createRecordingScopeForTesting()) const imageData = context.createImageData(1, 1) const drawingOperations: Array<{ method: string; draw: () => void }> = [ { method: 'clearRect', draw: () => context.clearRect(0, 0, 1, 1) }, @@ -48,7 +49,7 @@ describe('trackCanvas2DMutations', () => { }) it('does not mark the canvas dirty for non-drawing operations', () => { - tracker = trackCanvas2DMutations(markCanvasDirtySpy) + tracker = trackCanvasContent(createRecordingScopeForTesting()) context.beginPath() context.moveTo(0, 0) @@ -58,14 +59,14 @@ describe('trackCanvas2DMutations', () => { }) it('does not mark the canvas dirty when a drawing operation throws', () => { - tracker = trackCanvas2DMutations(markCanvasDirtySpy) + tracker = trackCanvasContent(createRecordingScopeForTesting()) expect(() => context.putImageData(null as unknown as ImageData, 0, 0)).toThrow() expect(markCanvasDirtySpy).not.toHaveBeenCalled() }) it('stops tracking canvas mutations', () => { - tracker = trackCanvas2DMutations(markCanvasDirtySpy) + tracker = trackCanvasContent(createRecordingScopeForTesting()) tracker.stop() context.fillRect(0, 0, 1, 1) @@ -84,7 +85,7 @@ describe('markCanvasAndDescendantsDirty', () => { document.body.appendChild(container) registerCleanupTask(() => container.remove()) - markCanvasAndDescendantsDirty(container, canvasManager) + trackCanvasContent(createRecordingScopeForTesting({ canvasManager })) expect(canvasManager.getDirtyCanvases()).toEqual([canvas, nestedCanvas]) }) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts index c85f41ae16..4b0afc8555 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts @@ -1,7 +1,5 @@ -import { instrumentMethod } from '@datadog/browser-core' -import type { RumMutationRecord } from '@datadog/browser-rum-core' -import { forEachChildNodes, isElementNode } from '@datadog/browser-rum-core' -import type { CanvasManager } from '../canvas/canvasManager' +import { instrumentMethod, noop } from '@datadog/browser-core' +import type { RecordingScope } from '../recordingScope' import type { Tracker } from './tracker.types' export type MarkCanvasDirty = (canvas: HTMLCanvasElement) => void @@ -33,11 +31,18 @@ const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [ 'reset', ] -const CANVAS_SIZE_ATTRIBUTES = ['width', 'height'] +export function trackCanvasContent(scope: RecordingScope): Tracker { + if ( + !scope.configuration.sessionReplayCanvasRecording?.enable || + scope.configuration.sessionReplayCanvasRecording.maxFramesPerSecond === 0 + ) { + return { stop: noop } + } -export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracker { const instrumentationStoppers: Tracker[] = [] + const { markCanvasDirty } = scope.canvasManager + if (typeof CanvasRenderingContext2D !== 'undefined') { CANVAS_2D_DRAWING_METHODS.forEach((method) => { instrumentationStoppers.push( @@ -52,43 +57,3 @@ export function trackCanvas2DMutations(markCanvasDirty: MarkCanvasDirty): Tracke stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()), } } - -/** - * Canvas dimensions can be changed through several APIs, including the `width` and `height` - * properties and their corresponding attributes. The mutation observer consolidates these paths - * into attribute mutation records so that the canvas can be marked dirty consistently. - */ -export function markCanvasDirtyFromMutationRecords( - mutations: RumMutationRecord[], - canvasManager: CanvasManager | undefined -): void { - if (!canvasManager) { - return - } - - for (const mutation of mutations) { - if ( - mutation.type === 'attributes' && - isCanvasElement(mutation.target) && - (mutation.attributeNamespace === null || mutation.attributeNamespace === undefined) && - isCanvasSizeAttribute(mutation.attributeName) - ) { - canvasManager.markCanvasDirty(mutation.target) - } - } -} - -export function markCanvasAndDescendantsDirty(node: Node, canvasManager: CanvasManager): void { - if (isCanvasElement(node)) { - canvasManager.markCanvasDirty(node) - } - forEachChildNodes(node, (childNode) => markCanvasAndDescendantsDirty(childNode, canvasManager)) -} - -function isCanvasElement(node: Node): node is HTMLCanvasElement { - return isElementNode(node) && node.tagName.toLowerCase() === 'canvas' -} - -function isCanvasSizeAttribute(attributeName: string): boolean { - return CANVAS_SIZE_ATTRIBUTES.includes(attributeName.toLowerCase()) -} diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts index f2ff2d2e35..0184f10218 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts @@ -1,5 +1,5 @@ -import { DefaultPrivacyLevel, noop } from '@datadog/browser-core' -import { registerCleanupTask, waitNextMicrotask } from '@datadog/browser-core/test' +import { DefaultPrivacyLevel } from '@datadog/browser-core' +import { registerCleanupTask } from '@datadog/browser-core/test' import type { RumConfiguration } from '@datadog/browser-rum-core' import { PRIVACY_ATTR_NAME, @@ -15,7 +15,6 @@ import type { ChangeDecoder, SerializationStats } from '../serialization' import { aggregateSerializationStats, createSerializationStats } from '../serialization' import { serializeHtml } from '../test/serializeHtml.specHelper' import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' -import { createCanvasManager } from '../canvas/canvasManager' import { trackMutation } from './trackMutation' describe('trackMutation', () => { @@ -551,149 +550,6 @@ describe('trackMutation', () => { }) }) - describe('canvas mutations', () => { - const canvasSizeMutations: Array<{ - name: string - mutate: (canvas: HTMLCanvasElement) => void - }> = [ - { name: 'the width property', mutate: (canvas) => (canvas.width = 101) }, - { name: 'setAttribute', mutate: (canvas) => canvas.setAttribute('width', '101') }, - { name: 'removeAttribute', mutate: (canvas) => canvas.removeAttribute('width') }, - { name: 'toggleAttribute', mutate: (canvas) => canvas.toggleAttribute('width') }, - { name: 'setAttributeNS', mutate: (canvas) => canvas.setAttributeNS(null, 'width', '101') }, - { name: 'removeAttributeNS', mutate: (canvas) => canvas.removeAttributeNS(null, 'width') }, - { - name: 'Attr.value', - mutate: (canvas) => { - canvas.attributes.getNamedItem('width')!.value = '101' - }, - }, - { - name: 'NamedNodeMap.setNamedItem', - mutate: (canvas) => { - const attribute = canvas.ownerDocument.createAttribute('width') - attribute.value = '101' - canvas.attributes.setNamedItem(attribute) - }, - }, - ] - - canvasSizeMutations.forEach(({ name, mutate }) => { - it(`marks a canvas dirty when its size is changed through ${name}`, async () => { - const canvasManager = createCanvasManager() - const scope = createRecordingScopeForTesting({ canvasManager }) - let canvas!: HTMLCanvasElement - - await recordMutationOf( - '
', - (sandbox) => { - canvas = sandbox.querySelector('canvas')! - canvasManager.markCanvasClean(canvas) - mutate(canvas) - }, - { scope } - ) - - expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() - }) - }) - - it('does not mark a canvas dirty for unrelated attributes', async () => { - const canvasManager = createCanvasManager() - const scope = createRecordingScopeForTesting({ canvasManager }) - let canvas!: HTMLCanvasElement - - await recordMutationOf( - '
', - (sandbox) => { - canvas = sandbox.querySelector('canvas')! - canvasManager.markCanvasClean(canvas) - canvas.setAttribute('class', 'foo') - }, - { scope } - ) - - expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() - }) - - it('does not mark a canvas dirty for namespaced size attributes', async () => { - const canvasManager = createCanvasManager() - const scope = createRecordingScopeForTesting({ canvasManager }) - let canvas!: HTMLCanvasElement - - await recordMutationOf( - '
', - (sandbox) => { - canvas = sandbox.querySelector('canvas')! - canvasManager.markCanvasClean(canvas) - canvas.setAttributeNS('urn:example', 'example:width', '101') - }, - { scope } - ) - - expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() - }) - - it('marks a canvas dirty when it is reinserted', async () => { - const canvasManager = createCanvasManager() - const scope = createRecordingScopeForTesting({ canvasManager }) - let canvas!: HTMLCanvasElement - - await recordMutationOf( - '
', - (sandbox) => { - canvas = sandbox.querySelector('canvas')! - canvasManager.markCanvasClean(canvas) - canvas.remove() - sandbox.appendChild(canvas) - }, - { scope } - ) - - expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() - }) - - it('marks canvases in an inserted subtree dirty', async () => { - const canvasManager = createCanvasManager() - const scope = createRecordingScopeForTesting({ canvasManager }) - let canvas!: HTMLCanvasElement - - await recordMutationOf( - '
', - (sandbox) => { - const container = sandbox.ownerDocument.createElement('div') - canvas = sandbox.ownerDocument.createElement('canvas') - container.appendChild(canvas) - canvasManager.markCanvasClean(canvas) - sandbox.appendChild(container) - }, - { scope } - ) - - expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() - }) - - it('marks a canvas dirty when its size changes in a shadow root', async () => { - const host = document.createElement('div') - const shadowRoot = host.attachShadow({ mode: 'open' }) - const canvas = document.createElement('canvas') - canvas.setAttribute('width', '100') - shadowRoot.appendChild(canvas) - document.body.appendChild(host) - registerCleanupTask(() => host.remove()) - - const canvasManager = createCanvasManager() - canvasManager.markCanvasClean(canvas) - const mutationTracker = trackMutation(shadowRoot, noop, noop, createRecordingScopeForTesting({ canvasManager })) - registerCleanupTask(() => mutationTracker.stop()) - - canvas.toggleAttribute('width') - await waitNextMicrotask() - - expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() - }) - }) - describe('ignored nodes', () => { it('skips ignored nodes when serializing', async () => { const { mutation } = await recordMutationOf('
', (sandbox: HTMLElement): void => { diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.ts index 57900cb294..1cb11d3c44 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.ts @@ -7,7 +7,6 @@ import { createMutationBatch } from '../mutationBatch' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import { serializeMutations } from '../serialization' import type { Tracker } from './tracker.types' -import { markCanvasDirtyFromMutationRecords } from './trackCanvas' export type MutationTracker = Tracker & { flush: () => void } @@ -26,19 +25,16 @@ export function trackMutation( } const mutationBatch = createMutationBatch((mutations) => { - // Process mutations still queued in the observer so canvas changes are tracked before flushing. - const queuedMutations = observer.takeRecords() as RumMutationRecord[] - markCanvasDirtyFromMutationRecords(queuedMutations, scope.canvasManager) - serializeMutations(timeStampNow(), mutations.concat(queuedMutations), emitRecord, emitStats, scope) + serializeMutations( + timeStampNow(), + mutations.concat(observer.takeRecords() as RumMutationRecord[]), + emitRecord, + emitStats, + scope + ) }) - const observer = new MutationObserver( - monitor((mutations) => { - // Keep the canvas manager in sync with DOM mutations so unchanged canvases can be skipped. - markCanvasDirtyFromMutationRecords(mutations, scope.canvasManager) - mutationBatch.addMutations(mutations) - }) - ) + const observer = new MutationObserver(monitor(mutationBatch.addMutations)) observer.observe(target, { attributeOldValue: true, From fe5e50cbc94d2c3def86ba6790d5b14740d12ac0 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 18 Aug 2026 17:27:42 +0200 Subject: [PATCH 16/21] test fixes --- .../domain/record/canvas/canvasUtils.spec.ts | 58 ++++++++ .../src/domain/record/internalApi.ts | 4 +- .../serialization/serializeNode.node.spec.ts | 14 ++ .../record/test/recordingScope.specHelper.ts | 3 +- .../record/trackers/trackCanvas.spec.ts | 50 ++++--- .../record/trackers/trackMutation.spec.ts | 19 +++ test/e2e/scenario/recorder/canvas.scenario.ts | 133 ------------------ 7 files changed, 127 insertions(+), 154 deletions(-) create mode 100644 packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts delete mode 100644 test/e2e/scenario/recorder/canvas.scenario.ts diff --git a/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts new file mode 100644 index 0000000000..9b93a20375 --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts @@ -0,0 +1,58 @@ +import type { RumMutationRecord } from '@datadog/browser-rum-core' +import { registerCleanupTask } from '@datadog/browser-core/test' +import { createCanvasManager } from './canvasManager' +import { isCanvasElement, markCanvasDirtyFromMutationRecords } from './canvasUtils' + +describe('canvasUtils', () => { + function createAttributeMutation( + target: Element, + attributeName: string, + attributeNamespace: string | null = null + ): RumMutationRecord { + return { type: 'attributes', target, attributeName, attributeNamespace, oldValue: null } + } + + function appendCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas') + document.body.appendChild(canvas) + registerCleanupTask(() => canvas.remove()) + return canvas + } + + it('marks canvases dirty for width and height attribute mutations', () => { + const canvas = appendCanvas() + const canvasManager = createCanvasManager() + + markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'width')], canvasManager) + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + + canvasManager.markCanvasClean(canvas) + markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'height')], canvasManager) + + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) + + it('does not mark canvases dirty for unrelated attributes', () => { + const canvas = appendCanvas() + const canvasManager = createCanvasManager() + + markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'class')], canvasManager) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) + + it('does not mark canvases dirty for namespaced size attributes', () => { + const canvas = appendCanvas() + const canvasManager = createCanvasManager() + + markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'width', 'urn:example')], canvasManager) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) + + it('identifies only canvas elements', () => { + expect(isCanvasElement(document.createElement('canvas'))).toBeTrue() + expect(isCanvasElement(document.createElement('div'))).toBeFalse() + expect(isCanvasElement(document.createTextNode('canvas'))).toBeFalse() + }) +}) diff --git a/packages/browser-rum/src/domain/record/internalApi.ts b/packages/browser-rum/src/domain/record/internalApi.ts index a62bdde92e..6cca5b2162 100644 --- a/packages/browser-rum/src/domain/record/internalApi.ts +++ b/packages/browser-rum/src/domain/record/internalApi.ts @@ -10,6 +10,7 @@ import { createElementsScrollPositions } from './elementsScrollPositions' import type { EmitRecordCallback } from './record.types' import type { SerializationTransaction } from './serialization' import { createRootInsertionCursor, SerializationKind, serializeInTransaction, serializeNode } from './serialization' +import { createCanvasManager } from './canvas/canvasManager' /** * Take a full snapshot of the document, generating the same records that the browser SDK @@ -84,6 +85,7 @@ function createTemporaryRecordingScope(configuration?: Partial removeShadowRoot: noop, flush: noop, stop: noop, - } + }, + createCanvasManager() ) } diff --git a/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts b/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts index 009aa497ea..650abf616c 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts @@ -5,6 +5,7 @@ import { ChangeType, PlaybackState } from '../../../types' import type { RecordingScope } from '../recordingScope' import type { ScrollPositions } from '../elementsScrollPositions' import { serializeHtml } from '../test/serializeHtml.specHelper' +import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' import { SerializationKind } from './serializationTransaction' describe('serializeNode for DOM nodes', () => { @@ -118,6 +119,19 @@ describe('serializeNode for DOM nodes', () => { ], ]) }) + + it('marks nested canvases dirty when their subtree is serialized', async () => { + const scope = createRecordingScopeForTesting() + + await serializeHtml('
', { + scope, + after: (target) => { + expect(scope.canvasManager.getDirtyCanvases()).toEqual( + Array.from((target as Element).querySelectorAll('canvas')) + ) + }, + }) + }) }) describe('for SVG elements', () => { diff --git a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts index dd3d24529a..e0a9b6ba9c 100644 --- a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts +++ b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts @@ -13,6 +13,7 @@ export function createRecordingScopeForTesting({ configuration, addShadowRoot, removeShadowRoot, + canvasManager = createCanvasManager(), }: { configuration?: Partial addShadowRoot?: AddShadowRootCallBack @@ -30,6 +31,6 @@ export function createRecordingScopeForTesting({ addShadowRoot: addShadowRoot || noop, removeShadowRoot: removeShadowRoot || noop, }, - createCanvasManager() + canvasManager ) } diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts index ece5f5378a..88221b0da3 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts @@ -1,25 +1,38 @@ import { registerCleanupTask } from '@datadog/browser-core/test' +import type { CanvasManager } from '../canvas/canvasManager' import { createCanvasManager } from '../canvas/canvasManager' import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' import type { Tracker } from './tracker.types' import { trackCanvasContent } from './trackCanvas' -describe('trackCanvas2DMutations', () => { +describe('trackCanvasContent', () => { let canvas: HTMLCanvasElement let context: CanvasRenderingContext2D let markCanvasDirtySpy: jasmine.Spy<(canvas: HTMLCanvasElement) => void> + let canvasManager: CanvasManager let tracker: Tracker | undefined beforeEach(() => { canvas = document.createElement('canvas') context = canvas.getContext('2d')! markCanvasDirtySpy = jasmine.createSpy() + canvasManager = { ...createCanvasManager(), markCanvasDirty: markCanvasDirtySpy } registerCleanupTask(() => tracker?.stop()) }) + function startTracking(enable = true, maxFramesPerSecond = 1): Tracker { + tracker = trackCanvasContent( + createRecordingScopeForTesting({ + canvasManager, + configuration: { sessionReplayCanvasRecording: { enable, maxFramesPerSecond } }, + }) + ) + return tracker + } + it('marks the canvas dirty after drawing operations', () => { - tracker = trackCanvasContent(createRecordingScopeForTesting()) + startTracking() const imageData = context.createImageData(1, 1) const drawingOperations: Array<{ method: string; draw: () => void }> = [ { method: 'clearRect', draw: () => context.clearRect(0, 0, 1, 1) }, @@ -49,7 +62,7 @@ describe('trackCanvas2DMutations', () => { }) it('does not mark the canvas dirty for non-drawing operations', () => { - tracker = trackCanvasContent(createRecordingScopeForTesting()) + startTracking() context.beginPath() context.moveTo(0, 0) @@ -59,34 +72,33 @@ describe('trackCanvas2DMutations', () => { }) it('does not mark the canvas dirty when a drawing operation throws', () => { - tracker = trackCanvasContent(createRecordingScopeForTesting()) + startTracking() expect(() => context.putImageData(null as unknown as ImageData, 0, 0)).toThrow() expect(markCanvasDirtySpy).not.toHaveBeenCalled() }) - it('stops tracking canvas mutations', () => { - tracker = trackCanvasContent(createRecordingScopeForTesting()) - tracker.stop() + it('restores the original behavior when stopped', () => { + startTracking().stop() context.fillRect(0, 0, 1, 1) expect(markCanvasDirtySpy).not.toHaveBeenCalled() }) -}) -describe('markCanvasAndDescendantsDirty', () => { - it('marks connected canvases in a subtree dirty', () => { - const canvasManager = createCanvasManager() - const container = document.createElement('div') - const canvas = document.createElement('canvas') - const nestedCanvas = document.createElement('canvas') - container.append(canvas, nestedCanvas) - document.body.appendChild(container) - registerCleanupTask(() => container.remove()) + it('does not track canvas content when canvas recording is disabled', () => { + startTracking(false) - trackCanvasContent(createRecordingScopeForTesting({ canvasManager })) + context.fillRect(0, 0, 1, 1) - expect(canvasManager.getDirtyCanvases()).toEqual([canvas, nestedCanvas]) + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('does not track canvas content when the maximum frame rate is zero', () => { + startTracking(true, 0) + + context.fillRect(0, 0, 1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() }) }) diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts index 0184f10218..68dcdb24b1 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts @@ -10,6 +10,7 @@ import { import type { BrowserChangeRecord, BrowserFullSnapshotChangeRecord, BrowserRecord } from '../../../types' import { ChangeType } from '../../../types' import type { RecordingScope } from '../recordingScope' +import { createCanvasManager } from '../canvas/canvasManager' import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowRootsController' import type { ChangeDecoder, SerializationStats } from '../serialization' import { aggregateSerializationStats, createSerializationStats } from '../serialization' @@ -548,6 +549,24 @@ describe('trackMutation', () => { ) expect(mutation?.data).toEqual([[ChangeType.Attribute, [0, ['data-foo', '***']]]]) }) + + it('marks a canvas dirty when a size attribute changes', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '', + (sandbox) => { + canvas = sandbox as HTMLCanvasElement + canvasManager.markCanvasClean(canvas) + canvas.setAttribute('width', '101') + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) }) describe('ignored nodes', () => { diff --git a/test/e2e/scenario/recorder/canvas.scenario.ts b/test/e2e/scenario/recorder/canvas.scenario.ts deleted file mode 100644 index 599e861076..0000000000 --- a/test/e2e/scenario/recorder/canvas.scenario.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { test, expect } from '@playwright/test' -import { createTest, html } from '../../lib/framework' - -// Since there is no requests being send we just mock by using a probe. -// This will be removed in future PRs. - -declare global { - interface Window { - canvasDirtyProbe: { - dirtyCanvases?: Set - dirtyCalls: string[] - originalFillRect: CanvasRenderingContext2D['fillRect'] - } - } -} - -const CANVAS_DIRTY_PROBE = html` - -` - -test.describe('canvas recording', () => { - createTest('tracks dirty canvases') - .withHead(CANVAS_DIRTY_PROBE) - .withRum({ - enableExperimentalFeatures: ['session_replay_record_canvas'], - sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 }, - }) - .run(async ({ page }) => { - // Wait until the recorder instruments the 2D drawing API. - await expect - .poll(() => - page.evaluate(() => CanvasRenderingContext2D.prototype.fillRect !== window.canvasDirtyProbe.originalFillRect) - ) - .toBe(true) - - // Newly inserted canvases are dirty and must be considered for capture. - await page.evaluate(() => { - for (const id of ['drawn', 'resized', 'unchanged']) { - const canvas = document.createElement('canvas') - canvas.id = id - canvas.width = 100 - canvas.height = 100 - document.body.appendChild(canvas) - } - }) - - await expect - .poll(() => - page.evaluate(() => { - const dirtyCanvases = window.canvasDirtyProbe.dirtyCanvases - return { - dirtyCalls: [...window.canvasDirtyProbe.dirtyCalls].sort(), - dirtyStates: dirtyCanvases - ? ['drawn', 'resized', 'unchanged'].map((id) => - dirtyCanvases.has(document.querySelector(`#${id}`)!) - ) - : undefined, - } - }) - ) - .toEqual({ - dirtyCalls: ['drawn', 'resized', 'unchanged'], - dirtyStates: [true, true, true], - }) - - // Establish a clean state, then exercise the real canvas drawing and DOM APIs. - await page.evaluate(() => { - const probe = window.canvasDirtyProbe - const canvases = document.querySelectorAll('canvas') - - canvases.forEach((canvas) => probe.dirtyCanvases!.delete(canvas)) - probe.dirtyCalls.length = 0 - - document.querySelector('#drawn')!.getContext('2d')!.fillRect(0, 0, 10, 10) - document.querySelector('#resized')!.toggleAttribute('width') - document.querySelector('#unchanged')!.getContext('2d')!.beginPath() - }) - - // Drawing and resizing make canvases dirty; beginPath() does not change the canvas bitmap. - await expect - .poll(() => page.evaluate(() => [...window.canvasDirtyProbe.dirtyCalls].sort())) - .toEqual(['drawn', 'resized']) - - expect( - await page.evaluate(() => { - const dirtyCanvases = window.canvasDirtyProbe.dirtyCanvases! - return ['drawn', 'resized', 'unchanged'].map((id) => - dirtyCanvases.has(document.querySelector(`#${id}`)!) - ) - }) - ).toEqual([true, true, false]) - }) - - createTest('seeds canvases rendered before recording starts') - .withHead(CANVAS_DIRTY_PROBE) - .withBody(html` - - - `) - .withRum({ - enableExperimentalFeatures: ['session_replay_record_canvas'], - sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 }, - startSessionReplayRecordingManually: true, - }) - .run(async ({ page }) => { - await page.evaluate(() => window.DD_RUM!.startSessionReplayRecording()) - - await expect.poll(() => page.evaluate(() => window.canvasDirtyProbe.dirtyCalls)).toContain('pre-rendered') - }) -}) From feca8f2c1ec4cf0d7dbeb77ee87bbad3de11e26a Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 19 Aug 2026 09:59:01 +0200 Subject: [PATCH 17/21] Remove dirty canvases --- .../serialization/serializeMutations.ts | 6 +++++- .../record/trackers/trackMutation.spec.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts index c4ecfd5f91..774abb0605 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts @@ -12,7 +12,7 @@ import type { AttributeChange } from '../../../types' import type { RecordingScope } from '../recordingScope' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import type { NodeId, NodeIds } from '../itemIds' -import { markCanvasDirtyFromMutationRecords } from '../canvas/canvasUtils' +import { isCanvasElement, markCanvasDirtyFromMutationRecords } from '../canvas/canvasUtils' import type { SerializationTransaction } from './serializationTransaction' import { SerializationKind, serializeInTransaction } from './serializationTransaction' import { serializeNode } from './serializeNode' @@ -117,6 +117,10 @@ function processRemovedNodes(nodes: Set, transaction: SerializationTransac transaction.scope.shadowRootsController.removeShadowRoot(node.shadowRoot) } + if (isCanvasElement(node)) { + transaction.scope.canvasManager.markCanvasClean(node) + } + // Forget this node's identity. If it's added to the DOM again in another mutation, // we'll treat it as a new node. This reduces the number of edge cases that we need // to deal with; for example, changes in privacy level as a result of the different diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts index 68dcdb24b1..9a64394c1a 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts @@ -440,6 +440,25 @@ describe('trackMutation', () => { expect(removeShadowRootSpy.calls.argsFor(1)[0]).toBe(childShadowRoot) }) }) + + it('removes a canvas from dirty canvases when it is removed', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '', + (sandbox) => { + canvas = sandbox as HTMLCanvasElement + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + + canvas.remove() + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) }) describe('characterData mutations', () => { From 697ea42c985f307651e62a19f1d352b1466c49e5 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 19 Aug 2026 10:51:30 +0200 Subject: [PATCH 18/21] Clean up dirty canvases skipped by serialization --- .../serialization/serializeMutations.ts | 19 ++++++++++++------- .../record/trackers/trackMutation.spec.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts index 774abb0605..2a621cd5f3 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts @@ -108,19 +108,20 @@ function processRemovedNodes(nodes: Set, transaction: SerializationTransac for (const node of nodes) { const nodeId = nodeIds.get(node) - if (nodeId === undefined) { - continue // This node wasn't serialized. - } forNodeAndDescendants(node, (node: Node) => { - if (isNodeShadowHost(node)) { - transaction.scope.shadowRootsController.removeShadowRoot(node.shadowRoot) - } - if (isCanvasElement(node)) { transaction.scope.canvasManager.markCanvasClean(node) } + if (nodeId === undefined) { + return + } + + if (isNodeShadowHost(node)) { + transaction.scope.shadowRootsController.removeShadowRoot(node.shadowRoot) + } + // Forget this node's identity. If it's added to the DOM again in another mutation, // we'll treat it as a new node. This reduces the number of edge cases that we need // to deal with; for example, changes in privacy level as a result of the different @@ -129,6 +130,10 @@ function processRemovedNodes(nodes: Set, transaction: SerializationTransac nodeIds.delete(node) }) + if (nodeId === undefined) { + continue // This node wasn't serialized. + } + transaction.removeNode(nodeId) } } diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts index 9a64394c1a..74441244a4 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts @@ -459,6 +459,25 @@ describe('trackMutation', () => { expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() }) + + it('removes an unserialized canvas from dirty canvases when it is removed', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '
', + (sandbox) => { + canvas = sandbox.ownerDocument.createElement('canvas') + sandbox.appendChild(canvas) + canvasManager.markCanvasDirty(canvas) + canvas.remove() + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) }) describe('characterData mutations', () => { From 71b0ba17f3120c1f2f986b8aa12b876ba8e5af26 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 19 Aug 2026 11:44:34 +0200 Subject: [PATCH 19/21] Address nits --- .../src/domain/record/internalApi.ts | 4 ++-- .../browser-rum/src/domain/record/record.ts | 7 +++---- .../src/domain/record/recordingScope.ts | 4 ++-- .../record/test/recordingScope.specHelper.ts | 4 ++-- .../src/domain/record/trackers/index.ts | 2 +- ...vas.spec.ts => trackCanvasContent.spec.ts} | 2 +- .../{trackCanvas.ts => trackCanvasContent.ts} | 19 +++++++++---------- 7 files changed, 20 insertions(+), 22 deletions(-) rename packages/browser-rum/src/domain/record/trackers/{trackCanvas.spec.ts => trackCanvasContent.spec.ts} (98%) rename packages/browser-rum/src/domain/record/trackers/{trackCanvas.ts => trackCanvasContent.ts} (75%) diff --git a/packages/browser-rum/src/domain/record/internalApi.ts b/packages/browser-rum/src/domain/record/internalApi.ts index 6cca5b2162..ced9abd5bb 100644 --- a/packages/browser-rum/src/domain/record/internalApi.ts +++ b/packages/browser-rum/src/domain/record/internalApi.ts @@ -75,6 +75,7 @@ export function takeNodeSnapshot( function createTemporaryRecordingScope(configuration?: Partial): RecordingScope { return createRecordingScope( + createCanvasManager(), { defaultPrivacyLevel: NodePrivacyLevel.ALLOW, ...configuration, @@ -85,7 +86,6 @@ function createTemporaryRecordingScope(configuration?: Partial removeShadowRoot: noop, flush: noop, stop: noop, - }, - createCanvasManager() + } ) } diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index 5218a44d70..960eff80dc 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -56,10 +56,10 @@ export function record(options: RecordOptions): RecordAPI { const canvasManager = createCanvasManager() const shadowRootsController = initShadowRootsController(processRecord, emitStats) const scope = createRecordingScope( + canvasManager, configuration, createElementsScrollPositions(), - shadowRootsController, - canvasManager + shadowRootsController ) const { stop: stopFullSnapshots } = startFullSnapshots(lifeCycle, processRecord, emitStats, flushMutations, scope) @@ -82,10 +82,9 @@ export function record(options: RecordOptions): RecordAPI { trackFocus(processRecord), trackVisualViewportResize(processRecord), trackViewEnd(lifeCycle, processRecord, flushMutations), + trackCanvasContent(scope), ] - trackers.push(trackCanvasContent(scope)) - return { stop: () => { shadowRootsController.stop() diff --git a/packages/browser-rum/src/domain/record/recordingScope.ts b/packages/browser-rum/src/domain/record/recordingScope.ts index a8253b3021..4a479ec9bb 100644 --- a/packages/browser-rum/src/domain/record/recordingScope.ts +++ b/packages/browser-rum/src/domain/record/recordingScope.ts @@ -26,10 +26,10 @@ export interface RecordingScope { } export function createRecordingScope( + canvasManager: CanvasManager, configuration: RumConfiguration, elementsScrollPositions: ElementsScrollPositions, - shadowRootsController: ShadowRootsController, - canvasManager: CanvasManager + shadowRootsController: ShadowRootsController ): RecordingScope { const eventIds = createEventIds() const nodeIds = createNodeIds() diff --git a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts index e0a9b6ba9c..13e5c34974 100644 --- a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts +++ b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts @@ -21,6 +21,7 @@ export function createRecordingScopeForTesting({ canvasManager?: CanvasManager } = {}): RecordingScope { return createRecordingScope( + canvasManager, { ...DEFAULT_CONFIGURATION, ...configuration, @@ -30,7 +31,6 @@ export function createRecordingScopeForTesting({ ...DEFAULT_SHADOW_ROOT_CONTROLLER, addShadowRoot: addShadowRoot || noop, removeShadowRoot: removeShadowRoot || noop, - }, - canvasManager + } ) } diff --git a/packages/browser-rum/src/domain/record/trackers/index.ts b/packages/browser-rum/src/domain/record/trackers/index.ts index 06d531d6e8..e2e4786cd8 100644 --- a/packages/browser-rum/src/domain/record/trackers/index.ts +++ b/packages/browser-rum/src/domain/record/trackers/index.ts @@ -8,5 +8,5 @@ export { trackFocus } from './trackFocus' export { trackViewEnd } from './trackViewEnd' export { trackInput } from './trackInput' export { trackMutation } from './trackMutation' -export { trackCanvasContent } from './trackCanvas' +export { trackCanvasContent } from './trackCanvasContent' export type { Tracker } from './tracker.types' diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts similarity index 98% rename from packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts rename to packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts index 88221b0da3..d4d945a3d0 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts @@ -3,7 +3,7 @@ import type { CanvasManager } from '../canvas/canvasManager' import { createCanvasManager } from '../canvas/canvasManager' import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' import type { Tracker } from './tracker.types' -import { trackCanvasContent } from './trackCanvas' +import { trackCanvasContent } from './trackCanvasContent' describe('trackCanvasContent', () => { let canvas: HTMLCanvasElement diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts similarity index 75% rename from packages/browser-rum/src/domain/record/trackers/trackCanvas.ts rename to packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts index 4b0afc8555..e3dd1919fc 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvas.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts @@ -34,7 +34,8 @@ const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [ export function trackCanvasContent(scope: RecordingScope): Tracker { if ( !scope.configuration.sessionReplayCanvasRecording?.enable || - scope.configuration.sessionReplayCanvasRecording.maxFramesPerSecond === 0 + scope.configuration.sessionReplayCanvasRecording.maxFramesPerSecond === 0 || + typeof CanvasRenderingContext2D === 'undefined' ) { return { stop: noop } } @@ -43,15 +44,13 @@ export function trackCanvasContent(scope: RecordingScope): Tracker { const { markCanvasDirty } = scope.canvasManager - if (typeof CanvasRenderingContext2D !== 'undefined') { - CANVAS_2D_DRAWING_METHODS.forEach((method) => { - instrumentationStoppers.push( - instrumentMethod(CanvasRenderingContext2D.prototype, method, ({ target: context, onPostCall }) => { - onPostCall(() => markCanvasDirty(context.canvas)) - }) - ) - }) - } + CANVAS_2D_DRAWING_METHODS.forEach((method) => { + instrumentationStoppers.push( + instrumentMethod(CanvasRenderingContext2D.prototype, method, ({ target: context, onPostCall }) => { + onPostCall(() => markCanvasDirty(context.canvas)) + }) + ) + }) return { stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()), From e9e087d59655ebb449024caee4d57c31974236af Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 19 Aug 2026 12:01:46 +0200 Subject: [PATCH 20/21] Drop attributeName, and markCanvasDirtyFromMutationRecords. --- .../src/browser/domMutationObservable.ts | 1 - .../domain/record/canvas/canvasUtils.spec.ts | 57 +++---------------- .../src/domain/record/canvas/canvasUtils.ts | 24 +------- .../serialization/serializeMutations.ts | 18 +++--- .../trackers/trackCanvasContent.spec.ts | 24 ++++++-- .../record/trackers/trackCanvasContent.ts | 8 ++- .../record/trackers/trackMutation.spec.ts | 37 ++++++------ 7 files changed, 58 insertions(+), 111 deletions(-) diff --git a/packages/browser-rum-core/src/browser/domMutationObservable.ts b/packages/browser-rum-core/src/browser/domMutationObservable.ts index e91ae395e3..769ce0538e 100644 --- a/packages/browser-rum-core/src/browser/domMutationObservable.ts +++ b/packages/browser-rum-core/src/browser/domMutationObservable.ts @@ -12,7 +12,6 @@ export interface RumAttributesMutationRecord { target: Element oldValue: string | null attributeName: string - attributeNamespace: string | null } export interface RumChildListMutationRecord { diff --git a/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts index 9b93a20375..e3b8635ce1 100644 --- a/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts +++ b/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts @@ -1,58 +1,15 @@ -import type { RumMutationRecord } from '@datadog/browser-rum-core' -import { registerCleanupTask } from '@datadog/browser-core/test' -import { createCanvasManager } from './canvasManager' -import { isCanvasElement, markCanvasDirtyFromMutationRecords } from './canvasUtils' +import { isCanvasElement, isCanvasSizeAttribute } from './canvasUtils' describe('canvasUtils', () => { - function createAttributeMutation( - target: Element, - attributeName: string, - attributeNamespace: string | null = null - ): RumMutationRecord { - return { type: 'attributes', target, attributeName, attributeNamespace, oldValue: null } - } - - function appendCanvas(): HTMLCanvasElement { - const canvas = document.createElement('canvas') - document.body.appendChild(canvas) - registerCleanupTask(() => canvas.remove()) - return canvas - } - - it('marks canvases dirty for width and height attribute mutations', () => { - const canvas = appendCanvas() - const canvasManager = createCanvasManager() - - markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'width')], canvasManager) - expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() - - canvasManager.markCanvasClean(canvas) - markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'height')], canvasManager) - - expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() - }) - - it('does not mark canvases dirty for unrelated attributes', () => { - const canvas = appendCanvas() - const canvasManager = createCanvasManager() - - markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'class')], canvasManager) - - expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() - }) - - it('does not mark canvases dirty for namespaced size attributes', () => { - const canvas = appendCanvas() - const canvasManager = createCanvasManager() - - markCanvasDirtyFromMutationRecords([createAttributeMutation(canvas, 'width', 'urn:example')], canvasManager) - - expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() - }) - it('identifies only canvas elements', () => { expect(isCanvasElement(document.createElement('canvas'))).toBeTrue() expect(isCanvasElement(document.createElement('div'))).toBeFalse() expect(isCanvasElement(document.createTextNode('canvas'))).toBeFalse() }) + + it('identifies canvas size attributes', () => { + expect(isCanvasSizeAttribute('width')).toBeTrue() + expect(isCanvasSizeAttribute('HEIGHT')).toBeTrue() + expect(isCanvasSizeAttribute('class')).toBeFalse() + }) }) diff --git a/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts b/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts index aa33fa3145..d44b8e15d3 100644 --- a/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts +++ b/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts @@ -1,31 +1,11 @@ -import type { RumMutationRecord } from '@datadog/browser-rum-core' import { isElementNode } from '@datadog/browser-rum-core' -import type { CanvasManager } from './canvasManager' const CANVAS_SIZE_ATTRIBUTES = ['width', 'height'] -/** - * Canvas dimensions can be changed through several APIs, including the `width` and `height` - * properties and their corresponding attributes. The mutation observer consolidates these paths - * into attribute mutation records so that the canvas can be marked dirty consistently. - */ -export function markCanvasDirtyFromMutationRecords(mutations: RumMutationRecord[], canvasManager: CanvasManager): void { - for (const mutation of mutations) { - if ( - mutation.type === 'attributes' && - isCanvasElement(mutation.target) && - !mutation.attributeNamespace && - isCanvasSizeAttribute(mutation.attributeName) - ) { - canvasManager.markCanvasDirty(mutation.target) - } - } -} - export function isCanvasElement(node: Node): node is HTMLCanvasElement { - return isElementNode(node) && node.tagName.toLowerCase() === 'canvas' + return isElementNode(node) && node.tagName === 'CANVAS' } -function isCanvasSizeAttribute(attributeName: string): boolean { +export function isCanvasSizeAttribute(attributeName: string): boolean { return CANVAS_SIZE_ATTRIBUTES.includes(attributeName.toLowerCase()) } diff --git a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts index 2a621cd5f3..8f7ea2ab70 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts @@ -12,7 +12,7 @@ import type { AttributeChange } from '../../../types' import type { RecordingScope } from '../recordingScope' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import type { NodeId, NodeIds } from '../itemIds' -import { isCanvasElement, markCanvasDirtyFromMutationRecords } from '../canvas/canvasUtils' +import { isCanvasElement, isCanvasSizeAttribute } from '../canvas/canvasUtils' import type { SerializationTransaction } from './serializationTransaction' import { SerializationKind, serializeInTransaction } from './serializationTransaction' import { serializeNode } from './serializeNode' @@ -27,7 +27,6 @@ export function serializeMutations( emitStats: EmitStatsCallback, scope: RecordingScope ): void { - markCanvasDirtyFromMutationRecords(mutations, scope.canvasManager) serializeInTransaction( SerializationKind.INCREMENTAL_SNAPSHOT, emitRecord, @@ -108,16 +107,15 @@ function processRemovedNodes(nodes: Set, transaction: SerializationTransac for (const node of nodes) { const nodeId = nodeIds.get(node) + if (nodeId === undefined) { + continue // This node wasn't serialized. + } forNodeAndDescendants(node, (node: Node) => { if (isCanvasElement(node)) { transaction.scope.canvasManager.markCanvasClean(node) } - if (nodeId === undefined) { - return - } - if (isNodeShadowHost(node)) { transaction.scope.shadowRootsController.removeShadowRoot(node.shadowRoot) } @@ -130,10 +128,6 @@ function processRemovedNodes(nodes: Set, transaction: SerializationTransac nodeIds.delete(node) }) - if (nodeId === undefined) { - continue // This node wasn't serialized. - } - transaction.removeNode(nodeId) } } @@ -268,6 +262,10 @@ function processAttributeMutations( continue // No change since the last snapshot. } + if (isCanvasElement(node) && isCanvasSizeAttribute(attributeName)) { + transaction.scope.canvasManager.markCanvasDirty(node) + } + if (attributeName === 'value') { const attributeValue = getElementInputValue(node, privacyLevel) if (attributeValue !== undefined) { diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts index d4d945a3d0..84d73786ff 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts @@ -22,12 +22,12 @@ describe('trackCanvasContent', () => { }) function startTracking(enable = true, maxFramesPerSecond = 1): Tracker { - tracker = trackCanvasContent( - createRecordingScopeForTesting({ - canvasManager, - configuration: { sessionReplayCanvasRecording: { enable, maxFramesPerSecond } }, - }) - ) + const scope = createRecordingScopeForTesting({ + canvasManager, + configuration: { sessionReplayCanvasRecording: { enable, maxFramesPerSecond } }, + }) + scope.nodeIds.getOrInsert(canvas) + tracker = trackCanvasContent(scope) return tracker } @@ -71,6 +71,18 @@ describe('trackCanvasContent', () => { expect(markCanvasDirtySpy).not.toHaveBeenCalled() }) + it('does not mark an unserialized canvas dirty', () => { + const scope = createRecordingScopeForTesting({ + canvasManager, + configuration: { sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 } }, + }) + tracker = trackCanvasContent(scope) + + context.fillRect(0, 0, 1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + it('does not mark the canvas dirty when a drawing operation throws', () => { startTracking() diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts index e3dd1919fc..c4cb0e225d 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts @@ -42,12 +42,14 @@ export function trackCanvasContent(scope: RecordingScope): Tracker { const instrumentationStoppers: Tracker[] = [] - const { markCanvasDirty } = scope.canvasManager - CANVAS_2D_DRAWING_METHODS.forEach((method) => { instrumentationStoppers.push( instrumentMethod(CanvasRenderingContext2D.prototype, method, ({ target: context, onPostCall }) => { - onPostCall(() => markCanvasDirty(context.canvas)) + onPostCall(() => { + if (scope.nodeIds.get(context.canvas) !== undefined) { + scope.canvasManager.markCanvasDirty(context.canvas) + } + }) }) ) }) diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts index 74441244a4..a924c2abb5 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts @@ -459,25 +459,6 @@ describe('trackMutation', () => { expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() }) - - it('removes an unserialized canvas from dirty canvases when it is removed', async () => { - const canvasManager = createCanvasManager() - const scope = createRecordingScopeForTesting({ canvasManager }) - let canvas!: HTMLCanvasElement - - await recordMutationOf( - '
', - (sandbox) => { - canvas = sandbox.ownerDocument.createElement('canvas') - sandbox.appendChild(canvas) - canvasManager.markCanvasDirty(canvas) - canvas.remove() - }, - { scope } - ) - - expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() - }) }) describe('characterData mutations', () => { @@ -605,6 +586,24 @@ describe('trackMutation', () => { expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() }) + + it('does not mark a canvas dirty when an unrelated attribute changes', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '', + (sandbox) => { + canvas = sandbox as HTMLCanvasElement + canvasManager.markCanvasClean(canvas) + canvas.setAttribute('class', 'foo') + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) }) describe('ignored nodes', () => { From 94135733f7e5c31e740b163beb9a8375d833dce1 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 21 Aug 2026 11:25:03 +0200 Subject: [PATCH 21/21] Simplify CANVAS_2D_DRAWING_METHODS --- .../record/trackers/trackCanvasContent.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts index c4cb0e225d..822bd56555 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts @@ -4,20 +4,7 @@ import type { Tracker } from './tracker.types' export type MarkCanvasDirty = (canvas: HTMLCanvasElement) => void -type Canvas2DDrawingMethod = - | 'clearRect' - | 'fillRect' - | 'strokeRect' - | 'fill' - | 'stroke' - | 'fillText' - | 'strokeText' - | 'drawImage' - | 'putImageData' - | 'drawFocusIfNeeded' - | 'reset' - -const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [ +const CANVAS_2D_DRAWING_METHODS = [ 'clearRect', 'fillRect', 'strokeRect', @@ -29,7 +16,7 @@ const CANVAS_2D_DRAWING_METHODS: readonly Canvas2DDrawingMethod[] = [ 'putImageData', 'drawFocusIfNeeded', 'reset', -] +] as const export function trackCanvasContent(scope: RecordingScope): Tracker { if (