-
Notifications
You must be signed in to change notification settings - Fork 1.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: canvas replay #19583
feat: canvas replay #19583
Changes from all commits
4c97723
f26b5a8
e142002
b31cfe0
11dd3d1
74fda4c
bf2fd51
0cf8814
6f695be
cd3bc4a
e98434b
ff770d3
272fb38
c35814b
aad21d8
8fde514
5cfd8ac
03f508e
0087042
d4e5183
5855334
ea9d7db
d6a7660
e0993a9
38bfddd
fadd150
4a7e848
ea4d85a
caa2c4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
declare module 'rrweb/es/rrweb/packages/rrweb/src/replay/canvas' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import { CanvasArg, canvasMutationData, canvasMutationParam, eventWithTime } from '@rrweb/types' | ||
import { EventType, IncrementalSource, Replayer } from 'rrweb' | ||
import { canvasMutation } from 'rrweb/es/rrweb/packages/rrweb/src/replay/canvas' | ||
import { ReplayPlugin } from 'rrweb/typings/types' | ||
|
||
import { deserializeCanvasArg } from './deserialize-canvas-args' | ||
|
||
export const CanvasReplayerPlugin = (events: eventWithTime[]): ReplayPlugin => { | ||
const canvases = new Map<number, HTMLCanvasElement>([]) | ||
const containers = new Map<number, HTMLImageElement>([]) | ||
const imageMap = new Map<eventWithTime | string, HTMLImageElement>() | ||
const canvasEventMap = new Map<eventWithTime | string, canvasMutationParam>() | ||
|
||
const deserializeAndPreloadCanvasEvents = async (data: canvasMutationData, event: eventWithTime): Promise<void> => { | ||
if (!canvasEventMap.has(event)) { | ||
const status = { isUnchanged: true } | ||
|
||
if ('commands' in data) { | ||
const commands = await Promise.all( | ||
data.commands.map(async (c) => { | ||
const args = await Promise.all( | ||
(c.args as CanvasArg[]).map(deserializeCanvasArg(imageMap, null, status)) | ||
) | ||
return { ...c, args } | ||
}) | ||
) | ||
if (status.isUnchanged === false) { | ||
canvasEventMap.set(event, { ...data, commands }) | ||
} | ||
} else { | ||
const args = await Promise.all( | ||
(data.args as CanvasArg[]).map(deserializeCanvasArg(imageMap, null, status)) | ||
) | ||
if (status.isUnchanged === false) { | ||
canvasEventMap.set(event, { ...data, args }) | ||
} | ||
} | ||
} | ||
} | ||
|
||
const cloneCanvas = (id: number, node: HTMLCanvasElement): HTMLCanvasElement => { | ||
const cloneNode = node.cloneNode() as HTMLCanvasElement | ||
canvases.set(id, cloneNode) | ||
document.adoptNode(cloneNode) | ||
return cloneNode | ||
} | ||
|
||
const promises: Promise<any>[] = [] | ||
for (const event of events) { | ||
if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.CanvasMutation) { | ||
promises.push(deserializeAndPreloadCanvasEvents(event.data, event)) | ||
} | ||
} | ||
|
||
return { | ||
onBuild: (node, { id }) => { | ||
if (!node) { | ||
return | ||
} | ||
|
||
if (node.nodeName === 'CANVAS' && node.nodeType === 1) { | ||
const el = containers.get(id) || document.createElement('img') | ||
;(node as HTMLCanvasElement).appendChild(el) | ||
containers.set(id, el) | ||
} | ||
}, | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-misused-promises | ||
handler: async (e: eventWithTime, _isSync: boolean, { replayer }: { replayer: Replayer }) => { | ||
if (e.type === EventType.IncrementalSnapshot && e.data.source === IncrementalSource.CanvasMutation) { | ||
const source = replayer.getMirror().getNode(e.data.id) | ||
const target = | ||
canvases.get(e.data.id) || (source && cloneCanvas(e.data.id, source as HTMLCanvasElement)) | ||
|
||
if (!target) { | ||
return | ||
} | ||
|
||
await canvasMutation({ | ||
event: e, | ||
mutation: e.data, | ||
target: target, | ||
imageMap, | ||
canvasEventMap, | ||
}) | ||
|
||
const img = containers.get(e.data.id) | ||
if (img) { | ||
img.src = target.toDataURL() | ||
} | ||
} | ||
}, | ||
} as ReplayPlugin | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { CanvasArg } from '@rrweb/types' | ||
import { base64ArrayBuffer } from 'lib/utils' | ||
import { Replayer } from 'rrweb' | ||
|
||
type GLVarMap = Map<string, any[]> | ||
type CanvasContexts = CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | ||
const webGLVarMap: Map<CanvasContexts, GLVarMap> = new Map() | ||
|
||
const variableListFor = (ctx: CanvasContexts, ctor: string): any[] => { | ||
let contextMap = webGLVarMap.get(ctx) | ||
if (!contextMap) { | ||
contextMap = new Map() | ||
webGLVarMap.set(ctx, contextMap) | ||
} | ||
if (!contextMap.has(ctor)) { | ||
contextMap.set(ctor, []) | ||
} | ||
|
||
return contextMap.get(ctor) as any[] | ||
} | ||
|
||
export const deserializeCanvasArg = ( | ||
imageMap: Replayer['imageMap'], | ||
ctx: CanvasContexts | null, | ||
preload?: { | ||
isUnchanged: boolean | ||
} | ||
): ((arg: CanvasArg) => Promise<any>) => { | ||
return async (arg: CanvasArg): Promise<any> => { | ||
if (arg && typeof arg === 'object' && 'rr_type' in arg) { | ||
if (preload) { | ||
preload.isUnchanged = false | ||
} | ||
if (arg.rr_type === 'ImageBitmap' && 'args' in arg) { | ||
const args = await deserializeCanvasArg(imageMap, ctx, preload)(arg.args) | ||
// eslint-disable-next-line prefer-spread | ||
return await createImageBitmap.apply(null, args) | ||
} | ||
if ('index' in arg) { | ||
if (preload || ctx === null) { | ||
return arg | ||
} | ||
const { rr_type: name, index } = arg | ||
return variableListFor(ctx, name)[index] | ||
} | ||
if ('args' in arg) { | ||
return arg | ||
} | ||
if ('base64' in arg) { | ||
return base64ArrayBuffer(arg.base64) | ||
} | ||
if ('src' in arg) { | ||
return arg | ||
} | ||
if ('data' in arg && arg.rr_type === 'Blob') { | ||
const blobContents = await Promise.all(arg.data.map(deserializeCanvasArg(imageMap, ctx, preload))) | ||
const blob = new Blob(blobContents, { | ||
type: arg.type, | ||
}) | ||
return blob | ||
} | ||
} else if (Array.isArray(arg)) { | ||
const result = await Promise.all(arg.map(deserializeCanvasArg(imageMap, ctx, preload))) | ||
return result | ||
} | ||
return arg | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -102,6 +102,7 @@ class Meta: | |
"session_recording_minimum_duration_milliseconds", | ||
"session_recording_linked_flag", | ||
"session_recording_network_payload_capture_config", | ||
"session_replay_config", | ||
"recording_domains", | ||
"inject_web_apps", | ||
"surveys_opt_in", | ||
|
@@ -146,6 +147,7 @@ class Meta: | |
"session_recording_minimum_duration_milliseconds", | ||
"session_recording_linked_flag", | ||
"session_recording_network_payload_capture_config", | ||
"session_replay_config", | ||
"effective_membership_level", | ||
"access_control", | ||
"week_start_day", | ||
|
@@ -208,6 +210,18 @@ def validate_session_recording_network_payload_capture_config(self, value) -> Di | |
|
||
return value | ||
|
||
def validate_session_replay_config(self, value) -> Dict | None: | ||
if value is None: | ||
return None | ||
|
||
if not isinstance(value, Dict): | ||
raise exceptions.ValidationError("Must provide a dictionary or None.") | ||
|
||
if not all(key in ["record_canvas"] for key in value.keys()): | ||
raise exceptions.ValidationError("Must provide a dictionary with only 'record_canvas' key.") | ||
Comment on lines
+220
to
+221
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe it should not be a dictionary then? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I'd forgotten my pre-xmas good citizen push that we should move session replay config into one place so we can stop adding more nad more columns to the team model Maybe we should get this in and then I use it for the config in #19887 |
||
|
||
return value | ||
|
||
def validate(self, attrs: Any) -> Any: | ||
if "primary_dashboard" in attrs and attrs["primary_dashboard"].team != self.instance: | ||
raise exceptions.PermissionDenied("Dashboard does not belong to this team.") | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it possible to get in a state where I'm collecting canvas recordings but this flag is off?
not blocking here but we should have a good story for "this replay has a canvas in it"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also makes me think it'd be good to have a PostHog insight showing web vs android vs canvas vs etc playback so we can measure feature usage
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, possibly could be. We should also consider in future that recordings might be captured with canvas and then the config option is turned off. Should we continue to playback recordings with the canvas or not in that situation 🤔
Good news is that it's pretty easy to know if a recording contains a canvas in the rrweb plugin during playback