Files
rrweb/src/record/observer.ts
Justin Halsall a4426d2927 Add ability to mask passwords (#494)
* add ability to mask passwords

* remove duplicate mask snapshot

* make sure only one click happens at a time
2026-04-01 12:00:00 +08:00

882 lines
23 KiB
TypeScript

import { INode, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { FontFaceDescriptors, FontFaceSet } from 'css-font-loading-module';
import {
throttle,
on,
hookSetter,
getWindowHeight,
getWindowWidth,
isBlocked,
isTouchEvent,
patch,
} from '../utils';
import {
mutationCallBack,
observerParam,
mousemoveCallBack,
mousePosition,
mouseInteractionCallBack,
MouseInteractions,
listenerHandler,
scrollCallback,
styleSheetRuleCallback,
viewportResizeCallback,
inputValue,
inputCallback,
hookResetter,
blockClass,
maskTextClass,
IncrementalSource,
hooksParam,
Arguments,
mediaInteractionCallback,
MediaInteractions,
SamplingStrategy,
canvasMutationCallback,
fontCallback,
fontParam,
MaskInputFn,
MaskTextFn,
logCallback,
LogRecordOptions,
Logger,
LogLevel,
Mirror,
} from '../types';
import MutationBuffer from './mutation';
import { stringify } from './stringify';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
import { StackFrame, ErrorStackParser } from './error-stack-parser';
type WindowWithStoredMutationObserver = Window & {
__rrMutationObserver?: MutationObserver;
};
type WindowWithAngularZone = Window & {
Zone?: {
__symbol__?: (key: string) => string;
};
};
export const mutationBuffers: MutationBuffer[] = [];
function getEventTarget(event: Event): EventTarget | null {
try {
if ('composedPath' in event) {
const path = event.composedPath();
if (path.length) {
return path[0];
}
} else if (
'path' in event &&
(event as { path: EventTarget[] }).path.length
) {
return (event as { path: EventTarget[] }).path[0];
}
return event.target;
} catch {
return event.target;
}
}
export function initMutationObserver(
cb: mutationCallBack,
doc: Document,
blockClass: blockClass,
blockSelector: string | null,
maskTextClass: maskTextClass,
maskTextSelector: string | null,
inlineStylesheet: boolean,
maskInputOptions: MaskInputOptions,
maskTextFn: MaskTextFn | undefined,
recordCanvas: boolean,
slimDOMOptions: SlimDOMOptions,
mirror: Mirror,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
rootEl: Node,
): MutationObserver {
const mutationBuffer = new MutationBuffer();
mutationBuffers.push(mutationBuffer);
// see mutation.ts for details
mutationBuffer.init(
cb,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskInputOptions,
maskTextFn,
recordCanvas,
slimDOMOptions,
doc,
mirror,
iframeManager,
shadowDomManager,
);
let mutationObserverCtor =
window.MutationObserver ||
/**
* Some websites may disable MutationObserver by removing it from the window object.
* If someone is using rrweb to build a browser extention or things like it, they
* could not change the website's code but can have an opportunity to inject some
* code before the website executing its JS logic.
* Then they can do this to store the native MutationObserver:
* window.__rrMutationObserver = MutationObserver
*/
(window as WindowWithStoredMutationObserver).__rrMutationObserver;
const angularZoneSymbol = (window as WindowWithAngularZone)?.Zone?.__symbol__?.(
'MutationObserver',
);
if (
angularZoneSymbol &&
((window as unknown) as Record<string, typeof MutationObserver>)[
angularZoneSymbol
]
) {
mutationObserverCtor = ((window as unknown) as Record<
string,
typeof MutationObserver
>)[angularZoneSymbol];
}
const observer = new mutationObserverCtor(
mutationBuffer.processMutations.bind(mutationBuffer),
);
observer.observe(rootEl, {
attributes: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true,
});
return observer;
}
function initMoveObserver(
cb: mousemoveCallBack,
sampling: SamplingStrategy,
doc: Document,
mirror: Mirror,
): listenerHandler {
if (sampling.mousemove === false) {
return () => {};
}
const threshold =
typeof sampling.mousemove === 'number' ? sampling.mousemove : 50;
const callbackThreshold =
typeof sampling.mousemoveCallback === 'number'
? sampling.mousemoveCallback
: 500;
let positions: mousePosition[] = [];
let timeBaseline: number | null;
const wrappedCb = throttle(
(
source:
| IncrementalSource.MouseMove
| IncrementalSource.TouchMove
| IncrementalSource.Drag,
) => {
const totalOffset = Date.now() - timeBaseline!;
cb(
positions.map((p) => {
p.timeOffset -= totalOffset;
return p;
}),
source,
);
positions = [];
timeBaseline = null;
},
callbackThreshold,
);
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>(
(evt) => {
const target = getEventTarget(evt);
const { clientX, clientY } = isTouchEvent(evt)
? evt.changedTouches[0]
: evt;
if (!timeBaseline) {
timeBaseline = Date.now();
}
positions.push({
x: clientX,
y: clientY,
id: mirror.getId(target as INode),
timeOffset: Date.now() - timeBaseline,
});
wrappedCb(
evt instanceof DragEvent
? IncrementalSource.Drag
: evt instanceof MouseEvent
? IncrementalSource.MouseMove
: IncrementalSource.TouchMove,
);
},
threshold,
{
trailing: false,
},
);
const handlers = [
on('mousemove', updatePosition, doc),
on('touchmove', updatePosition, doc),
on('drag', updatePosition, doc),
];
return () => {
handlers.forEach((h) => h());
};
}
function initMouseInteractionObserver(
cb: mouseInteractionCallBack,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
sampling: SamplingStrategy,
): listenerHandler {
if (sampling.mouseInteraction === false) {
return () => {};
}
const disableMap: Record<string, boolean | undefined> =
sampling.mouseInteraction === true ||
sampling.mouseInteraction === undefined
? {}
: sampling.mouseInteraction;
const handlers: listenerHandler[] = [];
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent | TouchEvent) => {
const target = getEventTarget(event) as Node;
if (isBlocked(target as Node, blockClass)) {
return;
}
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
if (!e) {
return;
}
const id = mirror.getId(target as INode);
const { clientX, clientY } = e;
cb({
type: MouseInteractions[eventKey],
id,
x: clientX,
y: clientY,
});
};
};
Object.keys(MouseInteractions)
.filter(
(key) =>
Number.isNaN(Number(key)) &&
!key.endsWith('_Departed') &&
disableMap[key] !== false,
)
.forEach((eventKey: keyof typeof MouseInteractions) => {
const eventName = eventKey.toLowerCase();
const handler = getHandler(eventKey);
handlers.push(on(eventName, handler, doc));
});
return () => {
handlers.forEach((h) => h());
};
}
export function initScrollObserver(
cb: scrollCallback,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
sampling: SamplingStrategy,
): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => {
const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass)) {
return;
}
const id = mirror.getId(target as INode);
if (target === doc) {
const scrollEl = (doc.scrollingElement || doc.documentElement)!;
cb({
id,
x: scrollEl.scrollLeft,
y: scrollEl.scrollTop,
});
} else {
cb({
id,
x: (target as HTMLElement).scrollLeft,
y: (target as HTMLElement).scrollTop,
});
}
}, sampling.scroll || 100);
return on('scroll', updatePosition, doc);
}
function initViewportResizeObserver(
cb: viewportResizeCallback,
): listenerHandler {
let lastH = -1;
let lastW = -1;
const updateDimension = throttle(() => {
const height = getWindowHeight();
const width = getWindowWidth();
if (lastH !== height || lastW !== width) {
cb({
width: Number(width),
height: Number(height),
});
lastH = height;
lastW = width;
}
}, 200);
return on('resize', updateDimension, window);
}
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver(
cb: inputCallback,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
ignoreClass: string,
maskInputOptions: MaskInputOptions,
maskInputFn: MaskInputFn | undefined,
sampling: SamplingStrategy,
): listenerHandler {
function eventHandler(event: Event) {
const target = getEventTarget(event);
if (
!target ||
!(target as Element).tagName ||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
isBlocked(target as Node, blockClass)
) {
return;
}
const type: string | undefined = (target as HTMLInputElement).type;
if ((target as HTMLElement).classList.contains(ignoreClass)) {
return;
}
let text = (target as HTMLInputElement).value;
let isChecked = false;
if (type === 'radio' || type === 'checkbox') {
isChecked = (target as HTMLInputElement).checked;
} else if (
maskInputOptions[
(target as Element).tagName.toLowerCase() as keyof MaskInputOptions
] ||
maskInputOptions[type as keyof MaskInputOptions]
) {
if (maskInputFn) {
text = maskInputFn(text);
} else {
text = '*'.repeat(text.length);
}
}
cbWithDedup(target, { text, isChecked });
// if a radio was checked
// the other radios with the same name attribute will be unchecked.
const name: string | undefined = (target as HTMLInputElement).name;
if (type === 'radio' && name && isChecked) {
doc
.querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach((el) => {
if (el !== target) {
cbWithDedup(el, {
text: (el as HTMLInputElement).value,
isChecked: !isChecked,
});
}
});
}
}
function cbWithDedup(target: EventTarget, v: inputValue) {
const lastInputValue = lastInputValueMap.get(target);
if (
!lastInputValue ||
lastInputValue.text !== v.text ||
lastInputValue.isChecked !== v.isChecked
) {
lastInputValueMap.set(target, v);
const id = mirror.getId(target as INode);
cb({
...v,
id,
});
}
}
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
const handlers: Array<
listenerHandler | hookResetter
> = events.map((eventName) => on(eventName, eventHandler, doc));
const propertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
);
const hookProperties: Array<[HTMLElement, string]> = [
[HTMLInputElement.prototype, 'value'],
[HTMLInputElement.prototype, 'checked'],
[HTMLSelectElement.prototype, 'value'],
[HTMLTextAreaElement.prototype, 'value'],
// Some UI library use selectedIndex to set select value
[HTMLSelectElement.prototype, 'selectedIndex'],
];
if (propertyDescriptor && propertyDescriptor.set) {
handlers.push(
...hookProperties.map((p) =>
hookSetter<HTMLElement>(p[0], p[1], {
set() {
// mock to a normal event
eventHandler({ target: this } as Event);
},
}),
),
);
}
return () => {
handlers.forEach((h) => h());
};
}
function initStyleSheetObserver(
cb: styleSheetRuleCallback,
mirror: Mirror,
): listenerHandler {
const insertRule = CSSStyleSheet.prototype.insertRule;
CSSStyleSheet.prototype.insertRule = function (rule: string, index?: number) {
const id = mirror.getId(this.ownerNode as INode);
if (id !== -1) {
cb({
id,
adds: [{ rule, index }],
});
}
return insertRule.apply(this, arguments);
};
const deleteRule = CSSStyleSheet.prototype.deleteRule;
CSSStyleSheet.prototype.deleteRule = function (index: number) {
const id = mirror.getId(this.ownerNode as INode);
if (id !== -1) {
cb({
id,
removes: [{ index }],
});
}
return deleteRule.apply(this, arguments);
};
return () => {
CSSStyleSheet.prototype.insertRule = insertRule;
CSSStyleSheet.prototype.deleteRule = deleteRule;
};
}
function initMediaInteractionObserver(
mediaInteractionCb: mediaInteractionCallback,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const handler = (type: 'play' | 'pause') => (event: Event) => {
const target = getEventTarget(event);
if (!target || isBlocked(target as Node, blockClass)) {
return;
}
mediaInteractionCb({
type: type === 'play' ? MediaInteractions.Play : MediaInteractions.Pause,
id: mirror.getId(target as INode),
});
};
const handlers = [on('play', handler('play')), on('pause', handler('pause'))];
return () => {
handlers.forEach((h) => h());
};
}
function initCanvasMutationObserver(
cb: canvasMutationCallback,
blockClass: blockClass,
mirror: Mirror,
): 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
) {
const canvas = recordArgs[0]
const ctx = canvas.getContext('2d')
let imgd = ctx?.getImageData(0, 0, canvas.width, canvas.height)
let pix = imgd?.data;
recordArgs[0] = JSON.stringify(pix)
}
}
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 initFontObserver(cb: fontCallback): listenerHandler {
const handlers: listenerHandler[] = [];
const fontMap = new WeakMap<FontFace, fontParam>();
const originalFontFace = FontFace;
// tslint:disable-next-line: no-any
(window as any).FontFace = function FontFace(
family: string,
source: string | ArrayBufferView,
descriptors?: FontFaceDescriptors,
) {
const fontFace = new originalFontFace(family, source, descriptors);
fontMap.set(fontFace, {
family,
buffer: typeof source !== 'string',
descriptors,
fontSource:
typeof source === 'string'
? source
: // tslint:disable-next-line: no-any
JSON.stringify(Array.from(new Uint8Array(source as any))),
});
return fontFace;
};
const restoreHandler = patch(document.fonts, 'add', function (original) {
return function (this: FontFaceSet, fontFace: FontFace) {
setTimeout(() => {
const p = fontMap.get(fontFace);
if (p) {
cb(p);
fontMap.delete(fontFace);
}
}, 0);
return original.apply(this, [fontFace]);
};
});
handlers.push(() => {
// tslint:disable-next-line: no-any
(window as any).FonFace = originalFontFace;
});
handlers.push(restoreHandler);
return () => {
handlers.forEach((h) => h());
};
}
function initLogObserver(
cb: logCallback,
logOptions: LogRecordOptions,
): listenerHandler {
const logger = logOptions.logger;
if (!logger) {
return () => {};
}
let logCount = 0;
const cancelHandlers: listenerHandler[] = [];
// add listener to thrown errors
if (logOptions.level!.includes('error')) {
if (window) {
const originalOnError = window.onerror;
window.onerror = (
msg: Event | string,
file: string,
line: number,
col: number,
error: Error,
) => {
if (originalOnError) {
originalOnError.apply(this, [msg, file, line, col, error]);
}
const trace: string[] = ErrorStackParser.parse(
error,
).map((stackFrame: StackFrame) => stackFrame.toString());
const payload = [stringify(msg, logOptions.stringifyOptions)];
cb({
level: 'error',
trace,
payload,
});
};
cancelHandlers.push(() => {
window.onerror = originalOnError;
});
}
}
for (const levelType of logOptions.level!) {
cancelHandlers.push(replace(logger, levelType));
}
return () => {
cancelHandlers.forEach((h) => h());
};
/**
* replace the original console function and record logs
* @param logger the logger object such as Console
* @param level the name of log function to be replaced
*/
function replace(_logger: Logger, level: LogLevel) {
if (!_logger[level]) {
return () => {};
}
// replace the logger.{level}. return a restore function
return patch(_logger, level, (original) => {
return (...args: unknown[]) => {
original.apply(this, args);
try {
const trace = ErrorStackParser.parse(new Error())
.map((stackFrame: StackFrame) => stackFrame.toString())
.splice(1); // splice(1) to omit the hijacked log function
const payload = args.map((s) =>
stringify(s, logOptions.stringifyOptions),
);
logCount++;
if (logCount < logOptions.lengthThreshold!) {
cb({
level,
trace,
payload,
});
} else if (logCount === logOptions.lengthThreshold) {
// notify the user
cb({
level: 'warn',
trace: [],
payload: [
stringify('The number of log records reached the threshold.'),
],
});
}
} catch (error) {
original('rrweb logger error:', error, ...args);
}
};
});
}
}
function mergeHooks(o: observerParam, hooks: hooksParam) {
const {
mutationCb,
mousemoveCb,
mouseInteractionCb,
scrollCb,
viewportResizeCb,
inputCb,
mediaInteractionCb,
styleSheetRuleCb,
canvasMutationCb,
fontCb,
logCb,
} = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) {
hooks.mutation(...p);
}
mutationCb(...p);
};
o.mousemoveCb = (...p: Arguments<mousemoveCallBack>) => {
if (hooks.mousemove) {
hooks.mousemove(...p);
}
mousemoveCb(...p);
};
o.mouseInteractionCb = (...p: Arguments<mouseInteractionCallBack>) => {
if (hooks.mouseInteraction) {
hooks.mouseInteraction(...p);
}
mouseInteractionCb(...p);
};
o.scrollCb = (...p: Arguments<scrollCallback>) => {
if (hooks.scroll) {
hooks.scroll(...p);
}
scrollCb(...p);
};
o.viewportResizeCb = (...p: Arguments<viewportResizeCallback>) => {
if (hooks.viewportResize) {
hooks.viewportResize(...p);
}
viewportResizeCb(...p);
};
o.inputCb = (...p: Arguments<inputCallback>) => {
if (hooks.input) {
hooks.input(...p);
}
inputCb(...p);
};
o.mediaInteractionCb = (...p: Arguments<mediaInteractionCallback>) => {
if (hooks.mediaInteaction) {
hooks.mediaInteaction(...p);
}
mediaInteractionCb(...p);
};
o.styleSheetRuleCb = (...p: Arguments<styleSheetRuleCallback>) => {
if (hooks.styleSheetRule) {
hooks.styleSheetRule(...p);
}
styleSheetRuleCb(...p);
};
o.canvasMutationCb = (...p: Arguments<canvasMutationCallback>) => {
if (hooks.canvasMutation) {
hooks.canvasMutation(...p);
}
canvasMutationCb(...p);
};
o.fontCb = (...p: Arguments<fontCallback>) => {
if (hooks.font) {
hooks.font(...p);
}
fontCb(...p);
};
o.logCb = (...p: Arguments<logCallback>) => {
if (hooks.log) {
hooks.log(...p);
}
logCb(...p);
};
}
export function initObservers(
o: observerParam,
hooks: hooksParam = {},
): listenerHandler {
mergeHooks(o, hooks);
const mutationObserver = initMutationObserver(
o.mutationCb,
o.doc,
o.blockClass,
o.blockSelector,
o.maskTextClass,
o.maskTextSelector,
o.inlineStylesheet,
o.maskInputOptions,
o.maskTextFn,
o.recordCanvas,
o.slimDOMOptions,
o.mirror,
o.iframeManager,
o.shadowDomManager,
o.doc,
);
const mousemoveHandler = initMoveObserver(
o.mousemoveCb,
o.sampling,
o.doc,
o.mirror,
);
const mouseInteractionHandler = initMouseInteractionObserver(
o.mouseInteractionCb,
o.doc,
o.mirror,
o.blockClass,
o.sampling,
);
const scrollHandler = initScrollObserver(
o.scrollCb,
o.doc,
o.mirror,
o.blockClass,
o.sampling,
);
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
const inputHandler = initInputObserver(
o.inputCb,
o.doc,
o.mirror,
o.blockClass,
o.ignoreClass,
o.maskInputOptions,
o.maskInputFn,
o.sampling,
);
const mediaInteractionHandler = initMediaInteractionObserver(
o.mediaInteractionCb,
o.blockClass,
o.mirror,
);
const styleSheetObserver = initStyleSheetObserver(
o.styleSheetRuleCb,
o.mirror,
);
const canvasMutationObserver = o.recordCanvas
? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass, o.mirror)
: () => {};
const fontObserver = o.collectFonts ? initFontObserver(o.fontCb) : () => {};
const logObserver = o.logOptions
? initLogObserver(o.logCb, o.logOptions)
: () => {};
return () => {
mutationObserver.disconnect();
mousemoveHandler();
mouseInteractionHandler();
scrollHandler();
viewportResizeHandler();
inputHandler();
mediaInteractionHandler();
styleSheetObserver();
canvasMutationObserver();
fontObserver();
logObserver();
};
}