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:
yz-yu
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 5b0539419c
commit 8bb1c791f5
8 changed files with 888 additions and 93 deletions

View File

@@ -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,
),

View File

@@ -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 = {};
};
}
/**

View File

@@ -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,