mask input options and sampling options (#252)
* part of #80, support mask input options * close #188 enhance sampling options Use a more general sampling strategy interface to describe the configuration of sampling events collection. Implemented mousmove, mouse interaction, scroll and input sampling strategy.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { snapshot } from 'rrweb-snapshot';
|
||||
import { snapshot, MaskInputOptions } from 'rrweb-snapshot';
|
||||
import initObservers from './observer';
|
||||
import {
|
||||
mirror,
|
||||
@@ -35,15 +35,44 @@ function record<T = eventWithTime>(
|
||||
blockClass = 'rr-block',
|
||||
ignoreClass = 'rr-ignore',
|
||||
inlineStylesheet = true,
|
||||
maskAllInputs = false,
|
||||
maskAllInputs,
|
||||
maskInputOptions: _maskInputOptions,
|
||||
hooks,
|
||||
mousemoveWait = 50,
|
||||
packFn,
|
||||
sampling = {},
|
||||
mousemoveWait,
|
||||
} = options;
|
||||
// runtime checks for user options
|
||||
if (!emit) {
|
||||
throw new Error('emit function is required');
|
||||
}
|
||||
// move departed options to new options
|
||||
if (mousemoveWait !== undefined && sampling.mousemove === undefined) {
|
||||
sampling.mousemove = mousemoveWait;
|
||||
}
|
||||
|
||||
const maskInputOptions: MaskInputOptions =
|
||||
maskAllInputs === true
|
||||
? {
|
||||
color: true,
|
||||
date: true,
|
||||
'datetime-local': true,
|
||||
email: true,
|
||||
month: true,
|
||||
number: true,
|
||||
range: true,
|
||||
search: true,
|
||||
tel: true,
|
||||
text: true,
|
||||
time: true,
|
||||
url: true,
|
||||
week: true,
|
||||
textarea: true,
|
||||
select: true,
|
||||
}
|
||||
: _maskInputOptions !== undefined
|
||||
? _maskInputOptions
|
||||
: {};
|
||||
|
||||
polyfill();
|
||||
|
||||
@@ -83,7 +112,7 @@ function record<T = eventWithTime>(
|
||||
document,
|
||||
blockClass,
|
||||
inlineStylesheet,
|
||||
maskAllInputs,
|
||||
maskInputOptions,
|
||||
);
|
||||
|
||||
if (!node) {
|
||||
@@ -217,9 +246,9 @@ function record<T = eventWithTime>(
|
||||
),
|
||||
blockClass,
|
||||
ignoreClass,
|
||||
maskAllInputs,
|
||||
maskInputOptions,
|
||||
inlineStylesheet,
|
||||
mousemoveWait,
|
||||
sampling,
|
||||
},
|
||||
hooks,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { INode, serializeNodeWithId, transformAttribute } from 'rrweb-snapshot';
|
||||
import {
|
||||
INode,
|
||||
serializeNodeWithId,
|
||||
transformAttribute,
|
||||
MaskInputOptions,
|
||||
} from 'rrweb-snapshot';
|
||||
import {
|
||||
mutationRecord,
|
||||
blockClass,
|
||||
@@ -50,17 +55,17 @@ export default class MutationBuffer {
|
||||
private emissionCallback: mutationCallBack;
|
||||
private blockClass: blockClass;
|
||||
private inlineStylesheet: boolean;
|
||||
private maskAllInputs: boolean;
|
||||
private maskInputOptions: MaskInputOptions;
|
||||
|
||||
constructor(
|
||||
cb: mutationCallBack,
|
||||
blockClass: blockClass,
|
||||
inlineStylesheet: boolean,
|
||||
maskAllInputs: boolean,
|
||||
maskInputOptions: MaskInputOptions,
|
||||
) {
|
||||
this.blockClass = blockClass;
|
||||
this.inlineStylesheet = inlineStylesheet;
|
||||
this.maskAllInputs = maskAllInputs;
|
||||
this.maskInputOptions = maskInputOptions;
|
||||
this.emissionCallback = cb;
|
||||
}
|
||||
|
||||
@@ -89,7 +94,7 @@ export default class MutationBuffer {
|
||||
this.blockClass,
|
||||
true,
|
||||
this.inlineStylesheet,
|
||||
this.maskAllInputs,
|
||||
this.maskInputOptions,
|
||||
)!,
|
||||
});
|
||||
};
|
||||
@@ -130,6 +135,47 @@ export default class MutationBuffer {
|
||||
this.emit();
|
||||
};
|
||||
|
||||
public emit = () => {
|
||||
const payload = {
|
||||
texts: this.texts
|
||||
.map((text) => ({
|
||||
id: mirror.getId(text.node as INode),
|
||||
value: text.value,
|
||||
}))
|
||||
// text mutation's id was not in the mirror map means the target node has been removed
|
||||
.filter((text) => mirror.has(text.id)),
|
||||
attributes: this.attributes
|
||||
.map((attribute) => ({
|
||||
id: mirror.getId(attribute.node as INode),
|
||||
attributes: attribute.attributes,
|
||||
}))
|
||||
// attribute mutation's id was not in the mirror map means the target node has been removed
|
||||
.filter((attribute) => mirror.has(attribute.id)),
|
||||
removes: this.removes,
|
||||
adds: this.adds,
|
||||
};
|
||||
// payload may be empty if the mutations happened in some blocked elements
|
||||
if (
|
||||
!payload.texts.length &&
|
||||
!payload.attributes.length &&
|
||||
!payload.removes.length &&
|
||||
!payload.adds.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.emissionCallback(payload);
|
||||
|
||||
// reset
|
||||
this.texts = [];
|
||||
this.attributes = [];
|
||||
this.removes = [];
|
||||
this.adds = [];
|
||||
this.addedSet = new Set<Node>();
|
||||
this.movedSet = new Set<Node>();
|
||||
this.droppedSet = new Set<Node>();
|
||||
this.movedMap = {};
|
||||
};
|
||||
|
||||
private processMutation = (m: mutationRecord) => {
|
||||
switch (m.type) {
|
||||
case 'characterData': {
|
||||
@@ -231,47 +277,6 @@ export default class MutationBuffer {
|
||||
}
|
||||
n.childNodes.forEach((childN) => this.genAdds(childN));
|
||||
};
|
||||
|
||||
public emit = () => {
|
||||
const payload = {
|
||||
texts: this.texts
|
||||
.map((text) => ({
|
||||
id: mirror.getId(text.node as INode),
|
||||
value: text.value,
|
||||
}))
|
||||
// text mutation's id was not in the mirror map means the target node has been removed
|
||||
.filter((text) => mirror.has(text.id)),
|
||||
attributes: this.attributes
|
||||
.map((attribute) => ({
|
||||
id: mirror.getId(attribute.node as INode),
|
||||
attributes: attribute.attributes,
|
||||
}))
|
||||
// attribute mutation's id was not in the mirror map means the target node has been removed
|
||||
.filter((attribute) => mirror.has(attribute.id)),
|
||||
removes: this.removes,
|
||||
adds: this.adds,
|
||||
};
|
||||
// payload may be empty if the mutations happened in some blocked elements
|
||||
if (
|
||||
!payload.texts.length &&
|
||||
!payload.attributes.length &&
|
||||
!payload.removes.length &&
|
||||
!payload.adds.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.emissionCallback(payload);
|
||||
|
||||
// reset
|
||||
this.texts = [];
|
||||
this.attributes = [];
|
||||
this.removes = [];
|
||||
this.adds = [];
|
||||
this.addedSet = new Set<Node>();
|
||||
this.movedSet = new Set<Node>();
|
||||
this.droppedSet = new Set<Node>();
|
||||
this.movedMap = {};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INode } from 'rrweb-snapshot';
|
||||
import { INode, MaskInputOptions } from 'rrweb-snapshot';
|
||||
import {
|
||||
mirror,
|
||||
throttle,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Arguments,
|
||||
mediaInteractionCallback,
|
||||
MediaInteractions,
|
||||
SamplingStrategy,
|
||||
} from '../types';
|
||||
import MutationBuffer from './mutation';
|
||||
|
||||
@@ -36,14 +37,14 @@ function initMutationObserver(
|
||||
cb: mutationCallBack,
|
||||
blockClass: blockClass,
|
||||
inlineStylesheet: boolean,
|
||||
maskAllInputs: boolean,
|
||||
maskInputOptions: MaskInputOptions,
|
||||
): MutationObserver {
|
||||
// see mutation.ts for details
|
||||
const mutationBuffer = new MutationBuffer(
|
||||
cb,
|
||||
blockClass,
|
||||
inlineStylesheet,
|
||||
maskAllInputs,
|
||||
maskInputOptions,
|
||||
);
|
||||
const observer = new MutationObserver(mutationBuffer.processMutations);
|
||||
observer.observe(document, {
|
||||
@@ -59,8 +60,15 @@ function initMutationObserver(
|
||||
|
||||
function initMoveObserver(
|
||||
cb: mousemoveCallBack,
|
||||
mousemoveWait: number,
|
||||
sampling: SamplingStrategy,
|
||||
): listenerHandler {
|
||||
if (sampling.mousemove === false) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const threshold =
|
||||
typeof sampling.mousemove === 'number' ? sampling.mousemove : 50;
|
||||
|
||||
let positions: mousePosition[] = [];
|
||||
let timeBaseline: number | null;
|
||||
const wrappedCb = throttle((isTouch: boolean) => {
|
||||
@@ -92,7 +100,7 @@ function initMoveObserver(
|
||||
});
|
||||
wrappedCb(isTouchEvent(evt));
|
||||
},
|
||||
mousemoveWait,
|
||||
threshold,
|
||||
{
|
||||
trailing: false,
|
||||
},
|
||||
@@ -109,7 +117,17 @@ function initMoveObserver(
|
||||
function initMouseInteractionObserver(
|
||||
cb: mouseInteractionCallBack,
|
||||
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) => {
|
||||
@@ -129,7 +147,12 @@ function initMouseInteractionObserver(
|
||||
};
|
||||
};
|
||||
Object.keys(MouseInteractions)
|
||||
.filter((key) => Number.isNaN(Number(key)) && !key.endsWith('_Departed'))
|
||||
.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);
|
||||
@@ -143,6 +166,7 @@ function initMouseInteractionObserver(
|
||||
function initScrollObserver(
|
||||
cb: scrollCallback,
|
||||
blockClass: blockClass,
|
||||
sampling: SamplingStrategy,
|
||||
): listenerHandler {
|
||||
const updatePosition = throttle<UIEvent>((evt) => {
|
||||
if (!evt.target || isBlocked(evt.target as Node, blockClass)) {
|
||||
@@ -163,7 +187,7 @@ function initScrollObserver(
|
||||
y: (evt.target as HTMLElement).scrollTop,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}, sampling.scroll || 100);
|
||||
return on('scroll', updatePosition);
|
||||
}
|
||||
|
||||
@@ -182,27 +206,13 @@ function initViewportResizeObserver(
|
||||
}
|
||||
|
||||
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
|
||||
export const MASK_TYPES = [
|
||||
'color',
|
||||
'date',
|
||||
'datetime-local',
|
||||
'email',
|
||||
'month',
|
||||
'number',
|
||||
'range',
|
||||
'search',
|
||||
'tel',
|
||||
'text',
|
||||
'time',
|
||||
'url',
|
||||
'week',
|
||||
];
|
||||
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
|
||||
function initInputObserver(
|
||||
cb: inputCallback,
|
||||
blockClass: blockClass,
|
||||
ignoreClass: string,
|
||||
maskAllInputs: boolean,
|
||||
maskInputOptions: MaskInputOptions,
|
||||
sampling: SamplingStrategy,
|
||||
): listenerHandler {
|
||||
function eventHandler(event: Event) {
|
||||
const { target } = event;
|
||||
@@ -223,11 +233,14 @@ function initInputObserver(
|
||||
}
|
||||
let text = (target as HTMLInputElement).value;
|
||||
let isChecked = false;
|
||||
const hasTextInput =
|
||||
MASK_TYPES.includes(type) || (target as Element).tagName === 'TEXTAREA';
|
||||
if (type === 'radio' || type === 'checkbox') {
|
||||
isChecked = (target as HTMLInputElement).checked;
|
||||
} else if (hasTextInput && maskAllInputs) {
|
||||
} else if (
|
||||
maskInputOptions[
|
||||
(target as Element).tagName.toLowerCase() as keyof MaskInputOptions
|
||||
] ||
|
||||
maskInputOptions[type as keyof MaskInputOptions]
|
||||
) {
|
||||
text = '*'.repeat(text.length);
|
||||
}
|
||||
cbWithDedup(target, { text, isChecked });
|
||||
@@ -262,10 +275,10 @@ function initInputObserver(
|
||||
});
|
||||
}
|
||||
}
|
||||
const handlers: Array<listenerHandler | hookResetter> = [
|
||||
'input',
|
||||
'change',
|
||||
].map((eventName) => on(eventName, eventHandler));
|
||||
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
|
||||
const handlers: Array<
|
||||
listenerHandler | hookResetter
|
||||
> = events.map((eventName) => on(eventName, eventHandler));
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
@@ -414,20 +427,26 @@ export default function initObservers(
|
||||
o.mutationCb,
|
||||
o.blockClass,
|
||||
o.inlineStylesheet,
|
||||
o.maskAllInputs,
|
||||
o.maskInputOptions,
|
||||
);
|
||||
const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.mousemoveWait);
|
||||
const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling);
|
||||
const mouseInteractionHandler = initMouseInteractionObserver(
|
||||
o.mouseInteractionCb,
|
||||
o.blockClass,
|
||||
o.sampling,
|
||||
);
|
||||
const scrollHandler = initScrollObserver(
|
||||
o.scrollCb,
|
||||
o.blockClass,
|
||||
o.sampling,
|
||||
);
|
||||
const scrollHandler = initScrollObserver(o.scrollCb, o.blockClass);
|
||||
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
|
||||
const inputHandler = initInputObserver(
|
||||
o.inputCb,
|
||||
o.blockClass,
|
||||
o.ignoreClass,
|
||||
o.maskAllInputs,
|
||||
o.maskInputOptions,
|
||||
o.sampling,
|
||||
);
|
||||
const mediaInteractionHandler = initMediaInteractionObserver(
|
||||
o.mediaInteractionCb,
|
||||
|
||||
38
src/types.ts
38
src/types.ts
@@ -1,4 +1,9 @@
|
||||
import { serializedNodeWithId, idNodeMap, INode } from 'rrweb-snapshot';
|
||||
import {
|
||||
serializedNodeWithId,
|
||||
idNodeMap,
|
||||
INode,
|
||||
MaskInputOptions,
|
||||
} from 'rrweb-snapshot';
|
||||
import { PackFn, UnpackFn } from './packer/base';
|
||||
|
||||
export enum EventType {
|
||||
@@ -126,6 +131,28 @@ export type eventWithTime = event & {
|
||||
|
||||
export type blockClass = string | RegExp;
|
||||
|
||||
export type SamplingStrategy = Partial<{
|
||||
/**
|
||||
* false means not to record mouse/touch move events
|
||||
* number is the throttle threshold of recording mouse/touch move
|
||||
*/
|
||||
mousemove: boolean | number;
|
||||
/**
|
||||
* false means not to record mouse interaction events
|
||||
* can also specify record some kinds of mouse interactions
|
||||
*/
|
||||
mouseInteraction: boolean | Record<string, boolean | undefined>;
|
||||
/**
|
||||
* number is the throttle threshold of recording scroll
|
||||
*/
|
||||
scroll: number;
|
||||
/**
|
||||
* 'all' will record all the input events
|
||||
* 'last' will only record the last input value while input a sequence of chars
|
||||
*/
|
||||
input: 'all' | 'last';
|
||||
}>;
|
||||
|
||||
export type recordOptions<T> = {
|
||||
emit?: (e: T, isCheckout?: boolean) => void;
|
||||
checkoutEveryNth?: number;
|
||||
@@ -133,10 +160,13 @@ export type recordOptions<T> = {
|
||||
blockClass?: blockClass;
|
||||
ignoreClass?: string;
|
||||
maskAllInputs?: boolean;
|
||||
maskInputOptions?: MaskInputOptions;
|
||||
inlineStylesheet?: boolean;
|
||||
hooks?: hooksParam;
|
||||
mousemoveWait?: number;
|
||||
packFn?: PackFn;
|
||||
sampling?: SamplingStrategy;
|
||||
// departed, please use sampling options
|
||||
mousemoveWait?: number;
|
||||
};
|
||||
|
||||
export type observerParam = {
|
||||
@@ -149,10 +179,10 @@ export type observerParam = {
|
||||
mediaInteractionCb: mediaInteractionCallback;
|
||||
blockClass: blockClass;
|
||||
ignoreClass: string;
|
||||
maskAllInputs: boolean;
|
||||
maskInputOptions: MaskInputOptions;
|
||||
inlineStylesheet: boolean;
|
||||
styleSheetRuleCb: styleSheetRuleCallback;
|
||||
mousemoveWait: number;
|
||||
sampling: SamplingStrategy;
|
||||
};
|
||||
|
||||
export type hooksParam = {
|
||||
|
||||
Reference in New Issue
Block a user