Skip to content

Commit

Permalink
record canvas mutations
Browse files Browse the repository at this point in the history
close #60, #261

This patch implements the canvas mutation observer.
It consists of both the record and the replay side changes.

In the record side, we add a `recordCanvas` flag to indicate
whether to record canvas elements and the flag defaults to false.
Different from our other observers, the canvas observer was
disabled by default. Because some applications with heavy canvas
usage may emit a lot of data as canvas changed, especially the
scenarios that use a lot of `drawImage` API.
So the behavior should be audited by users and only record canvas
when the flag was set to true.

In the replay side, we add a `UNSAFE_replayCanvas` flag to indicate
whether to replay canvas mutations.
Similar to the `recordCanvas` flag, `UNSAFE_replayCanvas` defaults
to false. But unlike the record canvas implementation is stable and
safe, the replay canvas implementation is UNSAFE.
It's unsafe because we need to add `allow-scripts` to the replay
sandbox, which may cause some unexpected script execution. Currently,
users should be aware of this implementation detail and enable this
feature carefully.
  • Loading branch information
Yuyz0112 committed Aug 22, 2020
1 parent 5d83ee3 commit 8a94d20
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 29 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@xstate/fsm": "^1.4.0",
"mitt": "^1.1.3",
"pako": "^1.0.11",
"rrweb-snapshot": "^0.8.0",
"rrweb-snapshot": "^0.8.1",
"smoothscroll-polyfill": "^0.4.3"
}
}
19 changes: 11 additions & 8 deletions scripts/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function getCode(): string {
width: 1600,
height: 900,
},
args: ['--start-maximized'],
args: ['--start-maximized', '--ignore-certificate-errors'],
});
const page = await browser.newPage();
await page.goto(url, {
Expand All @@ -94,7 +94,8 @@ function getCode(): string {
await page.evaluate(`;${code}
window.__IS_RECORDING__ = true
rrweb.record({
emit: event => window._replLog(event)
emit: event => window._replLog(event),
recordCanvas: true
});
`);
page.on('framenavigated', async () => {
Expand All @@ -103,14 +104,14 @@ function getCode(): string {
await page.evaluate(`;${code}
window.__IS_RECORDING__ = true
rrweb.record({
emit: event => window._replLog(event)
emit: event => window._replLog(event),
recordCanvas: true
});
`);
}
});

emitter.once('done', async shouldReplay => {
console.log(`Recorded ${events.length} events`);
emitter.once('done', async (shouldReplay) => {
await browser.close();
if (shouldReplay) {
await replay();
Expand Down Expand Up @@ -170,7 +171,9 @@ function getCode(): string {
'<\\/script>',
)};
/*-->*/
const replayer = new rrweb.Replayer(events);
const replayer = new rrweb.Replayer(events, {
UNSAFE_replayCanvas: true
});
replayer.play();
</script>
</body>
Expand All @@ -182,10 +185,10 @@ function getCode(): string {
}

process
.on('uncaughtException', error => {
.on('uncaughtException', (error) => {
console.error(error);
})
.on('unhandledRejection', error => {
.on('unhandledRejection', (error) => {
console.error(error);
});
})();
13 changes: 13 additions & 0 deletions src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function record<T = eventWithTime>(
packFn,
sampling = {},
mousemoveWait,
recordCanvas = false,
} = options;
// runtime checks for user options
if (!emit) {
Expand Down Expand Up @@ -113,6 +114,7 @@ function record<T = eventWithTime>(
blockClass,
inlineStylesheet,
maskInputOptions,
recordCanvas,
);

if (!node) {
Expand Down Expand Up @@ -244,11 +246,22 @@ function record<T = eventWithTime>(
},
}),
),
canvasMutationCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CanvasMutation,
...p,
},
}),
),
blockClass,
ignoreClass,
maskInputOptions,
inlineStylesheet,
sampling,
recordCanvas,
},
hooks,
),
Expand Down
4 changes: 4 additions & 0 deletions src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,19 @@ export default class MutationBuffer {
private blockClass: blockClass;
private inlineStylesheet: boolean;
private maskInputOptions: MaskInputOptions;
private recordCanvas: boolean;

constructor(
cb: mutationCallBack,
blockClass: blockClass,
inlineStylesheet: boolean,
maskInputOptions: MaskInputOptions,
recordCanvas: boolean,
) {
this.blockClass = blockClass;
this.inlineStylesheet = inlineStylesheet;
this.maskInputOptions = maskInputOptions;
this.recordCanvas = recordCanvas;
this.emissionCallback = cb;
}

Expand Down Expand Up @@ -187,6 +190,7 @@ export default class MutationBuffer {
true,
this.inlineStylesheet,
this.maskInputOptions,
this.recordCanvas,
)!,
});
};
Expand Down
85 changes: 85 additions & 0 deletions src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getWindowWidth,
isBlocked,
isTouchEvent,
patch,
} from '../utils';
import {
mutationCallBack,
Expand All @@ -30,6 +31,7 @@ import {
mediaInteractionCallback,
MediaInteractions,
SamplingStrategy,
canvasMutationCallback,
} from '../types';
import MutationBuffer from './mutation';

Expand All @@ -38,13 +40,15 @@ function initMutationObserver(
blockClass: blockClass,
inlineStylesheet: boolean,
maskInputOptions: MaskInputOptions,
recordCanvas: boolean,
): MutationObserver {
// see mutation.ts for details
const mutationBuffer = new MutationBuffer(
cb,
blockClass,
inlineStylesheet,
maskInputOptions,
recordCanvas,
);
const observer = new MutationObserver(mutationBuffer.processMutations);
observer.observe(document, {
Expand Down Expand Up @@ -357,6 +361,75 @@ function initMediaInteractionObserver(
};
}

function initCanvasMutationObserver(
cb: canvasMutationCallback,
blockClass: blockClass,
): listenerHandler {
const props = Object.getOwnPropertyNames(CanvasRenderingContext2D.prototype);
const handlers: listenerHandler[] = [];
for (const prop of props) {
try {
if (
typeof CanvasRenderingContext2D.prototype[
prop as keyof CanvasRenderingContext2D
] !== 'function'
) {
continue;
}
const restoreHandler = patch(
CanvasRenderingContext2D.prototype,
prop,
function (original) {
return function (
this: CanvasRenderingContext2D,
...args: Array<unknown>
) {
if (!isBlocked(this.canvas, blockClass)) {
setTimeout(() => {
const recordArgs = [...args];
if (prop === 'drawImage') {
if (
recordArgs[0] &&
recordArgs[0] instanceof HTMLCanvasElement
) {
recordArgs[0] = recordArgs[0].toDataURL();
}
}
cb({
id: mirror.getId((this.canvas as unknown) as INode),
property: prop,
args: recordArgs,
});
}, 0);
}
return original.apply(this, args);
};
},
);
handlers.push(restoreHandler);
} catch {
const hookHandler = hookSetter<CanvasRenderingContext2D>(
CanvasRenderingContext2D.prototype,
prop,
{
set(v) {
cb({
id: mirror.getId((this.canvas as unknown) as INode),
property: prop,
args: [v],
setter: true,
});
},
},
);
handlers.push(hookHandler);
}
}
return () => {
handlers.forEach((h) => h());
};
}

function mergeHooks(o: observerParam, hooks: hooksParam) {
const {
mutationCb,
Expand All @@ -367,6 +440,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
inputCb,
mediaInteractionCb,
styleSheetRuleCb,
canvasMutationCb,
} = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) {
Expand Down Expand Up @@ -416,6 +490,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
}
styleSheetRuleCb(...p);
};
o.canvasMutationCb = (...p: Arguments<canvasMutationCallback>) => {
if (hooks.canvasMutation) {
hooks.canvasMutation(...p);
}
canvasMutationCb(...p);
};
}

export default function initObservers(
Expand All @@ -428,6 +508,7 @@ export default function initObservers(
o.blockClass,
o.inlineStylesheet,
o.maskInputOptions,
o.recordCanvas,
);
const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling);
const mouseInteractionHandler = initMouseInteractionObserver(
Expand All @@ -453,6 +534,9 @@ export default function initObservers(
o.blockClass,
);
const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb);
const canvasMutationObserver = o.recordCanvas
? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass)
: () => {};

return () => {
mutationObserver.disconnect();
Expand All @@ -463,5 +547,6 @@ export default function initObservers(
inputHandler();
mediaInteractionHandler();
styleSheetObserver();
canvasMutationObserver();
};
}
Loading

0 comments on commit 8a94d20

Please sign in to comment.