Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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 = appendCanvas()

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', () => {
const canvasManager = createCanvasManager()
const dirtyCanvas = appendCanvas()
const cleanCanvas = appendCanvas()

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
}
35 changes: 35 additions & 0 deletions packages/browser-rum/src/domain/record/canvas/canvasManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface CanvasManager {
clearDirtyCanvases: () => void
getDirtyCanvases: () => HTMLCanvasElement[]
isCanvasDirty: (canvas: HTMLCanvasElement) => boolean
markCanvasClean: (canvas: HTMLCanvasElement) => void
markCanvasDirty: (canvas: HTMLCanvasElement) => void
}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid strongly retaining detached dirty canvases

issue: When an enabled recording observes canvas churn (for example, charts that create, draw, and remove canvases), this Set keeps every removed dirty canvas and its backing bitmap reachable. Removal mutations never delete canvases, and no production code in this commit calls getDirtyCanvases() or clearDirtyCanvases(), so the pruning inside the getter never runs and memory grows for the lifetime of the recording; detached canvases need weak bookkeeping or cleanup when they are removed.

Useful? React with 👍 / 👎.


return {
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)
}
},
}
}
36 changes: 34 additions & 2 deletions packages/browser-rum/src/domain/record/record.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ describe('record', () => {
])
})

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

startRecording({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 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()

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({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 0 } })

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

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

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

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

export interface RecordOptions {
emitRecord: EmitRecordCallback
Expand Down Expand Up @@ -51,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)

Expand All @@ -76,6 +88,10 @@ export function record(options: RecordOptions): RecordAPI {
trackViewEnd(lifeCycle, processRecord, flushMutations),
]

if (canvasManager) {
trackers.push(trackCanvas2DMutations(canvasManager.markCanvasDirty))
}

return {
stop: () => {
shadowRootsController.stop()
Expand Down
6 changes: 5 additions & 1 deletion packages/browser-rum/src/domain/record/recordingScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -41,6 +44,7 @@ export function createRecordingScope(
scope.styleSheetIds.clear()
},

canvasManager,
configuration,
elementsScrollPositions,
eventIds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -11,10 +12,12 @@ export function createRecordingScopeForTesting({
configuration,
addShadowRoot,
removeShadowRoot,
canvasManager,
}: {
configuration?: Partial<RumConfiguration>
addShadowRoot?: AddShadowRootCallBack
removeShadowRoot?: RemoveShadowRootCallBack
canvasManager?: CanvasManager
} = {}): RecordingScope {
return createRecordingScope(
{
Expand All @@ -26,6 +29,7 @@ export function createRecordingScopeForTesting({
...DEFAULT_SHADOW_ROOT_CONTROLLER,
addShadowRoot: addShadowRoot || noop,
removeShadowRoot: removeShadowRoot || noop,
}
},
canvasManager
)
}
1 change: 1 addition & 0 deletions packages/browser-rum/src/domain/record/trackers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export { trackFocus } from './trackFocus'
export { trackViewEnd } from './trackViewEnd'
export { trackInput } from './trackInput'
export { trackMutation } from './trackMutation'
export { trackCanvas2DMutations } from './trackCanvas'
export type { Tracker } from './tracker.types'
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { registerCleanupTask } 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: 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() },
]

// 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', () => {
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('stops tracking canvas mutations', () => {
tracker = trackCanvas2DMutations(markCanvasDirtySpy)
tracker.stop()

context.fillRect(0, 0, 1, 1)

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