Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c0c0524
Add canvas change detetion
BeltranBulbarellaDD Aug 14, 2026
3b02647
Add canvas change detetion
BeltranBulbarellaDD Aug 14, 2026
b76c1ca
filter out unnsupported versions for canvas APIs
BeltranBulbarellaDD Aug 14, 2026
e013ae7
Track canvas resizes performed through attributes
BeltranBulbarellaDD Aug 14, 2026
0e9b67a
adapt for init config changes
BeltranBulbarellaDD Aug 14, 2026
dc6b3d5
Update config structure
BeltranBulbarellaDD Aug 17, 2026
b056d28
⚗️ Add canvas dirty-state prefilter
BeltranBulbarellaDD Aug 17, 2026
07aed32
Make attributeNamespace optional
BeltranBulbarellaDD Aug 17, 2026
b97a735
track dirty canvases
BeltranBulbarellaDD Aug 18, 2026
faf302e
Seed existing canvases before full snapshots
BeltranBulbarellaDD Aug 18, 2026
aab401f
Recognize canvas elements in XHTML documents
BeltranBulbarellaDD Aug 18, 2026
5b3a6ad
Recognize canvas elements in XHTML documents
BeltranBulbarellaDD Aug 18, 2026
bb0700a
format
BeltranBulbarellaDD Aug 18, 2026
4c65728
fix nits
BeltranBulbarellaDD Aug 18, 2026
7336322
refactor: move canvas detection to serialization files
BeltranBulbarellaDD Aug 18, 2026
fe5e50c
test fixes
BeltranBulbarellaDD Aug 18, 2026
feca8f2
Remove dirty canvases
BeltranBulbarellaDD Aug 19, 2026
697ea42
Clean up dirty canvases skipped by serialization
BeltranBulbarellaDD Aug 19, 2026
71b0ba1
Address nits
BeltranBulbarellaDD Aug 19, 2026
e9e087d
Drop attributeName, and markCanvasDirtyFromMutationRecords.
BeltranBulbarellaDD Aug 19, 2026
9413573
Simplify CANVAS_2D_DRAWING_METHODS
BeltranBulbarellaDD Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { registerCleanupTask } from '@datadog/browser-core/test'
import { createCanvasManager } from './canvasManager'

describe('CanvasManager', () => {
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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is accurate, though the headline ("avoid strongly retaining...") points in the wrong direction, I think. You need to iterate the contents of this set, it seems, so strongly retaining is necessary. However, you should have a way to remove canvases from the set of dirty canvas, and you should perform this removal in trackMutation.ts when canvas elements are removed from the DOM.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah I think I get what you mean but I think that's scope for the next PR. Something like this?


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)
}
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { isCanvasElement, isCanvasSizeAttribute } from './canvasUtils'

describe('canvasUtils', () => {
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()
})
})
11 changes: 11 additions & 0 deletions packages/browser-rum/src/domain/record/canvas/canvasUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { isElementNode } from '@datadog/browser-rum-core'

const CANVAS_SIZE_ATTRIBUTES = ['width', 'height']

export function isCanvasElement(node: Node): node is HTMLCanvasElement {
return isElementNode(node) && node.tagName === 'CANVAS'
}

export function isCanvasSizeAttribute(attributeName: string): boolean {
return CANVAS_SIZE_ATTRIBUTES.includes(attributeName.toLowerCase())
}
2 changes: 2 additions & 0 deletions packages/browser-rum/src/domain/record/internalApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,6 +75,7 @@ export function takeNodeSnapshot(

function createTemporaryRecordingScope(configuration?: Partial<RumConfiguration>): RecordingScope {
return createRecordingScope(
createCanvasManager(),
{
defaultPrivacyLevel: NodePrivacyLevel.ALLOW,
...configuration,
Expand Down
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
11 changes: 10 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,
trackCanvasContent,
} 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,14 @@ export function record(options: RecordOptions): RecordAPI {
replayStats.addRecord(view.id)
}

const canvasManager = createCanvasManager()
const shadowRootsController = initShadowRootsController(processRecord, emitStats)
const scope = createRecordingScope(configuration, createElementsScrollPositions(), shadowRootsController)
const scope = createRecordingScope(
canvasManager,
configuration,
createElementsScrollPositions(),
shadowRootsController
)

const { stop: stopFullSnapshots } = startFullSnapshots(lifeCycle, processRecord, emitStats, flushMutations, scope)

Expand All @@ -74,6 +82,7 @@ export function record(options: RecordOptions): RecordAPI {
trackFocus(processRecord),
trackVisualViewportResize(processRecord),
trackViewEnd(lifeCycle, processRecord, flushMutations),
trackCanvasContent(scope),
]

return {
Expand Down
4 changes: 4 additions & 0 deletions 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 @@ -14,6 +15,7 @@ import type { ShadowRootsController } from './shadowRootsController'
export interface RecordingScope {
resetIds(): void

canvasManager: CanvasManager
configuration: RumConfiguration
elementsScrollPositions: ElementsScrollPositions
eventIds: EventIds
Expand All @@ -24,6 +26,7 @@ export interface RecordingScope {
}

export function createRecordingScope(
canvasManager: CanvasManager,
configuration: RumConfiguration,
elementsScrollPositions: ElementsScrollPositions,
shadowRootsController: ShadowRootsController
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 @@ -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 { isCanvasElement, isCanvasSizeAttribute } from '../canvas/canvasUtils'
import type { SerializationTransaction } from './serializationTransaction'
import { SerializationKind, serializeInTransaction } from './serializationTransaction'
import { serializeNode } from './serializeNode'
Expand Down Expand Up @@ -111,6 +112,10 @@ function processRemovedNodes(nodes: Set<Node>, transaction: SerializationTransac
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't remove these lines; we should never be tracking any information about nodes that don't have node ids. You probably felt the need to do this because in trackCanvas.ts you are unconditionally adding canvases to the dirty set when they are mutated; the right approach is to only do that if the canvas element has a node id already, and to additional mark canvases as unconditionally dirty when they are first serialized (i.e., when they are first assigned a node id).


forNodeAndDescendants(node, (node: Node) => {
if (isCanvasElement(node)) {
transaction.scope.canvasManager.markCanvasClean(node)
}
Comment on lines +115 to +117

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 Clean up dirty canvases skipped by serialization

issue: When a canvas is appended, drawn into, and removed before the mutation batch is processed, drawing adds it to dirtyCanvases, but the node has no serialized ID, so processRemovedNodes() returns at the preceding nodeId === undefined check and never reaches this cleanup. Fresh evidence after the earlier retention report is that the new removal cleanup still excludes exactly these unserialized nodes; because no production path currently calls getDirtyCanvases() to prune them, repeated transient canvases and their backing bitmaps remain retained for the recording lifetime.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is either stale or wrong; it looks to me like trackCanvasContent correctly checks that canvases are assigned a node id before marking them as dirty.


if (isNodeShadowHost(node)) {
transaction.scope.shadowRootsController.removeShadowRoot(node.shadowRoot)
}
Expand Down Expand Up @@ -257,6 +262,10 @@ function processAttributeMutations(
continue // No change since the last snapshot.
}

if (isCanvasElement(node) && isCanvasSizeAttribute(attributeName)) {
transaction.scope.canvasManager.markCanvasDirty(node)
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
}

if (attributeName === 'value') {
const attributeValue = getElementInputValue(node, privacyLevel)
if (attributeValue !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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('<div><canvas></canvas><div><canvas></canvas></div></div>', {
scope,
after: (target) => {
expect(scope.canvasManager.getDirtyCanvases()).toEqual(
Array.from((target as Element).querySelectorAll('canvas'))
)
},
})
})
})

describe('for SVG elements', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Comment on lines +142 to +144

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 Skip canvas bookkeeping when canvas capture is disabled

issue: When sessionReplayCanvasRecording is absent, disabled, or configured with a zero frame rate, serialization still unconditionally adds every canvas to the manager's strongly held Set. Fresh evidence in the final diff is that the manager is now always created, while the canvas tracker is the only component gated by configuration; because no production path calls getDirtyCanvases() or clearDirtyCanvases(), applications that repeatedly create and remove canvases retain those elements and their backing bitmaps for the recording's lifetime even though canvas capture is off. Gate these serialization and mutation updates on the enabled configuration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is on purpose. See


const {
_cssText: cssText,
rr_mediaState: mediaState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,25 @@ 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 { createCanvasManager } from '../canvas/canvasManager'
import { DEFAULT_CONFIGURATION } from './rumConfiguration.specHelper'
import { DEFAULT_SHADOW_ROOT_CONTROLLER } from './shadowRootsController.specHelper'

export function createRecordingScopeForTesting({
configuration,
addShadowRoot,
removeShadowRoot,
canvasManager = createCanvasManager(),
}: {
configuration?: Partial<RumConfiguration>
addShadowRoot?: AddShadowRootCallBack
removeShadowRoot?: RemoveShadowRootCallBack
canvasManager?: CanvasManager
} = {}): RecordingScope {
return createRecordingScope(
canvasManager,
{
...DEFAULT_CONFIGURATION,
...configuration,
Expand Down
1 change: 1 addition & 0 deletions packages/browser-rum/src/domain/record/trackers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export { trackFocus } from './trackFocus'
export { trackViewEnd } from './trackViewEnd'
export { trackInput } from './trackInput'
export { trackMutation } from './trackMutation'
export { trackCanvasContent } from './trackCanvasContent'
export type { Tracker } from './tracker.types'
Loading
Loading