Add userTriggered (#495)
* add `userTriggered` * update snapshots to add userTriggered * add `userTriggered` * update snapshots to add userTriggered * update snapshot to include userTrigger * only set userTriggered on `userTriggeredOnInput: true` * What is user triggered? * correct snapshot * add second radio to demonstrate userTriggered
This commit is contained in:
45
guide.md
45
guide.md
@@ -135,28 +135,29 @@ setInterval(save, 10 * 1000);
|
||||
|
||||
The parameter of `rrweb.record` accepts the following options.
|
||||
|
||||
| key | default | description |
|
||||
| ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| emit | required | the callback function to get emitted events |
|
||||
| checkoutEveryNth | - | take a full snapshot after every N events<br />refer to the [checkout](#checkout) chapter |
|
||||
| checkoutEveryNms | - | take a full snapshot after every N ms<br />refer to the [checkout](#checkout) chapter |
|
||||
| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter |
|
||||
| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter |
|
||||
| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter |
|
||||
| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter |
|
||||
| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter |
|
||||
| maskAllInputs | false | mask all input content as \* |
|
||||
| maskInputOptions | { password: true } | mask some kinds of input \*<br />refer to the [list](https://github.com/rrweb-io/rrweb-snapshot/blob/0bb95f1ee77fef03166a68f75b959ad997171442/src/types.ts#L77-L95) |
|
||||
| maskInputFn | - | customize mask input content recording logic |
|
||||
| maskTextFn | - | customize mask text content recording logic |
|
||||
| slimDOMOptions | {} | remove unnecessary parts of the DOM <br />refer to the [list](https://github.com/rrweb-io/rrweb-snapshot/blob/6728d12b3cddd96951c86d948578f99ada5749ff/src/types.ts#L91) |
|
||||
| inlineStylesheet | true | whether to inline the stylesheet in the events |
|
||||
| hooks | {} | hooks for events<br />refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
|
||||
| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
|
||||
| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
|
||||
| recordCanvas | false | whether to record the canvas element |
|
||||
| collectFonts | false | whether to collect fonts in the website |
|
||||
| recordLog | false | whether to record console output, refer to the [console recipe](./docs/recipes/console.md) |
|
||||
| key | default | description |
|
||||
| -------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| emit | required | the callback function to get emitted events |
|
||||
| checkoutEveryNth | - | take a full snapshot after every N events<br />refer to the [checkout](#checkout) chapter |
|
||||
| checkoutEveryNms | - | take a full snapshot after every N ms<br />refer to the [checkout](#checkout) chapter |
|
||||
| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter |
|
||||
| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter |
|
||||
| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter |
|
||||
| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter |
|
||||
| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter |
|
||||
| maskAllInputs | false | mask all input content as \* |
|
||||
| maskInputOptions | { password: true } | mask some kinds of input \*<br />refer to the [list](https://github.com/rrweb-io/rrweb-snapshot/blob/0bb95f1ee77fef03166a68f75b959ad997171442/src/types.ts#L77-L95) |
|
||||
| maskInputFn | - | customize mask input content recording logic |
|
||||
| maskTextFn | - | customize mask text content recording logic |
|
||||
| slimDOMOptions | {} | remove unnecessary parts of the DOM <br />refer to the [list](https://github.com/rrweb-io/rrweb-snapshot/blob/6728d12b3cddd96951c86d948578f99ada5749ff/src/types.ts#L91) |
|
||||
| inlineStylesheet | true | whether to inline the stylesheet in the events |
|
||||
| hooks | {} | hooks for events<br />refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
|
||||
| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
|
||||
| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
|
||||
| recordCanvas | false | whether to record the canvas element |
|
||||
| collectFonts | false | whether to collect fonts in the website |
|
||||
| recordLog | false | whether to record console output, refer to the [console recipe](./docs/recipes/console.md) |
|
||||
| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) |
|
||||
|
||||
#### Privacy
|
||||
|
||||
|
||||
@@ -131,28 +131,29 @@ setInterval(save, 10 * 1000);
|
||||
|
||||
`rrweb.record(config)` 的 config 部分接受以下参数
|
||||
|
||||
| key | 默认值 | 功能 |
|
||||
| ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| emit | 必填 | 获取当前录制的数据 |
|
||||
| checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
|
||||
| checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
|
||||
| blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 |
|
||||
| blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 |
|
||||
| ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 |
|
||||
| maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 |
|
||||
| maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 |
|
||||
| maskAllInputs | false | 将所有输入内容记录为 \* |
|
||||
| maskInputOptions | { password: true } | 选择将特定类型的输入框内容记录为 \*<br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/0bb95f1ee77fef03166a68f75b959ad997171442/src/types.ts#L77-L95) |
|
||||
| maskInputFn | - | 自定义特定类型的输入框内容记录逻辑 |
|
||||
| maskTextFn | - | 自定义文字内容的记录逻辑 |
|
||||
| slimDOMOptions | {} | 去除 DOM 中不必要的部分 <br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/6728d12b3cddd96951c86d948578f99ada5749ff/src/types.ts#L91) |
|
||||
| inlineStylesheet | true | 是否将样式表内联 |
|
||||
| hooks | {} | 各类事件的回调<br />类型详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
|
||||
| packFn | - | 数据压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
|
||||
| sampling | - | 数据抽样策略,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
|
||||
| recordCanvas | false | 是否记录 canvas 内容 |
|
||||
| collectFonts | false | 是否记录页面中的字体文件 |
|
||||
| recordLog | false | 是否记录 console 输出,详见[console 录制和播放](./docs/recipes/console.zh_CN.md) |
|
||||
| key | 默认值 | 功能 |
|
||||
| -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| emit | 必填 | 获取当前录制的数据 |
|
||||
| checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
|
||||
| checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
|
||||
| blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 |
|
||||
| blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 |
|
||||
| ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 |
|
||||
| maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 |
|
||||
| maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 |
|
||||
| maskAllInputs | false | 将所有输入内容记录为 \* |
|
||||
| maskInputOptions | { password: true } | 选择将特定类型的输入框内容记录为 \*<br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/0bb95f1ee77fef03166a68f75b959ad997171442/src/types.ts#L77-L95) |
|
||||
| maskInputFn | - | 自定义特定类型的输入框内容记录逻辑 |
|
||||
| maskTextFn | - | 自定义文字内容的记录逻辑 |
|
||||
| slimDOMOptions | {} | 去除 DOM 中不必要的部分 <br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/6728d12b3cddd96951c86d948578f99ada5749ff/src/types.ts#L91) |
|
||||
| inlineStylesheet | true | 是否将样式表内联 |
|
||||
| hooks | {} | 各类事件的回调<br />类型详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
|
||||
| packFn | - | 数据压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
|
||||
| sampling | - | 数据抽样策略,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
|
||||
| recordCanvas | false | 是否记录 canvas 内容 |
|
||||
| collectFonts | false | 是否记录页面中的字体文件 |
|
||||
| recordLog | false | 是否记录 console 输出,详见[console 录制和播放](./docs/recipes/console.zh_CN.md) |
|
||||
| userTriggeredOnInput | false | [什么是 `userTriggered`](https://github.com/rrweb-io/rrweb/pull/495) |
|
||||
|
||||
#### 隐私
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ function record<T = eventWithTime>(
|
||||
sampling = {},
|
||||
mousemoveWait,
|
||||
recordCanvas = false,
|
||||
userTriggeredOnInput = false,
|
||||
collectFonts = false,
|
||||
plugins,
|
||||
keepIframeSrcFn = () => false,
|
||||
@@ -379,6 +380,7 @@ function record<T = eventWithTime>(
|
||||
inlineStylesheet,
|
||||
sampling,
|
||||
recordCanvas,
|
||||
userTriggeredOnInput,
|
||||
collectFonts,
|
||||
doc,
|
||||
maskInputFn,
|
||||
|
||||
@@ -337,6 +337,15 @@ function initViewportResizeObserver(
|
||||
return on('resize', updateDimension, window);
|
||||
}
|
||||
|
||||
function wrapEventWithUserTriggeredFlag(
|
||||
v: inputValue,
|
||||
enable: boolean,
|
||||
): inputValue {
|
||||
const value = { ...v };
|
||||
if (!enable) delete value.userTriggered;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
|
||||
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
|
||||
function initInputObserver(
|
||||
@@ -348,9 +357,11 @@ function initInputObserver(
|
||||
maskInputOptions: MaskInputOptions,
|
||||
maskInputFn: MaskInputFn | undefined,
|
||||
sampling: SamplingStrategy,
|
||||
userTriggeredOnInput: boolean,
|
||||
): listenerHandler {
|
||||
function eventHandler(event: Event) {
|
||||
const target = getEventTarget(event);
|
||||
const userTriggered = event.isTrusted;
|
||||
if (
|
||||
!target ||
|
||||
!(target as Element).tagName ||
|
||||
@@ -381,7 +392,13 @@ function initInputObserver(
|
||||
maskInputFn,
|
||||
});
|
||||
}
|
||||
cbWithDedup(target, { text, isChecked });
|
||||
cbWithDedup(
|
||||
target,
|
||||
wrapEventWithUserTriggeredFlag(
|
||||
{ text, isChecked, userTriggered },
|
||||
userTriggeredOnInput,
|
||||
),
|
||||
);
|
||||
// if a radio was checked
|
||||
// the other radios with the same name attribute will be unchecked.
|
||||
const name: string | undefined = (target as HTMLInputElement).name;
|
||||
@@ -390,10 +407,17 @@ function initInputObserver(
|
||||
.querySelectorAll(`input[type="radio"][name="${name}"]`)
|
||||
.forEach((el) => {
|
||||
if (el !== target) {
|
||||
cbWithDedup(el, {
|
||||
text: (el as HTMLInputElement).value,
|
||||
isChecked: !isChecked,
|
||||
});
|
||||
cbWithDedup(
|
||||
el,
|
||||
wrapEventWithUserTriggeredFlag(
|
||||
{
|
||||
text: (el as HTMLInputElement).value,
|
||||
isChecked: !isChecked,
|
||||
userTriggered: false,
|
||||
},
|
||||
userTriggeredOnInput,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -763,6 +787,7 @@ export function initObservers(
|
||||
o.maskInputOptions,
|
||||
o.maskInputFn,
|
||||
o.sampling,
|
||||
o.userTriggeredOnInput,
|
||||
);
|
||||
const mediaInteractionHandler = initMediaInteractionObserver(
|
||||
o.mediaInteractionCb,
|
||||
|
||||
@@ -115,6 +115,7 @@ export class Replayer {
|
||||
triggerFocus: true,
|
||||
UNSAFE_replayCanvas: false,
|
||||
pauseAnimation: true,
|
||||
userTriggeredOnInput: true,
|
||||
mouseTail: defaultMouseTailConfig,
|
||||
};
|
||||
this.config = Object.assign({}, defaultConfig, config);
|
||||
@@ -501,10 +502,7 @@ export class Replayer {
|
||||
|
||||
// events are kept sorted by timestamp, check if this is the last event
|
||||
let last_index = this.service.state.context.events.length - 1;
|
||||
if (
|
||||
event ===
|
||||
this.service.state.context.events[last_index]
|
||||
) {
|
||||
if (event === this.service.state.context.events[last_index]) {
|
||||
const finish = () => {
|
||||
if (last_index < this.service.state.context.events.length - 1) {
|
||||
// more events have been added since the setTimeout
|
||||
|
||||
@@ -218,6 +218,7 @@ export type recordOptions<T> = {
|
||||
packFn?: PackFn;
|
||||
sampling?: SamplingStrategy;
|
||||
recordCanvas?: boolean;
|
||||
userTriggeredOnInput?: boolean;
|
||||
collectFonts?: boolean;
|
||||
plugins?: RecordPlugin[];
|
||||
// departed, please use sampling options
|
||||
@@ -247,6 +248,7 @@ export type observerParam = {
|
||||
fontCb: fontCallback;
|
||||
sampling: SamplingStrategy;
|
||||
recordCanvas: boolean;
|
||||
userTriggeredOnInput: boolean;
|
||||
collectFonts: boolean;
|
||||
slimDOMOptions: SlimDOMOptions;
|
||||
doc: Document;
|
||||
@@ -419,6 +421,12 @@ export type viewportResizeCallback = (d: viewportResizeDimension) => void;
|
||||
export type inputValue = {
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
|
||||
// `userTriggered` indicates if this event was triggered directly by user (userTriggered: true)
|
||||
// or was triggered indirectly (userTriggered: false)
|
||||
// Example of `userTriggered` in action:
|
||||
// User clicks on radio element (userTriggered: true) which triggers the other radio element to change (userTriggered: false)
|
||||
userTriggered?: boolean;
|
||||
};
|
||||
|
||||
export type inputCallback = (v: inputValue & { id: number }) => void;
|
||||
@@ -484,6 +492,7 @@ export type playerConfig = {
|
||||
triggerFocus: boolean;
|
||||
UNSAFE_replayCanvas: boolean;
|
||||
pauseAnimation?: boolean;
|
||||
userTriggeredOnInput: boolean;
|
||||
mouseTail:
|
||||
| boolean
|
||||
| {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>form fields</title>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>form fields</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<form>
|
||||
<label for="text">
|
||||
<input type="text">
|
||||
</label>
|
||||
<label for="radio">
|
||||
<input type="radio">
|
||||
</label>
|
||||
<label for="checkbox">
|
||||
<input type="checkbox">
|
||||
</label>
|
||||
<label for="textarea">
|
||||
<textarea name="" id="" cols="30" rows="10"></textarea>
|
||||
</label>
|
||||
<label for="select">
|
||||
<select name="" id="">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</label>
|
||||
<label for="password">
|
||||
<input type="password" />
|
||||
</label>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
<body>
|
||||
<form>
|
||||
<label for="text">
|
||||
<input type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="toggle" value="on" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="toggle" value="off" checked />
|
||||
</label>
|
||||
<label for="checkbox">
|
||||
<input type="checkbox" />
|
||||
</label>
|
||||
<label for="textarea">
|
||||
<textarea name="" id="" cols="30" rows="10"></textarea>
|
||||
</label>
|
||||
<label for="select">
|
||||
<select name="" id="">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</label>
|
||||
<label for="password">
|
||||
<input type="password" />
|
||||
</label>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('record integration tests', function (this: ISuite) {
|
||||
maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
|
||||
maskAllInputs: ${options.maskAllInputs},
|
||||
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
|
||||
userTriggeredOnInput: ${options.userTriggeredOnInput},
|
||||
maskTextFn: ${options.maskTextFn},
|
||||
recordCanvas: ${options.recordCanvas},
|
||||
plugins: ${options.plugins}
|
||||
@@ -284,6 +285,24 @@ describe('record integration tests', function (this: ISuite) {
|
||||
assertSnapshot(snapshots, __filename, 'maskPassword');
|
||||
});
|
||||
|
||||
it('should record input userTriggered values if userTriggeredOnInput is enabled', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(
|
||||
getHtml.call(this, 'form.html', { userTriggeredOnInput: true }),
|
||||
);
|
||||
|
||||
await page.type('input[type="text"]', 'test');
|
||||
await page.click('input[type="radio"]');
|
||||
await page.click('input[type="checkbox"]');
|
||||
await page.type('input[type="password"]', 'password');
|
||||
await page.type('textarea', 'textarea test');
|
||||
await page.select('select', '1');
|
||||
|
||||
const snapshots = await page.evaluate('window.snapshots');
|
||||
assertSnapshot(snapshots, __filename, 'userTriggered');
|
||||
});
|
||||
|
||||
it('should not record blocked elements and its child nodes', async () => {
|
||||
const page: puppeteer.Page = await this.browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
Reference in New Issue
Block a user