record canvas mutations (#296)
* record canvas mutations 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. * update canvas integration test
This commit is contained in:
@@ -62,7 +62,7 @@
|
|||||||
"@xstate/fsm": "^1.4.0",
|
"@xstate/fsm": "^1.4.0",
|
||||||
"mitt": "^1.1.3",
|
"mitt": "^1.1.3",
|
||||||
"pako": "^1.0.11",
|
"pako": "^1.0.11",
|
||||||
"rrweb-snapshot": "^0.8.0",
|
"rrweb-snapshot": "^0.8.1",
|
||||||
"smoothscroll-polyfill": "^0.4.3"
|
"smoothscroll-polyfill": "^0.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function getCode(): string {
|
|||||||
width: 1600,
|
width: 1600,
|
||||||
height: 900,
|
height: 900,
|
||||||
},
|
},
|
||||||
args: ['--start-maximized'],
|
args: ['--start-maximized', '--ignore-certificate-errors'],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto(url, {
|
await page.goto(url, {
|
||||||
@@ -94,7 +94,8 @@ function getCode(): string {
|
|||||||
await page.evaluate(`;${code}
|
await page.evaluate(`;${code}
|
||||||
window.__IS_RECORDING__ = true
|
window.__IS_RECORDING__ = true
|
||||||
rrweb.record({
|
rrweb.record({
|
||||||
emit: event => window._replLog(event)
|
emit: event => window._replLog(event),
|
||||||
|
recordCanvas: true
|
||||||
});
|
});
|
||||||
`);
|
`);
|
||||||
page.on('framenavigated', async () => {
|
page.on('framenavigated', async () => {
|
||||||
@@ -103,14 +104,14 @@ function getCode(): string {
|
|||||||
await page.evaluate(`;${code}
|
await page.evaluate(`;${code}
|
||||||
window.__IS_RECORDING__ = true
|
window.__IS_RECORDING__ = true
|
||||||
rrweb.record({
|
rrweb.record({
|
||||||
emit: event => window._replLog(event)
|
emit: event => window._replLog(event),
|
||||||
|
recordCanvas: true
|
||||||
});
|
});
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.once('done', async shouldReplay => {
|
emitter.once('done', async (shouldReplay) => {
|
||||||
console.log(`Recorded ${events.length} events`);
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
if (shouldReplay) {
|
if (shouldReplay) {
|
||||||
await replay();
|
await replay();
|
||||||
@@ -170,7 +171,9 @@ function getCode(): string {
|
|||||||
'<\\/script>',
|
'<\\/script>',
|
||||||
)};
|
)};
|
||||||
/*-->*/
|
/*-->*/
|
||||||
const replayer = new rrweb.Replayer(events);
|
const replayer = new rrweb.Replayer(events, {
|
||||||
|
UNSAFE_replayCanvas: true
|
||||||
|
});
|
||||||
replayer.play();
|
replayer.play();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -182,10 +185,10 @@ function getCode(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
process
|
process
|
||||||
.on('uncaughtException', error => {
|
.on('uncaughtException', (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
})
|
})
|
||||||
.on('unhandledRejection', error => {
|
.on('unhandledRejection', (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function record<T = eventWithTime>(
|
|||||||
packFn,
|
packFn,
|
||||||
sampling = {},
|
sampling = {},
|
||||||
mousemoveWait,
|
mousemoveWait,
|
||||||
|
recordCanvas = false,
|
||||||
} = options;
|
} = options;
|
||||||
// runtime checks for user options
|
// runtime checks for user options
|
||||||
if (!emit) {
|
if (!emit) {
|
||||||
@@ -113,6 +114,7 @@ function record<T = eventWithTime>(
|
|||||||
blockClass,
|
blockClass,
|
||||||
inlineStylesheet,
|
inlineStylesheet,
|
||||||
maskInputOptions,
|
maskInputOptions,
|
||||||
|
recordCanvas,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@@ -244,11 +246,22 @@ function record<T = eventWithTime>(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
canvasMutationCb: (p) =>
|
||||||
|
wrappedEmit(
|
||||||
|
wrapEvent({
|
||||||
|
type: EventType.IncrementalSnapshot,
|
||||||
|
data: {
|
||||||
|
source: IncrementalSource.CanvasMutation,
|
||||||
|
...p,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
blockClass,
|
blockClass,
|
||||||
ignoreClass,
|
ignoreClass,
|
||||||
maskInputOptions,
|
maskInputOptions,
|
||||||
inlineStylesheet,
|
inlineStylesheet,
|
||||||
sampling,
|
sampling,
|
||||||
|
recordCanvas,
|
||||||
},
|
},
|
||||||
hooks,
|
hooks,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -138,16 +138,19 @@ export default class MutationBuffer {
|
|||||||
private blockClass: blockClass;
|
private blockClass: blockClass;
|
||||||
private inlineStylesheet: boolean;
|
private inlineStylesheet: boolean;
|
||||||
private maskInputOptions: MaskInputOptions;
|
private maskInputOptions: MaskInputOptions;
|
||||||
|
private recordCanvas: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
cb: mutationCallBack,
|
cb: mutationCallBack,
|
||||||
blockClass: blockClass,
|
blockClass: blockClass,
|
||||||
inlineStylesheet: boolean,
|
inlineStylesheet: boolean,
|
||||||
maskInputOptions: MaskInputOptions,
|
maskInputOptions: MaskInputOptions,
|
||||||
|
recordCanvas: boolean,
|
||||||
) {
|
) {
|
||||||
this.blockClass = blockClass;
|
this.blockClass = blockClass;
|
||||||
this.inlineStylesheet = inlineStylesheet;
|
this.inlineStylesheet = inlineStylesheet;
|
||||||
this.maskInputOptions = maskInputOptions;
|
this.maskInputOptions = maskInputOptions;
|
||||||
|
this.recordCanvas = recordCanvas;
|
||||||
this.emissionCallback = cb;
|
this.emissionCallback = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +190,7 @@ export default class MutationBuffer {
|
|||||||
true,
|
true,
|
||||||
this.inlineStylesheet,
|
this.inlineStylesheet,
|
||||||
this.maskInputOptions,
|
this.maskInputOptions,
|
||||||
|
this.recordCanvas,
|
||||||
)!,
|
)!,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getWindowWidth,
|
getWindowWidth,
|
||||||
isBlocked,
|
isBlocked,
|
||||||
isTouchEvent,
|
isTouchEvent,
|
||||||
|
patch,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import {
|
import {
|
||||||
mutationCallBack,
|
mutationCallBack,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
mediaInteractionCallback,
|
mediaInteractionCallback,
|
||||||
MediaInteractions,
|
MediaInteractions,
|
||||||
SamplingStrategy,
|
SamplingStrategy,
|
||||||
|
canvasMutationCallback,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import MutationBuffer from './mutation';
|
import MutationBuffer from './mutation';
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ function initMutationObserver(
|
|||||||
blockClass: blockClass,
|
blockClass: blockClass,
|
||||||
inlineStylesheet: boolean,
|
inlineStylesheet: boolean,
|
||||||
maskInputOptions: MaskInputOptions,
|
maskInputOptions: MaskInputOptions,
|
||||||
|
recordCanvas: boolean,
|
||||||
): MutationObserver {
|
): MutationObserver {
|
||||||
// see mutation.ts for details
|
// see mutation.ts for details
|
||||||
const mutationBuffer = new MutationBuffer(
|
const mutationBuffer = new MutationBuffer(
|
||||||
@@ -45,6 +48,7 @@ function initMutationObserver(
|
|||||||
blockClass,
|
blockClass,
|
||||||
inlineStylesheet,
|
inlineStylesheet,
|
||||||
maskInputOptions,
|
maskInputOptions,
|
||||||
|
recordCanvas,
|
||||||
);
|
);
|
||||||
const observer = new MutationObserver(mutationBuffer.processMutations);
|
const observer = new MutationObserver(mutationBuffer.processMutations);
|
||||||
observer.observe(document, {
|
observer.observe(document, {
|
||||||
@@ -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) {
|
function mergeHooks(o: observerParam, hooks: hooksParam) {
|
||||||
const {
|
const {
|
||||||
mutationCb,
|
mutationCb,
|
||||||
@@ -367,6 +440,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
|
|||||||
inputCb,
|
inputCb,
|
||||||
mediaInteractionCb,
|
mediaInteractionCb,
|
||||||
styleSheetRuleCb,
|
styleSheetRuleCb,
|
||||||
|
canvasMutationCb,
|
||||||
} = o;
|
} = o;
|
||||||
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
|
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
|
||||||
if (hooks.mutation) {
|
if (hooks.mutation) {
|
||||||
@@ -416,6 +490,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
|
|||||||
}
|
}
|
||||||
styleSheetRuleCb(...p);
|
styleSheetRuleCb(...p);
|
||||||
};
|
};
|
||||||
|
o.canvasMutationCb = (...p: Arguments<canvasMutationCallback>) => {
|
||||||
|
if (hooks.canvasMutation) {
|
||||||
|
hooks.canvasMutation(...p);
|
||||||
|
}
|
||||||
|
canvasMutationCb(...p);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function initObservers(
|
export default function initObservers(
|
||||||
@@ -428,6 +508,7 @@ export default function initObservers(
|
|||||||
o.blockClass,
|
o.blockClass,
|
||||||
o.inlineStylesheet,
|
o.inlineStylesheet,
|
||||||
o.maskInputOptions,
|
o.maskInputOptions,
|
||||||
|
o.recordCanvas,
|
||||||
);
|
);
|
||||||
const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling);
|
const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling);
|
||||||
const mouseInteractionHandler = initMouseInteractionObserver(
|
const mouseInteractionHandler = initMouseInteractionObserver(
|
||||||
@@ -453,6 +534,9 @@ export default function initObservers(
|
|||||||
o.blockClass,
|
o.blockClass,
|
||||||
);
|
);
|
||||||
const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb);
|
const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb);
|
||||||
|
const canvasMutationObserver = o.recordCanvas
|
||||||
|
? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass)
|
||||||
|
: () => {};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mutationObserver.disconnect();
|
mutationObserver.disconnect();
|
||||||
@@ -463,5 +547,6 @@ export default function initObservers(
|
|||||||
inputHandler();
|
inputHandler();
|
||||||
mediaInteractionHandler();
|
mediaInteractionHandler();
|
||||||
styleSheetObserver();
|
styleSheetObserver();
|
||||||
|
canvasMutationObserver();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
mutationData,
|
mutationData,
|
||||||
scrollData,
|
scrollData,
|
||||||
inputData,
|
inputData,
|
||||||
|
canvasMutationData,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { mirror, polyfill, TreeIndex } from '../utils';
|
import { mirror, polyfill, TreeIndex } from '../utils';
|
||||||
import getInjectStyleRules from './styles/inject-style';
|
import getInjectStyleRules from './styles/inject-style';
|
||||||
@@ -50,6 +51,7 @@ const defaultConfig: playerConfig = {
|
|||||||
liveMode: false,
|
liveMode: false,
|
||||||
insertStyleRules: [],
|
insertStyleRules: [],
|
||||||
triggerFocus: true,
|
triggerFocus: true,
|
||||||
|
UNSAFE_replayCanvas: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Replayer {
|
export class Replayer {
|
||||||
@@ -76,6 +78,8 @@ export class Replayer {
|
|||||||
private treeIndex!: TreeIndex;
|
private treeIndex!: TreeIndex;
|
||||||
private fragmentParentMap!: Map<INode, INode>;
|
private fragmentParentMap!: Map<INode, INode>;
|
||||||
|
|
||||||
|
private imageMap: Map<eventWithTime, HTMLImageElement> = new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
events: Array<eventWithTime | string>,
|
events: Array<eventWithTime | string>,
|
||||||
config?: Partial<playerConfig>,
|
config?: Partial<playerConfig>,
|
||||||
@@ -289,7 +293,11 @@ export class Replayer {
|
|||||||
this.wrapper.appendChild(this.mouse);
|
this.wrapper.appendChild(this.mouse);
|
||||||
|
|
||||||
this.iframe = document.createElement('iframe');
|
this.iframe = document.createElement('iframe');
|
||||||
this.iframe.setAttribute('sandbox', 'allow-same-origin');
|
const attributes = ['allow-same-origin'];
|
||||||
|
if (this.config.UNSAFE_replayCanvas) {
|
||||||
|
attributes.push('allow-scripts');
|
||||||
|
}
|
||||||
|
this.iframe.setAttribute('sandbox', attributes.join(' '));
|
||||||
this.disableInteract();
|
this.disableInteract();
|
||||||
this.wrapper.appendChild(this.iframe);
|
this.wrapper.appendChild(this.iframe);
|
||||||
}
|
}
|
||||||
@@ -413,6 +421,9 @@ export class Replayer {
|
|||||||
}
|
}
|
||||||
this.emitter.emit(ReplayerEvents.FullsnapshotRebuilded, event);
|
this.emitter.emit(ReplayerEvents.FullsnapshotRebuilded, event);
|
||||||
this.waitForStylesheetLoad();
|
this.waitForStylesheetLoad();
|
||||||
|
if (this.config.UNSAFE_replayCanvas) {
|
||||||
|
this.preloadAllImages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -465,6 +476,44 @@ export class Replayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pause when there are some canvas drawImage args need to be loaded
|
||||||
|
*/
|
||||||
|
private preloadAllImages() {
|
||||||
|
let beforeLoadState = this.service.state;
|
||||||
|
const { unsubscribe } = this.service.subscribe((state) => {
|
||||||
|
beforeLoadState = state;
|
||||||
|
});
|
||||||
|
let count = 0;
|
||||||
|
let resolved = 0;
|
||||||
|
for (const event of this.service.state.context.events) {
|
||||||
|
if (
|
||||||
|
event.type === EventType.IncrementalSnapshot &&
|
||||||
|
event.data.source === IncrementalSource.CanvasMutation &&
|
||||||
|
event.data.property === 'drawImage' &&
|
||||||
|
typeof event.data.args[0] === 'string' &&
|
||||||
|
!this.imageMap.has(event)
|
||||||
|
) {
|
||||||
|
count++;
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.src = event.data.args[0];
|
||||||
|
this.imageMap.set(event, image);
|
||||||
|
image.onload = () => {
|
||||||
|
resolved++;
|
||||||
|
if (resolved === count) {
|
||||||
|
if (beforeLoadState.matches('playing')) {
|
||||||
|
this.play(this.getCurrentTime());
|
||||||
|
}
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count !== resolved) {
|
||||||
|
this.service.send({ type: 'PAUSE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private applyIncremental(
|
private applyIncremental(
|
||||||
e: incrementalSnapshotEvent & { timestamp: number },
|
e: incrementalSnapshotEvent & { timestamp: number },
|
||||||
isSync: boolean,
|
isSync: boolean,
|
||||||
@@ -643,6 +692,43 @@ export class Replayer {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case IncrementalSource.CanvasMutation: {
|
||||||
|
if (!this.config.UNSAFE_replayCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = mirror.getNode(d.id);
|
||||||
|
if (!target) {
|
||||||
|
return this.debugNodeNotFound(d, d.id);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ctx = ((target as unknown) as HTMLCanvasElement).getContext(
|
||||||
|
'2d',
|
||||||
|
)!;
|
||||||
|
if (d.setter) {
|
||||||
|
// skip some read-only type checks
|
||||||
|
// tslint:disable-next-line:no-any
|
||||||
|
(ctx as any)[d.property] = d.args[0];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const original = ctx[
|
||||||
|
d.property as keyof CanvasRenderingContext2D
|
||||||
|
] as Function;
|
||||||
|
/**
|
||||||
|
* We have serialized the image source into base64 string during recording,
|
||||||
|
* which has been preloaded before replay.
|
||||||
|
* So we can get call drawImage SYNCHRONOUSLY which avoid some fragile cast.
|
||||||
|
*/
|
||||||
|
if (d.property === 'drawImage' && typeof d.args[0] === 'string') {
|
||||||
|
const image = this.imageMap.get(e);
|
||||||
|
d.args[0] = image;
|
||||||
|
original.apply(ctx, d.args);
|
||||||
|
} else {
|
||||||
|
original.apply(ctx, d.args);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.warnCanvasMutationFailed(d, d.id, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -914,6 +1000,19 @@ export class Replayer {
|
|||||||
console.warn(REPLAY_CONSOLE_PREFIX, `Node with id '${id}' not found in`, d);
|
console.warn(REPLAY_CONSOLE_PREFIX, `Node with id '${id}' not found in`, d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private warnCanvasMutationFailed(
|
||||||
|
d: canvasMutationData,
|
||||||
|
id: number,
|
||||||
|
error: unknown,
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
REPLAY_CONSOLE_PREFIX,
|
||||||
|
`Has error on update canvas '${id}'`,
|
||||||
|
d,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private debugNodeNotFound(d: incrementalData, id: number) {
|
private debugNodeNotFound(d: incrementalData, id: number) {
|
||||||
/**
|
/**
|
||||||
* There maybe some valid scenes of node not being found.
|
* There maybe some valid scenes of node not being found.
|
||||||
|
|||||||
22
src/types.ts
22
src/types.ts
@@ -70,6 +70,7 @@ export enum IncrementalSource {
|
|||||||
TouchMove,
|
TouchMove,
|
||||||
MediaInteraction,
|
MediaInteraction,
|
||||||
StyleSheetRule,
|
StyleSheetRule,
|
||||||
|
CanvasMutation,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type mutationData = {
|
export type mutationData = {
|
||||||
@@ -106,6 +107,10 @@ export type styleSheetRuleData = {
|
|||||||
source: IncrementalSource.StyleSheetRule;
|
source: IncrementalSource.StyleSheetRule;
|
||||||
} & styleSheetRuleParam;
|
} & styleSheetRuleParam;
|
||||||
|
|
||||||
|
export type canvasMutationData = {
|
||||||
|
source: IncrementalSource.CanvasMutation;
|
||||||
|
} & canvasMutationParam;
|
||||||
|
|
||||||
export type incrementalData =
|
export type incrementalData =
|
||||||
| mutationData
|
| mutationData
|
||||||
| mousemoveData
|
| mousemoveData
|
||||||
@@ -114,7 +119,8 @@ export type incrementalData =
|
|||||||
| viewportResizeData
|
| viewportResizeData
|
||||||
| inputData
|
| inputData
|
||||||
| mediaInteractionData
|
| mediaInteractionData
|
||||||
| styleSheetRuleData;
|
| styleSheetRuleData
|
||||||
|
| canvasMutationData;
|
||||||
|
|
||||||
export type event =
|
export type event =
|
||||||
| domContentLoadedEvent
|
| domContentLoadedEvent
|
||||||
@@ -165,6 +171,7 @@ export type recordOptions<T> = {
|
|||||||
hooks?: hooksParam;
|
hooks?: hooksParam;
|
||||||
packFn?: PackFn;
|
packFn?: PackFn;
|
||||||
sampling?: SamplingStrategy;
|
sampling?: SamplingStrategy;
|
||||||
|
recordCanvas?: boolean;
|
||||||
// departed, please use sampling options
|
// departed, please use sampling options
|
||||||
mousemoveWait?: number;
|
mousemoveWait?: number;
|
||||||
};
|
};
|
||||||
@@ -182,7 +189,9 @@ export type observerParam = {
|
|||||||
maskInputOptions: MaskInputOptions;
|
maskInputOptions: MaskInputOptions;
|
||||||
inlineStylesheet: boolean;
|
inlineStylesheet: boolean;
|
||||||
styleSheetRuleCb: styleSheetRuleCallback;
|
styleSheetRuleCb: styleSheetRuleCallback;
|
||||||
|
canvasMutationCb: canvasMutationCallback;
|
||||||
sampling: SamplingStrategy;
|
sampling: SamplingStrategy;
|
||||||
|
recordCanvas: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type hooksParam = {
|
export type hooksParam = {
|
||||||
@@ -194,6 +203,7 @@ export type hooksParam = {
|
|||||||
input?: inputCallback;
|
input?: inputCallback;
|
||||||
mediaInteaction?: mediaInteractionCallback;
|
mediaInteaction?: mediaInteractionCallback;
|
||||||
styleSheetRule?: styleSheetRuleCallback;
|
styleSheetRule?: styleSheetRuleCallback;
|
||||||
|
canvasMutation?: canvasMutationCallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
// https://dom.spec.whatwg.org/#interface-mutationrecord
|
// https://dom.spec.whatwg.org/#interface-mutationrecord
|
||||||
@@ -309,6 +319,15 @@ export type styleSheetRuleParam = {
|
|||||||
|
|
||||||
export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void;
|
export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void;
|
||||||
|
|
||||||
|
export type canvasMutationCallback = (p: canvasMutationParam) => void;
|
||||||
|
|
||||||
|
export type canvasMutationParam = {
|
||||||
|
id: number;
|
||||||
|
property: string;
|
||||||
|
args: Array<unknown>;
|
||||||
|
setter?: true;
|
||||||
|
};
|
||||||
|
|
||||||
export type viewportResizeDimention = {
|
export type viewportResizeDimention = {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -362,6 +381,7 @@ export type playerConfig = {
|
|||||||
liveMode: boolean;
|
liveMode: boolean;
|
||||||
insertStyleRules: string[];
|
insertStyleRules: string[];
|
||||||
triggerFocus: boolean;
|
triggerFocus: boolean;
|
||||||
|
UNSAFE_replayCanvas: boolean;
|
||||||
unpackFn?: UnpackFn;
|
unpackFn?: UnpackFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
39
src/utils.ts
39
src/utils.ts
@@ -124,18 +124,18 @@ export function patch(
|
|||||||
// tslint:disable-next-line:no-any
|
// tslint:disable-next-line:no-any
|
||||||
replacement: (...args: any[]) => any,
|
replacement: (...args: any[]) => any,
|
||||||
): () => void {
|
): () => void {
|
||||||
if (!(name in source)) {
|
try {
|
||||||
return () => {};
|
if (!(name in source)) {
|
||||||
}
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
const original = source[name] as () => unknown;
|
const original = source[name] as () => unknown;
|
||||||
const wrapped = replacement(original);
|
const wrapped = replacement(original);
|
||||||
|
|
||||||
// Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work
|
// Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work
|
||||||
// otherwise it'll throw "TypeError: Object.defineProperties called on non-object"
|
// otherwise it'll throw "TypeError: Object.defineProperties called on non-object"
|
||||||
// tslint:disable-next-line:strict-type-predicates
|
// tslint:disable-next-line:strict-type-predicates
|
||||||
if (typeof wrapped === 'function') {
|
if (typeof wrapped === 'function') {
|
||||||
try {
|
|
||||||
wrapped.prototype = wrapped.prototype || {};
|
wrapped.prototype = wrapped.prototype || {};
|
||||||
Object.defineProperties(wrapped, {
|
Object.defineProperties(wrapped, {
|
||||||
__rrweb_original__: {
|
__rrweb_original__: {
|
||||||
@@ -143,17 +143,18 @@ export function patch(
|
|||||||
value: original,
|
value: original,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// This can throw if multiple fill happens on a global object like XMLHttpRequest
|
|
||||||
// Fixes https://github.com/getsentry/sentry-javascript/issues/2043
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
source[name] = wrapped;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
source[name] = original;
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return () => {};
|
||||||
|
// This can throw if multiple fill happens on a global object like XMLHttpRequest
|
||||||
|
// Fixes https://github.com/getsentry/sentry-javascript/issues/2043
|
||||||
}
|
}
|
||||||
|
|
||||||
source[name] = wrapped;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
source[name] = original;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindowHeight(): number {
|
export function getWindowHeight(): number {
|
||||||
|
|||||||
@@ -333,6 +333,230 @@ exports[`block 1`] = `
|
|||||||
]"
|
]"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`canvas 1`] = `
|
||||||
|
"[
|
||||||
|
{
|
||||||
|
\\"type\\": 0,
|
||||||
|
\\"data\\": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 1,
|
||||||
|
\\"data\\": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 4,
|
||||||
|
\\"data\\": {
|
||||||
|
\\"href\\": \\"about:blank\\",
|
||||||
|
\\"width\\": 1920,
|
||||||
|
\\"height\\": 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"data\\": {
|
||||||
|
\\"node\\": {
|
||||||
|
\\"type\\": 0,
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 1,
|
||||||
|
\\"name\\": \\"html\\",
|
||||||
|
\\"publicId\\": \\"\\",
|
||||||
|
\\"systemId\\": \\"\\",
|
||||||
|
\\"id\\": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"html\\",
|
||||||
|
\\"attributes\\": {
|
||||||
|
\\"lang\\": \\"en\\"
|
||||||
|
},
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"head\\",
|
||||||
|
\\"attributes\\": {},
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"meta\\",
|
||||||
|
\\"attributes\\": {
|
||||||
|
\\"charset\\": \\"UTF-8\\"
|
||||||
|
},
|
||||||
|
\\"childNodes\\": [],
|
||||||
|
\\"id\\": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"meta\\",
|
||||||
|
\\"attributes\\": {
|
||||||
|
\\"name\\": \\"viewport\\",
|
||||||
|
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
|
||||||
|
},
|
||||||
|
\\"childNodes\\": [],
|
||||||
|
\\"id\\": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"title\\",
|
||||||
|
\\"attributes\\": {},
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"canvas\\",
|
||||||
|
\\"id\\": 11
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 12
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"body\\",
|
||||||
|
\\"attributes\\": {},
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"canvas\\",
|
||||||
|
\\"attributes\\": {
|
||||||
|
\\"id\\": \\"myCanvas\\",
|
||||||
|
\\"width\\": \\"200\\",
|
||||||
|
\\"height\\": \\"100\\",
|
||||||
|
\\"style\\": \\"border: 1px solid #000000;\\",
|
||||||
|
\\"rr_dataURL\\": \\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAABkCAYAAADDhn8LAAAEK0lEQVR4Xu3d3VLjMAyG4YZzuBvu/9K6w2xZtqVJFcv5Ux5mOCgQ43yJ8lqSpQwXXxSgwKgCA20oQIFxBRiIu4MCEwowELcHBRiIe4ACbQogSJtujjqJAgzkJBfaabYpwEDadHPUSRRgICe50E6zTQEG0qabo06iQNhAru/v18vb2+UyDD/fY5+/fv719f37Z58j43z/zdQ4j/8nMq75/b2Grdelo37D52f4HtzCJsOTu358XLsZx5Y3deTimt/Pw63lITVDvzoGgiDrkTNixBFSttzckXE7zq+OgSDIz7Lk2U3U66aJjDPjCT25zF1qnBnj1jEQBEGQ3r7gMFzqGAiCIMiYY58IyNQxEARBEAQZD6SJYt3C20uHtvkgW0RzR/9nPMyLIAiCIAiyefIMQRAknJE/QBz/X0Y66sCunGfY+/zqOOmiWKJY0YeAPMhKDiuC5PZMHUA/BImsqddcdkTmM+MJuPdM9d7nV8dARLFEsUSxRLFEsTosn2cQGEEydQcHWEPvPUq09/nVMRBRLFEsUayJJRYfhA/CB+GD8EH4IP9bgb1YfKT73gFrhtzVg4w0BpgR5dh7HN/8cg066jjpfBA+CB+ED8IH4YPwQSLbTSJrcXma9F6xOksseRB5EHkQeZC7zPMjIV597kWmXuMcgHAI0utirzmOKJvOijdWyIPIg8iDTBT5xg2ED8IH4YPwQfgg63bv54Os6TtEQrOR+fBB+CB8EO8v2Twpai+WvVh3e7UQ7heZ6iyx7MWyF8teLHuxNl92nMxHQhB5hk3zDGrSc51M5UEiT+xevkOvcQpF2RAEQRBk7JURoliiWKJY068ORxAEQRAEuZFigb06ar5zNd971w9BEARBEARBnuZPloo2LTXuBlE2BEEQBEEQBEGQW7TqWV5pgkwIgiAIgiAIgiAIcg2/nbbX9o0DdOXY+16nvc+vzhJLTbqa9AXyXHUMRD2IehD1IOpB1IPozas3Lx8p11O3o351llh8ED4IH0RfLH2x9MVqW2IhCIIgCIIgCIIgyONu2Fefe+1y7TWO3by5Tgwzjo43bZAHkQeRB5EHkQeRB2lbYiEIgiAIgiAIgiBIx0zw3nfL7n1+Mum9bsY1xykUJdLVZEbI6smfimKpeNy04hFB1nzyyzPk3gy1gX51DEQUSxRLFEsUSxRLFEsUq9eyT818rq5Ed3fd3XV3193999rcEzr3ZD2RfnWcdPUg6kHUg6gHUQ+iHqTNSUcQBEEQBEEQBEGQVxWE9mLlMvIz9KvjpMuky6TLpMuky6TLpLctsRAEQRAEQRAEQZoIkis7cTQFjqlAuGDqmKdn1hTIKcBAcvo5urgCDKT4BXZ6OQUYSE4/RxdXgIEUv8BOL6cAA8np5+jiCjCQ4hfY6eUUYCA5/RxdXAEGUvwCO72cAn8Aw0BAdBcYDcUAAAAASUVORK5CYII=\\"
|
||||||
|
},
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 17
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\",
|
||||||
|
\\"id\\": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"script\\",
|
||||||
|
\\"attributes\\": {},
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
|
||||||
|
\\"id\\": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\\\n \\",
|
||||||
|
\\"id\\": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 2,
|
||||||
|
\\"tagName\\": \\"script\\",
|
||||||
|
\\"attributes\\": {},
|
||||||
|
\\"childNodes\\": [
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
|
||||||
|
\\"id\\": 23
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
|
||||||
|
\\"id\\": 24
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 14
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
\\"id\\": 1
|
||||||
|
},
|
||||||
|
\\"initialOffset\\": {
|
||||||
|
\\"left\\": 0,
|
||||||
|
\\"top\\": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"data\\": {
|
||||||
|
\\"source\\": 9,
|
||||||
|
\\"id\\": 16,
|
||||||
|
\\"property\\": \\"moveTo\\",
|
||||||
|
\\"args\\": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"data\\": {
|
||||||
|
\\"source\\": 9,
|
||||||
|
\\"id\\": 16,
|
||||||
|
\\"property\\": \\"lineTo\\",
|
||||||
|
\\"args\\": [
|
||||||
|
200,
|
||||||
|
100
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
\\"type\\": 3,
|
||||||
|
\\"data\\": {
|
||||||
|
\\"source\\": 9,
|
||||||
|
\\"id\\": 16,
|
||||||
|
\\"property\\": \\"stroke\\",
|
||||||
|
\\"args\\": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`character-data 1`] = `
|
exports[`character-data 1`] = `
|
||||||
"[
|
"[
|
||||||
{
|
{
|
||||||
|
|||||||
34
test/html/canvas.html
Normal file
34
test/html/canvas.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>canvas</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas
|
||||||
|
id="myCanvas"
|
||||||
|
width="200"
|
||||||
|
height="100"
|
||||||
|
style="border: 1px solid #000000;"
|
||||||
|
>
|
||||||
|
</canvas>
|
||||||
|
<script>
|
||||||
|
var c = document.getElementById('myCanvas');
|
||||||
|
var ctx = c.getContext('2d');
|
||||||
|
// Create gradient
|
||||||
|
var grd = ctx.createLinearGradient(0, 0, 200, 0);
|
||||||
|
grd.addColorStop(0, 'red');
|
||||||
|
grd.addColorStop(1, 'white');
|
||||||
|
|
||||||
|
// Fill with gradient
|
||||||
|
ctx.fillStyle = grd;
|
||||||
|
ctx.fillRect(10, 10, 150, 80);
|
||||||
|
setTimeout(() => {
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(200, 100);
|
||||||
|
ctx.stroke();
|
||||||
|
}, 10);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -33,7 +33,8 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
window.snapshots.push(event);
|
window.snapshots.push(event);
|
||||||
},
|
},
|
||||||
maskAllInputs: ${options.maskAllInputs},
|
maskAllInputs: ${options.maskAllInputs},
|
||||||
maskInputOptions: ${JSON.stringify(options.maskAllInputs)}
|
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
|
||||||
|
recordCanvas: ${options.recordCanvas}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -244,6 +245,19 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
assertSnapshot(snapshots, __filename, 'react-styled-components');
|
assertSnapshot(snapshots, __filename, 'react-styled-components');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should record canvas mutations', async () => {
|
||||||
|
const page: puppeteer.Page = await this.browser.newPage();
|
||||||
|
await page.goto('about:blank');
|
||||||
|
await page.setContent(
|
||||||
|
getHtml.call(this, 'canvas.html', {
|
||||||
|
recordCanvas: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.waitFor(50);
|
||||||
|
const snapshots = await page.evaluate('window.snapshots');
|
||||||
|
assertSnapshot(snapshots, __filename, 'canvas');
|
||||||
|
});
|
||||||
|
|
||||||
it('will serialize node before record', async () => {
|
it('will serialize node before record', async () => {
|
||||||
const page: puppeteer.Page = await this.browser.newPage();
|
const page: puppeteer.Page = await this.browser.newPage();
|
||||||
await page.goto('about:blank');
|
await page.goto('about:blank');
|
||||||
|
|||||||
Reference in New Issue
Block a user