Skip to content

⚗️ Add Canvas image capture [3/n] - #4980

Open
BeltranBulbarellaDD wants to merge 8 commits into
mainfrom
beltran.bulbarella/canvas_support-3-capture-pixels
Open

⚗️ Add Canvas image capture [3/n]#4980
BeltranBulbarellaDD wants to merge 8 commits into
mainfrom
beltran.bulbarella/canvas_support-3-capture-pixels

Conversation

@BeltranBulbarellaDD

@BeltranBulbarellaDD BeltranBulbarellaDD commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Motivation

Canvas capture can be expensive. Once a canvas has been marked dirty, we need to periodically inspect its bitmap, avoid recapturing unchanged content, and downscale captured images before they are sent with Session Replay data.

This PR builds on #4949 and #4947, which add canvas dirty-state tracking and the experimental canvas-recording configuration.

Design diagram: View the Mermaid diagram

Changes

  • Add hashingMaxDimension and maxImageDimension to the experimental canvas-recording configuration, with defaults of 100 and 1000 pixels.
  • Add a periodic canvas-capture tracker driven by maxFramesPerSecond.
  • Compute a grayscale FNV-1a hash of a downscaled canvas thumbnail to skip unchanged canvases.
  • Capture changed canvas images as PNG blobs and proportionally downscale them to the configured maximum dimension.
  • Keep canvases dirty when hashing or image capture fails so they can be retried later.
  • Avoid overlapping captures for the same canvas and restore tracker behavior when recording stops.
  • Add unit coverage for defaults, capture scheduling, hashing, image downscaling, retries, deduplication, and tracker shutdown.

Scope

This PR adds the capture primitive and callback interface. Wiring captured blobs into Session Replay event serialization and intake delivery is a follow-up step.

Test instructions

yarn test:unit \
  --spec packages/browser-rum-core/src/domain/configuration/configuration.spec.ts \
  --spec packages/browser-rum/src/domain/record/record.spec.ts \
  --spec packages/browser-rum/src/domain/record/trackers/trackCanvasCapture.spec.ts \
  --spec packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts

yarn typecheck

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated documentation and/or relevant AGENTS.md file

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 25, 2026

Copy link
Copy Markdown

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 181.20 KiB 181.32 KiB +122 B +0.07%
Rum Profiler 8.43 KiB 8.43 KiB 0 B 0.00%
Rum Recorder 22.31 KiB 24.01 KiB +1.70 KiB +7.60% ⚠️
Logs 57.52 KiB 57.52 KiB 0 B 0.00%
Rum Salesforce N/A 139.34 KiB N/A N/A N/A
Rum Slim 139.21 KiB 139.33 KiB +122 B +0.09%
Worker 22.96 KiB 22.96 KiB 0 B 0.00%
Rum Shopify N/A 203.95 KiB N/A N/A N/A
Rum-shopify Profiler N/A 8.43 KiB N/A N/A N/A
Rum-shopify Recorder N/A 3.72 KiB N/A N/A N/A

⚠️ The increase is particularly high and exceeds 5.00%. Please check the changes.

@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented Aug 25, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 2 Pipeline jobs failed

DataDog/browser-sdk | unit — ❌ 1 test failed · 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

❌ console log observable should notify log from Chrome Headless 151.0.0.0 (Linux 0.0.0)
TypeError: Cannot read properties of undefined (reading 'args')
    at UserContext.<anonymous> (/go/src/github.com/DataDog/browser-sdk/packages/browser-core/src/domain/console/consoleObservable.spec.ts:36:54 <- /tmp/_karma_webpack_802325/commons.js:396952:60)
    at <Jasmine>
DataDog/browser-sdk | format — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

ℹ️ Info

No other issues found (see more)

❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 79.27%
Overall Coverage: 77.13% (+0.00%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 04324e8 | Docs | View more details | Give us feedback!

@BeltranBulbarellaDD BeltranBulbarellaDD changed the title Beltran.bulbarella/canvas support 3 capture pixels ⚗️ Add Session Replay canvas image capture Aug 25, 2026
@BeltranBulbarellaDD BeltranBulbarellaDD changed the title ⚗️ Add Session Replay canvas image capture ⚗️ Add Canvas image capture Aug 25, 2026
@BeltranBulbarellaDD BeltranBulbarellaDD changed the title ⚗️ Add Canvas image capture ⚗️ Add Canvas image capture [3/n] Aug 25, 2026
@BeltranBulbarellaDD
BeltranBulbarellaDD force-pushed the beltran.bulbarella/canvas_support-3-capture-pixels branch from 144fbb9 to af5efab Compare August 25, 2026 10:18
@BeltranBulbarellaDD
BeltranBulbarellaDD marked this pull request as ready for review August 25, 2026 14:44
@BeltranBulbarellaDD
BeltranBulbarellaDD requested review from a team as code owners August 25, 2026 14:44

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e88d1dda23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +25 to +26
const previousHashes = new WeakMap<HTMLCanvasElement, string>()
const inFlightCaptures = new WeakSet<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.

P1 Badge Reset capture state when a full snapshot starts

issue: When VIEW_CREATED triggers serializeFullSnapshot(), the scope resets all node IDs and rebuilds a fresh replay tree, but these hash and in-flight caches survive. An unchanged canvas is consequently marked clean without emitting an image for the new tree, leaving it blank, while an in-flight callback can emit the previous tree's nodeId; scope this state to the current full-snapshot generation or force a fresh canvas mapping after each reset.

Useful? React with 👍 / 👎.

Comment on lines +38 to +40
if (shouldMaskNode(canvas, nodePrivacyLevel)) {
canvasManager.markCanvasClean(canvas)
return // Do not read pixels from masked canvases

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 Requeue canvases when masking is removed

issue: If a canvas is dirty while explicitly masked, this branch removes its only pending capture. Removing the privacy attribute or changing a masking ancestor does not re-dirty it—serializeMutations() only does that for canvas size attributes—so a now-allowed static canvas remains blank in replay until another drawing operation happens; retain enough state to capture it when its privacy level becomes permissive.

Useful? React with 👍 / 👎.

context.filter = 'grayscale(1)'
context.drawImage(canvas, 0, 0, width, height)

return fnv1aHash(context.getImageData(0, 0, width, height).data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include canvas dimensions in change detection

issue: The hash covers only the downscaled pixels, not the canvas dimensions. Responsive code can resize a canvas proportionally and redraw the same scene—for example, a solid 1000×1000 canvas becoming 2000×2000—producing the same 100×100 hash; the replay applies the width/height mutation, which clears its bitmap, but the hash comparison suppresses the callback that would redraw it. Include intrinsic dimensions in the change key or invalidate the cached hash on size changes.

Useful? React with 👍 / 👎.

Comment on lines +44 to +47
try {
hash = computeImageHash(canvas, configuration?.hashingMaxDimension ?? 100)
} catch {
return // capture failed; leave it dirty

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 Stop retrying permanently tainted canvases

issue: When a canvas contains cross-origin pixels without CORS, getImageData() throws a SecurityError on every attempt until the bitmap is reset. This catch deliberately leaves the canvas dirty, so every interval creates a thumbnail, draws it, and throws again—up to five times per second per canvas—even though capture cannot succeed; handle origin-security failures separately and defer retries until a later draw or size reset.

Useful? React with 👍 / 👎.

Comment thread packages/browser-rum/src/domain/record/trackers/trackCanvasCapture.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04324e8b22

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return undefined
}
context.imageSmoothingQuality = 'low'
context.filter = 'grayscale(1)'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve chroma in canvas change hashes

issue: When a canvas changes between distinct colors that produce the same grayscale luminance—such as a hue animation with constant luminance—this filter maps both frames to identical thumbnail pixels. previousHashes then treats the second frame as unchanged and suppresses its image, potentially freezing or skipping visually significant color changes in the replay; hash the color channels rather than discarding chroma.

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.

I believe more of an edge case? Mobile does not address this case.

trackVisualViewportResize(processRecord),
trackViewEnd(lifeCycle, processRecord, flushMutations),
trackCanvasContent(scope),
trackCanvasCapture(scope),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Defer capture until a callback consumes the images

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

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not an issue.

try {
hash = computeImageHash(canvas, configuration?.hashingMaxDimension ?? 100)
} catch {
return // capture failed; leave it dirty

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.

+1 to Codex's comment here; if computeImageHash() throws, it'd be best to note that the canvas is tainted and stop trying to snapshot its contents. I'd go further than Codex recommended ; there's no need to try again even on draw or resize. Once a canvas becomes tainted, it stays that way.

Comment on lines +58 to +62
const nodeId = scope.nodeIds.get(canvas)
if (nodeId === undefined) {
canvasManager.markCanvasClean(canvas)
return
}

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'd make this check earlier.

}

/* eslint-disable no-bitwise */
function fnv1aHash(data: ArrayLike<number>): string {

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.

SubtleCrypto.digest() is preferred if our browser support matrix lets us use it. It will almost certainly be significantly faster. (Note that it's not necessary to use the same hash function mobile uses.)

return undefined
}
context.imageSmoothingQuality = 'low'
context.filter = 'grayscale(1)'

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.

Is the reasoning behind the grayscale filter that it'll produce a smaller image to hash? I don't think that actually works, because getImageData() is still giving you the image in RGBA format. The grayscale filter also has a cost. It's possible I'm missing something, but based on my current understanding, I think we'd likely be better off without it.

In general we may be able to get better performance using other APIs that are more directly tailored to our needs here; take a look at ImageBitmap, and in particular the resizeWidth, resizeHeight, and resizeQuality options of createImageBitmap, for an alternative approach.

Comment on lines +80 to +84
const currentNodePrivacyLevel = getNodePrivacyLevel(canvas, scope.configuration.defaultPrivacyLevel)
if (currentNodePrivacyLevel !== NodePrivacyLevel.ALLOW) {
canvasManager.markCanvasClean(canvas)
return // Do not emit pixels if the canvas became privacy level other than allow during capture
}

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 it'd be OK to remove this. The async part of toBlob() is the image encoding part, but the image data you're encoding is a snapshot of the canvas as it existed while it was still NodePrivacyLevel.ALLOW.

Comment on lines +25 to +26
const previousHashes = new WeakMap<HTMLCanvasElement, string>()
const inFlightCaptures = new WeakSet<HTMLCanvasElement>()

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'd store this kind of state on the CanvasManager, probably. That way RecordingScope#reset() will reset it naturally when a new full snapshot is taken. (We probably don't want to reset our knowledge about which hashes have been uploaded to the server, but that's a different thing than previousHashes, as I understand it.)

I think CanvasManager knowing more about canvases than just whether they were dirty might let you simplify some of the logic here. If it also knew whether canvas were tainted (so they should never be captured, because we can't see their contents) and whether a capture for them was in flight, then you could handle much of the logic for deciding which canvases are ready for capture totally within CanvasManager. You might have a getCapturableCanvases() getter that does some of the filtering you're doing here up front. Not saying other approaches can't work, but that kind of thing might feel nicer as you start to move more state onto CanvasManager.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants