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:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent a224fdd903
commit 9df2aa8e0e
9 changed files with 1541 additions and 470 deletions

View File

@@ -135,28 +135,29 @@ setInterval(save, 10 * 1000);
The parameter of `rrweb.record` accepts the following options. The parameter of `rrweb.record` accepts the following options.
| key | default | description | | key | default | description |
| ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| emit | required | the callback function to get emitted events | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 \* | | 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) | | 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 | | maskInputFn | - | customize mask input content recording logic |
| maskTextFn | - | customize mask text 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) | | 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 | | 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) | | 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) | | packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
| sampling | - | 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 | | recordCanvas | false | whether to record the canvas element |
| collectFonts | false | whether to collect fonts in the website | | 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) | | 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 #### Privacy

View File

@@ -131,28 +131,29 @@ setInterval(save, 10 * 1000);
`rrweb.record(config)` 的 config 部分接受以下参数 `rrweb.record(config)` 的 config 部分接受以下参数
| key | 默认值 | 功能 | | key | 默认值 | 功能 |
| ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| emit | 必填 | 获取当前录制的数据 | | emit | 必填 | 获取当前录制的数据 |
| checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 | | checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
| checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 | | checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
| blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 | | blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 |
| blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 | | blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 |
| ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 | | ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 |
| maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 | | maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 |
| maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 | | maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 |
| maskAllInputs | false | 将所有输入内容记录为 \* | | maskAllInputs | false | 将所有输入内容记录为 \* |
| maskInputOptions | { password: true } | 选择将特定类型的输入框内容记录为 \*<br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/0bb95f1ee77fef03166a68f75b959ad997171442/src/types.ts#L77-L95) | | maskInputOptions | { password: true } | 选择将特定类型的输入框内容记录为 \*<br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/0bb95f1ee77fef03166a68f75b959ad997171442/src/types.ts#L77-L95) |
| maskInputFn | - | 自定义特定类型的输入框内容记录逻辑 | | maskInputFn | - | 自定义特定类型的输入框内容记录逻辑 |
| maskTextFn | - | 自定义文字内容的记录逻辑 | | maskTextFn | - | 自定义文字内容的记录逻辑 |
| slimDOMOptions | {} | 去除 DOM 中不必要的部分 <br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/6728d12b3cddd96951c86d948578f99ada5749ff/src/types.ts#L91) | | slimDOMOptions | {} | 去除 DOM 中不必要的部分 <br />类型详见[列表](https://github.com/rrweb-io/rrweb-snapshot/blob/6728d12b3cddd96951c86d948578f99ada5749ff/src/types.ts#L91) |
| inlineStylesheet | true | 是否将样式表内联 | | inlineStylesheet | true | 是否将样式表内联 |
| hooks | {} | 各类事件的回调<br />类型详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) | | hooks | {} | 各类事件的回调<br />类型详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
| packFn | - | 数据压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) | | packFn | - | 数据压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
| sampling | - | 数据抽样策略,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) | | sampling | - | 数据抽样策略,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
| recordCanvas | false | 是否记录 canvas 内容 | | recordCanvas | false | 是否记录 canvas 内容 |
| collectFonts | false | 是否记录页面中的字体文件 | | collectFonts | false | 是否记录页面中的字体文件 |
| recordLog | false | 是否记录 console 输出,详见[console 录制和播放](./docs/recipes/console.zh_CN.md) | | recordLog | false | 是否记录 console 输出,详见[console 录制和播放](./docs/recipes/console.zh_CN.md) |
| userTriggeredOnInput | false | [什么是 `userTriggered`](https://github.com/rrweb-io/rrweb/pull/495) |
#### 隐私 #### 隐私

View File

@@ -57,6 +57,7 @@ function record<T = eventWithTime>(
sampling = {}, sampling = {},
mousemoveWait, mousemoveWait,
recordCanvas = false, recordCanvas = false,
userTriggeredOnInput = false,
collectFonts = false, collectFonts = false,
plugins, plugins,
keepIframeSrcFn = () => false, keepIframeSrcFn = () => false,
@@ -379,6 +380,7 @@ function record<T = eventWithTime>(
inlineStylesheet, inlineStylesheet,
sampling, sampling,
recordCanvas, recordCanvas,
userTriggeredOnInput,
collectFonts, collectFonts,
doc, doc,
maskInputFn, maskInputFn,

View File

@@ -337,6 +337,15 @@ function initViewportResizeObserver(
return on('resize', updateDimension, window); 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']; export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap(); const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver( function initInputObserver(
@@ -348,9 +357,11 @@ function initInputObserver(
maskInputOptions: MaskInputOptions, maskInputOptions: MaskInputOptions,
maskInputFn: MaskInputFn | undefined, maskInputFn: MaskInputFn | undefined,
sampling: SamplingStrategy, sampling: SamplingStrategy,
userTriggeredOnInput: boolean,
): listenerHandler { ): listenerHandler {
function eventHandler(event: Event) { function eventHandler(event: Event) {
const target = getEventTarget(event); const target = getEventTarget(event);
const userTriggered = event.isTrusted;
if ( if (
!target || !target ||
!(target as Element).tagName || !(target as Element).tagName ||
@@ -381,7 +392,13 @@ function initInputObserver(
maskInputFn, maskInputFn,
}); });
} }
cbWithDedup(target, { text, isChecked }); cbWithDedup(
target,
wrapEventWithUserTriggeredFlag(
{ text, isChecked, userTriggered },
userTriggeredOnInput,
),
);
// if a radio was checked // if a radio was checked
// the other radios with the same name attribute will be unchecked. // the other radios with the same name attribute will be unchecked.
const name: string | undefined = (target as HTMLInputElement).name; const name: string | undefined = (target as HTMLInputElement).name;
@@ -390,10 +407,17 @@ function initInputObserver(
.querySelectorAll(`input[type="radio"][name="${name}"]`) .querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach((el) => { .forEach((el) => {
if (el !== target) { if (el !== target) {
cbWithDedup(el, { cbWithDedup(
text: (el as HTMLInputElement).value, el,
isChecked: !isChecked, wrapEventWithUserTriggeredFlag(
}); {
text: (el as HTMLInputElement).value,
isChecked: !isChecked,
userTriggered: false,
},
userTriggeredOnInput,
),
);
} }
}); });
} }
@@ -763,6 +787,7 @@ export function initObservers(
o.maskInputOptions, o.maskInputOptions,
o.maskInputFn, o.maskInputFn,
o.sampling, o.sampling,
o.userTriggeredOnInput,
); );
const mediaInteractionHandler = initMediaInteractionObserver( const mediaInteractionHandler = initMediaInteractionObserver(
o.mediaInteractionCb, o.mediaInteractionCb,

View File

@@ -115,6 +115,7 @@ export class Replayer {
triggerFocus: true, triggerFocus: true,
UNSAFE_replayCanvas: false, UNSAFE_replayCanvas: false,
pauseAnimation: true, pauseAnimation: true,
userTriggeredOnInput: true,
mouseTail: defaultMouseTailConfig, mouseTail: defaultMouseTailConfig,
}; };
this.config = Object.assign({}, defaultConfig, config); 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 // events are kept sorted by timestamp, check if this is the last event
let last_index = this.service.state.context.events.length - 1; let last_index = this.service.state.context.events.length - 1;
if ( if (event === this.service.state.context.events[last_index]) {
event ===
this.service.state.context.events[last_index]
) {
const finish = () => { const finish = () => {
if (last_index < this.service.state.context.events.length - 1) { if (last_index < this.service.state.context.events.length - 1) {
// more events have been added since the setTimeout // more events have been added since the setTimeout

View File

@@ -218,6 +218,7 @@ export type recordOptions<T> = {
packFn?: PackFn; packFn?: PackFn;
sampling?: SamplingStrategy; sampling?: SamplingStrategy;
recordCanvas?: boolean; recordCanvas?: boolean;
userTriggeredOnInput?: boolean;
collectFonts?: boolean; collectFonts?: boolean;
plugins?: RecordPlugin[]; plugins?: RecordPlugin[];
// departed, please use sampling options // departed, please use sampling options
@@ -247,6 +248,7 @@ export type observerParam = {
fontCb: fontCallback; fontCb: fontCallback;
sampling: SamplingStrategy; sampling: SamplingStrategy;
recordCanvas: boolean; recordCanvas: boolean;
userTriggeredOnInput: boolean;
collectFonts: boolean; collectFonts: boolean;
slimDOMOptions: SlimDOMOptions; slimDOMOptions: SlimDOMOptions;
doc: Document; doc: Document;
@@ -419,6 +421,12 @@ export type viewportResizeCallback = (d: viewportResizeDimension) => void;
export type inputValue = { export type inputValue = {
text: string; text: string;
isChecked: boolean; 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; export type inputCallback = (v: inputValue & { id: number }) => void;
@@ -484,6 +492,7 @@ export type playerConfig = {
triggerFocus: boolean; triggerFocus: boolean;
UNSAFE_replayCanvas: boolean; UNSAFE_replayCanvas: boolean;
pauseAnimation?: boolean; pauseAnimation?: boolean;
userTriggeredOnInput: boolean;
mouseTail: mouseTail:
| boolean | boolean
| { | {

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,38 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <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> <body>
<meta charset="UTF-8"> <form>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <label for="text">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <input type="text" />
<title>form fields</title> </label>
</head> <label>
<input type="radio" name="toggle" value="on" />
<body> </label>
<form> <label>
<label for="text"> <input type="radio" name="toggle" value="off" checked />
<input type="text"> </label>
</label> <label for="checkbox">
<label for="radio"> <input type="checkbox" />
<input type="radio"> </label>
</label> <label for="textarea">
<label for="checkbox"> <textarea name="" id="" cols="30" rows="10"></textarea>
<input type="checkbox"> </label>
</label> <label for="select">
<label for="textarea"> <select name="" id="">
<textarea name="" id="" cols="30" rows="10"></textarea> <option value="1">1</option>
</label> <option value="2">2</option>
<label for="select"> </select>
<select name="" id=""> </label>
<option value="1">1</option> <label for="password">
<option value="2">2</option> <input type="password" />
</select> </label>
</label> </form>
<label for="password"> </body>
<input type="password" />
</label>
</form>
</body>
</html> </html>

View File

@@ -75,6 +75,7 @@ describe('record integration tests', function (this: ISuite) {
maskTextSelector: ${JSON.stringify(options.maskTextSelector)}, maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs}, maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextFn: ${options.maskTextFn}, maskTextFn: ${options.maskTextFn},
recordCanvas: ${options.recordCanvas}, recordCanvas: ${options.recordCanvas},
plugins: ${options.plugins} plugins: ${options.plugins}
@@ -284,6 +285,24 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots, __filename, 'maskPassword'); 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 () => { it('should not record blocked elements and its child nodes', 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');