diff --git a/guide.md b/guide.md index 54e9f9f94e..6d2173df37 100644 --- a/guide.md +++ b/guide.md @@ -143,6 +143,7 @@ The parameter of `rrweb.record` accepts the following options. | 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 | +| ignoreCSSAttributes | null | array of CSS attributes that should be ignored | | 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 \* | diff --git a/guide.zh_CN.md b/guide.zh_CN.md index d69f5c77c6..926357121d 100644 --- a/guide.zh_CN.md +++ b/guide.zh_CN.md @@ -139,6 +139,7 @@ setInterval(save, 10 * 1000); | blockClass | 'rr-block' | 字符串或正则表达式,可用于自定义屏蔽元素的类名,详见[“隐私”](#隐私)章节 | | blockSelector | null | 所有 element.matches(blockSelector)为 true 的元素都不会被录制,回放时取而代之的是一个同等宽高的占位元素 | | ignoreClass | 'rr-ignore' | 字符串或正则表达式,可用于自定义忽略元素的类名,详见[“隐私”](#隐私)章节 | +| ignoreCSSAttributes | null | 应该被忽略的 CSS 属性数组 | | maskTextClass | 'rr-mask' | 字符串或正则表达式,可用于自定义忽略元素 text 内容的类名,详见[“隐私”](#隐私)章节 | | maskTextSelector | null | 所有 element.matches(maskTextSelector)为 true 的元素及其子元素的 text 内容将会被屏蔽 | | maskAllInputs | false | 将所有输入内容记录为 \* | diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 756c21c56b..b566977599 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -70,7 +70,9 @@ function record( inlineImages = false, plugins, keepIframeSrcFn = () => false, + ignoreCSSAttributes = new Set([]), } = options; + // runtime checks for user options if (!emit) { throw new Error('emit function is required'); @@ -226,6 +228,7 @@ function record( mutationCb: wrappedCanvasMutationEmit, win: window, blockClass, + blockSelector, mirror, sampling: sampling.canvas, }); @@ -464,6 +467,7 @@ function record( stylesheetManager, shadowDomManager, canvasManager, + ignoreCSSAttributes, plugins: plugins ?.filter((p) => p.observer) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index df3b3017c2..44ea19fc66 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -273,7 +273,7 @@ export default class MutationBuffer { rootShadowHost = (rootShadowHost?.getRootNode?.() as ShadowRoot | undefined)?.host || null; - // ensure shadowHost is a Node, or doc.contains will throw an error + // ensure contains is passed a Node, or it will throw an error const notInDoc = !this.doc.contains(n) && (!rootShadowHost || !this.doc.contains(rootShadowHost)); @@ -446,7 +446,7 @@ export default class MutationBuffer { case 'characterData': { const value = m.target.textContent; if ( - !isBlocked(m.target, this.blockClass, false) && + !isBlocked(m.target, this.blockClass, this.blockSelector, false) && value !== m.oldValue ) { this.texts.push({ @@ -478,7 +478,7 @@ export default class MutationBuffer { }); } if ( - isBlocked(m.target, this.blockClass, false) || + isBlocked(m.target, this.blockClass, this.blockSelector, false) || value === m.oldValue ) { return; @@ -554,7 +554,7 @@ export default class MutationBuffer { /** * Parent is blocked, ignore all child mutations */ - if (isBlocked(m.target, this.blockClass, true)) return; + if (isBlocked(m.target, this.blockClass, this.blockSelector, true)) return; m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { @@ -563,7 +563,7 @@ export default class MutationBuffer { ? this.mirror.getId(m.target.host) : this.mirror.getId(m.target); if ( - isBlocked(m.target, this.blockClass, false) || + isBlocked(m.target, this.blockClass, this.blockSelector, false) || isIgnored(n, this.mirror) || !isSerialized(n, this.mirror) ) { @@ -635,7 +635,7 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too - if (!isBlocked(n, this.blockClass, false)) + if (!isBlocked(n, this.blockClass, this.blockSelector, false)) n.childNodes.forEach((childN) => this.genAdds(childN)); }; } diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 9350906cec..5322df3cd2 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -210,6 +210,7 @@ function initMouseInteractionObserver({ doc, mirror, blockClass, + blockSelector, sampling, }: observerParam): listenerHandler { if (sampling.mouseInteraction === false) { @@ -227,7 +228,7 @@ function initMouseInteractionObserver({ const getHandler = (eventKey: keyof typeof MouseInteractions) => { return (event: MouseEvent | TouchEvent) => { const target = getEventTarget(event) as Node; - if (isBlocked(target, blockClass, true)) { + if (isBlocked(target, blockClass, blockSelector, true)) { return; } const e = isTouchEvent(event) ? event.changedTouches[0] : event; @@ -266,14 +267,15 @@ export function initScrollObserver({ doc, mirror, blockClass, + blockSelector, sampling, }: Pick< observerParam, - 'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'sampling' + 'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling' >): listenerHandler { const updatePosition = throttle((evt) => { const target = getEventTarget(evt); - if (!target || isBlocked(target as Node, blockClass, true)) { + if (!target || isBlocked(target as Node, blockClass, blockSelector, true)) { return; } const id = mirror.getId(target as Node); @@ -331,6 +333,7 @@ function initInputObserver({ doc, mirror, blockClass, + blockSelector, ignoreClass, maskInputOptions, maskInputFn, @@ -350,7 +353,7 @@ function initInputObserver({ !target || !(target as Element).tagName || INPUT_TAGS.indexOf((target as Element).tagName) < 0 || - isBlocked(target as Node, blockClass, true) + isBlocked(target as Node, blockClass, blockSelector, true) ) { return; } @@ -613,7 +616,7 @@ function initStyleSheetObserver( } function initStyleDeclarationObserver( - { styleDeclarationCb, mirror }: observerParam, + { styleDeclarationCb, mirror, ignoreCSSAttributes }: observerParam, { win }: { win: IWindow }, ): listenerHandler { // eslint-disable-next-line @typescript-eslint/unbound-method @@ -624,6 +627,10 @@ function initStyleDeclarationObserver( value: string, priority: string, ) { + // ignore this mutation if we do not care about this css attribute + if (ignoreCSSAttributes.has(property)) { + return setProperty.apply(this, [property, value, priority]); + } const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode); if (id !== -1) { styleDeclarationCb({ @@ -645,6 +652,10 @@ function initStyleDeclarationObserver( this: CSSStyleDeclaration, property: string, ) { + // ignore this mutation if we do not care about this css attribute + if (ignoreCSSAttributes.has(property)) { + return removeProperty.apply(this, [property]); + } const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode); if (id !== -1) { styleDeclarationCb({ @@ -667,13 +678,14 @@ function initStyleDeclarationObserver( function initMediaInteractionObserver({ mediaInteractionCb, blockClass, + blockSelector, mirror, sampling, }: observerParam): listenerHandler { const handler = (type: MediaInteractions) => throttle((event: Event) => { const target = getEventTarget(event); - if (!target || isBlocked(target as Node, blockClass, true)) { + if (!target || isBlocked(target as Node, blockClass, blockSelector, true)) { return; } const { currentTime, volume, muted } = target as HTMLMediaElement; @@ -755,7 +767,7 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { } function initSelectionObserver(param: observerParam): listenerHandler { - const { doc, mirror, blockClass, selectionCb } = param; + const { doc, mirror, blockClass, blockSelector, selectionCb } = param; let collapsed = true; const updateSelection = () => { @@ -774,8 +786,8 @@ function initSelectionObserver(param: observerParam): listenerHandler { const { startContainer, startOffset, endContainer, endOffset } = range; const blocked = - isBlocked(startContainer, blockClass, true) || - isBlocked(endContainer, blockClass, true); + isBlocked(startContainer, blockClass, blockSelector, true) || + isBlocked(endContainer, blockClass, blockSelector, true); if (blocked) continue; diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index 52ba1bf3aa..dd54e55452 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -13,6 +13,7 @@ export default function initCanvas2DMutationObserver( cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, + blockSelector: string | null, mirror: Mirror, ): listenerHandler { const handlers: listenerHandler[] = []; @@ -41,7 +42,7 @@ export default function initCanvas2DMutationObserver( this: CanvasRenderingContext2D, ...args: Array ) { - if (!isBlocked(this.canvas, blockClass, true)) { + if (!isBlocked(this.canvas, blockClass, blockSelector, true)) { // Using setTimeout as toDataURL can be heavy // and we'd rather not block the main thread setTimeout(() => { diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index 22b62358ae..5b2a874d01 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -60,17 +60,18 @@ export class CanvasManager { mutationCb: canvasMutationCallback; win: IWindow; blockClass: blockClass; + blockSelector: string | null, mirror: Mirror; sampling?: 'all' | number; }) { - const { sampling = 'all', win, blockClass, recordCanvas } = options; + const { sampling = 'all', win, blockClass, blockSelector, recordCanvas } = options; this.mutationCb = options.mutationCb; this.mirror = options.mirror; if (recordCanvas && sampling === 'all') - this.initCanvasMutationObserver(win, blockClass); + this.initCanvasMutationObserver(win, blockClass, blockSelector); if (recordCanvas && typeof sampling === 'number') - this.initCanvasFPSObserver(sampling, win, blockClass); + this.initCanvasFPSObserver(sampling, win, blockClass, blockSelector); } private processMutation: canvasManagerMutationCallback = ( @@ -94,8 +95,9 @@ export class CanvasManager { fps: number, win: IWindow, blockClass: blockClass, + blockSelector: string | null, ) { - const canvasContextReset = initCanvasContextObserver(win, blockClass); + const canvasContextReset = initCanvasContextObserver(win, blockClass, blockSelector); const snapshotInProgressMap: Map = new Map(); const worker = new ImageBitmapDataURLWorker() as ImageBitmapDataURLRequestWorker; worker.onmessage = (e) => { @@ -141,7 +143,7 @@ export class CanvasManager { const getCanvas = (): HTMLCanvasElement[] => { const matchedCanvas: HTMLCanvasElement[] = []; win.document.querySelectorAll('canvas').forEach(canvas => { - if (!isBlocked(canvas, blockClass, true)) { + if (!isBlocked(canvas, blockClass, blockSelector, true)) { matchedCanvas.push(canvas); } }) @@ -208,15 +210,17 @@ export class CanvasManager { private initCanvasMutationObserver( win: IWindow, blockClass: blockClass, + blockSelector: string | null, ): void { this.startRAFTimestamping(); this.startPendingCanvasMutationFlusher(); - const canvasContextReset = initCanvasContextObserver(win, blockClass); + const canvasContextReset = initCanvasContextObserver(win, blockClass, blockSelector); const canvas2DReset = initCanvas2DMutationObserver( this.processMutation.bind(this), win, blockClass, + blockSelector, this.mirror, ); @@ -224,6 +228,7 @@ export class CanvasManager { this.processMutation.bind(this), win, blockClass, + blockSelector, this.mirror, ); diff --git a/packages/rrweb/src/record/observers/canvas/canvas.ts b/packages/rrweb/src/record/observers/canvas/canvas.ts index 9411b91c7b..c12d93c1cc 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas.ts @@ -5,6 +5,7 @@ import { isBlocked, patch } from '../../../utils'; export default function initCanvasContextObserver( win: IWindow, blockClass: blockClass, + blockSelector: string | null, ): listenerHandler { const handlers: listenerHandler[] = []; try { @@ -23,7 +24,7 @@ export default function initCanvasContextObserver( contextType: string, ...args: Array ) { - if (!isBlocked(this, blockClass, true)) { + if (!isBlocked(this, blockClass, blockSelector, true)) { if (!('__context' in this)) this.__context = contextType; } return original.apply(this, [contextType, ...args]); diff --git a/packages/rrweb/src/record/observers/canvas/webgl.ts b/packages/rrweb/src/record/observers/canvas/webgl.ts index f4f003f116..9c6a3b7cbd 100644 --- a/packages/rrweb/src/record/observers/canvas/webgl.ts +++ b/packages/rrweb/src/record/observers/canvas/webgl.ts @@ -15,6 +15,7 @@ function patchGLPrototype( type: CanvasContext, cb: canvasManagerMutationCallback, blockClass: blockClass, + blockSelector: string | null, mirror: Mirror, win: IWindow, ): listenerHandler[] { @@ -36,7 +37,7 @@ function patchGLPrototype( return function (this: typeof prototype, ...args: Array) { const result = original.apply(this, args); saveWebGLVar(result, win, prototype); - if (!isBlocked(this.canvas, blockClass, true)) { + if (!isBlocked(this.canvas, blockClass, blockSelector, true)) { const recordArgs = serializeArgs([...args], win, prototype); const mutation: canvasMutationWithType = { type, @@ -76,6 +77,7 @@ export default function initCanvasWebGLMutationObserver( cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, + blockSelector: string | null, mirror: Mirror, ): listenerHandler { const handlers: listenerHandler[] = []; @@ -86,6 +88,7 @@ export default function initCanvasWebGLMutationObserver( CanvasContext.WebGL, cb, blockClass, + blockSelector, mirror, win, ), @@ -98,6 +101,7 @@ export default function initCanvasWebGLMutationObserver( CanvasContext.WebGL2, cb, blockClass, + blockSelector, mirror, win, ), diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 90a1d321d6..b2aeae01b2 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -39,7 +39,7 @@ export class ShadowDomManager { const manager = this; this.restorePatches.push( patch( - HTMLElement.prototype, + Element.prototype, 'attachShadow', function (original: (init: ShadowRootInit) => ShadowRoot) { return function (this: HTMLElement, option: ShadowRootInit) { diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 7a1779147c..4da6d275a5 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -245,6 +245,7 @@ export type recordOptions = { maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; slimDOMOptions?: SlimDOMOptions | 'all' | true; + ignoreCSSAttributes?:Set; inlineStylesheet?: boolean; hooks?: hooksParam; packFn?: PackFn; @@ -294,6 +295,7 @@ export type observerParam = { stylesheetManager: StylesheetManager; shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; + ignoreCSSAttributes:Set; plugins: Array<{ observer: ( cb: (...arg: Array) => void, diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 2d0838b6f3..209d99d22c 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -187,12 +187,14 @@ export function getWindowWidth(): number { * Checks if the given element set to be blocked by rrweb * @param node - node to check * @param blockClass - class name to check - * @param ignoreParents - whether to search through parent nodes for the block class + * @param blockSelector - css selectors to check + * @param checkAncestors - whether to search through parent nodes for the block class * @returns true/false if the node was blocked or not */ export function isBlocked( node: Node | null, blockClass: blockClass, + blockSelector: string | null, checkAncestors: boolean, ): boolean { if (!node) { @@ -203,13 +205,17 @@ export function isBlocked( ? (node as HTMLElement) : node.parentElement; if (!el) return false; - + if (typeof blockClass === 'string') { if (el.classList.contains(blockClass)) return true; if (checkAncestors && el.closest('.' + blockClass) !== null) return true; } else { if (classMatchesRegex(el, blockClass, checkAncestors)) return true; } + if (blockSelector) { + if ((node as HTMLElement).matches(blockSelector)) return true; + if (checkAncestors && el.closest(blockSelector) !== null) return true; + } return false; } diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 2c2b7c8d0d..a86e45dd09 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -581,7 +581,7 @@ exports[`record captures style property changes 1`] = ` \\"source\\": 13, \\"id\\": 9, \\"set\\": { - \\"property\\": \\"color\\", + \\"property\\": \\"border-color\\", \\"value\\": \\"green\\" }, \\"index\\": [ diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index f9b46aea02..f803c3302f 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -385,6 +385,7 @@ describe('record', function (this: ISuite) { record({ emit: ((window as unknown) as IWindow).emit, + ignoreCSSAttributes: new Set(['color']), }); const styleElement = document.createElement('style'); @@ -393,10 +394,18 @@ describe('record', function (this: ISuite) { const styleSheet = styleElement.sheet; styleSheet.insertRule('body { background: #000; }'); setTimeout(() => { + // should be ignored (styleSheet.cssRules[0] as CSSStyleRule).style.setProperty( 'color', 'green', ); + + // should be captured because we did not block it + (styleSheet.cssRules[0] as CSSStyleRule).style.setProperty( + 'border-color', + 'green', + ); + (styleSheet.cssRules[0] as CSSStyleRule).style.removeProperty( 'background', );