diff --git a/package.json b/package.json index 9b9aecfb..c4a5a9af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@highlight-run/rrweb", - "version": "0.9.29", + "version": "0.10.0", "description": "record and replay the web", "scripts": { "test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.test.ts", diff --git a/src/record/index.ts b/src/record/index.ts index 38a00ee0..95396d20 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -33,9 +33,9 @@ function record( emit, checkoutEveryNms, checkoutEveryNth, - blockClass = 'rr-block', + blockClass = 'highlight-block', blockSelector = null, - ignoreClass = 'rr-ignore', + ignoreClass = 'highlight-ignore', inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, @@ -49,6 +49,7 @@ function record( collectFonts = false, recordLog = false, debug, + enableStrictPrivacy = false, } = options; // runtime checks for user options if (!emit) { @@ -191,6 +192,7 @@ function record( maskAllInputs: maskInputOptions, slimDOM: slimDOMOptions, recordCanvas, + enableStrictPrivacy, }); if (!node) { @@ -366,6 +368,7 @@ function record( collectFonts, slimDOMOptions, logOptions, + enableStrictPrivacy, }, hooks, ), diff --git a/src/record/mutation.ts b/src/record/mutation.ts index 1fc13d1f..b35c0ce5 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -147,6 +147,7 @@ export default class MutationBuffer { private inlineStylesheet: boolean; private maskInputOptions: MaskInputOptions; private recordCanvas: boolean; + private enableStrictPrivacy: boolean; private slimDOMOptions: SlimDOMOptions; public init( @@ -157,6 +158,7 @@ export default class MutationBuffer { maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, + enableStrictPrivacy: boolean, ) { this.blockClass = blockClass; this.blockSelector = blockSelector; @@ -164,6 +166,7 @@ export default class MutationBuffer { this.maskInputOptions = maskInputOptions; this.recordCanvas = recordCanvas; this.slimDOMOptions = slimDOMOptions; + this.enableStrictPrivacy = enableStrictPrivacy; this.emissionCallback = cb; } @@ -228,6 +231,7 @@ export default class MutationBuffer { maskInputOptions: this.maskInputOptions, slimDOMOptions: this.slimDOMOptions, recordCanvas: this.recordCanvas, + enableStrictPrivacy: this.enableStrictPrivacy, }); if (sn) { adds.push({ diff --git a/src/record/observer.ts b/src/record/observer.ts index ba67c4ae..317beef0 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -60,6 +60,7 @@ function initMutationObserver( maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, + enableStrictPrivacy: boolean, ): MutationObserver { // see mutation.ts for details mutationBuffer.init( @@ -70,6 +71,7 @@ function initMutationObserver( maskInputOptions, recordCanvas, slimDOMOptions, + enableStrictPrivacy, ); let mutationBufferCtor = window.MutationObserver; const angularZoneSymbol = (window as WindowWithAngularZone)?.Zone?.__symbol__?.( @@ -720,6 +722,7 @@ export function initObservers( o.maskInputOptions, o.recordCanvas, o.slimDOMOptions, + o.enableStrictPrivacy, ); const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling); const mouseInteractionHandler = initMouseInteractionObserver( diff --git a/src/replay/index.ts b/src/replay/index.ts index 55d952ce..ac8ddca3 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -127,7 +127,7 @@ export class Replayer { skipInactive: false, showWarning: true, showDebug: false, - blockClass: 'rr-block', + blockClass: 'highlight-block', liveMode: false, insertStyleRules: [], triggerFocus: true, @@ -641,6 +641,7 @@ export class Replayer { const styleEl = document.createElement('style'); const { documentElement, head } = this.iframe.contentDocument; documentElement!.insertBefore(styleEl, head); + console.log(this.config.blockClass); const injectStylesRules = getInjectStyleRules( this.config.blockClass, ).concat(this.config.insertStyleRules); diff --git a/src/replay/styles/inject-style.ts b/src/replay/styles/inject-style.ts index a0ac939c..01981354 100644 --- a/src/replay/styles/inject-style.ts +++ b/src/replay/styles/inject-style.ts @@ -1,6 +1,8 @@ const rules: (blockClass: string) => string[] = (blockClass: string) => [ `iframe, .${blockClass} { background: #ccc }`, 'noscript { display: none !important; }', + `.${blockClass} { background: black; border-radius: 5px; }`, + `.${blockClass}:hover::after {content: 'Redacted'; color: white; text-align: center; width: 100%; display: block;}`, ]; export default rules; diff --git a/src/snapshot/snapshot.ts b/src/snapshot/snapshot.ts index fcfb9f56..d9555254 100644 --- a/src/snapshot/snapshot.ts +++ b/src/snapshot/snapshot.ts @@ -205,6 +205,7 @@ function serializeNode( inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; recordCanvas: boolean; + enableStrictPrivacy: boolean; }, ): serializedNode | false { const { @@ -214,7 +215,9 @@ function serializeNode( inlineStylesheet, maskInputOptions = {}, recordCanvas, + enableStrictPrivacy, } = options; + switch (n.nodeType) { case n.DOCUMENT_NODE: return { @@ -229,7 +232,7 @@ function serializeNode( systemId: (n as DocumentType).systemId, }; case n.ELEMENT_NODE: - const needBlock = _isBlockedElement( + let needBlock = _isBlockedElement( n as HTMLElement, blockClass, blockSelector, @@ -318,13 +321,14 @@ function serializeNode( if ((n as HTMLElement).scrollTop) { attributes.rr_scrollTop = (n as HTMLElement).scrollTop; } - if (needBlock) { + if (needBlock || (tagName === 'img' && enableStrictPrivacy)) { const { width, height } = (n as HTMLElement).getBoundingClientRect(); attributes = { class: attributes.class, rr_width: `${width}px`, rr_height: `${height}px`, }; + needBlock = true; } return { type: NodeType.Element, @@ -341,11 +345,38 @@ function serializeNode( n.parentNode && (n.parentNode as HTMLElement).tagName; let textContent = (n as Text).textContent; const isStyle = parentTagName === 'STYLE' ? true : undefined; + /** Determines if this node has been handled already. */ + let textContentHandled = false; if (isStyle && textContent) { textContent = absoluteToStylesheet(textContent, getHref()); + textContentHandled = true; } if (parentTagName === 'SCRIPT') { textContent = 'SCRIPT_PLACEHOLDER'; + textContentHandled = true; + } else if (parentTagName === 'NOSCRIPT') { + textContent = ''; + textContentHandled = true; + } + + // Randomizes the text content to a string of the same length. + if (enableStrictPrivacy && !textContentHandled && parentTagName) { + const IGNORE_TAG_NAMES = new Set([ + 'HEAD', + 'TITLE', + 'STYLE', + 'SCRIPT', + 'HTML', + 'BODY', + 'NOSCRIPT', + ]); + if (!IGNORE_TAG_NAMES.has(parentTagName)) { + textContent = + textContent + ?.split(' ') + .map((word) => Math.random().toString(20).substr(2, word.length)) + .join(' ') || ''; + } } return { type: NodeType.Text, @@ -472,6 +503,7 @@ export function serializeNodeWithId( slimDOMOptions: SlimDOMOptions; recordCanvas?: boolean; preserveWhiteSpace?: boolean; + enableStrictPrivacy: boolean; }, ): serializedNodeWithId | null { const { @@ -484,6 +516,7 @@ export function serializeNodeWithId( maskInputOptions = {}, slimDOMOptions, recordCanvas = false, + enableStrictPrivacy, } = options; let { preserveWhiteSpace = true } = options; const _serializedNode = serializeNode(n, { @@ -493,6 +526,7 @@ export function serializeNodeWithId( inlineStylesheet, maskInputOptions, recordCanvas, + enableStrictPrivacy, }); if (!_serializedNode) { // TODO: dev only @@ -524,6 +558,16 @@ export function serializeNodeWithId( let recordChild = !skipChild; if (serializedNode.type === NodeType.Element) { recordChild = recordChild && !serializedNode.needBlock; + + /** Highlight Code Begin */ + // Remove the image's src if enableStrictPrivacy. + if (serializedNode.needBlock && serializedNode.tagName === 'img') { + const clone = n.cloneNode(); + ((clone as unknown) as HTMLImageElement).src = ''; + map[id] = clone as INode; + } + /** Highlight Code End */ + // this property was not needed in replay side delete serializedNode.needBlock; } @@ -552,6 +596,7 @@ export function serializeNodeWithId( slimDOMOptions, recordCanvas, preserveWhiteSpace, + enableStrictPrivacy, }); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); @@ -570,15 +615,17 @@ function snapshot( slimDOM?: boolean | SlimDOMOptions; recordCanvas?: boolean; blockSelector?: string | null; + enableStrictPrivacy: boolean; }, ): [serializedNodeWithId | null, idNodeMap] { const { - blockClass = 'rr-block', + blockClass = 'highlight-block', inlineStylesheet = true, recordCanvas = false, blockSelector = null, maskAllInputs = false, slimDOM = false, + enableStrictPrivacy = false, } = options || {}; const idNodeMap: idNodeMap = {}; const maskInputOptions: MaskInputOptions = @@ -632,6 +679,7 @@ function snapshot( maskInputOptions, slimDOMOptions, recordCanvas, + enableStrictPrivacy, }), idNodeMap, ]; diff --git a/src/types.ts b/src/types.ts index b3c8d916..4615b953 100644 --- a/src/types.ts +++ b/src/types.ts @@ -207,6 +207,11 @@ export type recordOptions = { mousemoveWait?: number; recordLog?: boolean | LogRecordOptions; debug?: boolean; + /** + * Enabling this will disable recording of text data on the page. This is useful if you do not want to record personally identifiable information. + * Text will be randomized. Instead of seeing "Hello World" in a recording, you will see "1fds1 j59a0". + */ + enableStrictPrivacy?: boolean; }; export type observerParam = { @@ -232,6 +237,7 @@ export type observerParam = { recordCanvas: boolean; collectFonts: boolean; slimDOMOptions: SlimDOMOptions; + enableStrictPrivacy: boolean; }; export type hooksParam = { diff --git a/test/html/block.html b/test/html/block.html index 6fee77f7..a2c5b614 100644 --- a/test/html/block.html +++ b/test/html/block.html @@ -7,7 +7,7 @@ Block record -
+
diff --git a/typings/record/mutation.d.ts b/typings/record/mutation.d.ts index 14245a0a..3584a0ff 100644 --- a/typings/record/mutation.d.ts +++ b/typings/record/mutation.d.ts @@ -16,8 +16,9 @@ export default class MutationBuffer { private inlineStylesheet; private maskInputOptions; private recordCanvas; + private enableStrictPrivacy; private slimDOMOptions; - init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions): void; + init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, enableStrictPrivacy: boolean): void; freeze(): void; unfreeze(): void; isFrozen(): boolean; diff --git a/typings/replay/index.d.ts b/typings/replay/index.d.ts index ee6d02fa..696cadfd 100644 --- a/typings/replay/index.d.ts +++ b/typings/replay/index.d.ts @@ -13,13 +13,13 @@ export declare class Replayer { private mouseTail; private tailPositions; private emitter; + private activityIntervals; private inactiveEndTimestamp; private legacy_missingNodeRetryMap; private treeIndex; private fragmentParentMap; private elementStateMap; private imageMap; - private activityIntervals; constructor(events: Array, config?: Partial); on(event: string, handler: Handler): this; setConfig(config: Partial): void; diff --git a/typings/snapshot/rebuild.d.ts b/typings/snapshot/rebuild.d.ts index 6ab9f04b..35eb6020 100644 --- a/typings/snapshot/rebuild.d.ts +++ b/typings/snapshot/rebuild.d.ts @@ -5,12 +5,10 @@ export declare function buildNodeWithSN(n: serializedNodeWithId, options: { map: idNodeMap; skipChild?: boolean; hackCss: boolean; - afterAppend?: (n: INode) => unknown; }): INode | null; declare function rebuild(n: serializedNodeWithId, options: { doc: Document; onVisit?: (node: INode) => unknown; hackCss?: boolean; - afterAppend?: (n: INode) => unknown; }): [Node | null, idNodeMap]; export default rebuild; diff --git a/typings/snapshot/snapshot.d.ts b/typings/snapshot/snapshot.d.ts index 62626737..9d12b13f 100644 --- a/typings/snapshot/snapshot.d.ts +++ b/typings/snapshot/snapshot.d.ts @@ -15,10 +15,7 @@ export declare function serializeNodeWithId(n: Node | INode, options: { slimDOMOptions: SlimDOMOptions; recordCanvas?: boolean; preserveWhiteSpace?: boolean; - onSerialize?: (n: INode) => unknown; - onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; - iframeLoadTimeout?: number; - debug?: boolean; + enableStrictPrivacy: boolean; }): serializedNodeWithId | null; declare function snapshot(n: Document, options?: { blockClass?: string | RegExp; @@ -27,11 +24,7 @@ declare function snapshot(n: Document, options?: { slimDOM?: boolean | SlimDOMOptions; recordCanvas?: boolean; blockSelector?: string | null; - preserveWhiteSpace?: boolean; - onSerialize?: (n: INode) => unknown; - onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; - iframeLoadTimeout?: number; - debug?: boolean; + enableStrictPrivacy: boolean; }): [serializedNodeWithId | null, idNodeMap]; export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void; export declare function cleanupSnapshot(): void; diff --git a/typings/types.d.ts b/typings/types.d.ts index 199e684a..4a0f4cc8 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -140,6 +140,7 @@ export declare type recordOptions = { mousemoveWait?: number; recordLog?: boolean | LogRecordOptions; debug?: boolean; + enableStrictPrivacy?: boolean; }; export declare type observerParam = { mutationCb: mutationCallBack; @@ -164,6 +165,7 @@ export declare type observerParam = { recordCanvas: boolean; collectFonts: boolean; slimDOMOptions: SlimDOMOptions; + enableStrictPrivacy: boolean; }; export declare type hooksParam = { mutation?: mutationCallBack; @@ -367,6 +369,8 @@ export declare type playerConfig = { unpackFn?: UnpackFn; logConfig: LogReplayConfig; inactiveThreshold: number; + inactiveSkipTime: number; + maxSkipSpeed: number; }; export declare type LogReplayConfig = { level?: Array | undefined;