import { _nullishCoalesce, _optionalChain } from '@sentry/utils'; import { addBreadcrumb, getClient, isSentryRequestUrl, getCurrentScope, addEventProcessor, prepareEvent, getIsolationScope, setContext, captureException, spanToJSON, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import { GLOBAL_OBJ, normalize, fill, htmlTreeAsString, browserPerformanceTimeOrigin, logger, uuid4, SENTRY_XHR_DATA_KEY, dropUndefinedKeys, stringMatchesSomePattern, addFetchInstrumentationHandler, addXhrInstrumentationHandler, addClickKeypressInstrumentationHandler, addHistoryInstrumentationHandler, createEnvelope, createEventEnvelopeHeaders, getSdkMetadataForEnvelopeHeader, updateRateLimits, isRateLimited, consoleSandbox, isBrowser } from '@sentry/utils'; import { addPerformanceInstrumentationHandler, addLcpInstrumentationHandler } from '@sentry-internal/tracing'; // exporting a separate copy of `WINDOW` rather than exporting the one from `@sentry/browser` // prevents the browser package from being bundled in the CDN bundle, and avoids a // circular dependency between the browser and replay packages should `@sentry/browser` import // from `@sentry/replay` in the future const WINDOW = GLOBAL_OBJ ; const REPLAY_SESSION_KEY = 'sentryReplaySession'; const REPLAY_EVENT_NAME = 'replay_event'; const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay'; // The idle limit for a session after which recording is paused. const SESSION_IDLE_PAUSE_DURATION = 300000; // 5 minutes in ms // The idle limit for a session after which the session expires. const SESSION_IDLE_EXPIRE_DURATION = 900000; // 15 minutes in ms /** Default flush delays */ const DEFAULT_FLUSH_MIN_DELAY = 5000; // XXX: Temp fix for our debounce logic where `maxWait` would never occur if it // was the same as `wait` const DEFAULT_FLUSH_MAX_DELAY = 5500; /* How long to wait for error checkouts */ const BUFFER_CHECKOUT_TIME = 60000; const RETRY_BASE_INTERVAL = 5000; const RETRY_MAX_COUNT = 3; /* The max (uncompressed) size in bytes of a network body. Any body larger than this will be truncated. */ const NETWORK_BODY_MAX_SIZE = 150000; /* The max size of a single console arg that is captured. Any arg larger than this will be truncated. */ const CONSOLE_ARG_MAX_SIZE = 5000; /* Min. time to wait before we consider something a slow click. */ const SLOW_CLICK_THRESHOLD = 3000; /* For scroll actions after a click, we only look for a very short time period to detect programmatic scrolling. */ const SLOW_CLICK_SCROLL_TIMEOUT = 300; /** When encountering a total segment size exceeding this size, stop the replay (as we cannot properly ingest it). */ const REPLAY_MAX_EVENT_BUFFER_SIZE = 20000000; // ~20MB /** Replays must be min. 5s long before we send them. */ const MIN_REPLAY_DURATION = 4999; /* The max. allowed value that the minReplayDuration can be set to. */ const MIN_REPLAY_DURATION_LIMIT = 15000; /** The max. length of a replay. */ const MAX_REPLAY_DURATION = 3600000; // 60 minutes in ms; function _nullishCoalesce$1(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }function _optionalChain$5(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }var NodeType$1; (function (NodeType) { NodeType[NodeType["Document"] = 0] = "Document"; NodeType[NodeType["DocumentType"] = 1] = "DocumentType"; NodeType[NodeType["Element"] = 2] = "Element"; NodeType[NodeType["Text"] = 3] = "Text"; NodeType[NodeType["CDATA"] = 4] = "CDATA"; NodeType[NodeType["Comment"] = 5] = "Comment"; })(NodeType$1 || (NodeType$1 = {})); function isElement$1(n) { return n.nodeType === n.ELEMENT_NODE; } function isShadowRoot(n) { const host = _optionalChain$5([n, 'optionalAccess', _ => _.host]); return Boolean(_optionalChain$5([host, 'optionalAccess', _2 => _2.shadowRoot]) === n); } function isNativeShadowDom(shadowRoot) { return Object.prototype.toString.call(shadowRoot) === '[object ShadowRoot]'; } function fixBrowserCompatibilityIssuesInCSS(cssText) { if (cssText.includes(' background-clip: text;') && !cssText.includes(' -webkit-background-clip: text;')) { cssText = cssText.replace(' background-clip: text;', ' -webkit-background-clip: text; background-clip: text;'); } return cssText; } function escapeImportStatement(rule) { const { cssText } = rule; if (cssText.split('"').length < 3) return cssText; const statement = ['@import', `url(${JSON.stringify(rule.href)})`]; if (rule.layerName === '') { statement.push(`layer`); } else if (rule.layerName) { statement.push(`layer(${rule.layerName})`); } if (rule.supportsText) { statement.push(`supports(${rule.supportsText})`); } if (rule.media.length) { statement.push(rule.media.mediaText); } return statement.join(' ') + ';'; } function stringifyStylesheet(s) { try { const rules = s.rules || s.cssRules; return rules ? fixBrowserCompatibilityIssuesInCSS(Array.from(rules, stringifyRule).join('')) : null; } catch (error) { return null; } } function stringifyRule(rule) { let importStringified; if (isCSSImportRule(rule)) { try { importStringified = stringifyStylesheet(rule.styleSheet) || escapeImportStatement(rule); } catch (error) { } } else if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) { return fixSafariColons(rule.cssText); } return importStringified || rule.cssText; } function fixSafariColons(cssStringified) { const regex = /(\[(?:[\w-]+)[^\\])(:(?:[\w-]+)\])/gm; return cssStringified.replace(regex, '$1\\$2'); } function isCSSImportRule(rule) { return 'styleSheet' in rule; } function isCSSStyleRule(rule) { return 'selectorText' in rule; } class Mirror { constructor() { this.idNodeMap = new Map(); this.nodeMetaMap = new WeakMap(); } getId(n) { if (!n) return -1; const id = _optionalChain$5([this, 'access', _3 => _3.getMeta, 'call', _4 => _4(n), 'optionalAccess', _5 => _5.id]); return _nullishCoalesce$1(id, () => ( -1)); } getNode(id) { return this.idNodeMap.get(id) || null; } getIds() { return Array.from(this.idNodeMap.keys()); } getMeta(n) { return this.nodeMetaMap.get(n) || null; } removeNodeFromMap(n) { const id = this.getId(n); this.idNodeMap.delete(id); if (n.childNodes) { n.childNodes.forEach((childNode) => this.removeNodeFromMap(childNode)); } } has(id) { return this.idNodeMap.has(id); } hasNode(node) { return this.nodeMetaMap.has(node); } add(n, meta) { const id = meta.id; this.idNodeMap.set(id, n); this.nodeMetaMap.set(n, meta); } replace(id, n) { const oldNode = this.getNode(id); if (oldNode) { const meta = this.nodeMetaMap.get(oldNode); if (meta) this.nodeMetaMap.set(n, meta); } this.idNodeMap.set(id, n); } reset() { this.idNodeMap = new Map(); this.nodeMetaMap = new WeakMap(); } } function createMirror() { return new Mirror(); } function shouldMaskInput({ maskInputOptions, tagName, type, }) { if (tagName === 'OPTION') { tagName = 'SELECT'; } return Boolean(maskInputOptions[tagName.toLowerCase()] || (type && maskInputOptions[type]) || type === 'password' || (tagName === 'INPUT' && !type && maskInputOptions['text'])); } function maskInputValue({ isMasked, element, value, maskInputFn, }) { let text = value || ''; if (!isMasked) { return text; } if (maskInputFn) { text = maskInputFn(text, element); } return '*'.repeat(text.length); } function toLowerCase(str) { return str.toLowerCase(); } function toUpperCase(str) { return str.toUpperCase(); } const ORIGINAL_ATTRIBUTE_NAME = '__rrweb_original__'; function is2DCanvasBlank(canvas) { const ctx = canvas.getContext('2d'); if (!ctx) return true; const chunkSize = 50; for (let x = 0; x < canvas.width; x += chunkSize) { for (let y = 0; y < canvas.height; y += chunkSize) { const getImageData = ctx.getImageData; const originalGetImageData = ORIGINAL_ATTRIBUTE_NAME in getImageData ? getImageData[ORIGINAL_ATTRIBUTE_NAME] : getImageData; const pixelBuffer = new Uint32Array(originalGetImageData.call(ctx, x, y, Math.min(chunkSize, canvas.width - x), Math.min(chunkSize, canvas.height - y)).data.buffer); if (pixelBuffer.some((pixel) => pixel !== 0)) return false; } } return true; } function getInputType(element) { const type = element.type; return element.hasAttribute('data-rr-is-password') ? 'password' : type ? toLowerCase(type) : null; } function getInputValue(el, tagName, type) { if (tagName === 'INPUT' && (type === 'radio' || type === 'checkbox')) { return el.getAttribute('value') || ''; } return el.value; } let _id = 1; const tagNameRegex = new RegExp('[^a-z0-9-_:]'); const IGNORED_NODE = -2; function genId() { return _id++; } function getValidTagName(element) { if (element instanceof HTMLFormElement) { return 'form'; } const processedTagName = toLowerCase(element.tagName); if (tagNameRegex.test(processedTagName)) { return 'div'; } return processedTagName; } function extractOrigin(url) { let origin = ''; if (url.indexOf('//') > -1) { origin = url.split('/').slice(0, 3).join('/'); } else { origin = url.split('/')[0]; } origin = origin.split('?')[0]; return origin; } let canvasService; let canvasCtx; const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm; const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i; const URL_WWW_MATCH = /^www\..*/i; const DATA_URI = /^(data:)([^,]*),(.*)/i; function absoluteToStylesheet(cssText, href) { return (cssText || '').replace(URL_IN_CSS_REF, (origin, quote1, path1, quote2, path2, path3) => { const filePath = path1 || path2 || path3; const maybeQuote = quote1 || quote2 || ''; if (!filePath) { return origin; } if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) { return `url(${maybeQuote}${filePath}${maybeQuote})`; } if (DATA_URI.test(filePath)) { return `url(${maybeQuote}${filePath}${maybeQuote})`; } if (filePath[0] === '/') { return `url(${maybeQuote}${extractOrigin(href) + filePath}${maybeQuote})`; } const stack = href.split('/'); const parts = filePath.split('/'); stack.pop(); for (const part of parts) { if (part === '.') { continue; } else if (part === '..') { stack.pop(); } else { stack.push(part); } } return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`; }); } const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; const SRCSET_COMMAS_OR_SPACES = /^[, \t\n\r\u000c]+/; function getAbsoluteSrcsetString(doc, attributeValue) { if (attributeValue.trim() === '') { return attributeValue; } let pos = 0; function collectCharacters(regEx) { let chars; const match = regEx.exec(attributeValue.substring(pos)); if (match) { chars = match[0]; pos += chars.length; return chars; } return ''; } const output = []; while (true) { collectCharacters(SRCSET_COMMAS_OR_SPACES); if (pos >= attributeValue.length) { break; } let url = collectCharacters(SRCSET_NOT_SPACES); if (url.slice(-1) === ',') { url = absoluteToDoc(doc, url.substring(0, url.length - 1)); output.push(url); } else { let descriptorsStr = ''; url = absoluteToDoc(doc, url); let inParens = false; while (true) { const c = attributeValue.charAt(pos); if (c === '') { output.push((url + descriptorsStr).trim()); break; } else if (!inParens) { if (c === ',') { pos += 1; output.push((url + descriptorsStr).trim()); break; } else if (c === '(') { inParens = true; } } else { if (c === ')') { inParens = false; } } descriptorsStr += c; pos += 1; } } } return output.join(', '); } function absoluteToDoc(doc, attributeValue) { if (!attributeValue || attributeValue.trim() === '') { return attributeValue; } const a = doc.createElement('a'); a.href = attributeValue; return a.href; } function isSVGElement(el) { return Boolean(el.tagName === 'svg' || el.ownerSVGElement); } function getHref() { const a = document.createElement('a'); a.href = ''; return a.href; } function transformAttribute(doc, tagName, name, value, element, maskAttributeFn) { if (!value) { return value; } if (name === 'src' || (name === 'href' && !(tagName === 'use' && value[0] === '#'))) { return absoluteToDoc(doc, value); } else if (name === 'xlink:href' && value[0] !== '#') { return absoluteToDoc(doc, value); } else if (name === 'background' && (tagName === 'table' || tagName === 'td' || tagName === 'th')) { return absoluteToDoc(doc, value); } else if (name === 'srcset') { return getAbsoluteSrcsetString(doc, value); } else if (name === 'style') { return absoluteToStylesheet(value, getHref()); } else if (tagName === 'object' && name === 'data') { return absoluteToDoc(doc, value); } if (typeof maskAttributeFn === 'function') { return maskAttributeFn(name, value, element); } return value; } function ignoreAttribute(tagName, name, _value) { return (tagName === 'video' || tagName === 'audio') && name === 'autoplay'; } function _isBlockedElement(element, blockClass, blockSelector, unblockSelector) { try { if (unblockSelector && element.matches(unblockSelector)) { return false; } if (typeof blockClass === 'string') { if (element.classList.contains(blockClass)) { return true; } } else { for (let eIndex = element.classList.length; eIndex--;) { const className = element.classList[eIndex]; if (blockClass.test(className)) { return true; } } } if (blockSelector) { return element.matches(blockSelector); } } catch (e) { } return false; } function elementClassMatchesRegex(el, regex) { for (let eIndex = el.classList.length; eIndex--;) { const className = el.classList[eIndex]; if (regex.test(className)) { return true; } } return false; } function distanceToMatch(node, matchPredicate, limit = Infinity, distance = 0) { if (!node) return -1; if (node.nodeType !== node.ELEMENT_NODE) return -1; if (distance > limit) return -1; if (matchPredicate(node)) return distance; return distanceToMatch(node.parentNode, matchPredicate, limit, distance + 1); } function createMatchPredicate(className, selector) { return (node) => { const el = node; if (el === null) return false; try { if (className) { if (typeof className === 'string') { if (el.matches(`.${className}`)) return true; } else if (elementClassMatchesRegex(el, className)) { return true; } } if (selector && el.matches(selector)) return true; return false; } catch (e2) { return false; } }; } function needMaskingText(node, maskTextClass, maskTextSelector, unmaskTextClass, unmaskTextSelector, maskAllText) { try { const el = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement; if (el === null) return false; if (el.tagName === 'INPUT') { const autocomplete = el.getAttribute('autocomplete'); const disallowedAutocompleteValues = [ 'current-password', 'new-password', 'cc-number', 'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', ]; if (disallowedAutocompleteValues.includes(autocomplete)) { return true; } } let maskDistance = -1; let unmaskDistance = -1; if (maskAllText) { unmaskDistance = distanceToMatch(el, createMatchPredicate(unmaskTextClass, unmaskTextSelector)); if (unmaskDistance < 0) { return true; } maskDistance = distanceToMatch(el, createMatchPredicate(maskTextClass, maskTextSelector), unmaskDistance >= 0 ? unmaskDistance : Infinity); } else { maskDistance = distanceToMatch(el, createMatchPredicate(maskTextClass, maskTextSelector)); if (maskDistance < 0) { return false; } unmaskDistance = distanceToMatch(el, createMatchPredicate(unmaskTextClass, unmaskTextSelector), maskDistance >= 0 ? maskDistance : Infinity); } return maskDistance >= 0 ? unmaskDistance >= 0 ? maskDistance <= unmaskDistance : true : unmaskDistance >= 0 ? false : !!maskAllText; } catch (e) { } return !!maskAllText; } function onceIframeLoaded(iframeEl, listener, iframeLoadTimeout) { const win = iframeEl.contentWindow; if (!win) { return; } let fired = false; let readyState; try { readyState = win.document.readyState; } catch (error) { return; } if (readyState !== 'complete') { const timer = setTimeout(() => { if (!fired) { listener(); fired = true; } }, iframeLoadTimeout); iframeEl.addEventListener('load', () => { clearTimeout(timer); fired = true; listener(); }); return; } const blankUrl = 'about:blank'; if (win.location.href !== blankUrl || iframeEl.src === blankUrl || iframeEl.src === '') { setTimeout(listener, 0); return iframeEl.addEventListener('load', listener); } iframeEl.addEventListener('load', listener); } function onceStylesheetLoaded(link, listener, styleSheetLoadTimeout) { let fired = false; let styleSheetLoaded; try { styleSheetLoaded = link.sheet; } catch (error) { return; } if (styleSheetLoaded) return; const timer = setTimeout(() => { if (!fired) { listener(); fired = true; } }, styleSheetLoadTimeout); link.addEventListener('load', () => { clearTimeout(timer); fired = true; listener(); }); } function serializeNode(n, options) { const { doc, mirror, blockClass, blockSelector, unblockSelector, maskAllText, maskAttributeFn, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, inlineStylesheet, maskInputOptions = {}, maskTextFn, maskInputFn, dataURLOptions = {}, inlineImages, recordCanvas, keepIframeSrcFn, newlyAddedElement = false, } = options; const rootId = getRootId(doc, mirror); switch (n.nodeType) { case n.DOCUMENT_NODE: if (n.compatMode !== 'CSS1Compat') { return { type: NodeType$1.Document, childNodes: [], compatMode: n.compatMode, }; } else { return { type: NodeType$1.Document, childNodes: [], }; } case n.DOCUMENT_TYPE_NODE: return { type: NodeType$1.DocumentType, name: n.name, publicId: n.publicId, systemId: n.systemId, rootId, }; case n.ELEMENT_NODE: return serializeElementNode(n, { doc, blockClass, blockSelector, unblockSelector, inlineStylesheet, maskAttributeFn, maskInputOptions, maskInputFn, dataURLOptions, inlineImages, recordCanvas, keepIframeSrcFn, newlyAddedElement, rootId, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, }); case n.TEXT_NODE: return serializeTextNode(n, { maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, maskTextFn, maskInputOptions, maskInputFn, rootId, }); case n.CDATA_SECTION_NODE: return { type: NodeType$1.CDATA, textContent: '', rootId, }; case n.COMMENT_NODE: return { type: NodeType$1.Comment, textContent: n.textContent || '', rootId, }; default: return false; } } function getRootId(doc, mirror) { if (!mirror.hasNode(doc)) return undefined; const docId = mirror.getId(doc); return docId === 1 ? undefined : docId; } function serializeTextNode(n, options) { const { maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, maskTextFn, maskInputOptions, maskInputFn, rootId, } = options; const parentTagName = n.parentNode && n.parentNode.tagName; let textContent = n.textContent; const isStyle = parentTagName === 'STYLE' ? true : undefined; const isScript = parentTagName === 'SCRIPT' ? true : undefined; const isTextarea = parentTagName === 'TEXTAREA' ? true : undefined; if (isStyle && textContent) { try { if (n.nextSibling || n.previousSibling) { } else if (_optionalChain$5([n, 'access', _6 => _6.parentNode, 'access', _7 => _7.sheet, 'optionalAccess', _8 => _8.cssRules])) { textContent = stringifyStylesheet(n.parentNode.sheet); } } catch (err) { console.warn(`Cannot get CSS styles from text's parentNode. Error: ${err}`, n); } textContent = absoluteToStylesheet(textContent, getHref()); } if (isScript) { textContent = 'SCRIPT_PLACEHOLDER'; } const forceMask = needMaskingText(n, maskTextClass, maskTextSelector, unmaskTextClass, unmaskTextSelector, maskAllText); if (!isStyle && !isScript && !isTextarea && textContent && forceMask) { textContent = maskTextFn ? maskTextFn(textContent) : textContent.replace(/[\S]/g, '*'); } if (isTextarea && textContent && (maskInputOptions.textarea || forceMask)) { textContent = maskInputFn ? maskInputFn(textContent, n.parentNode) : textContent.replace(/[\S]/g, '*'); } if (parentTagName === 'OPTION' && textContent) { const isInputMasked = shouldMaskInput({ type: null, tagName: parentTagName, maskInputOptions, }); textContent = maskInputValue({ isMasked: needMaskingText(n, maskTextClass, maskTextSelector, unmaskTextClass, unmaskTextSelector, isInputMasked), element: n, value: textContent, maskInputFn, }); } return { type: NodeType$1.Text, textContent: textContent || '', isStyle, rootId, }; } function serializeElementNode(n, options) { const { doc, blockClass, blockSelector, unblockSelector, inlineStylesheet, maskInputOptions = {}, maskAttributeFn, maskInputFn, dataURLOptions = {}, inlineImages, recordCanvas, keepIframeSrcFn, newlyAddedElement = false, rootId, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector, unblockSelector); const tagName = getValidTagName(n); let attributes = {}; const len = n.attributes.length; for (let i = 0; i < len; i++) { const attr = n.attributes[i]; if (attr.name && !ignoreAttribute(tagName, attr.name, attr.value)) { attributes[attr.name] = transformAttribute(doc, tagName, toLowerCase(attr.name), attr.value, n, maskAttributeFn); } } if (tagName === 'link' && inlineStylesheet) { const stylesheet = Array.from(doc.styleSheets).find((s) => { return s.href === n.href; }); let cssText = null; if (stylesheet) { cssText = stringifyStylesheet(stylesheet); } if (cssText) { delete attributes.rel; delete attributes.href; attributes._cssText = absoluteToStylesheet(cssText, stylesheet.href); } } if (tagName === 'style' && n.sheet && !(n.innerText || n.textContent || '').trim().length) { const cssText = stringifyStylesheet(n.sheet); if (cssText) { attributes._cssText = absoluteToStylesheet(cssText, getHref()); } } if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || tagName === 'option') { const el = n; const type = getInputType(el); const value = getInputValue(el, toUpperCase(tagName), type); const checked = el.checked; if (type !== 'submit' && type !== 'button' && value) { const forceMask = needMaskingText(el, maskTextClass, maskTextSelector, unmaskTextClass, unmaskTextSelector, shouldMaskInput({ type, tagName: toUpperCase(tagName), maskInputOptions, })); attributes.value = maskInputValue({ isMasked: forceMask, element: el, value, maskInputFn, }); } if (checked) { attributes.checked = checked; } } if (tagName === 'option') { if (n.selected && !maskInputOptions['select']) { attributes.selected = true; } else { delete attributes.selected; } } if (tagName === 'canvas' && recordCanvas) { if (n.__context === '2d') { if (!is2DCanvasBlank(n)) { attributes.rr_dataURL = n.toDataURL(dataURLOptions.type, dataURLOptions.quality); } } else if (!('__context' in n)) { const canvasDataURL = n.toDataURL(dataURLOptions.type, dataURLOptions.quality); const blankCanvas = document.createElement('canvas'); blankCanvas.width = n.width; blankCanvas.height = n.height; const blankCanvasDataURL = blankCanvas.toDataURL(dataURLOptions.type, dataURLOptions.quality); if (canvasDataURL !== blankCanvasDataURL) { attributes.rr_dataURL = canvasDataURL; } } } if (tagName === 'img' && inlineImages) { if (!canvasService) { canvasService = doc.createElement('canvas'); canvasCtx = canvasService.getContext('2d'); } const image = n; const oldValue = image.crossOrigin; image.crossOrigin = 'anonymous'; const recordInlineImage = () => { image.removeEventListener('load', recordInlineImage); try { canvasService.width = image.naturalWidth; canvasService.height = image.naturalHeight; canvasCtx.drawImage(image, 0, 0); attributes.rr_dataURL = canvasService.toDataURL(dataURLOptions.type, dataURLOptions.quality); } catch (err) { console.warn(`Cannot inline img src=${image.currentSrc}! Error: ${err}`); } oldValue ? (attributes.crossOrigin = oldValue) : image.removeAttribute('crossorigin'); }; if (image.complete && image.naturalWidth !== 0) recordInlineImage(); else image.addEventListener('load', recordInlineImage); } if (tagName === 'audio' || tagName === 'video') { attributes.rr_mediaState = n.paused ? 'paused' : 'played'; attributes.rr_mediaCurrentTime = n.currentTime; } if (!newlyAddedElement) { if (n.scrollLeft) { attributes.rr_scrollLeft = n.scrollLeft; } if (n.scrollTop) { attributes.rr_scrollTop = n.scrollTop; } } if (needBlock) { const { width, height } = n.getBoundingClientRect(); attributes = { class: attributes.class, rr_width: `${width}px`, rr_height: `${height}px`, }; } if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src)) { if (!n.contentDocument) { attributes.rr_src = attributes.src; } delete attributes.src; } let isCustomElement; try { if (customElements.get(tagName)) isCustomElement = true; } catch (e) { } return { type: NodeType$1.Element, tagName, attributes, childNodes: [], isSVG: isSVGElement(n) || undefined, needBlock, rootId, isCustom: isCustomElement, }; } function lowerIfExists(maybeAttr) { if (maybeAttr === undefined || maybeAttr === null) { return ''; } else { return maybeAttr.toLowerCase(); } } function slimDOMExcluded(sn, slimDOMOptions) { if (slimDOMOptions.comment && sn.type === NodeType$1.Comment) { return true; } else if (sn.type === NodeType$1.Element) { if (slimDOMOptions.script && (sn.tagName === 'script' || (sn.tagName === 'link' && (sn.attributes.rel === 'preload' || sn.attributes.rel === 'modulepreload') && sn.attributes.as === 'script') || (sn.tagName === 'link' && sn.attributes.rel === 'prefetch' && typeof sn.attributes.href === 'string' && sn.attributes.href.endsWith('.js')))) { return true; } else if (slimDOMOptions.headFavicon && ((sn.tagName === 'link' && sn.attributes.rel === 'shortcut icon') || (sn.tagName === 'meta' && (lowerIfExists(sn.attributes.name).match(/^msapplication-tile(image|color)$/) || lowerIfExists(sn.attributes.name) === 'application-name' || lowerIfExists(sn.attributes.rel) === 'icon' || lowerIfExists(sn.attributes.rel) === 'apple-touch-icon' || lowerIfExists(sn.attributes.rel) === 'shortcut icon')))) { return true; } else if (sn.tagName === 'meta') { if (slimDOMOptions.headMetaDescKeywords && lowerIfExists(sn.attributes.name).match(/^description|keywords$/)) { return true; } else if (slimDOMOptions.headMetaSocial && (lowerIfExists(sn.attributes.property).match(/^(og|twitter|fb):/) || lowerIfExists(sn.attributes.name).match(/^(og|twitter):/) || lowerIfExists(sn.attributes.name) === 'pinterest')) { return true; } else if (slimDOMOptions.headMetaRobots && (lowerIfExists(sn.attributes.name) === 'robots' || lowerIfExists(sn.attributes.name) === 'googlebot' || lowerIfExists(sn.attributes.name) === 'bingbot')) { return true; } else if (slimDOMOptions.headMetaHttpEquiv && sn.attributes['http-equiv'] !== undefined) { return true; } else if (slimDOMOptions.headMetaAuthorship && (lowerIfExists(sn.attributes.name) === 'author' || lowerIfExists(sn.attributes.name) === 'generator' || lowerIfExists(sn.attributes.name) === 'framework' || lowerIfExists(sn.attributes.name) === 'publisher' || lowerIfExists(sn.attributes.name) === 'progid' || lowerIfExists(sn.attributes.property).match(/^article:/) || lowerIfExists(sn.attributes.property).match(/^product:/))) { return true; } else if (slimDOMOptions.headMetaVerification && (lowerIfExists(sn.attributes.name) === 'google-site-verification' || lowerIfExists(sn.attributes.name) === 'yandex-verification' || lowerIfExists(sn.attributes.name) === 'csrf-token' || lowerIfExists(sn.attributes.name) === 'p:domain_verify' || lowerIfExists(sn.attributes.name) === 'verify-v1' || lowerIfExists(sn.attributes.name) === 'verification' || lowerIfExists(sn.attributes.name) === 'shopify-checkout-api-token')) { return true; } } } return false; } function serializeNodeWithId(n, options) { const { doc, mirror, blockClass, blockSelector, unblockSelector, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, skipChild = false, inlineStylesheet = true, maskInputOptions = {}, maskAttributeFn, maskTextFn, maskInputFn, slimDOMOptions, dataURLOptions = {}, inlineImages = false, recordCanvas = false, onSerialize, onIframeLoad, iframeLoadTimeout = 5000, onStylesheetLoad, stylesheetLoadTimeout = 5000, keepIframeSrcFn = () => false, newlyAddedElement = false, } = options; let { preserveWhiteSpace = true } = options; const _serializedNode = serializeNode(n, { doc, mirror, blockClass, blockSelector, maskAllText, unblockSelector, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, inlineStylesheet, maskInputOptions, maskAttributeFn, maskTextFn, maskInputFn, dataURLOptions, inlineImages, recordCanvas, keepIframeSrcFn, newlyAddedElement, }); if (!_serializedNode) { console.warn(n, 'not serialized'); return null; } let id; if (mirror.hasNode(n)) { id = mirror.getId(n); } else if (slimDOMExcluded(_serializedNode, slimDOMOptions) || (!preserveWhiteSpace && _serializedNode.type === NodeType$1.Text && !_serializedNode.isStyle && !_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)) { id = IGNORED_NODE; } else { id = genId(); } const serializedNode = Object.assign(_serializedNode, { id }); mirror.add(n, serializedNode); if (id === IGNORED_NODE) { return null; } if (onSerialize) { onSerialize(n); } let recordChild = !skipChild; if (serializedNode.type === NodeType$1.Element) { recordChild = recordChild && !serializedNode.needBlock; delete serializedNode.needBlock; const shadowRoot = n.shadowRoot; if (shadowRoot && isNativeShadowDom(shadowRoot)) serializedNode.isShadowHost = true; } if ((serializedNode.type === NodeType$1.Document || serializedNode.type === NodeType$1.Element) && recordChild) { if (slimDOMOptions.headWhitespace && serializedNode.type === NodeType$1.Element && serializedNode.tagName === 'head') { preserveWhiteSpace = false; } const bypassOptions = { doc, mirror, blockClass, blockSelector, maskAllText, unblockSelector, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, skipChild, inlineStylesheet, maskInputOptions, maskAttributeFn, maskTextFn, maskInputFn, slimDOMOptions, dataURLOptions, inlineImages, recordCanvas, preserveWhiteSpace, onSerialize, onIframeLoad, iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, }; for (const childN of Array.from(n.childNodes)) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } } if (isElement$1(n) && n.shadowRoot) { for (const childN of Array.from(n.shadowRoot.childNodes)) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); if (serializedChildNode) { isNativeShadowDom(n.shadowRoot) && (serializedChildNode.isShadow = true); serializedNode.childNodes.push(serializedChildNode); } } } } if (n.parentNode && isShadowRoot(n.parentNode) && isNativeShadowDom(n.parentNode)) { serializedNode.isShadow = true; } if (serializedNode.type === NodeType$1.Element && serializedNode.tagName === 'iframe') { onceIframeLoaded(n, () => { const iframeDoc = n.contentDocument; if (iframeDoc && onIframeLoad) { const serializedIframeNode = serializeNodeWithId(iframeDoc, { doc: iframeDoc, mirror, blockClass, blockSelector, unblockSelector, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, maskAttributeFn, maskTextFn, maskInputFn, slimDOMOptions, dataURLOptions, inlineImages, recordCanvas, preserveWhiteSpace, onSerialize, onIframeLoad, iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, }); if (serializedIframeNode) { onIframeLoad(n, serializedIframeNode); } } }, iframeLoadTimeout); } if (serializedNode.type === NodeType$1.Element && serializedNode.tagName === 'link' && serializedNode.attributes.rel === 'stylesheet') { onceStylesheetLoaded(n, () => { if (onStylesheetLoad) { const serializedLinkNode = serializeNodeWithId(n, { doc, mirror, blockClass, blockSelector, unblockSelector, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, maskAttributeFn, maskTextFn, maskInputFn, slimDOMOptions, dataURLOptions, inlineImages, recordCanvas, preserveWhiteSpace, onSerialize, onIframeLoad, iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, }); if (serializedLinkNode) { onStylesheetLoad(n, serializedLinkNode); } } }, stylesheetLoadTimeout); } return serializedNode; } function snapshot(n, options) { const { mirror = new Mirror(), blockClass = 'rr-block', blockSelector = null, unblockSelector = null, maskAllText = false, maskTextClass = 'rr-mask', unmaskTextClass = null, maskTextSelector = null, unmaskTextSelector = null, inlineStylesheet = true, inlineImages = false, recordCanvas = false, maskAllInputs = false, maskAttributeFn, maskTextFn, maskInputFn, slimDOM = false, dataURLOptions, preserveWhiteSpace, onSerialize, onIframeLoad, iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn = () => false, } = options || {}; const 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, } : maskAllInputs === false ? {} : maskAllInputs; const slimDOMOptions = slimDOM === true || slimDOM === 'all' ? { script: true, comment: true, headFavicon: true, headWhitespace: true, headMetaDescKeywords: slimDOM === 'all', headMetaSocial: true, headMetaRobots: true, headMetaHttpEquiv: true, headMetaAuthorship: true, headMetaVerification: true, } : slimDOM === false ? {} : slimDOM; return serializeNodeWithId(n, { doc: n, mirror, blockClass, blockSelector, unblockSelector, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, skipChild: false, inlineStylesheet, maskInputOptions, maskAttributeFn, maskTextFn, maskInputFn, slimDOMOptions, dataURLOptions, inlineImages, recordCanvas, preserveWhiteSpace, onSerialize, onIframeLoad, iframeLoadTimeout, onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, }); } function _optionalChain$4(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } function on(type, fn, target = document) { const options = { capture: true, passive: true }; target.addEventListener(type, fn, options); return () => target.removeEventListener(type, fn, options); } const DEPARTED_MIRROR_ACCESS_WARNING = 'Please stop import mirror directly. Instead of that,' + '\r\n' + 'now you can use replayer.getMirror() to access the mirror instance of a replayer,' + '\r\n' + 'or you can use record.mirror to access the mirror instance during recording.'; let _mirror = { map: {}, getId() { console.error(DEPARTED_MIRROR_ACCESS_WARNING); return -1; }, getNode() { console.error(DEPARTED_MIRROR_ACCESS_WARNING); return null; }, removeNodeFromMap() { console.error(DEPARTED_MIRROR_ACCESS_WARNING); }, has() { console.error(DEPARTED_MIRROR_ACCESS_WARNING); return false; }, reset() { console.error(DEPARTED_MIRROR_ACCESS_WARNING); }, }; if (typeof window !== 'undefined' && window.Proxy && window.Reflect) { _mirror = new Proxy(_mirror, { get(target, prop, receiver) { if (prop === 'map') { console.error(DEPARTED_MIRROR_ACCESS_WARNING); } return Reflect.get(target, prop, receiver); }, }); } function throttle$1(func, wait, options = {}) { let timeout = null; let previous = 0; return function (...args) { const now = Date.now(); if (!previous && options.leading === false) { previous = now; } const remaining = wait - (now - previous); const context = this; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; func.apply(context, args); } else if (!timeout && options.trailing !== false) { timeout = setTimeout(() => { previous = options.leading === false ? 0 : Date.now(); timeout = null; func.apply(context, args); }, remaining); } }; } function hookSetter(target, key, d, isRevoked, win = window) { const original = win.Object.getOwnPropertyDescriptor(target, key); win.Object.defineProperty(target, key, isRevoked ? d : { set(value) { setTimeout(() => { d.set.call(this, value); }, 0); if (original && original.set) { original.set.call(this, value); } }, }); return () => hookSetter(target, key, original || {}, true); } function patch(source, name, replacement) { try { if (!(name in source)) { return () => { }; } const original = source[name]; const wrapped = replacement(original); if (typeof wrapped === 'function') { wrapped.prototype = wrapped.prototype || {}; Object.defineProperties(wrapped, { __rrweb_original__: { enumerable: false, value: original, }, }); } source[name] = wrapped; return () => { source[name] = original; }; } catch (e2) { return () => { }; } } let nowTimestamp = Date.now; if (!(/[1-9][0-9]{12}/.test(Date.now().toString()))) { nowTimestamp = () => new Date().getTime(); } function getWindowScroll(win) { const doc = win.document; return { left: doc.scrollingElement ? doc.scrollingElement.scrollLeft : win.pageXOffset !== undefined ? win.pageXOffset : _optionalChain$4([doc, 'optionalAccess', _ => _.documentElement, 'access', _2 => _2.scrollLeft]) || _optionalChain$4([doc, 'optionalAccess', _3 => _3.body, 'optionalAccess', _4 => _4.parentElement, 'optionalAccess', _5 => _5.scrollLeft]) || _optionalChain$4([doc, 'optionalAccess', _6 => _6.body, 'optionalAccess', _7 => _7.scrollLeft]) || 0, top: doc.scrollingElement ? doc.scrollingElement.scrollTop : win.pageYOffset !== undefined ? win.pageYOffset : _optionalChain$4([doc, 'optionalAccess', _8 => _8.documentElement, 'access', _9 => _9.scrollTop]) || _optionalChain$4([doc, 'optionalAccess', _10 => _10.body, 'optionalAccess', _11 => _11.parentElement, 'optionalAccess', _12 => _12.scrollTop]) || _optionalChain$4([doc, 'optionalAccess', _13 => _13.body, 'optionalAccess', _14 => _14.scrollTop]) || 0, }; } function getWindowHeight() { return (window.innerHeight || (document.documentElement && document.documentElement.clientHeight) || (document.body && document.body.clientHeight)); } function getWindowWidth() { return (window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || (document.body && document.body.clientWidth)); } function isBlocked(node, blockClass, blockSelector, unblockSelector, checkAncestors) { if (!node) { return false; } const el = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement; if (!el) return false; const blockedPredicate = createMatchPredicate(blockClass, blockSelector); if (!checkAncestors) { const isUnblocked = unblockSelector && el.matches(unblockSelector); return blockedPredicate(el) && !isUnblocked; } const blockDistance = distanceToMatch(el, blockedPredicate); let unblockDistance = -1; if (blockDistance < 0) { return false; } if (unblockSelector) { unblockDistance = distanceToMatch(el, createMatchPredicate(null, unblockSelector)); } if (blockDistance > -1 && unblockDistance < 0) { return true; } return blockDistance < unblockDistance; } function isSerialized(n, mirror) { return mirror.getId(n) !== -1; } function isIgnored(n, mirror) { return mirror.getId(n) === IGNORED_NODE; } function isAncestorRemoved(target, mirror) { if (isShadowRoot(target)) { return false; } const id = mirror.getId(target); if (!mirror.has(id)) { return true; } if (target.parentNode && target.parentNode.nodeType === target.DOCUMENT_NODE) { return false; } if (!target.parentNode) { return true; } return isAncestorRemoved(target.parentNode, mirror); } function legacy_isTouchEvent(event) { return Boolean(event.changedTouches); } function polyfill(win = window) { if ('NodeList' in win && !win.NodeList.prototype.forEach) { win.NodeList.prototype.forEach = Array.prototype .forEach; } if ('DOMTokenList' in win && !win.DOMTokenList.prototype.forEach) { win.DOMTokenList.prototype.forEach = Array.prototype .forEach; } if (!Node.prototype.contains) { Node.prototype.contains = (...args) => { let node = args[0]; if (!(0 in args)) { throw new TypeError('1 argument is required'); } do { if (this === node) { return true; } } while ((node = node && node.parentNode)); return false; }; } } function isSerializedIframe(n, mirror) { return Boolean(n.nodeName === 'IFRAME' && mirror.getMeta(n)); } function isSerializedStylesheet(n, mirror) { return Boolean(n.nodeName === 'LINK' && n.nodeType === n.ELEMENT_NODE && n.getAttribute && n.getAttribute('rel') === 'stylesheet' && mirror.getMeta(n)); } function hasShadowRoot(n) { return Boolean(_optionalChain$4([n, 'optionalAccess', _18 => _18.shadowRoot])); } class StyleSheetMirror { constructor() { this.id = 1; this.styleIDMap = new WeakMap(); this.idStyleMap = new Map(); } getId(stylesheet) { return _nullishCoalesce(this.styleIDMap.get(stylesheet), () => ( -1)); } has(stylesheet) { return this.styleIDMap.has(stylesheet); } add(stylesheet, id) { if (this.has(stylesheet)) return this.getId(stylesheet); let newId; if (id === undefined) { newId = this.id++; } else newId = id; this.styleIDMap.set(stylesheet, newId); this.idStyleMap.set(newId, stylesheet); return newId; } getStyle(id) { return this.idStyleMap.get(id) || null; } reset() { this.styleIDMap = new WeakMap(); this.idStyleMap = new Map(); this.id = 1; } generateId() { return this.id++; } } function getShadowHost(n) { let shadowHost = null; if (_optionalChain$4([n, 'access', _19 => _19.getRootNode, 'optionalCall', _20 => _20(), 'optionalAccess', _21 => _21.nodeType]) === Node.DOCUMENT_FRAGMENT_NODE && n.getRootNode().host) shadowHost = n.getRootNode().host; return shadowHost; } function getRootShadowHost(n) { let rootShadowHost = n; let shadowHost; while ((shadowHost = getShadowHost(rootShadowHost))) rootShadowHost = shadowHost; return rootShadowHost; } function shadowHostInDom(n) { const doc = n.ownerDocument; if (!doc) return false; const shadowHost = getRootShadowHost(n); return doc.contains(shadowHost); } function inDom(n) { const doc = n.ownerDocument; if (!doc) return false; return doc.contains(n) || shadowHostInDom(n); } let cachedRequestAnimationFrameImplementation; function getRequestAnimationFrameImplementation() { if (cachedRequestAnimationFrameImplementation) { return cachedRequestAnimationFrameImplementation; } const document = window.document; let requestAnimationFrameImplementation = window.requestAnimationFrame; if (document && typeof document.createElement === 'function') { try { const sandbox = document.createElement('iframe'); sandbox.hidden = true; document.head.appendChild(sandbox); const contentWindow = sandbox.contentWindow; if (contentWindow && contentWindow.requestAnimationFrame) { requestAnimationFrameImplementation = contentWindow.requestAnimationFrame; } document.head.removeChild(sandbox); } catch (e) { } } return (cachedRequestAnimationFrameImplementation = requestAnimationFrameImplementation.bind(window)); } function onRequestAnimationFrame(...rest) { return getRequestAnimationFrameImplementation()(...rest); } var EventType = /* @__PURE__ */ ((EventType2) => { EventType2[EventType2["DomContentLoaded"] = 0] = "DomContentLoaded"; EventType2[EventType2["Load"] = 1] = "Load"; EventType2[EventType2["FullSnapshot"] = 2] = "FullSnapshot"; EventType2[EventType2["IncrementalSnapshot"] = 3] = "IncrementalSnapshot"; EventType2[EventType2["Meta"] = 4] = "Meta"; EventType2[EventType2["Custom"] = 5] = "Custom"; EventType2[EventType2["Plugin"] = 6] = "Plugin"; return EventType2; })(EventType || {}); var IncrementalSource = /* @__PURE__ */ ((IncrementalSource2) => { IncrementalSource2[IncrementalSource2["Mutation"] = 0] = "Mutation"; IncrementalSource2[IncrementalSource2["MouseMove"] = 1] = "MouseMove"; IncrementalSource2[IncrementalSource2["MouseInteraction"] = 2] = "MouseInteraction"; IncrementalSource2[IncrementalSource2["Scroll"] = 3] = "Scroll"; IncrementalSource2[IncrementalSource2["ViewportResize"] = 4] = "ViewportResize"; IncrementalSource2[IncrementalSource2["Input"] = 5] = "Input"; IncrementalSource2[IncrementalSource2["TouchMove"] = 6] = "TouchMove"; IncrementalSource2[IncrementalSource2["MediaInteraction"] = 7] = "MediaInteraction"; IncrementalSource2[IncrementalSource2["StyleSheetRule"] = 8] = "StyleSheetRule"; IncrementalSource2[IncrementalSource2["CanvasMutation"] = 9] = "CanvasMutation"; IncrementalSource2[IncrementalSource2["Font"] = 10] = "Font"; IncrementalSource2[IncrementalSource2["Log"] = 11] = "Log"; IncrementalSource2[IncrementalSource2["Drag"] = 12] = "Drag"; IncrementalSource2[IncrementalSource2["StyleDeclaration"] = 13] = "StyleDeclaration"; IncrementalSource2[IncrementalSource2["Selection"] = 14] = "Selection"; IncrementalSource2[IncrementalSource2["AdoptedStyleSheet"] = 15] = "AdoptedStyleSheet"; IncrementalSource2[IncrementalSource2["CustomElement"] = 16] = "CustomElement"; return IncrementalSource2; })(IncrementalSource || {}); var MouseInteractions = /* @__PURE__ */ ((MouseInteractions2) => { MouseInteractions2[MouseInteractions2["MouseUp"] = 0] = "MouseUp"; MouseInteractions2[MouseInteractions2["MouseDown"] = 1] = "MouseDown"; MouseInteractions2[MouseInteractions2["Click"] = 2] = "Click"; MouseInteractions2[MouseInteractions2["ContextMenu"] = 3] = "ContextMenu"; MouseInteractions2[MouseInteractions2["DblClick"] = 4] = "DblClick"; MouseInteractions2[MouseInteractions2["Focus"] = 5] = "Focus"; MouseInteractions2[MouseInteractions2["Blur"] = 6] = "Blur"; MouseInteractions2[MouseInteractions2["TouchStart"] = 7] = "TouchStart"; MouseInteractions2[MouseInteractions2["TouchMove_Departed"] = 8] = "TouchMove_Departed"; MouseInteractions2[MouseInteractions2["TouchEnd"] = 9] = "TouchEnd"; MouseInteractions2[MouseInteractions2["TouchCancel"] = 10] = "TouchCancel"; return MouseInteractions2; })(MouseInteractions || {}); var PointerTypes = /* @__PURE__ */ ((PointerTypes2) => { PointerTypes2[PointerTypes2["Mouse"] = 0] = "Mouse"; PointerTypes2[PointerTypes2["Pen"] = 1] = "Pen"; PointerTypes2[PointerTypes2["Touch"] = 2] = "Touch"; return PointerTypes2; })(PointerTypes || {}); function _optionalChain$3(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } function isNodeInLinkedList(n) { return '__ln' in n; } class DoubleLinkedList { constructor() { this.length = 0; this.head = null; this.tail = null; } get(position) { if (position >= this.length) { throw new Error('Position outside of list range'); } let current = this.head; for (let index = 0; index < position; index++) { current = _optionalChain$3([current, 'optionalAccess', _ => _.next]) || null; } return current; } addNode(n) { const node = { value: n, previous: null, next: null, }; n.__ln = node; if (n.previousSibling && isNodeInLinkedList(n.previousSibling)) { const current = n.previousSibling.__ln.next; node.next = current; node.previous = n.previousSibling.__ln; n.previousSibling.__ln.next = node; if (current) { current.previous = node; } } else if (n.nextSibling && isNodeInLinkedList(n.nextSibling) && n.nextSibling.__ln.previous) { const current = n.nextSibling.__ln.previous; node.previous = current; node.next = n.nextSibling.__ln; n.nextSibling.__ln.previous = node; if (current) { current.next = node; } } else { if (this.head) { this.head.previous = node; } node.next = this.head; this.head = node; } if (node.next === null) { this.tail = node; } this.length++; } removeNode(n) { const current = n.__ln; if (!this.head) { return; } if (!current.previous) { this.head = current.next; if (this.head) { this.head.previous = null; } else { this.tail = null; } } else { current.previous.next = current.next; if (current.next) { current.next.previous = current.previous; } else { this.tail = current.previous; } } if (n.__ln) { delete n.__ln; } this.length--; } } const moveKey = (id, parentId) => `${id}@${parentId}`; class MutationBuffer { constructor() { this.frozen = false; this.locked = false; this.texts = []; this.attributes = []; this.removes = []; this.mapRemoves = []; this.movedMap = {}; this.addedSet = new Set(); this.movedSet = new Set(); this.droppedSet = new Set(); this.processMutations = (mutations) => { mutations.forEach(this.processMutation); this.emit(); }; this.emit = () => { if (this.frozen || this.locked) { return; } const adds = []; const addedIds = new Set(); const addList = new DoubleLinkedList(); const getNextId = (n) => { let ns = n; let nextId = IGNORED_NODE; while (nextId === IGNORED_NODE) { ns = ns && ns.nextSibling; nextId = ns && this.mirror.getId(ns); } return nextId; }; const pushAdd = (n) => { if (!n.parentNode || !inDom(n)) { return; } const parentId = isShadowRoot(n.parentNode) ? this.mirror.getId(getShadowHost(n)) : this.mirror.getId(n.parentNode); const nextId = getNextId(n); if (parentId === -1 || nextId === -1) { return addList.addNode(n); } const sn = serializeNodeWithId(n, { doc: this.doc, mirror: this.mirror, blockClass: this.blockClass, blockSelector: this.blockSelector, maskAllText: this.maskAllText, unblockSelector: this.unblockSelector, maskTextClass: this.maskTextClass, unmaskTextClass: this.unmaskTextClass, maskTextSelector: this.maskTextSelector, unmaskTextSelector: this.unmaskTextSelector, skipChild: true, newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, maskInputOptions: this.maskInputOptions, maskAttributeFn: this.maskAttributeFn, maskTextFn: this.maskTextFn, maskInputFn: this.maskInputFn, slimDOMOptions: this.slimDOMOptions, dataURLOptions: this.dataURLOptions, recordCanvas: this.recordCanvas, inlineImages: this.inlineImages, onSerialize: (currentN) => { if (isSerializedIframe(currentN, this.mirror)) { this.iframeManager.addIframe(currentN); } if (isSerializedStylesheet(currentN, this.mirror)) { this.stylesheetManager.trackLinkElement(currentN); } if (hasShadowRoot(n)) { this.shadowDomManager.addShadowRoot(n.shadowRoot, this.doc); } }, onIframeLoad: (iframe, childSn) => { this.iframeManager.attachIframe(iframe, childSn); this.shadowDomManager.observeAttachShadow(iframe); }, onStylesheetLoad: (link, childSn) => { this.stylesheetManager.attachLinkElement(link, childSn); }, }); if (sn) { adds.push({ parentId, nextId, node: sn, }); addedIds.add(sn.id); } }; while (this.mapRemoves.length) { this.mirror.removeNodeFromMap(this.mapRemoves.shift()); } for (const n of this.movedSet) { if (isParentRemoved(this.removes, n, this.mirror) && !this.movedSet.has(n.parentNode)) { continue; } pushAdd(n); } for (const n of this.addedSet) { if (!isAncestorInSet(this.droppedSet, n) && !isParentRemoved(this.removes, n, this.mirror)) { pushAdd(n); } else if (isAncestorInSet(this.movedSet, n)) { pushAdd(n); } else { this.droppedSet.add(n); } } let candidate = null; while (addList.length) { let node = null; if (candidate) { const parentId = this.mirror.getId(candidate.value.parentNode); const nextId = getNextId(candidate.value); if (parentId !== -1 && nextId !== -1) { node = candidate; } } if (!node) { let tailNode = addList.tail; while (tailNode) { const _node = tailNode; tailNode = tailNode.previous; if (_node) { const parentId = this.mirror.getId(_node.value.parentNode); const nextId = getNextId(_node.value); if (nextId === -1) continue; else if (parentId !== -1) { node = _node; break; } else { const unhandledNode = _node.value; if (unhandledNode.parentNode && unhandledNode.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { const shadowHost = unhandledNode.parentNode .host; const parentId = this.mirror.getId(shadowHost); if (parentId !== -1) { node = _node; break; } } } } } } if (!node) { while (addList.head) { addList.removeNode(addList.head.value); } break; } candidate = node.previous; addList.removeNode(node.value); pushAdd(node.value); } const payload = { texts: this.texts .map((text) => ({ id: this.mirror.getId(text.node), value: text.value, })) .filter((text) => !addedIds.has(text.id)) .filter((text) => this.mirror.has(text.id)), attributes: this.attributes .map((attribute) => { const { attributes } = attribute; if (typeof attributes.style === 'string') { const diffAsStr = JSON.stringify(attribute.styleDiff); const unchangedAsStr = JSON.stringify(attribute._unchangedStyles); if (diffAsStr.length < attributes.style.length) { if ((diffAsStr + unchangedAsStr).split('var(').length === attributes.style.split('var(').length) { attributes.style = attribute.styleDiff; } } } return { id: this.mirror.getId(attribute.node), attributes: attributes, }; }) .filter((attribute) => !addedIds.has(attribute.id)) .filter((attribute) => this.mirror.has(attribute.id)), removes: this.removes, adds, }; if (!payload.texts.length && !payload.attributes.length && !payload.removes.length && !payload.adds.length) { return; } this.texts = []; this.attributes = []; this.removes = []; this.addedSet = new Set(); this.movedSet = new Set(); this.droppedSet = new Set(); this.movedMap = {}; this.mutationCb(payload); }; this.processMutation = (m) => { if (isIgnored(m.target, this.mirror)) { return; } let unattachedDoc; try { unattachedDoc = document.implementation.createHTMLDocument(); } catch (e) { unattachedDoc = this.doc; } switch (m.type) { case 'characterData': { const value = m.target.textContent; if (!isBlocked(m.target, this.blockClass, this.blockSelector, this.unblockSelector, false) && value !== m.oldValue) { this.texts.push({ value: needMaskingText(m.target, this.maskTextClass, this.maskTextSelector, this.unmaskTextClass, this.unmaskTextSelector, this.maskAllText) && value ? this.maskTextFn ? this.maskTextFn(value) : value.replace(/[\S]/g, '*') : value, node: m.target, }); } break; } case 'attributes': { const target = m.target; let attributeName = m.attributeName; let value = m.target.getAttribute(attributeName); if (attributeName === 'value') { const type = getInputType(target); const tagName = target.tagName; value = getInputValue(target, tagName, type); const isInputMasked = shouldMaskInput({ maskInputOptions: this.maskInputOptions, tagName, type, }); const forceMask = needMaskingText(m.target, this.maskTextClass, this.maskTextSelector, this.unmaskTextClass, this.unmaskTextSelector, isInputMasked); value = maskInputValue({ isMasked: forceMask, element: target, value, maskInputFn: this.maskInputFn, }); } if (isBlocked(m.target, this.blockClass, this.blockSelector, this.unblockSelector, false) || value === m.oldValue) { return; } let item = this.attributes.find((a) => a.node === m.target); if (target.tagName === 'IFRAME' && attributeName === 'src' && !this.keepIframeSrcFn(value)) { if (!target.contentDocument) { attributeName = 'rr_src'; } else { return; } } if (!item) { item = { node: m.target, attributes: {}, styleDiff: {}, _unchangedStyles: {}, }; this.attributes.push(item); } if (attributeName === 'type' && target.tagName === 'INPUT' && (m.oldValue || '').toLowerCase() === 'password') { target.setAttribute('data-rr-is-password', 'true'); } if (!ignoreAttribute(target.tagName, attributeName)) { item.attributes[attributeName] = transformAttribute(this.doc, toLowerCase(target.tagName), toLowerCase(attributeName), value, target, this.maskAttributeFn); if (attributeName === 'style') { const old = unattachedDoc.createElement('span'); if (m.oldValue) { old.setAttribute('style', m.oldValue); } for (const pname of Array.from(target.style)) { const newValue = target.style.getPropertyValue(pname); const newPriority = target.style.getPropertyPriority(pname); if (newValue !== old.style.getPropertyValue(pname) || newPriority !== old.style.getPropertyPriority(pname)) { if (newPriority === '') { item.styleDiff[pname] = newValue; } else { item.styleDiff[pname] = [newValue, newPriority]; } } else { item._unchangedStyles[pname] = [newValue, newPriority]; } } for (const pname of Array.from(old.style)) { if (target.style.getPropertyValue(pname) === '') { item.styleDiff[pname] = false; } } } } break; } case 'childList': { if (isBlocked(m.target, this.blockClass, this.blockSelector, this.unblockSelector, true)) { return; } m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { const nodeId = this.mirror.getId(n); const parentId = isShadowRoot(m.target) ? this.mirror.getId(m.target.host) : this.mirror.getId(m.target); if (isBlocked(m.target, this.blockClass, this.blockSelector, this.unblockSelector, false) || isIgnored(n, this.mirror) || !isSerialized(n, this.mirror)) { return; } if (this.addedSet.has(n)) { deepDelete(this.addedSet, n); this.droppedSet.add(n); } else if (this.addedSet.has(m.target) && nodeId === -1) ; else if (isAncestorRemoved(m.target, this.mirror)) ; else if (this.movedSet.has(n) && this.movedMap[moveKey(nodeId, parentId)]) { deepDelete(this.movedSet, n); } else { this.removes.push({ parentId, id: nodeId, isShadow: isShadowRoot(m.target) && isNativeShadowDom(m.target) ? true : undefined, }); } this.mapRemoves.push(n); }); break; } } }; this.genAdds = (n, target) => { if (this.processedNodeManager.inOtherBuffer(n, this)) return; if (this.addedSet.has(n) || this.movedSet.has(n)) return; if (this.mirror.hasNode(n)) { if (isIgnored(n, this.mirror)) { return; } this.movedSet.add(n); let targetId = null; if (target && this.mirror.hasNode(target)) { targetId = this.mirror.getId(target); } if (targetId && targetId !== -1) { this.movedMap[moveKey(this.mirror.getId(n), targetId)] = true; } } else { this.addedSet.add(n); this.droppedSet.delete(n); } if (!isBlocked(n, this.blockClass, this.blockSelector, this.unblockSelector, false)) { n.childNodes.forEach((childN) => this.genAdds(childN)); if (hasShadowRoot(n)) { n.shadowRoot.childNodes.forEach((childN) => { this.processedNodeManager.add(childN, this); this.genAdds(childN, n); }); } } }; } init(options) { [ 'mutationCb', 'blockClass', 'blockSelector', 'unblockSelector', 'maskAllText', 'maskTextClass', 'unmaskTextClass', 'maskTextSelector', 'unmaskTextSelector', 'inlineStylesheet', 'maskInputOptions', 'maskAttributeFn', 'maskTextFn', 'maskInputFn', 'keepIframeSrcFn', 'recordCanvas', 'inlineImages', 'slimDOMOptions', 'dataURLOptions', 'doc', 'mirror', 'iframeManager', 'stylesheetManager', 'shadowDomManager', 'canvasManager', 'processedNodeManager', ].forEach((key) => { this[key] = options[key]; }); } freeze() { this.frozen = true; this.canvasManager.freeze(); } unfreeze() { this.frozen = false; this.canvasManager.unfreeze(); this.emit(); } isFrozen() { return this.frozen; } lock() { this.locked = true; this.canvasManager.lock(); } unlock() { this.locked = false; this.canvasManager.unlock(); this.emit(); } reset() { this.shadowDomManager.reset(); this.canvasManager.reset(); } } function deepDelete(addsSet, n) { addsSet.delete(n); n.childNodes.forEach((childN) => deepDelete(addsSet, childN)); } function isParentRemoved(removes, n, mirror) { if (removes.length === 0) return false; return _isParentRemoved(removes, n, mirror); } function _isParentRemoved(removes, n, mirror) { const { parentNode } = n; if (!parentNode) { return false; } const parentId = mirror.getId(parentNode); if (removes.some((r) => r.id === parentId)) { return true; } return _isParentRemoved(removes, parentNode, mirror); } function isAncestorInSet(set, n) { if (set.size === 0) return false; return _isAncestorInSet(set, n); } function _isAncestorInSet(set, n) { const { parentNode } = n; if (!parentNode) { return false; } if (set.has(parentNode)) { return true; } return _isAncestorInSet(set, parentNode); } let errorHandler; function registerErrorHandler(handler) { errorHandler = handler; } function unregisterErrorHandler() { errorHandler = undefined; } const callbackWrapper = (cb) => { if (!errorHandler) { return cb; } const rrwebWrapped = ((...rest) => { try { return cb(...rest); } catch (error) { if (errorHandler && errorHandler(error) === true) { return () => { }; } throw error; } }); return rrwebWrapped; }; function _optionalChain$2(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } const mutationBuffers = []; function getEventTarget(event) { try { if ('composedPath' in event) { const path = event.composedPath(); if (path.length) { return path[0]; } } else if ('path' in event && event.path.length) { return event.path[0]; } } catch (e2) { } return event && event.target; } function initMutationObserver(options, rootEl) { const mutationBuffer = new MutationBuffer(); mutationBuffers.push(mutationBuffer); mutationBuffer.init(options); let mutationObserverCtor = window.MutationObserver || window.__rrMutationObserver; const angularZoneSymbol = _optionalChain$2([window, 'optionalAccess', _ => _.Zone, 'optionalAccess', _2 => _2.__symbol__, 'optionalCall', _3 => _3('MutationObserver')]); if (angularZoneSymbol && window[angularZoneSymbol]) { mutationObserverCtor = window[angularZoneSymbol]; } const observer = new mutationObserverCtor(callbackWrapper((mutations) => { if (options.onMutation && options.onMutation(mutations) === false) { return; } mutationBuffer.processMutations.bind(mutationBuffer)(mutations); })); observer.observe(rootEl, { attributes: true, attributeOldValue: true, characterData: true, characterDataOldValue: true, childList: true, subtree: true, }); return observer; } function initMoveObserver({ mousemoveCb, sampling, doc, mirror, }) { if (sampling.mousemove === false) { return () => { }; } const threshold = typeof sampling.mousemove === 'number' ? sampling.mousemove : 50; const callbackThreshold = typeof sampling.mousemoveCallback === 'number' ? sampling.mousemoveCallback : 500; let positions = []; let timeBaseline; const wrappedCb = throttle$1(callbackWrapper((source) => { const totalOffset = Date.now() - timeBaseline; mousemoveCb(positions.map((p) => { p.timeOffset -= totalOffset; return p; }), source); positions = []; timeBaseline = null; }), callbackThreshold); const updatePosition = callbackWrapper(throttle$1(callbackWrapper((evt) => { const target = getEventTarget(evt); const { clientX, clientY } = legacy_isTouchEvent(evt) ? evt.changedTouches[0] : evt; if (!timeBaseline) { timeBaseline = nowTimestamp(); } positions.push({ x: clientX, y: clientY, id: mirror.getId(target), timeOffset: nowTimestamp() - timeBaseline, }); wrappedCb(typeof DragEvent !== 'undefined' && evt instanceof DragEvent ? IncrementalSource.Drag : evt instanceof MouseEvent ? IncrementalSource.MouseMove : IncrementalSource.TouchMove); }), threshold, { trailing: false, })); const handlers = [ on('mousemove', updatePosition, doc), on('touchmove', updatePosition, doc), on('drag', updatePosition, doc), ]; return callbackWrapper(() => { handlers.forEach((h) => h()); }); } function initMouseInteractionObserver({ mouseInteractionCb, doc, mirror, blockClass, blockSelector, unblockSelector, sampling, }) { if (sampling.mouseInteraction === false) { return () => { }; } const disableMap = sampling.mouseInteraction === true || sampling.mouseInteraction === undefined ? {} : sampling.mouseInteraction; const handlers = []; let currentPointerType = null; const getHandler = (eventKey) => { return (event) => { const target = getEventTarget(event); if (isBlocked(target, blockClass, blockSelector, unblockSelector, true)) { return; } let pointerType = null; let thisEventKey = eventKey; if ('pointerType' in event) { switch (event.pointerType) { case 'mouse': pointerType = PointerTypes.Mouse; break; case 'touch': pointerType = PointerTypes.Touch; break; case 'pen': pointerType = PointerTypes.Pen; break; } if (pointerType === PointerTypes.Touch) { if (MouseInteractions[eventKey] === MouseInteractions.MouseDown) { thisEventKey = 'TouchStart'; } else if (MouseInteractions[eventKey] === MouseInteractions.MouseUp) { thisEventKey = 'TouchEnd'; } } else if (pointerType === PointerTypes.Pen) ; } else if (legacy_isTouchEvent(event)) { pointerType = PointerTypes.Touch; } if (pointerType !== null) { currentPointerType = pointerType; if ((thisEventKey.startsWith('Touch') && pointerType === PointerTypes.Touch) || (thisEventKey.startsWith('Mouse') && pointerType === PointerTypes.Mouse)) { pointerType = null; } } else if (MouseInteractions[eventKey] === MouseInteractions.Click) { pointerType = currentPointerType; currentPointerType = null; } const e = legacy_isTouchEvent(event) ? event.changedTouches[0] : event; if (!e) { return; } const id = mirror.getId(target); const { clientX, clientY } = e; callbackWrapper(mouseInteractionCb)({ type: MouseInteractions[thisEventKey], id, x: clientX, y: clientY, ...(pointerType !== null && { pointerType }), }); }; }; Object.keys(MouseInteractions) .filter((key) => Number.isNaN(Number(key)) && !key.endsWith('_Departed') && disableMap[key] !== false) .forEach((eventKey) => { let eventName = toLowerCase(eventKey); const handler = getHandler(eventKey); if (window.PointerEvent) { switch (MouseInteractions[eventKey]) { case MouseInteractions.MouseDown: case MouseInteractions.MouseUp: eventName = eventName.replace('mouse', 'pointer'); break; case MouseInteractions.TouchStart: case MouseInteractions.TouchEnd: return; } } handlers.push(on(eventName, handler, doc)); }); return callbackWrapper(() => { handlers.forEach((h) => h()); }); } function initScrollObserver({ scrollCb, doc, mirror, blockClass, blockSelector, unblockSelector, sampling, }) { const updatePosition = callbackWrapper(throttle$1(callbackWrapper((evt) => { const target = getEventTarget(evt); if (!target || isBlocked(target, blockClass, blockSelector, unblockSelector, true)) { return; } const id = mirror.getId(target); if (target === doc && doc.defaultView) { const scrollLeftTop = getWindowScroll(doc.defaultView); scrollCb({ id, x: scrollLeftTop.left, y: scrollLeftTop.top, }); } else { scrollCb({ id, x: target.scrollLeft, y: target.scrollTop, }); } }), sampling.scroll || 100)); return on('scroll', updatePosition, doc); } function initViewportResizeObserver({ viewportResizeCb }, { win }) { let lastH = -1; let lastW = -1; const updateDimension = callbackWrapper(throttle$1(callbackWrapper(() => { const height = getWindowHeight(); const width = getWindowWidth(); if (lastH !== height || lastW !== width) { viewportResizeCb({ width: Number(width), height: Number(height), }); lastH = height; lastW = width; } }), 200)); return on('resize', updateDimension, win); } const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT']; const lastInputValueMap = new WeakMap(); function initInputObserver({ inputCb, doc, mirror, blockClass, blockSelector, unblockSelector, ignoreClass, ignoreSelector, maskInputOptions, maskInputFn, sampling, userTriggeredOnInput, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, }) { function eventHandler(event) { let target = getEventTarget(event); const userTriggered = event.isTrusted; const tagName = target && toUpperCase(target.tagName); if (tagName === 'OPTION') target = target.parentElement; if (!target || !tagName || INPUT_TAGS.indexOf(tagName) < 0 || isBlocked(target, blockClass, blockSelector, unblockSelector, true)) { return; } const el = target; if (el.classList.contains(ignoreClass) || (ignoreSelector && el.matches(ignoreSelector))) { return; } const type = getInputType(target); let text = getInputValue(el, tagName, type); let isChecked = false; const isInputMasked = shouldMaskInput({ maskInputOptions, tagName, type, }); const forceMask = needMaskingText(target, maskTextClass, maskTextSelector, unmaskTextClass, unmaskTextSelector, isInputMasked); if (type === 'radio' || type === 'checkbox') { isChecked = target.checked; } text = maskInputValue({ isMasked: forceMask, element: target, value: text, maskInputFn, }); cbWithDedup(target, userTriggeredOnInput ? { text, isChecked, userTriggered } : { text, isChecked }); const name = target.name; if (type === 'radio' && name && isChecked) { doc .querySelectorAll(`input[type="radio"][name="${name}"]`) .forEach((el) => { if (el !== target) { const text = maskInputValue({ isMasked: forceMask, element: el, value: getInputValue(el, tagName, type), maskInputFn, }); cbWithDedup(el, userTriggeredOnInput ? { text, isChecked: !isChecked, userTriggered: false } : { text, isChecked: !isChecked }); } }); } } function cbWithDedup(target, v) { const lastInputValue = lastInputValueMap.get(target); if (!lastInputValue || lastInputValue.text !== v.text || lastInputValue.isChecked !== v.isChecked) { lastInputValueMap.set(target, v); const id = mirror.getId(target); callbackWrapper(inputCb)({ ...v, id, }); } } const events = sampling.input === 'last' ? ['change'] : ['input', 'change']; const handlers = events.map((eventName) => on(eventName, callbackWrapper(eventHandler), doc)); const currentWindow = doc.defaultView; if (!currentWindow) { return () => { handlers.forEach((h) => h()); }; } const propertyDescriptor = currentWindow.Object.getOwnPropertyDescriptor(currentWindow.HTMLInputElement.prototype, 'value'); const hookProperties = [ [currentWindow.HTMLInputElement.prototype, 'value'], [currentWindow.HTMLInputElement.prototype, 'checked'], [currentWindow.HTMLSelectElement.prototype, 'value'], [currentWindow.HTMLTextAreaElement.prototype, 'value'], [currentWindow.HTMLSelectElement.prototype, 'selectedIndex'], [currentWindow.HTMLOptionElement.prototype, 'selected'], ]; if (propertyDescriptor && propertyDescriptor.set) { handlers.push(...hookProperties.map((p) => hookSetter(p[0], p[1], { set() { callbackWrapper(eventHandler)({ target: this, isTrusted: false, }); }, }, false, currentWindow))); } return callbackWrapper(() => { handlers.forEach((h) => h()); }); } function getNestedCSSRulePositions(rule) { const positions = []; function recurse(childRule, pos) { if ((hasNestedCSSRule('CSSGroupingRule') && childRule.parentRule instanceof CSSGroupingRule) || (hasNestedCSSRule('CSSMediaRule') && childRule.parentRule instanceof CSSMediaRule) || (hasNestedCSSRule('CSSSupportsRule') && childRule.parentRule instanceof CSSSupportsRule) || (hasNestedCSSRule('CSSConditionRule') && childRule.parentRule instanceof CSSConditionRule)) { const rules = Array.from(childRule.parentRule.cssRules); const index = rules.indexOf(childRule); pos.unshift(index); } else if (childRule.parentStyleSheet) { const rules = Array.from(childRule.parentStyleSheet.cssRules); const index = rules.indexOf(childRule); pos.unshift(index); } return pos; } return recurse(rule, positions); } function getIdAndStyleId(sheet, mirror, styleMirror) { let id, styleId; if (!sheet) return {}; if (sheet.ownerNode) id = mirror.getId(sheet.ownerNode); else styleId = styleMirror.getId(sheet); return { styleId, id, }; } function initStyleSheetObserver({ styleSheetRuleCb, mirror, stylesheetManager }, { win }) { if (!win.CSSStyleSheet || !win.CSSStyleSheet.prototype) { return () => { }; } const insertRule = win.CSSStyleSheet.prototype.insertRule; win.CSSStyleSheet.prototype.insertRule = new Proxy(insertRule, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [rule, index] = argumentsList; const { id, styleId } = getIdAndStyleId(thisArg, mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, styleId, adds: [{ rule, index }], }); } return target.apply(thisArg, argumentsList); }), }); const deleteRule = win.CSSStyleSheet.prototype.deleteRule; win.CSSStyleSheet.prototype.deleteRule = new Proxy(deleteRule, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [index] = argumentsList; const { id, styleId } = getIdAndStyleId(thisArg, mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, styleId, removes: [{ index }], }); } return target.apply(thisArg, argumentsList); }), }); let replace; if (win.CSSStyleSheet.prototype.replace) { replace = win.CSSStyleSheet.prototype.replace; win.CSSStyleSheet.prototype.replace = new Proxy(replace, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [text] = argumentsList; const { id, styleId } = getIdAndStyleId(thisArg, mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, styleId, replace: text, }); } return target.apply(thisArg, argumentsList); }), }); } let replaceSync; if (win.CSSStyleSheet.prototype.replaceSync) { replaceSync = win.CSSStyleSheet.prototype.replaceSync; win.CSSStyleSheet.prototype.replaceSync = new Proxy(replaceSync, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [text] = argumentsList; const { id, styleId } = getIdAndStyleId(thisArg, mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, styleId, replaceSync: text, }); } return target.apply(thisArg, argumentsList); }), }); } const supportedNestedCSSRuleTypes = {}; if (canMonkeyPatchNestedCSSRule('CSSGroupingRule')) { supportedNestedCSSRuleTypes.CSSGroupingRule = win.CSSGroupingRule; } else { if (canMonkeyPatchNestedCSSRule('CSSMediaRule')) { supportedNestedCSSRuleTypes.CSSMediaRule = win.CSSMediaRule; } if (canMonkeyPatchNestedCSSRule('CSSConditionRule')) { supportedNestedCSSRuleTypes.CSSConditionRule = win.CSSConditionRule; } if (canMonkeyPatchNestedCSSRule('CSSSupportsRule')) { supportedNestedCSSRuleTypes.CSSSupportsRule = win.CSSSupportsRule; } } const unmodifiedFunctions = {}; Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { unmodifiedFunctions[typeKey] = { insertRule: type.prototype.insertRule, deleteRule: type.prototype.deleteRule, }; type.prototype.insertRule = new Proxy(unmodifiedFunctions[typeKey].insertRule, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [rule, index] = argumentsList; const { id, styleId } = getIdAndStyleId(thisArg.parentStyleSheet, mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, styleId, adds: [ { rule, index: [ ...getNestedCSSRulePositions(thisArg), index || 0, ], }, ], }); } return target.apply(thisArg, argumentsList); }), }); type.prototype.deleteRule = new Proxy(unmodifiedFunctions[typeKey].deleteRule, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [index] = argumentsList; const { id, styleId } = getIdAndStyleId(thisArg.parentStyleSheet, mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleSheetRuleCb({ id, styleId, removes: [ { index: [...getNestedCSSRulePositions(thisArg), index] }, ], }); } return target.apply(thisArg, argumentsList); }), }); }); return callbackWrapper(() => { win.CSSStyleSheet.prototype.insertRule = insertRule; win.CSSStyleSheet.prototype.deleteRule = deleteRule; replace && (win.CSSStyleSheet.prototype.replace = replace); replaceSync && (win.CSSStyleSheet.prototype.replaceSync = replaceSync); Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule; type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule; }); }); } function initAdoptedStyleSheetObserver({ mirror, stylesheetManager, }, host) { let hostId = null; if (host.nodeName === '#document') hostId = mirror.getId(host); else hostId = mirror.getId(host.host); const patchTarget = host.nodeName === '#document' ? _optionalChain$2([host, 'access', _4 => _4.defaultView, 'optionalAccess', _5 => _5.Document]) : _optionalChain$2([host, 'access', _6 => _6.ownerDocument, 'optionalAccess', _7 => _7.defaultView, 'optionalAccess', _8 => _8.ShadowRoot]); const originalPropertyDescriptor = _optionalChain$2([patchTarget, 'optionalAccess', _9 => _9.prototype]) ? Object.getOwnPropertyDescriptor(_optionalChain$2([patchTarget, 'optionalAccess', _10 => _10.prototype]), 'adoptedStyleSheets') : undefined; if (hostId === null || hostId === -1 || !patchTarget || !originalPropertyDescriptor) return () => { }; Object.defineProperty(host, 'adoptedStyleSheets', { configurable: originalPropertyDescriptor.configurable, enumerable: originalPropertyDescriptor.enumerable, get() { return _optionalChain$2([originalPropertyDescriptor, 'access', _11 => _11.get, 'optionalAccess', _12 => _12.call, 'call', _13 => _13(this)]); }, set(sheets) { const result = _optionalChain$2([originalPropertyDescriptor, 'access', _14 => _14.set, 'optionalAccess', _15 => _15.call, 'call', _16 => _16(this, sheets)]); if (hostId !== null && hostId !== -1) { try { stylesheetManager.adoptStyleSheets(sheets, hostId); } catch (e) { } } return result; }, }); return callbackWrapper(() => { Object.defineProperty(host, 'adoptedStyleSheets', { configurable: originalPropertyDescriptor.configurable, enumerable: originalPropertyDescriptor.enumerable, get: originalPropertyDescriptor.get, set: originalPropertyDescriptor.set, }); }); } function initStyleDeclarationObserver({ styleDeclarationCb, mirror, ignoreCSSAttributes, stylesheetManager, }, { win }) { const setProperty = win.CSSStyleDeclaration.prototype.setProperty; win.CSSStyleDeclaration.prototype.setProperty = new Proxy(setProperty, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [property, value, priority] = argumentsList; if (ignoreCSSAttributes.has(property)) { return setProperty.apply(thisArg, [property, value, priority]); } const { id, styleId } = getIdAndStyleId(_optionalChain$2([thisArg, 'access', _17 => _17.parentRule, 'optionalAccess', _18 => _18.parentStyleSheet]), mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleDeclarationCb({ id, styleId, set: { property, value, priority, }, index: getNestedCSSRulePositions(thisArg.parentRule), }); } return target.apply(thisArg, argumentsList); }), }); const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty; win.CSSStyleDeclaration.prototype.removeProperty = new Proxy(removeProperty, { apply: callbackWrapper((target, thisArg, argumentsList) => { const [property] = argumentsList; if (ignoreCSSAttributes.has(property)) { return removeProperty.apply(thisArg, [property]); } const { id, styleId } = getIdAndStyleId(_optionalChain$2([thisArg, 'access', _19 => _19.parentRule, 'optionalAccess', _20 => _20.parentStyleSheet]), mirror, stylesheetManager.styleMirror); if ((id && id !== -1) || (styleId && styleId !== -1)) { styleDeclarationCb({ id, styleId, remove: { property, }, index: getNestedCSSRulePositions(thisArg.parentRule), }); } return target.apply(thisArg, argumentsList); }), }); return callbackWrapper(() => { win.CSSStyleDeclaration.prototype.setProperty = setProperty; win.CSSStyleDeclaration.prototype.removeProperty = removeProperty; }); } function initMediaInteractionObserver({ mediaInteractionCb, blockClass, blockSelector, unblockSelector, mirror, sampling, doc, }) { const handler = callbackWrapper((type) => throttle$1(callbackWrapper((event) => { const target = getEventTarget(event); if (!target || isBlocked(target, blockClass, blockSelector, unblockSelector, true)) { return; } const { currentTime, volume, muted, playbackRate } = target; mediaInteractionCb({ type, id: mirror.getId(target), currentTime, volume, muted, playbackRate, }); }), sampling.media || 500)); const handlers = [ on('play', handler(0), doc), on('pause', handler(1), doc), on('seeked', handler(2), doc), on('volumechange', handler(3), doc), on('ratechange', handler(4), doc), ]; return callbackWrapper(() => { handlers.forEach((h) => h()); }); } function initFontObserver({ fontCb, doc }) { const win = doc.defaultView; if (!win) { return () => { }; } const handlers = []; const fontMap = new WeakMap(); const originalFontFace = win.FontFace; win.FontFace = function FontFace(family, source, descriptors) { const fontFace = new originalFontFace(family, source, descriptors); fontMap.set(fontFace, { family, buffer: typeof source !== 'string', descriptors, fontSource: typeof source === 'string' ? source : JSON.stringify(Array.from(new Uint8Array(source))), }); return fontFace; }; const restoreHandler = patch(doc.fonts, 'add', function (original) { return function (fontFace) { setTimeout(callbackWrapper(() => { const p = fontMap.get(fontFace); if (p) { fontCb(p); fontMap.delete(fontFace); } }), 0); return original.apply(this, [fontFace]); }; }); handlers.push(() => { win.FontFace = originalFontFace; }); handlers.push(restoreHandler); return callbackWrapper(() => { handlers.forEach((h) => h()); }); } function initSelectionObserver(param) { const { doc, mirror, blockClass, blockSelector, unblockSelector, selectionCb, } = param; let collapsed = true; const updateSelection = callbackWrapper(() => { const selection = doc.getSelection(); if (!selection || (collapsed && _optionalChain$2([selection, 'optionalAccess', _21 => _21.isCollapsed]))) return; collapsed = selection.isCollapsed || false; const ranges = []; const count = selection.rangeCount || 0; for (let i = 0; i < count; i++) { const range = selection.getRangeAt(i); const { startContainer, startOffset, endContainer, endOffset } = range; const blocked = isBlocked(startContainer, blockClass, blockSelector, unblockSelector, true) || isBlocked(endContainer, blockClass, blockSelector, unblockSelector, true); if (blocked) continue; ranges.push({ start: mirror.getId(startContainer), startOffset, end: mirror.getId(endContainer), endOffset, }); } selectionCb({ ranges }); }); updateSelection(); return on('selectionchange', updateSelection); } function initCustomElementObserver({ doc, customElementCb, }) { const win = doc.defaultView; if (!win || !win.customElements) return () => { }; const restoreHandler = patch(win.customElements, 'define', function (original) { return function (name, constructor, options) { try { customElementCb({ define: { name, }, }); } catch (e) { } return original.apply(this, [name, constructor, options]); }; }); return restoreHandler; } function initObservers(o, _hooks = {}) { const currentWindow = o.doc.defaultView; if (!currentWindow) { return () => { }; } const mutationObserver = initMutationObserver(o, o.doc); const mousemoveHandler = initMoveObserver(o); const mouseInteractionHandler = initMouseInteractionObserver(o); const scrollHandler = initScrollObserver(o); const viewportResizeHandler = initViewportResizeObserver(o, { win: currentWindow, }); const inputHandler = initInputObserver(o); const mediaInteractionHandler = initMediaInteractionObserver(o); const styleSheetObserver = initStyleSheetObserver(o, { win: currentWindow }); const adoptedStyleSheetObserver = initAdoptedStyleSheetObserver(o, o.doc); const styleDeclarationObserver = initStyleDeclarationObserver(o, { win: currentWindow, }); const fontObserver = o.collectFonts ? initFontObserver(o) : () => { }; const selectionObserver = initSelectionObserver(o); const customElementObserver = initCustomElementObserver(o); const pluginHandlers = []; for (const plugin of o.plugins) { pluginHandlers.push(plugin.observer(plugin.callback, currentWindow, plugin.options)); } return callbackWrapper(() => { mutationBuffers.forEach((b) => b.reset()); mutationObserver.disconnect(); mousemoveHandler(); mouseInteractionHandler(); scrollHandler(); viewportResizeHandler(); inputHandler(); mediaInteractionHandler(); styleSheetObserver(); adoptedStyleSheetObserver(); styleDeclarationObserver(); fontObserver(); selectionObserver(); customElementObserver(); pluginHandlers.forEach((h) => h()); }); } function hasNestedCSSRule(prop) { return typeof window[prop] !== 'undefined'; } function canMonkeyPatchNestedCSSRule(prop) { return Boolean(typeof window[prop] !== 'undefined' && window[prop].prototype && 'insertRule' in window[prop].prototype && 'deleteRule' in window[prop].prototype); } class CrossOriginIframeMirror { constructor(generateIdFn) { this.generateIdFn = generateIdFn; this.iframeIdToRemoteIdMap = new WeakMap(); this.iframeRemoteIdToIdMap = new WeakMap(); } getId(iframe, remoteId, idToRemoteMap, remoteToIdMap) { const idToRemoteIdMap = idToRemoteMap || this.getIdToRemoteIdMap(iframe); const remoteIdToIdMap = remoteToIdMap || this.getRemoteIdToIdMap(iframe); let id = idToRemoteIdMap.get(remoteId); if (!id) { id = this.generateIdFn(); idToRemoteIdMap.set(remoteId, id); remoteIdToIdMap.set(id, remoteId); } return id; } getIds(iframe, remoteId) { const idToRemoteIdMap = this.getIdToRemoteIdMap(iframe); const remoteIdToIdMap = this.getRemoteIdToIdMap(iframe); return remoteId.map((id) => this.getId(iframe, id, idToRemoteIdMap, remoteIdToIdMap)); } getRemoteId(iframe, id, map) { const remoteIdToIdMap = map || this.getRemoteIdToIdMap(iframe); if (typeof id !== 'number') return id; const remoteId = remoteIdToIdMap.get(id); if (!remoteId) return -1; return remoteId; } getRemoteIds(iframe, ids) { const remoteIdToIdMap = this.getRemoteIdToIdMap(iframe); return ids.map((id) => this.getRemoteId(iframe, id, remoteIdToIdMap)); } reset(iframe) { if (!iframe) { this.iframeIdToRemoteIdMap = new WeakMap(); this.iframeRemoteIdToIdMap = new WeakMap(); return; } this.iframeIdToRemoteIdMap.delete(iframe); this.iframeRemoteIdToIdMap.delete(iframe); } getIdToRemoteIdMap(iframe) { let idToRemoteIdMap = this.iframeIdToRemoteIdMap.get(iframe); if (!idToRemoteIdMap) { idToRemoteIdMap = new Map(); this.iframeIdToRemoteIdMap.set(iframe, idToRemoteIdMap); } return idToRemoteIdMap; } getRemoteIdToIdMap(iframe) { let remoteIdToIdMap = this.iframeRemoteIdToIdMap.get(iframe); if (!remoteIdToIdMap) { remoteIdToIdMap = new Map(); this.iframeRemoteIdToIdMap.set(iframe, remoteIdToIdMap); } return remoteIdToIdMap; } } function _optionalChain$1(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } class IframeManagerNoop { constructor() { this.crossOriginIframeMirror = new CrossOriginIframeMirror(genId); this.crossOriginIframeRootIdMap = new WeakMap(); } addIframe() { } addLoadListener() { } attachIframe() { } } class IframeManager { constructor(options) { this.iframes = new WeakMap(); this.crossOriginIframeMap = new WeakMap(); this.crossOriginIframeMirror = new CrossOriginIframeMirror(genId); this.crossOriginIframeRootIdMap = new WeakMap(); this.mutationCb = options.mutationCb; this.wrappedEmit = options.wrappedEmit; this.stylesheetManager = options.stylesheetManager; this.recordCrossOriginIframes = options.recordCrossOriginIframes; this.crossOriginIframeStyleMirror = new CrossOriginIframeMirror(this.stylesheetManager.styleMirror.generateId.bind(this.stylesheetManager.styleMirror)); this.mirror = options.mirror; if (this.recordCrossOriginIframes) { window.addEventListener('message', this.handleMessage.bind(this)); } } addIframe(iframeEl) { this.iframes.set(iframeEl, true); if (iframeEl.contentWindow) this.crossOriginIframeMap.set(iframeEl.contentWindow, iframeEl); } addLoadListener(cb) { this.loadListener = cb; } attachIframe(iframeEl, childSn) { this.mutationCb({ adds: [ { parentId: this.mirror.getId(iframeEl), nextId: null, node: childSn, }, ], removes: [], texts: [], attributes: [], isAttachIframe: true, }); _optionalChain$1([this, 'access', _ => _.loadListener, 'optionalCall', _2 => _2(iframeEl)]); if (iframeEl.contentDocument && iframeEl.contentDocument.adoptedStyleSheets && iframeEl.contentDocument.adoptedStyleSheets.length > 0) this.stylesheetManager.adoptStyleSheets(iframeEl.contentDocument.adoptedStyleSheets, this.mirror.getId(iframeEl.contentDocument)); } handleMessage(message) { const crossOriginMessageEvent = message; if (crossOriginMessageEvent.data.type !== 'rrweb' || crossOriginMessageEvent.origin !== crossOriginMessageEvent.data.origin) return; const iframeSourceWindow = message.source; if (!iframeSourceWindow) return; const iframeEl = this.crossOriginIframeMap.get(message.source); if (!iframeEl) return; const transformedEvent = this.transformCrossOriginEvent(iframeEl, crossOriginMessageEvent.data.event); if (transformedEvent) this.wrappedEmit(transformedEvent, crossOriginMessageEvent.data.isCheckout); } transformCrossOriginEvent(iframeEl, e) { switch (e.type) { case EventType.FullSnapshot: { this.crossOriginIframeMirror.reset(iframeEl); this.crossOriginIframeStyleMirror.reset(iframeEl); this.replaceIdOnNode(e.data.node, iframeEl); const rootId = e.data.node.id; this.crossOriginIframeRootIdMap.set(iframeEl, rootId); this.patchRootIdOnNode(e.data.node, rootId); return { timestamp: e.timestamp, type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Mutation, adds: [ { parentId: this.mirror.getId(iframeEl), nextId: null, node: e.data.node, }, ], removes: [], texts: [], attributes: [], isAttachIframe: true, }, }; } case EventType.Meta: case EventType.Load: case EventType.DomContentLoaded: { return false; } case EventType.Plugin: { return e; } case EventType.Custom: { this.replaceIds(e.data.payload, iframeEl, ['id', 'parentId', 'previousId', 'nextId']); return e; } case EventType.IncrementalSnapshot: { switch (e.data.source) { case IncrementalSource.Mutation: { e.data.adds.forEach((n) => { this.replaceIds(n, iframeEl, [ 'parentId', 'nextId', 'previousId', ]); this.replaceIdOnNode(n.node, iframeEl); const rootId = this.crossOriginIframeRootIdMap.get(iframeEl); rootId && this.patchRootIdOnNode(n.node, rootId); }); e.data.removes.forEach((n) => { this.replaceIds(n, iframeEl, ['parentId', 'id']); }); e.data.attributes.forEach((n) => { this.replaceIds(n, iframeEl, ['id']); }); e.data.texts.forEach((n) => { this.replaceIds(n, iframeEl, ['id']); }); return e; } case IncrementalSource.Drag: case IncrementalSource.TouchMove: case IncrementalSource.MouseMove: { e.data.positions.forEach((p) => { this.replaceIds(p, iframeEl, ['id']); }); return e; } case IncrementalSource.ViewportResize: { return false; } case IncrementalSource.MediaInteraction: case IncrementalSource.MouseInteraction: case IncrementalSource.Scroll: case IncrementalSource.CanvasMutation: case IncrementalSource.Input: { this.replaceIds(e.data, iframeEl, ['id']); return e; } case IncrementalSource.StyleSheetRule: case IncrementalSource.StyleDeclaration: { this.replaceIds(e.data, iframeEl, ['id']); this.replaceStyleIds(e.data, iframeEl, ['styleId']); return e; } case IncrementalSource.Font: { return e; } case IncrementalSource.Selection: { e.data.ranges.forEach((range) => { this.replaceIds(range, iframeEl, ['start', 'end']); }); return e; } case IncrementalSource.AdoptedStyleSheet: { this.replaceIds(e.data, iframeEl, ['id']); this.replaceStyleIds(e.data, iframeEl, ['styleIds']); _optionalChain$1([e, 'access', _3 => _3.data, 'access', _4 => _4.styles, 'optionalAccess', _5 => _5.forEach, 'call', _6 => _6((style) => { this.replaceStyleIds(style, iframeEl, ['styleId']); })]); return e; } } } } return false; } replace(iframeMirror, obj, iframeEl, keys) { for (const key of keys) { if (!Array.isArray(obj[key]) && typeof obj[key] !== 'number') continue; if (Array.isArray(obj[key])) { obj[key] = iframeMirror.getIds(iframeEl, obj[key]); } else { obj[key] = iframeMirror.getId(iframeEl, obj[key]); } } return obj; } replaceIds(obj, iframeEl, keys) { return this.replace(this.crossOriginIframeMirror, obj, iframeEl, keys); } replaceStyleIds(obj, iframeEl, keys) { return this.replace(this.crossOriginIframeStyleMirror, obj, iframeEl, keys); } replaceIdOnNode(node, iframeEl) { this.replaceIds(node, iframeEl, ['id', 'rootId']); if ('childNodes' in node) { node.childNodes.forEach((child) => { this.replaceIdOnNode(child, iframeEl); }); } } patchRootIdOnNode(node, rootId) { if (node.type !== NodeType$1.Document && !node.rootId) node.rootId = rootId; if ('childNodes' in node) { node.childNodes.forEach((child) => { this.patchRootIdOnNode(child, rootId); }); } } } class ShadowDomManagerNoop { init() { } addShadowRoot() { } observeAttachShadow() { } reset() { } } class ShadowDomManager { constructor(options) { this.shadowDoms = new WeakSet(); this.restoreHandlers = []; this.mutationCb = options.mutationCb; this.scrollCb = options.scrollCb; this.bypassOptions = options.bypassOptions; this.mirror = options.mirror; this.init(); } init() { this.reset(); this.patchAttachShadow(Element, document); } addShadowRoot(shadowRoot, doc) { if (!isNativeShadowDom(shadowRoot)) return; if (this.shadowDoms.has(shadowRoot)) return; this.shadowDoms.add(shadowRoot); const observer = initMutationObserver({ ...this.bypassOptions, doc, mutationCb: this.mutationCb, mirror: this.mirror, shadowDomManager: this, }, shadowRoot); this.restoreHandlers.push(() => observer.disconnect()); this.restoreHandlers.push(initScrollObserver({ ...this.bypassOptions, scrollCb: this.scrollCb, doc: shadowRoot, mirror: this.mirror, })); setTimeout(() => { if (shadowRoot.adoptedStyleSheets && shadowRoot.adoptedStyleSheets.length > 0) this.bypassOptions.stylesheetManager.adoptStyleSheets(shadowRoot.adoptedStyleSheets, this.mirror.getId(shadowRoot.host)); this.restoreHandlers.push(initAdoptedStyleSheetObserver({ mirror: this.mirror, stylesheetManager: this.bypassOptions.stylesheetManager, }, shadowRoot)); }, 0); } observeAttachShadow(iframeElement) { if (!iframeElement.contentWindow || !iframeElement.contentDocument) return; this.patchAttachShadow(iframeElement.contentWindow.Element, iframeElement.contentDocument); } patchAttachShadow(element, doc) { const manager = this; this.restoreHandlers.push(patch(element.prototype, 'attachShadow', function (original) { return function (option) { const shadowRoot = original.call(this, option); if (this.shadowRoot && inDom(this)) manager.addShadowRoot(this.shadowRoot, doc); return shadowRoot; }; })); } reset() { this.restoreHandlers.forEach((handler) => { try { handler(); } catch (e) { } }); this.restoreHandlers = []; this.shadowDoms = new WeakSet(); } } class CanvasManagerNoop { reset() { } freeze() { } unfreeze() { } lock() { } unlock() { } snapshot() { } } class StylesheetManager { constructor(options) { this.trackedLinkElements = new WeakSet(); this.styleMirror = new StyleSheetMirror(); this.mutationCb = options.mutationCb; this.adoptedStyleSheetCb = options.adoptedStyleSheetCb; } attachLinkElement(linkEl, childSn) { if ('_cssText' in childSn.attributes) this.mutationCb({ adds: [], removes: [], texts: [], attributes: [ { id: childSn.id, attributes: childSn .attributes, }, ], }); this.trackLinkElement(linkEl); } trackLinkElement(linkEl) { if (this.trackedLinkElements.has(linkEl)) return; this.trackedLinkElements.add(linkEl); this.trackStylesheetInLinkElement(linkEl); } adoptStyleSheets(sheets, hostId) { if (sheets.length === 0) return; const adoptedStyleSheetData = { id: hostId, styleIds: [], }; const styles = []; for (const sheet of sheets) { let styleId; if (!this.styleMirror.has(sheet)) { styleId = this.styleMirror.add(sheet); styles.push({ styleId, rules: Array.from(sheet.rules || CSSRule, (r, index) => ({ rule: stringifyRule(r), index, })), }); } else styleId = this.styleMirror.getId(sheet); adoptedStyleSheetData.styleIds.push(styleId); } if (styles.length > 0) adoptedStyleSheetData.styles = styles; this.adoptedStyleSheetCb(adoptedStyleSheetData); } reset() { this.styleMirror.reset(); this.trackedLinkElements = new WeakSet(); } trackStylesheetInLinkElement(linkEl) { } } class ProcessedNodeManager { constructor() { this.nodeMap = new WeakMap(); this.loop = true; this.periodicallyClear(); } periodicallyClear() { onRequestAnimationFrame(() => { this.clear(); if (this.loop) this.periodicallyClear(); }); } inOtherBuffer(node, thisBuffer) { const buffers = this.nodeMap.get(node); return (buffers && Array.from(buffers).some((buffer) => buffer !== thisBuffer)); } add(node, buffer) { this.nodeMap.set(node, (this.nodeMap.get(node) || new Set()).add(buffer)); } clear() { this.nodeMap = new WeakMap(); } destroy() { this.loop = false; } } function wrapEvent(e) { const eWithTime = e; eWithTime.timestamp = nowTimestamp(); return eWithTime; } let _takeFullSnapshot; const mirror = createMirror(); function record(options = {}) { const { emit, checkoutEveryNms, checkoutEveryNth, blockClass = 'rr-block', blockSelector = null, unblockSelector = null, ignoreClass = 'rr-ignore', ignoreSelector = null, maskAllText = false, maskTextClass = 'rr-mask', unmaskTextClass = null, maskTextSelector = null, unmaskTextSelector = null, inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, slimDOMOptions: _slimDOMOptions, maskAttributeFn, maskInputFn, maskTextFn, packFn, sampling = {}, dataURLOptions = {}, mousemoveWait, recordCanvas = false, recordCrossOriginIframes = false, recordAfter = options.recordAfter === 'DOMContentLoaded' ? options.recordAfter : 'load', userTriggeredOnInput = false, collectFonts = false, inlineImages = false, plugins, keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), errorHandler, onMutation, getCanvasManager, } = options; registerErrorHandler(errorHandler); const inEmittingFrame = recordCrossOriginIframes ? window.parent === window : true; let passEmitsToParent = false; if (!inEmittingFrame) { try { if (window.parent.document) { passEmitsToParent = false; } } catch (e) { passEmitsToParent = true; } } if (inEmittingFrame && !emit) { throw new Error('emit function is required'); } if (mousemoveWait !== undefined && sampling.mousemove === undefined) { sampling.mousemove = mousemoveWait; } mirror.reset(); const 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, radio: true, checkbox: true, } : _maskInputOptions !== undefined ? _maskInputOptions : {}; const slimDOMOptions = _slimDOMOptions === true || _slimDOMOptions === 'all' ? { script: true, comment: true, headFavicon: true, headWhitespace: true, headMetaSocial: true, headMetaRobots: true, headMetaHttpEquiv: true, headMetaVerification: true, headMetaAuthorship: _slimDOMOptions === 'all', headMetaDescKeywords: _slimDOMOptions === 'all', } : _slimDOMOptions ? _slimDOMOptions : {}; polyfill(); let lastFullSnapshotEvent; let incrementalSnapshotCount = 0; const eventProcessor = (e) => { for (const plugin of plugins || []) { if (plugin.eventProcessor) { e = plugin.eventProcessor(e); } } if (packFn && !passEmitsToParent) { e = packFn(e); } return e; }; const wrappedEmit = (e, isCheckout) => { if (_optionalChain([mutationBuffers, 'access', _ => _[0], 'optionalAccess', _2 => _2.isFrozen, 'call', _3 => _3()]) && e.type !== EventType.FullSnapshot && !(e.type === EventType.IncrementalSnapshot && e.data.source === IncrementalSource.Mutation)) { mutationBuffers.forEach((buf) => buf.unfreeze()); } if (inEmittingFrame) { _optionalChain([emit, 'optionalCall', _4 => _4(eventProcessor(e), isCheckout)]); } else if (passEmitsToParent) { const message = { type: 'rrweb', event: eventProcessor(e), origin: window.location.origin, isCheckout, }; window.parent.postMessage(message, '*'); } if (e.type === EventType.FullSnapshot) { lastFullSnapshotEvent = e; incrementalSnapshotCount = 0; } else if (e.type === EventType.IncrementalSnapshot) { if (e.data.source === IncrementalSource.Mutation && e.data.isAttachIframe) { return; } incrementalSnapshotCount++; const exceedCount = checkoutEveryNth && incrementalSnapshotCount >= checkoutEveryNth; const exceedTime = checkoutEveryNms && e.timestamp - lastFullSnapshotEvent.timestamp > checkoutEveryNms; if (exceedCount || exceedTime) { takeFullSnapshot(true); } } }; const wrappedMutationEmit = (m) => { wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Mutation, ...m, }, })); }; const wrappedScrollEmit = (p) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Scroll, ...p, }, })); const wrappedCanvasMutationEmit = (p) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.CanvasMutation, ...p, }, })); const wrappedAdoptedStyleSheetEmit = (a) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.AdoptedStyleSheet, ...a, }, })); const stylesheetManager = new StylesheetManager({ mutationCb: wrappedMutationEmit, adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit, }); const iframeManager = typeof __RRWEB_EXCLUDE_IFRAME__ === 'boolean' && __RRWEB_EXCLUDE_IFRAME__ ? new IframeManagerNoop() : new IframeManager({ mirror, mutationCb: wrappedMutationEmit, stylesheetManager: stylesheetManager, recordCrossOriginIframes, wrappedEmit, }); for (const plugin of plugins || []) { if (plugin.getMirror) plugin.getMirror({ nodeMirror: mirror, crossOriginIframeMirror: iframeManager.crossOriginIframeMirror, crossOriginIframeStyleMirror: iframeManager.crossOriginIframeStyleMirror, }); } const processedNodeManager = new ProcessedNodeManager(); const canvasManager = _getCanvasManager(getCanvasManager, { mirror, win: window, mutationCb: (p) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.CanvasMutation, ...p, }, })), recordCanvas, blockClass, blockSelector, unblockSelector, sampling: sampling['canvas'], dataURLOptions, errorHandler, }); const shadowDomManager = typeof __RRWEB_EXCLUDE_SHADOW_DOM__ === 'boolean' && __RRWEB_EXCLUDE_SHADOW_DOM__ ? new ShadowDomManagerNoop() : new ShadowDomManager({ mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, bypassOptions: { onMutation, blockClass, blockSelector, unblockSelector, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, inlineStylesheet, maskInputOptions, dataURLOptions, maskAttributeFn, maskTextFn, maskInputFn, recordCanvas, inlineImages, sampling, slimDOMOptions, iframeManager, stylesheetManager, canvasManager, keepIframeSrcFn, processedNodeManager, }, mirror, }); const takeFullSnapshot = (isCheckout = false) => { wrappedEmit(wrapEvent({ type: EventType.Meta, data: { href: window.location.href, width: getWindowWidth(), height: getWindowHeight(), }, }), isCheckout); stylesheetManager.reset(); shadowDomManager.init(); mutationBuffers.forEach((buf) => buf.lock()); const node = snapshot(document, { mirror, blockClass, blockSelector, unblockSelector, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, inlineStylesheet, maskAllInputs: maskInputOptions, maskAttributeFn, maskInputFn, maskTextFn, slimDOM: slimDOMOptions, dataURLOptions, recordCanvas, inlineImages, onSerialize: (n) => { if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n); } if (isSerializedStylesheet(n, mirror)) { stylesheetManager.trackLinkElement(n); } if (hasShadowRoot(n)) { shadowDomManager.addShadowRoot(n.shadowRoot, document); } }, onIframeLoad: (iframe, childSn) => { iframeManager.attachIframe(iframe, childSn); shadowDomManager.observeAttachShadow(iframe); }, onStylesheetLoad: (linkEl, childSn) => { stylesheetManager.attachLinkElement(linkEl, childSn); }, keepIframeSrcFn, }); if (!node) { return console.warn('Failed to snapshot the document'); } wrappedEmit(wrapEvent({ type: EventType.FullSnapshot, data: { node, initialOffset: getWindowScroll(window), }, })); mutationBuffers.forEach((buf) => buf.unlock()); if (document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0) stylesheetManager.adoptStyleSheets(document.adoptedStyleSheets, mirror.getId(document)); }; _takeFullSnapshot = takeFullSnapshot; try { const handlers = []; const observe = (doc) => { return callbackWrapper(initObservers)({ onMutation, mutationCb: wrappedMutationEmit, mousemoveCb: (positions, source) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source, positions, }, })), mouseInteractionCb: (d) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.MouseInteraction, ...d, }, })), scrollCb: wrappedScrollEmit, viewportResizeCb: (d) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.ViewportResize, ...d, }, })), inputCb: (v) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Input, ...v, }, })), mediaInteractionCb: (p) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.MediaInteraction, ...p, }, })), styleSheetRuleCb: (r) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.StyleSheetRule, ...r, }, })), styleDeclarationCb: (r) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.StyleDeclaration, ...r, }, })), canvasMutationCb: wrappedCanvasMutationEmit, fontCb: (p) => wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Font, ...p, }, })), selectionCb: (p) => { wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Selection, ...p, }, })); }, customElementCb: (c) => { wrappedEmit(wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.CustomElement, ...c, }, })); }, blockClass, ignoreClass, ignoreSelector, maskAllText, maskTextClass, unmaskTextClass, maskTextSelector, unmaskTextSelector, maskInputOptions, inlineStylesheet, sampling, recordCanvas, inlineImages, userTriggeredOnInput, collectFonts, doc, maskAttributeFn, maskInputFn, maskTextFn, keepIframeSrcFn, blockSelector, unblockSelector, slimDOMOptions, dataURLOptions, mirror, iframeManager, stylesheetManager, shadowDomManager, processedNodeManager, canvasManager, ignoreCSSAttributes, plugins: _optionalChain([plugins , 'optionalAccess', _5 => _5.filter, 'call', _6 => _6((p) => p.observer) , 'optionalAccess', _7 => _7.map, 'call', _8 => _8((p) => ({ observer: p.observer, options: p.options, callback: (payload) => wrappedEmit(wrapEvent({ type: EventType.Plugin, data: { plugin: p.name, payload, }, })), }))]) || [], }, {}); }; iframeManager.addLoadListener((iframeEl) => { try { handlers.push(observe(iframeEl.contentDocument)); } catch (error) { console.warn(error); } }); const init = () => { takeFullSnapshot(); handlers.push(observe(document)); }; if (document.readyState === 'interactive' || document.readyState === 'complete') { init(); } else { handlers.push(on('DOMContentLoaded', () => { wrappedEmit(wrapEvent({ type: EventType.DomContentLoaded, data: {}, })); if (recordAfter === 'DOMContentLoaded') init(); })); handlers.push(on('load', () => { wrappedEmit(wrapEvent({ type: EventType.Load, data: {}, })); if (recordAfter === 'load') init(); }, window)); } return () => { handlers.forEach((h) => h()); processedNodeManager.destroy(); _takeFullSnapshot = undefined; unregisterErrorHandler(); }; } catch (error) { console.warn(error); } } function takeFullSnapshot(isCheckout) { if (!_takeFullSnapshot) { throw new Error('please take full snapshot after start recording'); } _takeFullSnapshot(isCheckout); } record.mirror = mirror; record.takeFullSnapshot = takeFullSnapshot; function _getCanvasManager(getCanvasManagerFn, options) { try { return getCanvasManagerFn ? getCanvasManagerFn(options) : new CanvasManagerNoop(); } catch (e2) { console.warn('Unable to initialize CanvasManager'); return new CanvasManagerNoop(); } } const ReplayEventTypeIncrementalSnapshot = 3; const ReplayEventTypeCustom = 5; /** * Converts a timestamp to ms, if it was in s, or keeps it as ms. */ function timestampToMs(timestamp) { const isMs = timestamp > 9999999999; return isMs ? timestamp : timestamp * 1000; } /** * Converts a timestamp to s, if it was in ms, or keeps it as s. */ function timestampToS(timestamp) { const isMs = timestamp > 9999999999; return isMs ? timestamp / 1000 : timestamp; } /** * Add a breadcrumb event to replay. */ function addBreadcrumbEvent(replay, breadcrumb) { if (breadcrumb.category === 'sentry.transaction') { return; } if (['ui.click', 'ui.input'].includes(breadcrumb.category )) { replay.triggerUserActivity(); } else { replay.checkAndHandleExpiredSession(); } replay.addUpdate(() => { // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises replay.throttledAddEvent({ type: EventType.Custom, // TODO: We were converting from ms to seconds for breadcrumbs, spans, // but maybe we should just keep them as milliseconds timestamp: (breadcrumb.timestamp || 0) * 1000, data: { tag: 'breadcrumb', // normalize to max. 10 depth and 1_000 properties per object payload: normalize(breadcrumb, 10, 1000), }, }); // Do not flush after console log messages return breadcrumb.category === 'console'; }); } const INTERACTIVE_SELECTOR = 'button,a'; /** Get the closest interactive parent element, or else return the given element. */ function getClosestInteractive(element) { const closestInteractive = element.closest(INTERACTIVE_SELECTOR); return closestInteractive || element; } /** * For clicks, we check if the target is inside of a button or link * If so, we use this as the target instead * This is useful because if you click on the image in , * The target will be the image, not the button, which we don't want here */ function getClickTargetNode(event) { const target = getTargetNode(event); if (!target || !(target instanceof Element)) { return target; } return getClosestInteractive(target); } /** Get the event target node. */ function getTargetNode(event) { if (isEventWithTarget(event)) { return event.target ; } return event; } function isEventWithTarget(event) { return typeof event === 'object' && !!event && 'target' in event; } let handlers; /** * Register a handler to be called when `window.open()` is called. * Returns a cleanup function. */ function onWindowOpen(cb) { // Ensure to only register this once if (!handlers) { handlers = []; monkeyPatchWindowOpen(); } handlers.push(cb); return () => { const pos = handlers ? handlers.indexOf(cb) : -1; if (pos > -1) { (handlers ).splice(pos, 1); } }; } function monkeyPatchWindowOpen() { fill(WINDOW, 'open', function (originalWindowOpen) { return function (...args) { if (handlers) { try { handlers.forEach(handler => handler()); } catch (e) { // ignore errors in here } } return originalWindowOpen.apply(WINDOW, args); }; }); } /** Handle a click. */ function handleClick(clickDetector, clickBreadcrumb, node) { clickDetector.handleClick(clickBreadcrumb, node); } /** A click detector class that can be used to detect slow or rage clicks on elements. */ class ClickDetector { // protected for testing constructor( replay, slowClickConfig, // Just for easier testing _addBreadcrumbEvent = addBreadcrumbEvent, ) { this._lastMutation = 0; this._lastScroll = 0; this._clicks = []; // We want everything in s, but options are in ms this._timeout = slowClickConfig.timeout / 1000; this._threshold = slowClickConfig.threshold / 1000; this._scollTimeout = slowClickConfig.scrollTimeout / 1000; this._replay = replay; this._ignoreSelector = slowClickConfig.ignoreSelector; this._addBreadcrumbEvent = _addBreadcrumbEvent; } /** Register click detection handlers on mutation or scroll. */ addListeners() { const cleanupWindowOpen = onWindowOpen(() => { // Treat window.open as mutation this._lastMutation = nowInSeconds(); }); this._teardown = () => { cleanupWindowOpen(); this._clicks = []; this._lastMutation = 0; this._lastScroll = 0; }; } /** Clean up listeners. */ removeListeners() { if (this._teardown) { this._teardown(); } if (this._checkClickTimeout) { clearTimeout(this._checkClickTimeout); } } /** @inheritDoc */ handleClick(breadcrumb, node) { if (ignoreElement(node, this._ignoreSelector) || !isClickBreadcrumb(breadcrumb)) { return; } const newClick = { timestamp: timestampToS(breadcrumb.timestamp), clickBreadcrumb: breadcrumb, // Set this to 0 so we know it originates from the click breadcrumb clickCount: 0, node, }; // If there was a click in the last 1s on the same element, ignore it - only keep a single reference per second if ( this._clicks.some(click => click.node === newClick.node && Math.abs(click.timestamp - newClick.timestamp) < 1) ) { return; } this._clicks.push(newClick); // If this is the first new click, set a timeout to check for multi clicks if (this._clicks.length === 1) { this._scheduleCheckClicks(); } } /** @inheritDoc */ registerMutation(timestamp = Date.now()) { this._lastMutation = timestampToS(timestamp); } /** @inheritDoc */ registerScroll(timestamp = Date.now()) { this._lastScroll = timestampToS(timestamp); } /** @inheritDoc */ registerClick(element) { const node = getClosestInteractive(element); this._handleMultiClick(node ); } /** Count multiple clicks on elements. */ _handleMultiClick(node) { this._getClicks(node).forEach(click => { click.clickCount++; }); } /** Get all pending clicks for a given node. */ _getClicks(node) { return this._clicks.filter(click => click.node === node); } /** Check the clicks that happened. */ _checkClicks() { const timedOutClicks = []; const now = nowInSeconds(); this._clicks.forEach(click => { if (!click.mutationAfter && this._lastMutation) { click.mutationAfter = click.timestamp <= this._lastMutation ? this._lastMutation - click.timestamp : undefined; } if (!click.scrollAfter && this._lastScroll) { click.scrollAfter = click.timestamp <= this._lastScroll ? this._lastScroll - click.timestamp : undefined; } // All of these are in seconds! if (click.timestamp + this._timeout <= now) { timedOutClicks.push(click); } }); // Remove "old" clicks for (const click of timedOutClicks) { const pos = this._clicks.indexOf(click); if (pos > -1) { this._generateBreadcrumbs(click); this._clicks.splice(pos, 1); } } // Trigger new check, unless no clicks left if (this._clicks.length) { this._scheduleCheckClicks(); } } /** Generate matching breadcrumb(s) for the click. */ _generateBreadcrumbs(click) { const replay = this._replay; const hadScroll = click.scrollAfter && click.scrollAfter <= this._scollTimeout; const hadMutation = click.mutationAfter && click.mutationAfter <= this._threshold; const isSlowClick = !hadScroll && !hadMutation; const { clickCount, clickBreadcrumb } = click; // Slow click if (isSlowClick) { // If `mutationAfter` is set, it means a mutation happened after the threshold, but before the timeout // If not, it means we just timed out without scroll & mutation const timeAfterClickMs = Math.min(click.mutationAfter || this._timeout, this._timeout) * 1000; const endReason = timeAfterClickMs < this._timeout * 1000 ? 'mutation' : 'timeout'; const breadcrumb = { type: 'default', message: clickBreadcrumb.message, timestamp: clickBreadcrumb.timestamp, category: 'ui.slowClickDetected', data: { ...clickBreadcrumb.data, url: WINDOW.location.href, route: replay.getCurrentRoute(), timeAfterClickMs, endReason, // If clickCount === 0, it means multiClick was not correctly captured here // - we still want to send 1 in this case clickCount: clickCount || 1, }, }; this._addBreadcrumbEvent(replay, breadcrumb); return; } // Multi click if (clickCount > 1) { const breadcrumb = { type: 'default', message: clickBreadcrumb.message, timestamp: clickBreadcrumb.timestamp, category: 'ui.multiClick', data: { ...clickBreadcrumb.data, url: WINDOW.location.href, route: replay.getCurrentRoute(), clickCount, metric: true, }, }; this._addBreadcrumbEvent(replay, breadcrumb); } } /** Schedule to check current clicks. */ _scheduleCheckClicks() { if (this._checkClickTimeout) { clearTimeout(this._checkClickTimeout); } this._checkClickTimeout = setTimeout(() => this._checkClicks(), 1000); } } const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT']; /** exported for tests only */ function ignoreElement(node, ignoreSelector) { if (!SLOW_CLICK_TAGS.includes(node.tagName)) { return true; } // If tag, we only want to consider input[type='submit'] & input[type='button'] if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) { return true; } // If tag, detect special variants that may not lead to an action // If target !== _self, we may open the link somewhere else, which would lead to no action // Also, when downloading a file, we may not leave the page, but still not trigger an action if ( node.tagName === 'A' && (node.hasAttribute('download') || (node.hasAttribute('target') && node.getAttribute('target') !== '_self')) ) { return true; } if (ignoreSelector && node.matches(ignoreSelector)) { return true; } return false; } function isClickBreadcrumb(breadcrumb) { return !!(breadcrumb.data && typeof breadcrumb.data.nodeId === 'number' && breadcrumb.timestamp); } // This is good enough for us, and is easier to test/mock than `timestampInSeconds` function nowInSeconds() { return Date.now() / 1000; } /** Update the click detector based on a recording event of rrweb. */ function updateClickDetectorForRecordingEvent(clickDetector, event) { try { // note: We only consider incremental snapshots here // This means that any full snapshot is ignored for mutation detection - the reason is that we simply cannot know if a mutation happened here. // E.g. think that we are buffering, an error happens and we take a full snapshot because we switched to session mode - // in this scenario, we would not know if a dead click happened because of the error, which is a key dead click scenario. // Instead, by ignoring full snapshots, we have the risk that we generate a false positive // (if a mutation _did_ happen but was "swallowed" by the full snapshot) // But this should be more unlikely as we'd generally capture the incremental snapshot right away if (!isIncrementalEvent(event)) { return; } const { source } = event.data; if (source === IncrementalSource.Mutation) { clickDetector.registerMutation(event.timestamp); } if (source === IncrementalSource.Scroll) { clickDetector.registerScroll(event.timestamp); } if (isIncrementalMouseInteraction(event)) { const { type, id } = event.data; const node = record.mirror.getNode(id); if (node instanceof HTMLElement && type === MouseInteractions.Click) { clickDetector.registerClick(node); } } } catch (e) { // ignore errors here, e.g. if accessing something that does not exist } } function isIncrementalEvent(event) { return event.type === ReplayEventTypeIncrementalSnapshot; } function isIncrementalMouseInteraction( event, ) { return event.data.source === IncrementalSource.MouseInteraction; } /** * Create a breadcrumb for a replay. */ function createBreadcrumb( breadcrumb, ) { return { timestamp: Date.now() / 1000, type: 'default', ...breadcrumb, }; } var NodeType; (function (NodeType) { NodeType[NodeType["Document"] = 0] = "Document"; NodeType[NodeType["DocumentType"] = 1] = "DocumentType"; NodeType[NodeType["Element"] = 2] = "Element"; NodeType[NodeType["Text"] = 3] = "Text"; NodeType[NodeType["CDATA"] = 4] = "CDATA"; NodeType[NodeType["Comment"] = 5] = "Comment"; })(NodeType || (NodeType = {})); // Note that these are the serialized attributes and not attributes directly on // the DOM Node. Attributes we are interested in: const ATTRIBUTES_TO_RECORD = new Set([ 'id', 'class', 'aria-label', 'role', 'name', 'alt', 'title', 'data-test-id', 'data-testid', 'disabled', 'aria-disabled', 'data-sentry-component', ]); /** * Inclusion list of attributes that we want to record from the DOM element */ function getAttributesToRecord(attributes) { const obj = {}; for (const key in attributes) { if (ATTRIBUTES_TO_RECORD.has(key)) { let normalizedKey = key; if (key === 'data-testid' || key === 'data-test-id') { normalizedKey = 'testId'; } obj[normalizedKey] = attributes[key]; } } return obj; } const handleDomListener = ( replay, ) => { return (handlerData) => { if (!replay.isEnabled()) { return; } const result = handleDom(handlerData); if (!result) { return; } const isClick = handlerData.name === 'click'; const event = isClick ? (handlerData.event ) : undefined; // Ignore clicks if ctrl/alt/meta/shift keys are held down as they alter behavior of clicks (e.g. open in new tab) if ( isClick && replay.clickDetector && event && event.target && !event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey ) { handleClick( replay.clickDetector, result , getClickTargetNode(handlerData.event ) , ); } addBreadcrumbEvent(replay, result); }; }; /** Get the base DOM breadcrumb. */ function getBaseDomBreadcrumb(target, message) { const nodeId = record.mirror.getId(target); const node = nodeId && record.mirror.getNode(nodeId); const meta = node && record.mirror.getMeta(node); const element = meta && isElement(meta) ? meta : null; return { message, data: element ? { nodeId, node: { id: nodeId, tagName: element.tagName, textContent: Array.from(element.childNodes) .map((node) => node.type === NodeType.Text && node.textContent) .filter(Boolean) // filter out empty values .map(text => (text ).trim()) .join(''), attributes: getAttributesToRecord(element.attributes), }, } : {}, }; } /** * An event handler to react to DOM events. * Exported for tests. */ function handleDom(handlerData) { const { target, message } = getDomTarget(handlerData); return createBreadcrumb({ category: `ui.${handlerData.name}`, ...getBaseDomBreadcrumb(target, message), }); } function getDomTarget(handlerData) { const isClick = handlerData.name === 'click'; let message; let target = null; // Accessing event.target can throw (see getsentry/raven-js#838, #768) try { target = isClick ? getClickTargetNode(handlerData.event ) : getTargetNode(handlerData.event ); message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; } catch (e) { message = ''; } return { target, message }; } function isElement(node) { return node.type === NodeType.Element; } /** Handle keyboard events & create breadcrumbs. */ function handleKeyboardEvent(replay, event) { if (!replay.isEnabled()) { return; } // Update user activity, but do not restart recording as it can create // noisy/low-value replays (e.g. user comes back from idle, hits alt-tab, new // session with a single "keydown" breadcrumb is created) replay.updateUserActivity(); const breadcrumb = getKeyboardBreadcrumb(event); if (!breadcrumb) { return; } addBreadcrumbEvent(replay, breadcrumb); } /** exported only for tests */ function getKeyboardBreadcrumb(event) { const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event; // never capture for input fields if (!target || isInputElement(target ) || !key) { return null; } // Note: We do not consider shift here, as that means "uppercase" const hasModifierKey = metaKey || ctrlKey || altKey; const isCharacterKey = key.length === 1; // other keys like Escape, Tab, etc have a longer length // Do not capture breadcrumb if only a word key is pressed // This could leak e.g. user input if (!hasModifierKey && isCharacterKey) { return null; } const message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; const baseBreadcrumb = getBaseDomBreadcrumb(target , message); return createBreadcrumb({ category: 'ui.keyDown', message, data: { ...baseBreadcrumb.data, metaKey, shiftKey, ctrlKey, altKey, key, }, }); } function isInputElement(target) { return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; } // Map entryType -> function to normalize data for event const ENTRY_TYPES = { // @ts-expect-error TODO: entry type does not fit the create* functions entry type resource: createResourceEntry, paint: createPaintEntry, // @ts-expect-error TODO: entry type does not fit the create* functions entry type navigation: createNavigationEntry, }; /** * Create replay performance entries from the browser performance entries. */ function createPerformanceEntries( entries, ) { return entries.map(createPerformanceEntry).filter(Boolean) ; } function createPerformanceEntry(entry) { if (!ENTRY_TYPES[entry.entryType]) { return null; } return ENTRY_TYPES[entry.entryType](entry); } function getAbsoluteTime(time) { // browserPerformanceTimeOrigin can be undefined if `performance` or // `performance.now` doesn't exist, but this is already checked by this integration return ((browserPerformanceTimeOrigin || WINDOW.performance.timeOrigin) + time) / 1000; } function createPaintEntry(entry) { const { duration, entryType, name, startTime } = entry; const start = getAbsoluteTime(startTime); return { type: entryType, name, start, end: start + duration, data: undefined, }; } function createNavigationEntry(entry) { const { entryType, name, decodedBodySize, duration, domComplete, encodedBodySize, domContentLoadedEventStart, domContentLoadedEventEnd, domInteractive, loadEventStart, loadEventEnd, redirectCount, startTime, transferSize, type, } = entry; // Ignore entries with no duration, they do not seem to be useful and cause dupes if (duration === 0) { return null; } return { type: `${entryType}.${type}`, start: getAbsoluteTime(startTime), end: getAbsoluteTime(domComplete), name, data: { size: transferSize, decodedBodySize, encodedBodySize, duration, domInteractive, domContentLoadedEventStart, domContentLoadedEventEnd, loadEventStart, loadEventEnd, domComplete, redirectCount, }, }; } function createResourceEntry( entry, ) { const { entryType, initiatorType, name, responseEnd, startTime, decodedBodySize, encodedBodySize, responseStatus, transferSize, } = entry; // Core SDK handles these if (['fetch', 'xmlhttprequest'].includes(initiatorType)) { return null; } return { type: `${entryType}.${initiatorType}`, start: getAbsoluteTime(startTime), end: getAbsoluteTime(responseEnd), name, data: { size: transferSize, statusCode: responseStatus, decodedBodySize, encodedBodySize, }, }; } /** * Add a LCP event to the replay based on an LCP metric. */ function getLargestContentfulPaint(metric ) { const entries = metric.entries; const lastEntry = entries[entries.length - 1] ; const element = lastEntry ? lastEntry.element : undefined; const value = metric.value; const end = getAbsoluteTime(value); const data = { type: 'largest-contentful-paint', name: 'largest-contentful-paint', start: end, end, data: { value, size: value, nodeId: element ? record.mirror.getId(element) : undefined, }, }; return data; } /** * Sets up a PerformanceObserver to listen to all performance entry types. * Returns a callback to stop observing. */ function setupPerformanceObserver(replay) { function addPerformanceEntry(entry) { // It is possible for entries to come up multiple times if (!replay.performanceEntries.includes(entry)) { replay.performanceEntries.push(entry); } } function onEntries({ entries }) { entries.forEach(addPerformanceEntry); } const clearCallbacks = []; (['navigation', 'paint', 'resource'] ).forEach(type => { clearCallbacks.push(addPerformanceInstrumentationHandler(type, onEntries)); }); clearCallbacks.push( addLcpInstrumentationHandler(({ metric }) => { replay.replayPerformanceEntries.push(getLargestContentfulPaint(metric)); }), ); // A callback to cleanup all handlers return () => { clearCallbacks.forEach(clearCallback => clearCallback()); }; } /** * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. * * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. */ const DEBUG_BUILD = (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__); const r = `var t=Uint8Array,n=Uint16Array,r=Int32Array,e=new t([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new t([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),a=new t([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),s=function(t,e){for(var i=new n(31),a=0;a<31;++a)i[a]=e+=1<>1|(21845&c)<<1;v=(61680&(v=(52428&v)>>2|(13107&v)<<2))>>4|(3855&v)<<4,u[c]=((65280&v)>>8|(255&v)<<8)>>1}var d=function(t,r,e){for(var i=t.length,a=0,s=new n(r);a>h]=l}else for(o=new n(i),a=0;a>15-t[a]);return o},g=new t(288);for(c=0;c<144;++c)g[c]=8;for(c=144;c<256;++c)g[c]=9;for(c=256;c<280;++c)g[c]=7;for(c=280;c<288;++c)g[c]=8;var w=new t(32);for(c=0;c<32;++c)w[c]=5;var p=d(g,9,0),y=d(w,5,0),m=function(t){return(t+7)/8|0},b=function(n,r,e){return(null==r||r<0)&&(r=0),(null==e||e>n.length)&&(e=n.length),new t(n.subarray(r,e))},M=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],E=function(t,n,r){var e=new Error(n||M[t]);if(e.code=t,Error.captureStackTrace&&Error.captureStackTrace(e,E),!r)throw e;return e},z=function(t,n,r){r<<=7&n;var e=n/8|0;t[e]|=r,t[e+1]|=r>>8},A=function(t,n,r){r<<=7&n;var e=n/8|0;t[e]|=r,t[e+1]|=r>>8,t[e+2]|=r>>16},_=function(r,e){for(var i=[],a=0;ad&&(d=o[a].s);var g=new n(d+1),w=x(i[c-1],g,0);if(w>e){a=0;var p=0,y=w-e,m=1<e))break;p+=m-(1<>=y;p>0;){var M=o[a].s;g[M]=0&&p;--a){var E=o[a].s;g[E]==e&&(--g[E],++p)}w=e}return{t:new t(g),l:w}},x=function(t,n,r){return-1==t.s?Math.max(x(t.l,n,r+1),x(t.r,n,r+1)):n[t.s]=r},D=function(t){for(var r=t.length;r&&!t[--r];);for(var e=new n(++r),i=0,a=t[0],s=1,o=function(t){e[i++]=t},f=1;f<=r;++f)if(t[f]==a&&f!=r)++s;else{if(!a&&s>2){for(;s>138;s-=138)o(32754);s>2&&(o(s>10?s-11<<5|28690:s-3<<5|12305),s=0)}else if(s>3){for(o(a),--s;s>6;s-=6)o(8304);s>2&&(o(s-3<<5|8208),s=0)}for(;s--;)o(a);s=1,a=t[f]}return{c:e.subarray(0,i),n:r}},T=function(t,n){for(var r=0,e=0;e>8,t[i+2]=255^t[i],t[i+3]=255^t[i+1];for(var a=0;a4&&!H[a[K-1]];--K);var N,P,Q,R,V=v+5<<3,W=T(f,g)+T(h,w)+l,X=T(f,M)+T(h,C)+l+14+3*K+T(q,H)+2*q[16]+3*q[17]+7*q[18];if(c>=0&&V<=W&&V<=X)return k(r,m,t.subarray(c,c+v));if(z(r,m,1+(X15&&(z(r,m,tt[B]>>5&127),m+=tt[B]>>12)}}}else N=p,P=g,Q=y,R=w;for(B=0;B255){A(r,m,N[(nt=rt>>18&31)+257]),m+=P[nt+257],nt>7&&(z(r,m,rt>>23&31),m+=e[nt]);var et=31&rt;A(r,m,Q[et]),m+=R[et],et>3&&(A(r,m,rt>>5&8191),m+=i[et])}else A(r,m,N[rt]),m+=P[rt]}return A(r,m,N[256]),m+P[256]},U=new r([65540,131080,131088,131104,262176,1048704,1048832,2114560,2117632]),F=new t(0),I=function(){for(var t=new Int32Array(256),n=0;n<256;++n){for(var r=n,e=9;--e;)r=(1&r&&-306674912)^r>>>1;t[n]=r}return t}(),S=function(){var t=1,n=0;return{p:function(r){for(var e=t,i=n,a=0|r.length,s=0;s!=a;){for(var o=Math.min(s+2655,a);s>16),i=(65535&i)+15*(i>>16)}t=e,n=i},d:function(){return(255&(t%=65521))<<24|(65280&t)<<8|(255&(n%=65521))<<8|n>>8}}},L=function(a,s,o,f,u){if(!u&&(u={l:1},s.dictionary)){var c=s.dictionary.subarray(-32768),v=new t(c.length+a.length);v.set(c),v.set(a,c.length),a=v,u.w=c.length}return function(a,s,o,f,u,c){var v=c.z||a.length,d=new t(f+v+5*(1+Math.ceil(v/7e3))+u),g=d.subarray(f,d.length-u),w=c.l,p=7&(c.r||0);if(s){p&&(g[0]=c.r>>3);for(var y=U[s-1],M=y>>13,E=8191&y,z=(1<7e3||q>24576)&&(N>423||!w)){p=C(a,g,0,F,I,S,O,q,G,j-G,p),q=L=O=0,G=j;for(var P=0;P<286;++P)I[P]=0;for(P=0;P<30;++P)S[P]=0}var Q=2,R=0,V=E,W=J-K&32767;if(N>2&&H==T(j-W))for(var X=Math.min(M,N)-1,Y=Math.min(32767,j),Z=Math.min(258,N);W<=Y&&--V&&J!=K;){if(a[j+Q]==a[j+Q-W]){for(var $=0;$Q){if(Q=$,R=W,$>X)break;var tt=Math.min(W,$-2),nt=0;for(P=0;Pnt&&(nt=et,K=rt)}}}W+=(J=K)-(K=A[J])&32767}if(R){F[q++]=268435456|h[Q]<<18|l[R];var it=31&h[Q],at=31&l[R];O+=e[it]+i[at],++I[257+it],++S[at],B=j+Q,++L}else F[q++]=a[j],++I[a[j]]}}for(j=Math.max(j,B);j=v&&(g[p/8|0]=w,st=v),p=k(g,p+1,a.subarray(j,st))}c.i=v}return b(d,0,f+m(p)+u)}(a,null==s.level?6:s.level,null==s.mem?Math.ceil(1.5*Math.max(8,Math.min(13,Math.log(a.length)))):12+s.mem,o,f,u)},O=function(t,n,r){for(;r;++n)t[n]=r,r>>>=8},j=function(){function n(n,r){if("function"==typeof n&&(r=n,n={}),this.ondata=r,this.o=n||{},this.s={l:0,i:32768,w:32768,z:32768},this.b=new t(98304),this.o.dictionary){var e=this.o.dictionary.subarray(-32768);this.b.set(e,32768-e.length),this.s.i=32768-e.length}}return n.prototype.p=function(t,n){this.ondata(L(t,this.o,0,0,this.s),n)},n.prototype.push=function(n,r){this.ondata||E(5),this.s.l&&E(4);var e=n.length+this.s.z;if(e>this.b.length){if(e>2*this.b.length-32768){var i=new t(-32768&e);i.set(this.b.subarray(0,this.s.z)),this.b=i}var a=this.b.length-this.s.z;a&&(this.b.set(n.subarray(0,a),this.s.z),this.s.z=this.b.length,this.p(this.b,!1)),this.b.set(this.b.subarray(-32768)),this.b.set(n.subarray(a),32768),this.s.z=n.length-a+32768,this.s.i=32766,this.s.w=32768}else this.b.set(n,this.s.z),this.s.z+=n.length;this.s.l=1&r,(this.s.z>this.s.w+8191||r)&&(this.p(this.b,r||!1),this.s.w=this.s.i,this.s.i-=2)},n}();function q(t,n){n||(n={});var r=function(){var t=-1;return{p:function(n){for(var r=t,e=0;e>>8;t=r},d:function(){return~t}}}(),e=t.length;r.p(t);var i,a=L(t,n,10+((i=n).filename?i.filename.length+1:0),8),s=a.length;return function(t,n){var r=n.filename;if(t[0]=31,t[1]=139,t[2]=8,t[8]=n.level<2?4:9==n.level?2:0,t[9]=3,0!=n.mtime&&O(t,4,Math.floor(new Date(n.mtime||Date.now())/1e3)),r){t[3]=8;for(var e=0;e<=r.length;++e)t[e+10]=r.charCodeAt(e)}}(a,n),O(a,s-8,r.d()),O(a,s-4,e),a}var B=function(){function t(t,n){this.c=S(),this.v=1,j.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),j.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=L(t,this.o,this.v&&(this.o.dictionary?6:2),n&&4,this.s);this.v&&(function(t,n){var r=n.level,e=0==r?0:r<6?1:9==r?3:2;if(t[0]=120,t[1]=e<<6|(n.dictionary&&32),t[1]|=31-(t[0]<<8|t[1])%31,n.dictionary){var i=S();i.p(n.dictionary),O(t,2,i.d())}}(r,this.o),this.v=0),n&&O(r,r.length-4,this.c.d()),this.ondata(r,n)},t}(),G="undefined"!=typeof TextEncoder&&new TextEncoder,H="undefined"!=typeof TextDecoder&&new TextDecoder;try{H.decode(F,{stream:!0})}catch(t){}var J=function(){function t(t){this.ondata=t}return t.prototype.push=function(t,n){this.ondata||E(5),this.d&&E(4),this.ondata(K(t),this.d=n||!1)},t}();function K(n,r){if(r){for(var e=new t(n.length),i=0;i>1)),o=0,f=function(t){s[o++]=t};for(i=0;is.length){var h=new t(o+8+(a-i<<1));h.set(s),s=h}var l=n.charCodeAt(i);l<128||r?f(l):l<2048?(f(192|l>>6),f(128|63&l)):l>55295&&l<57344?(f(240|(l=65536+(1047552&l)|1023&n.charCodeAt(++i))>>18),f(128|l>>12&63),f(128|l>>6&63),f(128|63&l)):(f(224|l>>12),f(128|l>>6&63),f(128|63&l))}return b(s,0,o)}const N=new class{constructor(){this._init()}clear(){this._init()}addEvent(t){if(!t)throw new Error("Adding invalid event");const n=this._hasEvents?",":"";this.stream.push(n+t),this._hasEvents=!0}finish(){this.stream.push("]",!0);const t=function(t){let n=0;for(let r=0,e=t.length;r{this._deflatedData.push(t)},this.stream=new J(((t,n)=>{this.deflate.push(t,n)})),this.stream.push("[")}},P={clear:()=>{N.clear()},addEvent:t=>N.addEvent(t),finish:()=>N.finish(),compress:t=>function(t){return q(K(t))}(t)};addEventListener("message",(function(t){const n=t.data.method,r=t.data.id,e=t.data.arg;if(n in P&&"function"==typeof P[n])try{const t=P[n](e);postMessage({id:r,method:n,success:!0,response:t})}catch(t){postMessage({id:r,method:n,success:!1,response:t.message}),console.error(t)}})),postMessage({id:void 0,method:"init",success:!0,response:void 0});`; function e(){const e=new Blob([r]);return URL.createObjectURL(e)} /** * Log a message in debug mode, and add a breadcrumb when _experiment.traceInternals is enabled. */ function logInfo(message, shouldAddBreadcrumb) { if (!DEBUG_BUILD) { return; } logger.info(message); if (shouldAddBreadcrumb) { addLogBreadcrumb(message); } } /** * Log a message, and add a breadcrumb in the next tick. * This is necessary when the breadcrumb may be added before the replay is initialized. */ function logInfoNextTick(message, shouldAddBreadcrumb) { if (!DEBUG_BUILD) { return; } logger.info(message); if (shouldAddBreadcrumb) { // Wait a tick here to avoid race conditions for some initial logs // which may be added before replay is initialized setTimeout(() => { addLogBreadcrumb(message); }, 0); } } function addLogBreadcrumb(message) { addBreadcrumb( { category: 'console', data: { logger: 'replay', }, level: 'info', message, }, { level: 'info' }, ); } /** This error indicates that the event buffer size exceeded the limit.. */ class EventBufferSizeExceededError extends Error { constructor() { super(`Event buffer exceeded maximum size of ${REPLAY_MAX_EVENT_BUFFER_SIZE}.`); } } /** * A basic event buffer that does not do any compression. * Used as fallback if the compression worker cannot be loaded or is disabled. */ class EventBufferArray { /** All the events that are buffered to be sent. */ /** @inheritdoc */ constructor() { this.events = []; this._totalSize = 0; this.hasCheckout = false; } /** @inheritdoc */ get hasEvents() { return this.events.length > 0; } /** @inheritdoc */ get type() { return 'sync'; } /** @inheritdoc */ destroy() { this.events = []; } /** @inheritdoc */ async addEvent(event) { const eventSize = JSON.stringify(event).length; this._totalSize += eventSize; if (this._totalSize > REPLAY_MAX_EVENT_BUFFER_SIZE) { throw new EventBufferSizeExceededError(); } this.events.push(event); } /** @inheritdoc */ finish() { return new Promise(resolve => { // Make a copy of the events array reference and immediately clear the // events member so that we do not lose new events while uploading // attachment. const eventsRet = this.events; this.clear(); resolve(JSON.stringify(eventsRet)); }); } /** @inheritdoc */ clear() { this.events = []; this._totalSize = 0; this.hasCheckout = false; } /** @inheritdoc */ getEarliestTimestamp() { const timestamp = this.events.map(event => event.timestamp).sort()[0]; if (!timestamp) { return null; } return timestampToMs(timestamp); } } /** * Event buffer that uses a web worker to compress events. * Exported only for testing. */ class WorkerHandler { constructor(worker) { this._worker = worker; this._id = 0; } /** * Ensure the worker is ready (or not). * This will either resolve when the worker is ready, or reject if an error occured. */ ensureReady() { // Ensure we only check once if (this._ensureReadyPromise) { return this._ensureReadyPromise; } this._ensureReadyPromise = new Promise((resolve, reject) => { this._worker.addEventListener( 'message', ({ data }) => { if ((data ).success) { resolve(); } else { reject(); } }, { once: true }, ); this._worker.addEventListener( 'error', error => { reject(error); }, { once: true }, ); }); return this._ensureReadyPromise; } /** * Destroy the worker. */ destroy() { logInfo('[Replay] Destroying compression worker'); this._worker.terminate(); } /** * Post message to worker and wait for response before resolving promise. */ postMessage(method, arg) { const id = this._getAndIncrementId(); return new Promise((resolve, reject) => { const listener = ({ data }) => { const response = data ; if (response.method !== method) { return; } // There can be multiple listeners for a single method, the id ensures // that the response matches the caller. if (response.id !== id) { return; } // At this point, we'll always want to remove listener regardless of result status this._worker.removeEventListener('message', listener); if (!response.success) { // TODO: Do some error handling, not sure what DEBUG_BUILD && logger.error('[Replay]', response.response); reject(new Error('Error in compression worker')); return; } resolve(response.response ); }; // Note: we can't use `once` option because it's possible it needs to // listen to multiple messages this._worker.addEventListener('message', listener); this._worker.postMessage({ id, method, arg }); }); } /** Get the current ID and increment it for the next call. */ _getAndIncrementId() { return this._id++; } } /** * Event buffer that uses a web worker to compress events. * Exported only for testing. */ class EventBufferCompressionWorker { /** @inheritdoc */ constructor(worker) { this._worker = new WorkerHandler(worker); this._earliestTimestamp = null; this._totalSize = 0; this.hasCheckout = false; } /** @inheritdoc */ get hasEvents() { return !!this._earliestTimestamp; } /** @inheritdoc */ get type() { return 'worker'; } /** * Ensure the worker is ready (or not). * This will either resolve when the worker is ready, or reject if an error occured. */ ensureReady() { return this._worker.ensureReady(); } /** * Destroy the event buffer. */ destroy() { this._worker.destroy(); } /** * Add an event to the event buffer. * * Returns true if event was successfuly received and processed by worker. */ addEvent(event) { const timestamp = timestampToMs(event.timestamp); if (!this._earliestTimestamp || timestamp < this._earliestTimestamp) { this._earliestTimestamp = timestamp; } const data = JSON.stringify(event); this._totalSize += data.length; if (this._totalSize > REPLAY_MAX_EVENT_BUFFER_SIZE) { return Promise.reject(new EventBufferSizeExceededError()); } return this._sendEventToWorker(data); } /** * Finish the event buffer and return the compressed data. */ finish() { return this._finishRequest(); } /** @inheritdoc */ clear() { this._earliestTimestamp = null; this._totalSize = 0; this.hasCheckout = false; // We do not wait on this, as we assume the order of messages is consistent for the worker this._worker.postMessage('clear').then(null, e => { DEBUG_BUILD && logger.warn('[Replay] Sending "clear" message to worker failed', e); }); } /** @inheritdoc */ getEarliestTimestamp() { return this._earliestTimestamp; } /** * Send the event to the worker. */ _sendEventToWorker(data) { return this._worker.postMessage('addEvent', data); } /** * Finish the request and return the compressed data from the worker. */ async _finishRequest() { const response = await this._worker.postMessage('finish'); this._earliestTimestamp = null; this._totalSize = 0; return response; } } /** * This proxy will try to use the compression worker, and fall back to use the simple buffer if an error occurs there. * This can happen e.g. if the worker cannot be loaded. * Exported only for testing. */ class EventBufferProxy { constructor(worker) { this._fallback = new EventBufferArray(); this._compression = new EventBufferCompressionWorker(worker); this._used = this._fallback; this._ensureWorkerIsLoadedPromise = this._ensureWorkerIsLoaded(); } /** @inheritdoc */ get type() { return this._used.type; } /** @inheritDoc */ get hasEvents() { return this._used.hasEvents; } /** @inheritdoc */ get hasCheckout() { return this._used.hasCheckout; } /** @inheritdoc */ set hasCheckout(value) { this._used.hasCheckout = value; } /** @inheritDoc */ destroy() { this._fallback.destroy(); this._compression.destroy(); } /** @inheritdoc */ clear() { return this._used.clear(); } /** @inheritdoc */ getEarliestTimestamp() { return this._used.getEarliestTimestamp(); } /** * Add an event to the event buffer. * * Returns true if event was successfully added. */ addEvent(event) { return this._used.addEvent(event); } /** @inheritDoc */ async finish() { // Ensure the worker is loaded, so the sent event is compressed await this.ensureWorkerIsLoaded(); return this._used.finish(); } /** Ensure the worker has loaded. */ ensureWorkerIsLoaded() { return this._ensureWorkerIsLoadedPromise; } /** Actually check if the worker has been loaded. */ async _ensureWorkerIsLoaded() { try { await this._compression.ensureReady(); } catch (error) { // If the worker fails to load, we fall back to the simple buffer. // Nothing more to do from our side here logInfo('[Replay] Failed to load the compression worker, falling back to simple buffer'); return; } // Now we need to switch over the array buffer to the compression worker await this._switchToCompressionWorker(); } /** Switch the used buffer to the compression worker. */ async _switchToCompressionWorker() { const { events, hasCheckout } = this._fallback; const addEventPromises = []; for (const event of events) { addEventPromises.push(this._compression.addEvent(event)); } this._compression.hasCheckout = hasCheckout; // We switch over to the new buffer immediately - any further events will be added // after the previously buffered ones this._used = this._compression; // Wait for original events to be re-added before resolving try { await Promise.all(addEventPromises); } catch (error) { DEBUG_BUILD && logger.warn('[Replay] Failed to add events when switching buffers.', error); } } } /** * Create an event buffer for replays. */ function createEventBuffer({ useCompression, workerUrl: customWorkerUrl, }) { if ( useCompression && // eslint-disable-next-line no-restricted-globals window.Worker ) { const worker = _loadWorker(customWorkerUrl); if (worker) { return worker; } } logInfo('[Replay] Using simple buffer'); return new EventBufferArray(); } function _loadWorker(customWorkerUrl) { try { const workerUrl = customWorkerUrl || _getWorkerUrl(); if (!workerUrl) { return; } logInfo(`[Replay] Using compression worker${customWorkerUrl ? ` from ${customWorkerUrl}` : ''}`); const worker = new Worker(workerUrl); return new EventBufferProxy(worker); } catch (error) { logInfo('[Replay] Failed to create compression worker'); // Fall back to use simple event buffer array } } function _getWorkerUrl() { if (typeof __SENTRY_EXCLUDE_REPLAY_WORKER__ === 'undefined' || !__SENTRY_EXCLUDE_REPLAY_WORKER__) { return e(); } return ''; } /** If sessionStorage is available. */ function hasSessionStorage() { try { // This can throw, e.g. when being accessed in a sandboxed iframe return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage; } catch (e) { return false; } } /** * Removes the session from Session Storage and unsets session in replay instance */ function clearSession(replay) { deleteSession(); replay.session = undefined; } /** * Deletes a session from storage */ function deleteSession() { if (!hasSessionStorage()) { return; } try { WINDOW.sessionStorage.removeItem(REPLAY_SESSION_KEY); } catch (e) { // Ignore potential SecurityError exceptions } } /** * Given a sample rate, returns true if replay should be sampled. * * 1.0 = 100% sampling * 0.0 = 0% sampling */ function isSampled(sampleRate) { if (sampleRate === undefined) { return false; } // Math.random() returns a number in range of 0 to 1 (inclusive of 0, but not 1) return Math.random() < sampleRate; } /** * Get a session with defaults & applied sampling. */ function makeSession(session) { const now = Date.now(); const id = session.id || uuid4(); // Note that this means we cannot set a started/lastActivity of `0`, but this should not be relevant outside of tests. const started = session.started || now; const lastActivity = session.lastActivity || now; const segmentId = session.segmentId || 0; const sampled = session.sampled; const previousSessionId = session.previousSessionId; return { id, started, lastActivity, segmentId, sampled, previousSessionId, }; } /** * Save a session to session storage. */ function saveSession(session) { if (!hasSessionStorage()) { return; } try { WINDOW.sessionStorage.setItem(REPLAY_SESSION_KEY, JSON.stringify(session)); } catch (e) { // Ignore potential SecurityError exceptions } } /** * Get the sampled status for a session based on sample rates & current sampled status. */ function getSessionSampleType(sessionSampleRate, allowBuffering) { return isSampled(sessionSampleRate) ? 'session' : allowBuffering ? 'buffer' : false; } /** * Create a new session, which in its current implementation is a Sentry event * that all replays will be saved to as attachments. Currently, we only expect * one of these Sentry events per "replay session". */ function createSession( { sessionSampleRate, allowBuffering, stickySession = false }, { previousSessionId } = {}, ) { const sampled = getSessionSampleType(sessionSampleRate, allowBuffering); const session = makeSession({ sampled, previousSessionId, }); if (stickySession) { saveSession(session); } return session; } /** * Fetches a session from storage */ function fetchSession(traceInternals) { if (!hasSessionStorage()) { return null; } try { // This can throw if cookies are disabled const sessionStringFromStorage = WINDOW.sessionStorage.getItem(REPLAY_SESSION_KEY); if (!sessionStringFromStorage) { return null; } const sessionObj = JSON.parse(sessionStringFromStorage) ; logInfoNextTick('[Replay] Loading existing session', traceInternals); return makeSession(sessionObj); } catch (e) { return null; } } /** * Given an initial timestamp and an expiry duration, checks to see if current * time should be considered as expired. */ function isExpired( initialTime, expiry, targetTime = +new Date(), ) { // Always expired if < 0 if (initialTime === null || expiry === undefined || expiry < 0) { return true; } // Never expires if == 0 if (expiry === 0) { return false; } return initialTime + expiry <= targetTime; } /** * Checks to see if session is expired */ function isSessionExpired( session, { maxReplayDuration, sessionIdleExpire, targetTime = Date.now(), }, ) { return ( // First, check that maximum session length has not been exceeded isExpired(session.started, maxReplayDuration, targetTime) || // check that the idle timeout has not been exceeded (i.e. user has // performed an action within the last `sessionIdleExpire` ms) isExpired(session.lastActivity, sessionIdleExpire, targetTime) ); } /** If the session should be refreshed or not. */ function shouldRefreshSession( session, { sessionIdleExpire, maxReplayDuration }, ) { // If not expired, all good, just keep the session if (!isSessionExpired(session, { sessionIdleExpire, maxReplayDuration })) { return false; } // If we are buffering & haven't ever flushed yet, always continue if (session.sampled === 'buffer' && session.segmentId === 0) { return false; } return true; } /** * Get or create a session, when initializing the replay. * Returns a session that may be unsampled. */ function loadOrCreateSession( { traceInternals, sessionIdleExpire, maxReplayDuration, previousSessionId, } , sessionOptions, ) { const existingSession = sessionOptions.stickySession && fetchSession(traceInternals); // No session exists yet, just create a new one if (!existingSession) { logInfoNextTick('[Replay] Creating new session', traceInternals); return createSession(sessionOptions, { previousSessionId }); } if (!shouldRefreshSession(existingSession, { sessionIdleExpire, maxReplayDuration })) { return existingSession; } logInfoNextTick('[Replay] Session in sessionStorage is expired, creating new one...'); return createSession(sessionOptions, { previousSessionId: existingSession.id }); } function isCustomEvent(event) { return event.type === EventType.Custom; } /** * Add an event to the event buffer. * In contrast to `addEvent`, this does not return a promise & does not wait for the adding of the event to succeed/fail. * Instead this returns `true` if we tried to add the event, else false. * It returns `false` e.g. if we are paused, disabled, or out of the max replay duration. * * `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`. */ function addEventSync(replay, event, isCheckout) { if (!shouldAddEvent(replay, event)) { return false; } // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises _addEvent(replay, event, isCheckout); return true; } /** * Add an event to the event buffer. * Resolves to `null` if no event was added, else to `void`. * * `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`. */ function addEvent( replay, event, isCheckout, ) { if (!shouldAddEvent(replay, event)) { return Promise.resolve(null); } return _addEvent(replay, event, isCheckout); } async function _addEvent( replay, event, isCheckout, ) { if (!replay.eventBuffer) { return null; } try { if (isCheckout && replay.recordingMode === 'buffer') { replay.eventBuffer.clear(); } if (isCheckout) { replay.eventBuffer.hasCheckout = true; } const replayOptions = replay.getOptions(); const eventAfterPossibleCallback = maybeApplyCallback(event, replayOptions.beforeAddRecordingEvent); if (!eventAfterPossibleCallback) { return; } return await replay.eventBuffer.addEvent(eventAfterPossibleCallback); } catch (error) { const reason = error && error instanceof EventBufferSizeExceededError ? 'addEventSizeExceeded' : 'addEvent'; DEBUG_BUILD && logger.error(error); await replay.stop({ reason }); const client = getClient(); if (client) { client.recordDroppedEvent('internal_sdk_error', 'replay'); } } } /** Exported only for tests. */ function shouldAddEvent(replay, event) { if (!replay.eventBuffer || replay.isPaused() || !replay.isEnabled()) { return false; } const timestampInMs = timestampToMs(event.timestamp); // Throw out events that happen more than 5 minutes ago. This can happen if // page has been left open and idle for a long period of time and user // comes back to trigger a new session. The performance entries rely on // `performance.timeOrigin`, which is when the page first opened. if (timestampInMs + replay.timeouts.sessionIdlePause < Date.now()) { return false; } // Throw out events that are +60min from the initial timestamp if (timestampInMs > replay.getContext().initialTimestamp + replay.getOptions().maxReplayDuration) { logInfo( `[Replay] Skipping event with timestamp ${timestampInMs} because it is after maxReplayDuration`, replay.getOptions()._experiments.traceInternals, ); return false; } return true; } function maybeApplyCallback( event, callback, ) { try { if (typeof callback === 'function' && isCustomEvent(event)) { return callback(event); } } catch (error) { DEBUG_BUILD && logger.error('[Replay] An error occured in the `beforeAddRecordingEvent` callback, skipping the event...', error); return null; } return event; } /** If the event is an error event */ function isErrorEvent(event) { return !event.type; } /** If the event is a transaction event */ function isTransactionEvent(event) { return event.type === 'transaction'; } /** If the event is an replay event */ function isReplayEvent(event) { return event.type === 'replay_event'; } /** If the event is a feedback event */ function isFeedbackEvent(event) { return event.type === 'feedback'; } /** * Returns a listener to be added to `client.on('afterSendErrorEvent, listener)`. */ function handleAfterSendEvent(replay) { // Custom transports may still be returning `Promise`, which means we cannot expect the status code to be available there // TODO (v8): remove this check as it will no longer be necessary const enforceStatusCode = isBaseTransportSend(); return (event, sendResponse) => { if (!replay.isEnabled() || (!isErrorEvent(event) && !isTransactionEvent(event))) { return; } const statusCode = sendResponse && sendResponse.statusCode; // We only want to do stuff on successful error sending, otherwise you get error replays without errors attached // If not using the base transport, we allow `undefined` response (as a custom transport may not implement this correctly yet) // If we do use the base transport, we skip if we encountered an non-OK status code if (enforceStatusCode && (!statusCode || statusCode < 200 || statusCode >= 300)) { return; } if (isTransactionEvent(event)) { handleTransactionEvent(replay, event); return; } handleErrorEvent(replay, event); }; } function handleTransactionEvent(replay, event) { const replayContext = replay.getContext(); // Collect traceIds in _context regardless of `recordingMode` // In error mode, _context gets cleared on every checkout // We limit to max. 100 transactions linked if (event.contexts && event.contexts.trace && event.contexts.trace.trace_id && replayContext.traceIds.size < 100) { replayContext.traceIds.add(event.contexts.trace.trace_id ); } } function handleErrorEvent(replay, event) { const replayContext = replay.getContext(); // Add error to list of errorIds of replay. This is ok to do even if not // sampled because context will get reset at next checkout. // XXX: There is also a race condition where it's possible to capture an // error to Sentry before Replay SDK has loaded, but response returns after // it was loaded, and this gets called. // We limit to max. 100 errors linked if (event.event_id && replayContext.errorIds.size < 100) { replayContext.errorIds.add(event.event_id); } // If error event is tagged with replay id it means it was sampled (when in buffer mode) // Need to be very careful that this does not cause an infinite loop if (replay.recordingMode !== 'buffer' || !event.tags || !event.tags.replayId) { return; } const { beforeErrorSampling } = replay.getOptions(); if (typeof beforeErrorSampling === 'function' && !beforeErrorSampling(event)) { return; } setTimeout(() => { // Capture current event buffer as new replay // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises replay.sendBufferedReplayOrFlush(); }); } function isBaseTransportSend() { const client = getClient(); if (!client) { return false; } const transport = client.getTransport(); if (!transport) { return false; } return ( (transport.send ).__sentry__baseTransport__ || false ); } /** * Returns a listener to be added to `client.on('afterSendErrorEvent, listener)`. */ function handleBeforeSendEvent(replay) { return (event) => { if (!replay.isEnabled() || !isErrorEvent(event)) { return; } handleHydrationError(replay, event); }; } function handleHydrationError(replay, event) { const exceptionValue = event.exception && event.exception.values && event.exception.values[0].value; if (typeof exceptionValue !== 'string') { return; } if ( // Only matches errors in production builds of react-dom // Example https://reactjs.org/docs/error-decoder.html?invariant=423 exceptionValue.match(/reactjs\.org\/docs\/error-decoder\.html\?invariant=(418|419|422|423|425)/) || // Development builds of react-dom // Error 1: Hydration failed because the initial UI does not match what was rendered on the server. // Error 2: Text content does not match server-rendered HTML. Warning: Text content did not match. exceptionValue.match(/(does not match server-rendered HTML|Hydration failed because)/i) ) { const breadcrumb = createBreadcrumb({ category: 'replay.hydrate-error', }); addBreadcrumbEvent(replay, breadcrumb); } } /** * Returns true if we think the given event is an error originating inside of rrweb. */ function isRrwebError(event, hint) { if (event.type || !event.exception || !event.exception.values || !event.exception.values.length) { return false; } // @ts-expect-error this may be set by rrweb when it finds errors if (hint.originalException && hint.originalException.__rrweb__) { return true; } return false; } /** * Add a feedback breadcrumb event to replay. */ function addFeedbackBreadcrumb(replay, event) { replay.triggerUserActivity(); replay.addUpdate(() => { if (!event.timestamp) { // Ignore events that don't have timestamps (this shouldn't happen, more of a typing issue) // Return true here so that we don't flush return true; } // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises replay.throttledAddEvent({ type: EventType.Custom, timestamp: event.timestamp * 1000, data: { tag: 'breadcrumb', payload: { timestamp: event.timestamp, type: 'default', category: 'sentry.feedback', data: { feedbackId: event.event_id, }, }, }, } ); return false; }); } /** * Determine if event should be sampled (only applies in buffer mode). * When an event is captured by `hanldleGlobalEvent`, when in buffer mode * we determine if we want to sample the error or not. */ function shouldSampleForBufferEvent(replay, event) { if (replay.recordingMode !== 'buffer') { return false; } // ignore this error because otherwise we could loop indefinitely with // trying to capture replay and failing if (event.message === UNABLE_TO_SEND_REPLAY) { return false; } // Require the event to be an error event & to have an exception if (!event.exception || event.type) { return false; } return isSampled(replay.getOptions().errorSampleRate); } /** * Returns a listener to be added to `addEventProcessor(listener)`. */ function handleGlobalEventListener( replay, includeAfterSendEventHandling = false, ) { const afterSendHandler = includeAfterSendEventHandling ? handleAfterSendEvent(replay) : undefined; return Object.assign( (event, hint) => { // Do nothing if replay has been disabled if (!replay.isEnabled()) { return event; } if (isReplayEvent(event)) { // Replays have separate set of breadcrumbs, do not include breadcrumbs // from core SDK delete event.breadcrumbs; return event; } // We only want to handle errors, transactions, and feedbacks, nothing else if (!isErrorEvent(event) && !isTransactionEvent(event) && !isFeedbackEvent(event)) { return event; } // Ensure we do not add replay_id if the session is expired const isSessionActive = replay.checkAndHandleExpiredSession(); if (!isSessionActive) { return event; } if (isFeedbackEvent(event)) { // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises replay.flush(); event.contexts.feedback.replay_id = replay.getSessionId(); // Add a replay breadcrumb for this piece of feedback addFeedbackBreadcrumb(replay, event); return event; } // Unless `captureExceptions` is enabled, we want to ignore errors coming from rrweb // As there can be a bunch of stuff going wrong in internals there, that we don't want to bubble up to users if (isRrwebError(event, hint) && !replay.getOptions()._experiments.captureExceptions) { DEBUG_BUILD && logger.log('[Replay] Ignoring error from rrweb internals', event); return null; } // When in buffer mode, we decide to sample here. // Later, in `handleAfterSendEvent`, if the replayId is set, we know that we sampled // And convert the buffer session to a full session const isErrorEventSampled = shouldSampleForBufferEvent(replay, event); // Tag errors if it has been sampled in buffer mode, or if it is session mode // Only tag transactions if in session mode const shouldTagReplayId = isErrorEventSampled || replay.recordingMode === 'session'; if (shouldTagReplayId) { event.tags = { ...event.tags, replayId: replay.getSessionId() }; } // In cases where a custom client is used that does not support the new hooks (yet), // we manually call this hook method here if (afterSendHandler) { // Pretend the error had a 200 response so we always capture it afterSendHandler(event, { statusCode: 200 }); } return event; }, { id: 'Replay' }, ); } /** * Create a "span" for each performance entry. */ function createPerformanceSpans( replay, entries, ) { return entries.map(({ type, start, end, name, data }) => { const response = replay.throttledAddEvent({ type: EventType.Custom, timestamp: start, data: { tag: 'performanceSpan', payload: { op: type, description: name, startTimestamp: start, endTimestamp: end, data, }, }, }); // If response is a string, it means its either THROTTLED or SKIPPED return typeof response === 'string' ? Promise.resolve(null) : response; }); } function handleHistory(handlerData) { const { from, to } = handlerData; const now = Date.now() / 1000; return { type: 'navigation.push', start: now, end: now, name: to, data: { previous: from, }, }; } /** * Returns a listener to be added to `addHistoryInstrumentationHandler(listener)`. */ function handleHistorySpanListener(replay) { return (handlerData) => { if (!replay.isEnabled()) { return; } const result = handleHistory(handlerData); if (result === null) { return; } // Need to collect visited URLs replay.getContext().urls.push(result.name); replay.triggerUserActivity(); replay.addUpdate(() => { createPerformanceSpans(replay, [result]); // Returning false to flush return false; }); }; } /** * Check whether a given request URL should be filtered out. This is so we * don't log Sentry ingest requests. */ function shouldFilterRequest(replay, url) { // If we enabled the `traceInternals` experiment, we want to trace everything if (DEBUG_BUILD && replay.getOptions()._experiments.traceInternals) { return false; } return isSentryRequestUrl(url, getClient()); } /** Add a performance entry breadcrumb */ function addNetworkBreadcrumb( replay, result, ) { if (!replay.isEnabled()) { return; } if (result === null) { return; } if (shouldFilterRequest(replay, result.name)) { return; } replay.addUpdate(() => { createPerformanceSpans(replay, [result]); // Returning true will cause `addUpdate` to not flush // We do not want network requests to cause a flush. This will prevent // recurring/polling requests from keeping the replay session alive. return true; }); } /** only exported for tests */ function handleFetch(handlerData) { const { startTimestamp, endTimestamp, fetchData, response } = handlerData; if (!endTimestamp) { return null; } // This is only used as a fallback, so we know the body sizes are never set here const { method, url } = fetchData; return { type: 'resource.fetch', start: startTimestamp / 1000, end: endTimestamp / 1000, name: url, data: { method, statusCode: response ? (response ).status : undefined, }, }; } /** * Returns a listener to be added to `addFetchInstrumentationHandler(listener)`. */ function handleFetchSpanListener(replay) { return (handlerData) => { if (!replay.isEnabled()) { return; } const result = handleFetch(handlerData); addNetworkBreadcrumb(replay, result); }; } /** only exported for tests */ function handleXhr(handlerData) { const { startTimestamp, endTimestamp, xhr } = handlerData; const sentryXhrData = xhr[SENTRY_XHR_DATA_KEY]; if (!startTimestamp || !endTimestamp || !sentryXhrData) { return null; } // This is only used as a fallback, so we know the body sizes are never set here const { method, url, status_code: statusCode } = sentryXhrData; if (url === undefined) { return null; } return { type: 'resource.xhr', name: url, start: startTimestamp / 1000, end: endTimestamp / 1000, data: { method, statusCode, }, }; } /** * Returns a listener to be added to `addXhrInstrumentationHandler(listener)`. */ function handleXhrSpanListener(replay) { return (handlerData) => { if (!replay.isEnabled()) { return; } const result = handleXhr(handlerData); addNetworkBreadcrumb(replay, result); }; } /** Get the size of a body. */ function getBodySize( body, textEncoder, ) { if (!body) { return undefined; } try { if (typeof body === 'string') { return textEncoder.encode(body).length; } if (body instanceof URLSearchParams) { return textEncoder.encode(body.toString()).length; } if (body instanceof FormData) { const formDataStr = _serializeFormData(body); return textEncoder.encode(formDataStr).length; } if (body instanceof Blob) { return body.size; } if (body instanceof ArrayBuffer) { return body.byteLength; } // Currently unhandled types: ArrayBufferView, ReadableStream } catch (e) { // just return undefined } return undefined; } /** Convert a Content-Length header to number/undefined. */ function parseContentLengthHeader(header) { if (!header) { return undefined; } const size = parseInt(header, 10); return isNaN(size) ? undefined : size; } /** Get the string representation of a body. */ function getBodyString(body) { try { if (typeof body === 'string') { return [body]; } if (body instanceof URLSearchParams) { return [body.toString()]; } if (body instanceof FormData) { return [_serializeFormData(body)]; } if (!body) { return [undefined]; } } catch (e2) { DEBUG_BUILD && logger.warn('[Replay] Failed to serialize body', body); return [undefined, 'BODY_PARSE_ERROR']; } DEBUG_BUILD && logger.info('[Replay] Skipping network body because of body type', body); return [undefined, 'UNPARSEABLE_BODY_TYPE']; } /** Merge a warning into an existing network request/response. */ function mergeWarning( info, warning, ) { if (!info) { return { headers: {}, size: undefined, _meta: { warnings: [warning], }, }; } const newMeta = { ...info._meta }; const existingWarnings = newMeta.warnings || []; newMeta.warnings = [...existingWarnings, warning]; info._meta = newMeta; return info; } /** Convert ReplayNetworkRequestData to a PerformanceEntry. */ function makeNetworkReplayBreadcrumb( type, data, ) { if (!data) { return null; } const { startTimestamp, endTimestamp, url, method, statusCode, request, response } = data; const result = { type, start: startTimestamp / 1000, end: endTimestamp / 1000, name: url, data: dropUndefinedKeys({ method, statusCode, request, response, }), }; return result; } /** Build the request or response part of a replay network breadcrumb that was skipped. */ function buildSkippedNetworkRequestOrResponse(bodySize) { return { headers: {}, size: bodySize, _meta: { warnings: ['URL_SKIPPED'], }, }; } /** Build the request or response part of a replay network breadcrumb. */ function buildNetworkRequestOrResponse( headers, bodySize, body, ) { if (!bodySize && Object.keys(headers).length === 0) { return undefined; } if (!bodySize) { return { headers, }; } if (!body) { return { headers, size: bodySize, }; } const info = { headers, size: bodySize, }; const { body: normalizedBody, warnings } = normalizeNetworkBody(body); info.body = normalizedBody; if (warnings && warnings.length > 0) { info._meta = { warnings, }; } return info; } /** Filter a set of headers */ function getAllowedHeaders(headers, allowedHeaders) { return Object.keys(headers).reduce((filteredHeaders, key) => { const normalizedKey = key.toLowerCase(); // Avoid putting empty strings into the headers if (allowedHeaders.includes(normalizedKey) && headers[key]) { filteredHeaders[normalizedKey] = headers[key]; } return filteredHeaders; }, {}); } function _serializeFormData(formData) { // This is a bit simplified, but gives us a decent estimate // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' // @ts-expect-error passing FormData to URLSearchParams actually works return new URLSearchParams(formData).toString(); } function normalizeNetworkBody(body) { if (!body || typeof body !== 'string') { return { body, }; } const exceedsSizeLimit = body.length > NETWORK_BODY_MAX_SIZE; const isProbablyJson = _strIsProbablyJson(body); if (exceedsSizeLimit) { const truncatedBody = body.slice(0, NETWORK_BODY_MAX_SIZE); if (isProbablyJson) { return { body: truncatedBody, warnings: ['MAYBE_JSON_TRUNCATED'], }; } return { body: `${truncatedBody}…`, warnings: ['TEXT_TRUNCATED'], }; } if (isProbablyJson) { try { const jsonBody = JSON.parse(body); return { body: jsonBody, }; } catch (e3) { // fall back to just send the body as string } } return { body, }; } function _strIsProbablyJson(str) { const first = str[0]; const last = str[str.length - 1]; // Simple check: If this does not start & end with {} or [], it's not JSON return (first === '[' && last === ']') || (first === '{' && last === '}'); } /** Match an URL against a list of strings/Regex. */ function urlMatches(url, urls) { const fullUrl = getFullUrl(url); return stringMatchesSomePattern(fullUrl, urls); } /** exported for tests */ function getFullUrl(url, baseURI = WINDOW.document.baseURI) { // Short circuit for common cases: if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith(WINDOW.location.origin)) { return url; } const fixedUrl = new URL(url, baseURI); // If these do not match, we are not dealing with a relative URL, so just return it if (fixedUrl.origin !== new URL(baseURI).origin) { return url; } const fullUrl = fixedUrl.href; // Remove trailing slashes, if they don't match the original URL if (!url.endsWith('/') && fullUrl.endsWith('/')) { return fullUrl.slice(0, -1); } return fullUrl; } /** * Capture a fetch breadcrumb to a replay. * This adds additional data (where approriate). */ async function captureFetchBreadcrumbToReplay( breadcrumb, hint, options , ) { try { const data = await _prepareFetchData(breadcrumb, hint, options); // Create a replay performance entry from this breadcrumb const result = makeNetworkReplayBreadcrumb('resource.fetch', data); addNetworkBreadcrumb(options.replay, result); } catch (error) { DEBUG_BUILD && logger.error('[Replay] Failed to capture fetch breadcrumb', error); } } /** * Enrich a breadcrumb with additional data. * This has to be sync & mutate the given breadcrumb, * as the breadcrumb is afterwards consumed by other handlers. */ function enrichFetchBreadcrumb( breadcrumb, hint, options, ) { const { input, response } = hint; const body = input ? _getFetchRequestArgBody(input) : undefined; const reqSize = getBodySize(body, options.textEncoder); const resSize = response ? parseContentLengthHeader(response.headers.get('content-length')) : undefined; if (reqSize !== undefined) { breadcrumb.data.request_body_size = reqSize; } if (resSize !== undefined) { breadcrumb.data.response_body_size = resSize; } } async function _prepareFetchData( breadcrumb, hint, options , ) { const now = Date.now(); const { startTimestamp = now, endTimestamp = now } = hint; const { url, method, status_code: statusCode = 0, request_body_size: requestBodySize, response_body_size: responseBodySize, } = breadcrumb.data; const captureDetails = urlMatches(url, options.networkDetailAllowUrls) && !urlMatches(url, options.networkDetailDenyUrls); const request = captureDetails ? _getRequestInfo(options, hint.input, requestBodySize) : buildSkippedNetworkRequestOrResponse(requestBodySize); const response = await _getResponseInfo(captureDetails, options, hint.response, responseBodySize); return { startTimestamp, endTimestamp, url, method, statusCode, request, response, }; } function _getRequestInfo( { networkCaptureBodies, networkRequestHeaders }, input, requestBodySize, ) { const headers = input ? getRequestHeaders(input, networkRequestHeaders) : {}; if (!networkCaptureBodies) { return buildNetworkRequestOrResponse(headers, requestBodySize, undefined); } // We only want to transmit string or string-like bodies const requestBody = _getFetchRequestArgBody(input); const [bodyStr, warning] = getBodyString(requestBody); const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); if (warning) { return mergeWarning(data, warning); } return data; } /** Exported only for tests. */ async function _getResponseInfo( captureDetails, { networkCaptureBodies, textEncoder, networkResponseHeaders, } , response, responseBodySize, ) { if (!captureDetails && responseBodySize !== undefined) { return buildSkippedNetworkRequestOrResponse(responseBodySize); } const headers = response ? getAllHeaders(response.headers, networkResponseHeaders) : {}; if (!response || (!networkCaptureBodies && responseBodySize !== undefined)) { return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } const [bodyText, warning] = await _parseFetchResponseBody(response); const result = getResponseData(bodyText, { networkCaptureBodies, textEncoder, responseBodySize, captureDetails, headers, }); if (warning) { return mergeWarning(result, warning); } return result; } function getResponseData( bodyText, { networkCaptureBodies, textEncoder, responseBodySize, captureDetails, headers, } , ) { try { const size = bodyText && bodyText.length && responseBodySize === undefined ? getBodySize(bodyText, textEncoder) : responseBodySize; if (!captureDetails) { return buildSkippedNetworkRequestOrResponse(size); } if (networkCaptureBodies) { return buildNetworkRequestOrResponse(headers, size, bodyText); } return buildNetworkRequestOrResponse(headers, size, undefined); } catch (error) { DEBUG_BUILD && logger.warn('[Replay] Failed to serialize response body', error); // fallback return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } } async function _parseFetchResponseBody(response) { const res = _tryCloneResponse(response); if (!res) { return [undefined, 'BODY_PARSE_ERROR']; } try { const text = await _tryGetResponseText(res); return [text]; } catch (error) { DEBUG_BUILD && logger.warn('[Replay] Failed to get text body from response', error); return [undefined, 'BODY_PARSE_ERROR']; } } function _getFetchRequestArgBody(fetchArgs = []) { // We only support getting the body from the fetch options if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') { return undefined; } return (fetchArgs[1] ).body; } function getAllHeaders(headers, allowedHeaders) { const allHeaders = {}; allowedHeaders.forEach(header => { if (headers.get(header)) { allHeaders[header] = headers.get(header) ; } }); return allHeaders; } function getRequestHeaders(fetchArgs, allowedHeaders) { if (fetchArgs.length === 1 && typeof fetchArgs[0] !== 'string') { return getHeadersFromOptions(fetchArgs[0] , allowedHeaders); } if (fetchArgs.length === 2) { return getHeadersFromOptions(fetchArgs[1] , allowedHeaders); } return {}; } function getHeadersFromOptions( input, allowedHeaders, ) { if (!input) { return {}; } const headers = input.headers; if (!headers) { return {}; } if (headers instanceof Headers) { return getAllHeaders(headers, allowedHeaders); } // We do not support this, as it is not really documented (anymore?) if (Array.isArray(headers)) { return {}; } return getAllowedHeaders(headers, allowedHeaders); } function _tryCloneResponse(response) { try { // We have to clone this, as the body can only be read once return response.clone(); } catch (error) { // this can throw if the response was already consumed before DEBUG_BUILD && logger.warn('[Replay] Failed to clone response body', error); } } /** * Get the response body of a fetch request, or timeout after 500ms. * Fetch can return a streaming body, that may not resolve (or not for a long time). * If that happens, we rather abort after a short time than keep waiting for this. */ function _tryGetResponseText(response) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timeout while trying to read response body')), 500); _getResponseText(response) .then( txt => resolve(txt), reason => reject(reason), ) .finally(() => clearTimeout(timeout)); }); } async function _getResponseText(response) { // Force this to be a promise, just to be safe // eslint-disable-next-line no-return-await return await response.text(); } /** * Capture an XHR breadcrumb to a replay. * This adds additional data (where approriate). */ async function captureXhrBreadcrumbToReplay( breadcrumb, hint, options, ) { try { const data = _prepareXhrData(breadcrumb, hint, options); // Create a replay performance entry from this breadcrumb const result = makeNetworkReplayBreadcrumb('resource.xhr', data); addNetworkBreadcrumb(options.replay, result); } catch (error) { DEBUG_BUILD && logger.error('[Replay] Failed to capture xhr breadcrumb', error); } } /** * Enrich a breadcrumb with additional data. * This has to be sync & mutate the given breadcrumb, * as the breadcrumb is afterwards consumed by other handlers. */ function enrichXhrBreadcrumb( breadcrumb, hint, options, ) { const { xhr, input } = hint; if (!xhr) { return; } const reqSize = getBodySize(input, options.textEncoder); const resSize = xhr.getResponseHeader('content-length') ? parseContentLengthHeader(xhr.getResponseHeader('content-length')) : _getBodySize(xhr.response, xhr.responseType, options.textEncoder); if (reqSize !== undefined) { breadcrumb.data.request_body_size = reqSize; } if (resSize !== undefined) { breadcrumb.data.response_body_size = resSize; } } function _prepareXhrData( breadcrumb, hint, options, ) { const now = Date.now(); const { startTimestamp = now, endTimestamp = now, input, xhr } = hint; const { url, method, status_code: statusCode = 0, request_body_size: requestBodySize, response_body_size: responseBodySize, } = breadcrumb.data; if (!url) { return null; } if (!xhr || !urlMatches(url, options.networkDetailAllowUrls) || urlMatches(url, options.networkDetailDenyUrls)) { const request = buildSkippedNetworkRequestOrResponse(requestBodySize); const response = buildSkippedNetworkRequestOrResponse(responseBodySize); return { startTimestamp, endTimestamp, url, method, statusCode, request, response, }; } const xhrInfo = xhr[SENTRY_XHR_DATA_KEY]; const networkRequestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) : {}; const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; const request = buildNetworkRequestOrResponse(networkRequestHeaders, requestBodySize, requestBody); const response = buildNetworkRequestOrResponse(networkResponseHeaders, responseBodySize, responseBody); return { startTimestamp, endTimestamp, url, method, statusCode, request: requestWarning ? mergeWarning(request, requestWarning) : request, response: responseWarning ? mergeWarning(response, responseWarning) : response, }; } function getResponseHeaders(xhr) { const headers = xhr.getAllResponseHeaders(); if (!headers) { return {}; } return headers.split('\r\n').reduce((acc, line) => { const [key, value] = line.split(': '); acc[key.toLowerCase()] = value; return acc; }, {}); } function _getXhrResponseBody(xhr) { // We collect errors that happen, but only log them if we can't get any response body const errors = []; try { return [xhr.responseText]; } catch (e) { errors.push(e); } // Try to manually parse the response body, if responseText fails try { return _parseXhrResponse(xhr.response, xhr.responseType); } catch (e) { errors.push(e); } DEBUG_BUILD && logger.warn('[Replay] Failed to get xhr response body', ...errors); return [undefined]; } /** * Get the string representation of the XHR response. * Based on MDN, these are the possible types of the response: * string * ArrayBuffer * Blob * Document * POJO * * Exported only for tests. */ function _parseXhrResponse( body, responseType, ) { try { if (typeof body === 'string') { return [body]; } if (body instanceof Document) { return [body.body.outerHTML]; } if (responseType === 'json' && body && typeof body === 'object') { return [JSON.stringify(body)]; } if (!body) { return [undefined]; } } catch (e2) { DEBUG_BUILD && logger.warn('[Replay] Failed to serialize body', body); return [undefined, 'BODY_PARSE_ERROR']; } DEBUG_BUILD && logger.info('[Replay] Skipping network body because of body type', body); return [undefined, 'UNPARSEABLE_BODY_TYPE']; } function _getBodySize( body, responseType, textEncoder, ) { try { const bodyStr = responseType === 'json' && body && typeof body === 'object' ? JSON.stringify(body) : body; return getBodySize(bodyStr, textEncoder); } catch (e3) { return undefined; } } /** * This method does two things: * - It enriches the regular XHR/fetch breadcrumbs with request/response size data * - It captures the XHR/fetch breadcrumbs to the replay * (enriching it with further data that is _not_ added to the regular breadcrumbs) */ function handleNetworkBreadcrumbs(replay) { const client = getClient(); try { const textEncoder = new TextEncoder(); const { networkDetailAllowUrls, networkDetailDenyUrls, networkCaptureBodies, networkRequestHeaders, networkResponseHeaders, } = replay.getOptions(); const options = { replay, textEncoder, networkDetailAllowUrls, networkDetailDenyUrls, networkCaptureBodies, networkRequestHeaders, networkResponseHeaders, }; if (client && client.on) { client.on('beforeAddBreadcrumb', (breadcrumb, hint) => beforeAddNetworkBreadcrumb(options, breadcrumb, hint)); } else { // Fallback behavior addFetchInstrumentationHandler(handleFetchSpanListener(replay)); addXhrInstrumentationHandler(handleXhrSpanListener(replay)); } } catch (e2) { // Do nothing } } /** just exported for tests */ function beforeAddNetworkBreadcrumb( options, breadcrumb, hint, ) { if (!breadcrumb.data) { return; } try { if (_isXhrBreadcrumb(breadcrumb) && _isXhrHint(hint)) { // This has to be sync, as we need to ensure the breadcrumb is enriched in the same tick // Because the hook runs synchronously, and the breadcrumb is afterwards passed on // So any async mutations to it will not be reflected in the final breadcrumb enrichXhrBreadcrumb(breadcrumb, hint, options); // This call should not reject // eslint-disable-next-line @typescript-eslint/no-floating-promises captureXhrBreadcrumbToReplay(breadcrumb, hint, options); } if (_isFetchBreadcrumb(breadcrumb) && _isFetchHint(hint)) { // This has to be sync, as we need to ensure the breadcrumb is enriched in the same tick // Because the hook runs synchronously, and the breadcrumb is afterwards passed on // So any async mutations to it will not be reflected in the final breadcrumb enrichFetchBreadcrumb(breadcrumb, hint, options); // This call should not reject // eslint-disable-next-line @typescript-eslint/no-floating-promises captureFetchBreadcrumbToReplay(breadcrumb, hint, options); } } catch (e) { DEBUG_BUILD && logger.warn('Error when enriching network breadcrumb'); } } function _isXhrBreadcrumb(breadcrumb) { return breadcrumb.category === 'xhr'; } function _isFetchBreadcrumb(breadcrumb) { return breadcrumb.category === 'fetch'; } function _isXhrHint(hint) { return hint && hint.xhr; } function _isFetchHint(hint) { return hint && hint.response; } let _LAST_BREADCRUMB = null; function isBreadcrumbWithCategory(breadcrumb) { return !!breadcrumb.category; } const handleScopeListener = (replay) => (scope) => { if (!replay.isEnabled()) { return; } const result = handleScope(scope); if (!result) { return; } addBreadcrumbEvent(replay, result); }; /** * An event handler to handle scope changes. */ function handleScope(scope) { // TODO (v8): Remove this guard. This was put in place because we introduced // Scope.getLastBreadcrumb mid-v7 which caused incompatibilities with older SDKs. // For now, we'll just return null if the method doesn't exist but we should eventually // get rid of this guard. const newBreadcrumb = scope.getLastBreadcrumb && scope.getLastBreadcrumb(); // Listener can be called when breadcrumbs have not changed, so we store the // reference to the last crumb and only return a crumb if it has changed if (_LAST_BREADCRUMB === newBreadcrumb || !newBreadcrumb) { return null; } _LAST_BREADCRUMB = newBreadcrumb; if ( !isBreadcrumbWithCategory(newBreadcrumb) || ['fetch', 'xhr', 'sentry.event', 'sentry.transaction'].includes(newBreadcrumb.category) || newBreadcrumb.category.startsWith('ui.') ) { return null; } if (newBreadcrumb.category === 'console') { return normalizeConsoleBreadcrumb(newBreadcrumb); } return createBreadcrumb(newBreadcrumb); } /** exported for tests only */ function normalizeConsoleBreadcrumb( breadcrumb, ) { const args = breadcrumb.data && breadcrumb.data.arguments; if (!Array.isArray(args) || args.length === 0) { return createBreadcrumb(breadcrumb); } let isTruncated = false; // Avoid giant args captures const normalizedArgs = args.map(arg => { if (!arg) { return arg; } if (typeof arg === 'string') { if (arg.length > CONSOLE_ARG_MAX_SIZE) { isTruncated = true; return `${arg.slice(0, CONSOLE_ARG_MAX_SIZE)}…`; } return arg; } if (typeof arg === 'object') { try { const normalizedArg = normalize(arg, 7); const stringified = JSON.stringify(normalizedArg); if (stringified.length > CONSOLE_ARG_MAX_SIZE) { isTruncated = true; // We use the pretty printed JSON string here as a base return `${JSON.stringify(normalizedArg, null, 2).slice(0, CONSOLE_ARG_MAX_SIZE)}…`; } return normalizedArg; } catch (e) { // fall back to default } } return arg; }); return createBreadcrumb({ ...breadcrumb, data: { ...breadcrumb.data, arguments: normalizedArgs, ...(isTruncated ? { _meta: { warnings: ['CONSOLE_ARG_TRUNCATED'] } } : {}), }, }); } /** * Add global listeners that cannot be removed. */ function addGlobalListeners(replay) { // Listeners from core SDK // const scope = getCurrentScope(); const client = getClient(); scope.addScopeListener(handleScopeListener(replay)); addClickKeypressInstrumentationHandler(handleDomListener(replay)); addHistoryInstrumentationHandler(handleHistorySpanListener(replay)); handleNetworkBreadcrumbs(replay); // Tag all (non replay) events that get sent to Sentry with the current // replay ID so that we can reference them later in the UI const eventProcessor = handleGlobalEventListener(replay, !hasHooks(client)); if (client && client.addEventProcessor) { client.addEventProcessor(eventProcessor); } else { addEventProcessor(eventProcessor); } // If a custom client has no hooks yet, we continue to use the "old" implementation if (hasHooks(client)) { client.on('beforeSendEvent', handleBeforeSendEvent(replay)); client.on('afterSendEvent', handleAfterSendEvent(replay)); client.on('createDsc', (dsc) => { const replayId = replay.getSessionId(); // We do not want to set the DSC when in buffer mode, as that means the replay has not been sent (yet) if (replayId && replay.isEnabled() && replay.recordingMode === 'session') { // Ensure to check that the session is still active - it could have expired in the meanwhile const isSessionActive = replay.checkAndHandleExpiredSession(); if (isSessionActive) { dsc.replay_id = replayId; } } }); client.on('startTransaction', transaction => { replay.lastTransaction = transaction; }); // We may be missing the initial startTransaction due to timing issues, // so we capture it on finish again. client.on('finishTransaction', transaction => { replay.lastTransaction = transaction; }); // We want to flush replay client.on('beforeSendFeedback', (feedbackEvent, options) => { const replayId = replay.getSessionId(); if (options && options.includeReplay && replay.isEnabled() && replayId) { // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises replay.flush(); if (feedbackEvent.contexts && feedbackEvent.contexts.feedback) { feedbackEvent.contexts.feedback.replay_id = replayId; } } }); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any function hasHooks(client) { return !!(client && client.on); } /** * Create a "span" for the total amount of memory being used by JS objects * (including v8 internal objects). */ async function addMemoryEntry(replay) { // window.performance.memory is a non-standard API and doesn't work on all browsers, so we try-catch this try { return Promise.all( createPerformanceSpans(replay, [ // @ts-expect-error memory doesn't exist on type Performance as the API is non-standard (we check that it exists above) createMemoryEntry(WINDOW.performance.memory), ]), ); } catch (error) { // Do nothing return []; } } function createMemoryEntry(memoryEntry) { const { jsHeapSizeLimit, totalJSHeapSize, usedJSHeapSize } = memoryEntry; // we don't want to use `getAbsoluteTime` because it adds the event time to the // time origin, so we get the current timestamp instead const time = Date.now() / 1000; return { type: 'memory', name: 'memory', start: time, end: time, data: { memory: { jsHeapSizeLimit, totalJSHeapSize, usedJSHeapSize, }, }, }; } /** * Heavily simplified debounce function based on lodash.debounce. * * This function takes a callback function (@param fun) and delays its invocation * by @param wait milliseconds. Optionally, a maxWait can be specified in @param options, * which ensures that the callback is invoked at least once after the specified max. wait time. * * @param func the function whose invocation is to be debounced * @param wait the minimum time until the function is invoked after it was called once * @param options the options object, which can contain the `maxWait` property * * @returns the debounced version of the function, which needs to be called at least once to start the * debouncing process. Subsequent calls will reset the debouncing timer and, in case @paramfunc * was already invoked in the meantime, return @param func's return value. * The debounced function has two additional properties: * - `flush`: Invokes the debounced function immediately and returns its return value * - `cancel`: Cancels the debouncing process and resets the debouncing timer */ function debounce(func, wait, options) { let callbackReturnValue; let timerId; let maxTimerId; const maxWait = options && options.maxWait ? Math.max(options.maxWait, wait) : 0; function invokeFunc() { cancelTimers(); callbackReturnValue = func(); return callbackReturnValue; } function cancelTimers() { timerId !== undefined && clearTimeout(timerId); maxTimerId !== undefined && clearTimeout(maxTimerId); timerId = maxTimerId = undefined; } function flush() { if (timerId !== undefined || maxTimerId !== undefined) { return invokeFunc(); } return callbackReturnValue; } function debounced() { if (timerId) { clearTimeout(timerId); } timerId = setTimeout(invokeFunc, wait); if (maxWait && maxTimerId === undefined) { maxTimerId = setTimeout(invokeFunc, maxWait); } return callbackReturnValue; } debounced.cancel = cancelTimers; debounced.flush = flush; return debounced; } /** * Handler for recording events. * * Adds to event buffer, and has varying flushing behaviors if the event was a checkout. */ function getHandleRecordingEmit(replay) { let hadFirstEvent = false; return (event, _isCheckout) => { // If this is false, it means session is expired, create and a new session and wait for checkout if (!replay.checkAndHandleExpiredSession()) { DEBUG_BUILD && logger.warn('[Replay] Received replay event after session expired.'); return; } // `_isCheckout` is only set when the checkout is due to `checkoutEveryNms` // We also want to treat the first event as a checkout, so we handle this specifically here const isCheckout = _isCheckout || !hadFirstEvent; hadFirstEvent = true; if (replay.clickDetector) { updateClickDetectorForRecordingEvent(replay.clickDetector, event); } // The handler returns `true` if we do not want to trigger debounced flush, `false` if we want to debounce flush. replay.addUpdate(() => { // The session is always started immediately on pageload/init, but for // error-only replays, it should reflect the most recent checkout // when an error occurs. Clear any state that happens before this current // checkout. This needs to happen before `addEvent()` which updates state // dependent on this reset. if (replay.recordingMode === 'buffer' && isCheckout) { replay.setInitialState(); } // If the event is not added (e.g. due to being paused, disabled, or out of the max replay duration), // Skip all further steps if (!addEventSync(replay, event, isCheckout)) { // Return true to skip scheduling a debounced flush return true; } // Different behavior for full snapshots (type=2), ignore other event types // See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16 if (!isCheckout) { return false; } // Additionally, create a meta event that will capture certain SDK settings. // In order to handle buffer mode, this needs to either be done when we // receive checkout events or at flush time. // // `isCheckout` is always true, but want to be explicit that it should // only be added for checkouts addSettingsEvent(replay, isCheckout); // If there is a previousSessionId after a full snapshot occurs, then // the replay session was started due to session expiration. The new session // is started before triggering a new checkout and contains the id // of the previous session. Do not immediately flush in this case // to avoid capturing only the checkout and instead the replay will // be captured if they perform any follow-up actions. if (replay.session && replay.session.previousSessionId) { return true; } // When in buffer mode, make sure we adjust the session started date to the current earliest event of the buffer // this should usually be the timestamp of the checkout event, but to be safe... if (replay.recordingMode === 'buffer' && replay.session && replay.eventBuffer) { const earliestEvent = replay.eventBuffer.getEarliestTimestamp(); if (earliestEvent) { logInfo( `[Replay] Updating session start time to earliest event in buffer to ${new Date(earliestEvent)}`, replay.getOptions()._experiments.traceInternals, ); replay.session.started = earliestEvent; if (replay.getOptions().stickySession) { saveSession(replay.session); } } } if (replay.recordingMode === 'session') { // If the full snapshot is due to an initial load, we will not have // a previous session ID. In this case, we want to buffer events // for a set amount of time before flushing. This can help avoid // capturing replays of users that immediately close the window. // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises void replay.flush(); } return true; }); }; } /** * Exported for tests */ function createOptionsEvent(replay) { const options = replay.getOptions(); return { type: EventType.Custom, timestamp: Date.now(), data: { tag: 'options', payload: { shouldRecordCanvas: replay.isRecordingCanvas(), sessionSampleRate: options.sessionSampleRate, errorSampleRate: options.errorSampleRate, useCompressionOption: options.useCompression, blockAllMedia: options.blockAllMedia, maskAllText: options.maskAllText, maskAllInputs: options.maskAllInputs, useCompression: replay.eventBuffer ? replay.eventBuffer.type === 'worker' : false, networkDetailHasUrls: options.networkDetailAllowUrls.length > 0, networkCaptureBodies: options.networkCaptureBodies, networkRequestHasHeaders: options.networkRequestHeaders.length > 0, networkResponseHasHeaders: options.networkResponseHeaders.length > 0, }, }, }; } /** * Add a "meta" event that contains a simplified view on current configuration * options. This should only be included on the first segment of a recording. */ function addSettingsEvent(replay, isCheckout) { // Only need to add this event when sending the first segment if (!isCheckout || !replay.session || replay.session.segmentId !== 0) { return; } addEventSync(replay, createOptionsEvent(replay), false); } /** * Create a replay envelope ready to be sent. * This includes both the replay event, as well as the recording data. */ function createReplayEnvelope( replayEvent, recordingData, dsn, tunnel, ) { return createEnvelope( createEventEnvelopeHeaders(replayEvent, getSdkMetadataForEnvelopeHeader(replayEvent), tunnel, dsn), [ [{ type: 'replay_event' }, replayEvent], [ { type: 'replay_recording', // If string then we need to encode to UTF8, otherwise will have // wrong size. TextEncoder has similar browser support to // MutationObserver, although it does not accept IE11. length: typeof recordingData === 'string' ? new TextEncoder().encode(recordingData).length : recordingData.length, }, recordingData, ], ], ); } /** * Prepare the recording data ready to be sent. */ function prepareRecordingData({ recordingData, headers, } ) { let payloadWithSequence; // XXX: newline is needed to separate sequence id from events const replayHeaders = `${JSON.stringify(headers)} `; if (typeof recordingData === 'string') { payloadWithSequence = `${replayHeaders}${recordingData}`; } else { const enc = new TextEncoder(); // XXX: newline is needed to separate sequence id from events const sequence = enc.encode(replayHeaders); // Merge the two Uint8Arrays payloadWithSequence = new Uint8Array(sequence.length + recordingData.length); payloadWithSequence.set(sequence); payloadWithSequence.set(recordingData, sequence.length); } return payloadWithSequence; } /** * Prepare a replay event & enrich it with the SDK metadata. */ async function prepareReplayEvent({ client, scope, replayId: event_id, event, } ) { const integrations = typeof client._integrations === 'object' && client._integrations !== null && !Array.isArray(client._integrations) ? Object.keys(client._integrations) : undefined; const eventHint = { event_id, integrations }; if (client.emit) { client.emit('preprocessEvent', event, eventHint); } const preparedEvent = (await prepareEvent( client.getOptions(), event, eventHint, scope, client, getIsolationScope(), )) ; // If e.g. a global event processor returned null if (!preparedEvent) { return null; } // This normally happens in browser client "_prepareEvent" // but since we do not use this private method from the client, but rather the plain import // we need to do this manually. preparedEvent.platform = preparedEvent.platform || 'javascript'; // extract the SDK name because `client._prepareEvent` doesn't add it to the event const metadata = client.getSdkMetadata && client.getSdkMetadata(); const { name, version } = (metadata && metadata.sdk) || {}; preparedEvent.sdk = { ...preparedEvent.sdk, name: name || 'sentry.javascript.unknown', version: version || '0.0.0', }; return preparedEvent; } /** * Send replay attachment using `fetch()` */ async function sendReplayRequest({ recordingData, replayId, segmentId: segment_id, eventContext, timestamp, session, }) { const preparedRecordingData = prepareRecordingData({ recordingData, headers: { segment_id, }, }); const { urls, errorIds, traceIds, initialTimestamp } = eventContext; const client = getClient(); const scope = getCurrentScope(); const transport = client && client.getTransport(); const dsn = client && client.getDsn(); if (!client || !transport || !dsn || !session.sampled) { return; } const baseEvent = { type: REPLAY_EVENT_NAME, replay_start_timestamp: initialTimestamp / 1000, timestamp: timestamp / 1000, error_ids: errorIds, trace_ids: traceIds, urls, replay_id: replayId, segment_id, replay_type: session.sampled, }; const replayEvent = await prepareReplayEvent({ scope, client, replayId, event: baseEvent }); if (!replayEvent) { // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions client.recordDroppedEvent('event_processor', 'replay', baseEvent); logInfo('An event processor returned `null`, will not send event.'); return; } /* For reference, the fully built event looks something like this: { "type": "replay_event", "timestamp": 1670837008.634, "error_ids": [ "errorId" ], "trace_ids": [ "traceId" ], "urls": [ "https://example.com" ], "replay_id": "eventId", "segment_id": 3, "replay_type": "error", "platform": "javascript", "event_id": "eventId", "environment": "production", "sdk": { "integrations": [ "BrowserTracing", "Replay" ], "name": "sentry.javascript.browser", "version": "7.25.0" }, "sdkProcessingMetadata": {}, "contexts": { }, } */ // Prevent this data (which, if it exists, was used in earlier steps in the processing pipeline) from being sent to // sentry. (Note: Our use of this property comes and goes with whatever we might be debugging, whatever hacks we may // have temporarily added, etc. Even if we don't happen to be using it at some point in the future, let's not get rid // of this `delete`, lest we miss putting it back in the next time the property is in use.) delete replayEvent.sdkProcessingMetadata; const envelope = createReplayEnvelope(replayEvent, preparedRecordingData, dsn, client.getOptions().tunnel); let response; try { response = await transport.send(envelope); } catch (err) { const error = new Error(UNABLE_TO_SEND_REPLAY); try { // In case browsers don't allow this property to be writable // @ts-expect-error This needs lib es2022 and newer error.cause = err; } catch (e) { // nothing to do } throw error; } // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore if (!response) { return response; } // If the status code is invalid, we want to immediately stop & not retry if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) { throw new TransportStatusCodeError(response.statusCode); } const rateLimits = updateRateLimits({}, response); if (isRateLimited(rateLimits, 'replay')) { throw new RateLimitError(rateLimits); } return response; } /** * This error indicates that the transport returned an invalid status code. */ class TransportStatusCodeError extends Error { constructor(statusCode) { super(`Transport returned status code ${statusCode}`); } } /** * This error indicates that we hit a rate limit API error. */ class RateLimitError extends Error { constructor(rateLimits) { super('Rate limit hit'); this.rateLimits = rateLimits; } } /** * Finalize and send the current replay event to Sentry */ async function sendReplay( replayData, retryConfig = { count: 0, interval: RETRY_BASE_INTERVAL, }, ) { const { recordingData, options } = replayData; // short circuit if there's no events to upload (this shouldn't happen as _runFlush makes this check) if (!recordingData.length) { return; } try { await sendReplayRequest(replayData); return true; } catch (err) { if (err instanceof TransportStatusCodeError || err instanceof RateLimitError) { throw err; } // Capture error for every failed replay setContext('Replays', { _retryCount: retryConfig.count, }); if (DEBUG_BUILD && options._experiments && options._experiments.captureExceptions) { captureException(err); } // If an error happened here, it's likely that uploading the attachment // failed, we'll can retry with the same events payload if (retryConfig.count >= RETRY_MAX_COUNT) { const error = new Error(`${UNABLE_TO_SEND_REPLAY} - max retries exceeded`); try { // In case browsers don't allow this property to be writable // @ts-expect-error This needs lib es2022 and newer error.cause = err; } catch (e) { // nothing to do } throw error; } // will retry in intervals of 5, 10, 30 retryConfig.interval *= ++retryConfig.count; return new Promise((resolve, reject) => { setTimeout(async () => { try { await sendReplay(replayData, retryConfig); resolve(true); } catch (err) { reject(err); } }, retryConfig.interval); }); } } const THROTTLED = '__THROTTLED'; const SKIPPED = '__SKIPPED'; /** * Create a throttled function off a given function. * When calling the throttled function, it will call the original function only * if it hasn't been called more than `maxCount` times in the last `durationSeconds`. * * Returns `THROTTLED` if throttled for the first time, after that `SKIPPED`, * or else the return value of the original function. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function throttle( fn, maxCount, durationSeconds, ) { const counter = new Map(); const _cleanup = (now) => { const threshold = now - durationSeconds; counter.forEach((_value, key) => { if (key < threshold) { counter.delete(key); } }); }; const _getTotalCount = () => { return [...counter.values()].reduce((a, b) => a + b, 0); }; let isThrottled = false; return (...rest) => { // Date in second-precision, which we use as basis for the throttling const now = Math.floor(Date.now() / 1000); // First, make sure to delete any old entries _cleanup(now); // If already over limit, do nothing if (_getTotalCount() >= maxCount) { const wasThrottled = isThrottled; isThrottled = true; return wasThrottled ? SKIPPED : THROTTLED; } isThrottled = false; const count = counter.get(now) || 0; counter.set(now, count + 1); return fn(...rest); }; } /* eslint-disable max-lines */ // TODO: We might want to split this file up /** * The main replay container class, which holds all the state and methods for recording and sending replays. */ class ReplayContainer { /** * Recording can happen in one of three modes: * - session: Record the whole session, sending it continuously * - buffer: Always keep the last 60s of recording, requires: * - having replaysOnErrorSampleRate > 0 to capture replay when an error occurs * - or calling `flush()` to send the replay */ /** * The current or last active transcation. * This is only available when performance is enabled. */ /** * These are here so we can overwrite them in tests etc. * @hidden */ /** * Options to pass to `rrweb.record()` */ /** * Timestamp of the last user activity. This lives across sessions. */ /** * Is the integration currently active? */ /** * Paused is a state where: * - DOM Recording is not listening at all * - Nothing will be added to event buffer (e.g. core SDK events) */ /** * Have we attached listeners to the core SDK? * Note we have to track this as there is no way to remove instrumentation handlers. */ /** * Function to stop recording */ /** * Internal use for canvas recording options */ constructor({ options, recordingOptions, } ) {ReplayContainer.prototype.__init.call(this);ReplayContainer.prototype.__init2.call(this);ReplayContainer.prototype.__init3.call(this);ReplayContainer.prototype.__init4.call(this);ReplayContainer.prototype.__init5.call(this);ReplayContainer.prototype.__init6.call(this); this.eventBuffer = null; this.performanceEntries = []; this.replayPerformanceEntries = []; this.recordingMode = 'session'; this.timeouts = { sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, } ; this._lastActivity = Date.now(); this._isEnabled = false; this._isPaused = false; this._hasInitializedCoreListeners = false; this._context = { errorIds: new Set(), traceIds: new Set(), urls: [], initialTimestamp: Date.now(), initialUrl: '', }; this._recordingOptions = recordingOptions; this._options = options; this._debouncedFlush = debounce(() => this._flush(), this._options.flushMinDelay, { maxWait: this._options.flushMaxDelay, }); this._throttledAddEvent = throttle( (event, isCheckout) => addEvent(this, event, isCheckout), // Max 300 events... 300, // ... per 5s 5, ); const { slowClickTimeout, slowClickIgnoreSelectors } = this.getOptions(); const slowClickConfig = slowClickTimeout ? { threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout), timeout: slowClickTimeout, scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT, ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '', } : undefined; if (slowClickConfig) { this.clickDetector = new ClickDetector(this, slowClickConfig); } } /** Get the event context. */ getContext() { return this._context; } /** If recording is currently enabled. */ isEnabled() { return this._isEnabled; } /** If recording is currently paused. */ isPaused() { return this._isPaused; } /** * Determine if canvas recording is enabled */ isRecordingCanvas() { return Boolean(this._canvas); } /** Get the replay integration options. */ getOptions() { return this._options; } /** * Initializes the plugin based on sampling configuration. Should not be * called outside of constructor. */ initializeSampling(previousSessionId) { const { errorSampleRate, sessionSampleRate } = this._options; // If neither sample rate is > 0, then do nothing - user will need to call one of // `start()` or `startBuffering` themselves. if (errorSampleRate <= 0 && sessionSampleRate <= 0) { return; } // Otherwise if there is _any_ sample rate set, try to load an existing // session, or create a new one. this._initializeSessionForSampling(previousSessionId); if (!this.session) { // This should not happen, something wrong has occurred this._handleException(new Error('Unable to initialize and create session')); return; } if (this.session.sampled === false) { // This should only occur if `errorSampleRate` is 0 and was unsampled for // session-based replay. In this case there is nothing to do. return; } // If segmentId > 0, it means we've previously already captured this session // In this case, we still want to continue in `session` recording mode this.recordingMode = this.session.sampled === 'buffer' && this.session.segmentId === 0 ? 'buffer' : 'session'; logInfoNextTick( `[Replay] Starting replay in ${this.recordingMode} mode`, this._options._experiments.traceInternals, ); this._initializeRecording(); } /** * Start a replay regardless of sampling rate. Calling this will always * create a new session. Will throw an error if replay is already in progress. * * Creates or loads a session, attaches listeners to varying events (DOM, * _performanceObserver, Recording, Sentry SDK, etc) */ start() { if (this._isEnabled && this.recordingMode === 'session') { throw new Error('Replay recording is already in progress'); } if (this._isEnabled && this.recordingMode === 'buffer') { throw new Error('Replay buffering is in progress, call `flush()` to save the replay'); } logInfoNextTick('[Replay] Starting replay in session mode', this._options._experiments.traceInternals); const session = loadOrCreateSession( { maxReplayDuration: this._options.maxReplayDuration, sessionIdleExpire: this.timeouts.sessionIdleExpire, traceInternals: this._options._experiments.traceInternals, }, { stickySession: this._options.stickySession, // This is intentional: create a new session-based replay when calling `start()` sessionSampleRate: 1, allowBuffering: false, }, ); this.session = session; this._initializeRecording(); } /** * Start replay buffering. Buffers until `flush()` is called or, if * `replaysOnErrorSampleRate` > 0, an error occurs. */ startBuffering() { if (this._isEnabled) { throw new Error('Replay recording is already in progress'); } logInfoNextTick('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals); const session = loadOrCreateSession( { sessionIdleExpire: this.timeouts.sessionIdleExpire, maxReplayDuration: this._options.maxReplayDuration, traceInternals: this._options._experiments.traceInternals, }, { stickySession: this._options.stickySession, sessionSampleRate: 0, allowBuffering: true, }, ); this.session = session; this.recordingMode = 'buffer'; this._initializeRecording(); } /** * Start recording. * * Note that this will cause a new DOM checkout */ startRecording() { try { const canvasOptions = this._canvas; this._stopRecording = record({ ...this._recordingOptions, // When running in error sampling mode, we need to overwrite `checkoutEveryNms` // Without this, it would record forever, until an error happens, which we don't want // instead, we'll always keep the last 60 seconds of replay before an error happened ...(this.recordingMode === 'buffer' && { checkoutEveryNms: BUFFER_CHECKOUT_TIME }), emit: getHandleRecordingEmit(this), onMutation: this._onMutationHandler, ...(canvasOptions ? { recordCanvas: canvasOptions.recordCanvas, getCanvasManager: canvasOptions.getCanvasManager, sampling: canvasOptions.sampling, dataURLOptions: canvasOptions.dataURLOptions, } : {}), }); } catch (err) { this._handleException(err); } } /** * Stops the recording, if it was running. * * Returns true if it was previously stopped, or is now stopped, * otherwise false. */ stopRecording() { try { if (this._stopRecording) { this._stopRecording(); this._stopRecording = undefined; } return true; } catch (err) { this._handleException(err); return false; } } /** * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown */ async stop({ forceFlush = false, reason } = {}) { if (!this._isEnabled) { return; } // We can't move `_isEnabled` after awaiting a flush, otherwise we can // enter into an infinite loop when `stop()` is called while flushing. this._isEnabled = false; try { logInfo( `[Replay] Stopping Replay${reason ? ` triggered by ${reason}` : ''}`, this._options._experiments.traceInternals, ); this._removeListeners(); this.stopRecording(); this._debouncedFlush.cancel(); // See comment above re: `_isEnabled`, we "force" a flush, ignoring the // `_isEnabled` state of the plugin since it was disabled above. if (forceFlush) { await this._flush({ force: true }); } // After flush, destroy event buffer this.eventBuffer && this.eventBuffer.destroy(); this.eventBuffer = null; // Clear session from session storage, note this means if a new session // is started after, it will not have `previousSessionId` clearSession(this); } catch (err) { this._handleException(err); } } /** * Pause some replay functionality. See comments for `_isPaused`. * This differs from stop as this only stops DOM recording, it is * not as thorough of a shutdown as `stop()`. */ pause() { if (this._isPaused) { return; } this._isPaused = true; this.stopRecording(); logInfo('[Replay] Pausing replay', this._options._experiments.traceInternals); } /** * Resumes recording, see notes for `pause(). * * Note that calling `startRecording()` here will cause a * new DOM checkout.` */ resume() { if (!this._isPaused || !this._checkSession()) { return; } this._isPaused = false; this.startRecording(); logInfo('[Replay] Resuming replay', this._options._experiments.traceInternals); } /** * If not in "session" recording mode, flush event buffer which will create a new replay. * Unless `continueRecording` is false, the replay will continue to record and * behave as a "session"-based replay. * * Otherwise, queue up a flush. */ async sendBufferedReplayOrFlush({ continueRecording = true } = {}) { if (this.recordingMode === 'session') { return this.flushImmediate(); } const activityTime = Date.now(); logInfo('[Replay] Converting buffer to session', this._options._experiments.traceInternals); // Allow flush to complete before resuming as a session recording, otherwise // the checkout from `startRecording` may be included in the payload. // Prefer to keep the error replay as a separate (and smaller) segment // than the session replay. await this.flushImmediate(); const hasStoppedRecording = this.stopRecording(); if (!continueRecording || !hasStoppedRecording) { return; } // To avoid race conditions where this is called multiple times, we check here again that we are still buffering if ((this.recordingMode ) === 'session') { return; } // Re-start recording in session-mode this.recordingMode = 'session'; // Once this session ends, we do not want to refresh it if (this.session) { this._updateUserActivity(activityTime); this._updateSessionActivity(activityTime); this._maybeSaveSession(); } this.startRecording(); } /** * We want to batch uploads of replay events. Save events only if * `` milliseconds have elapsed since the last event * *OR* if `` milliseconds have elapsed. * * Accepts a callback to perform side-effects and returns true to stop batch * processing and hand back control to caller. */ addUpdate(cb) { // We need to always run `cb` (e.g. in the case of `this.recordingMode == 'buffer'`) const cbResult = cb(); // If this option is turned on then we will only want to call `flush` // explicitly if (this.recordingMode === 'buffer') { return; } // If callback is true, we do not want to continue with flushing -- the // caller will need to handle it. if (cbResult === true) { return; } // addUpdate is called quite frequently - use _debouncedFlush so that it // respects the flush delays and does not flush immediately this._debouncedFlush(); } /** * Updates the user activity timestamp and resumes recording. This should be * called in an event handler for a user action that we consider as the user * being "active" (e.g. a mouse click). */ triggerUserActivity() { this._updateUserActivity(); // This case means that recording was once stopped due to inactivity. // Ensure that recording is resumed. if (!this._stopRecording) { // Create a new session, otherwise when the user action is flushed, it // will get rejected due to an expired session. if (!this._checkSession()) { return; } // Note: This will cause a new DOM checkout this.resume(); return; } // Otherwise... recording was never suspended, continue as normalish this.checkAndHandleExpiredSession(); this._updateSessionActivity(); } /** * Updates the user activity timestamp *without* resuming * recording. Some user events (e.g. keydown) can be create * low-value replays that only contain the keypress as a * breadcrumb. Instead this would require other events to * create a new replay after a session has expired. */ updateUserActivity() { this._updateUserActivity(); this._updateSessionActivity(); } /** * Only flush if `this.recordingMode === 'session'` */ conditionalFlush() { if (this.recordingMode === 'buffer') { return Promise.resolve(); } return this.flushImmediate(); } /** * Flush using debounce flush */ flush() { return this._debouncedFlush() ; } /** * Always flush via `_debouncedFlush` so that we do not have flushes triggered * from calling both `flush` and `_debouncedFlush`. Otherwise, there could be * cases of mulitple flushes happening closely together. */ flushImmediate() { this._debouncedFlush(); // `.flush` is provided by the debounced function, analogously to lodash.debounce return this._debouncedFlush.flush() ; } /** * Cancels queued up flushes. */ cancelFlush() { this._debouncedFlush.cancel(); } /** Get the current sesion (=replay) ID */ getSessionId() { return this.session && this.session.id; } /** * Checks if recording should be stopped due to user inactivity. Otherwise * check if session is expired and create a new session if so. Triggers a new * full snapshot on new session. * * Returns true if session is not expired, false otherwise. * @hidden */ checkAndHandleExpiredSession() { // Prevent starting a new session if the last user activity is older than // SESSION_IDLE_PAUSE_DURATION. Otherwise non-user activity can trigger a new // session+recording. This creates noisy replays that do not have much // content in them. if ( this._lastActivity && isExpired(this._lastActivity, this.timeouts.sessionIdlePause) && this.session && this.session.sampled === 'session' ) { // Pause recording only for session-based replays. Otherwise, resuming // will create a new replay and will conflict with users who only choose // to record error-based replays only. (e.g. the resumed replay will not // contain a reference to an error) this.pause(); return; } // --- There is recent user activity --- // // This will create a new session if expired, based on expiry length if (!this._checkSession()) { // Check session handles the refreshing itself return false; } return true; } /** * Capture some initial state that can change throughout the lifespan of the * replay. This is required because otherwise they would be captured at the * first flush. */ setInitialState() { const urlPath = `${WINDOW.location.pathname}${WINDOW.location.hash}${WINDOW.location.search}`; const url = `${WINDOW.location.origin}${urlPath}`; this.performanceEntries = []; this.replayPerformanceEntries = []; // Reset _context as well this._clearContext(); this._context.initialUrl = url; this._context.initialTimestamp = Date.now(); this._context.urls.push(url); } /** * Add a breadcrumb event, that may be throttled. * If it was throttled, we add a custom breadcrumb to indicate that. */ throttledAddEvent( event, isCheckout, ) { const res = this._throttledAddEvent(event, isCheckout); // If this is THROTTLED, it means we have throttled the event for the first time // In this case, we want to add a breadcrumb indicating that something was skipped if (res === THROTTLED) { const breadcrumb = createBreadcrumb({ category: 'replay.throttled', }); this.addUpdate(() => { // Return `false` if the event _was_ added, as that means we schedule a flush return !addEventSync(this, { type: ReplayEventTypeCustom, timestamp: breadcrumb.timestamp || 0, data: { tag: 'breadcrumb', payload: breadcrumb, metric: true, }, }); }); } return res; } /** * This will get the parametrized route name of the current page. * This is only available if performance is enabled, and if an instrumented router is used. */ getCurrentRoute() { // eslint-disable-next-line deprecation/deprecation const lastTransaction = this.lastTransaction || getCurrentScope().getTransaction(); const attributes = (lastTransaction && spanToJSON(lastTransaction).data) || {}; const source = attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; if (!lastTransaction || !source || !['route', 'custom'].includes(source)) { return undefined; } return spanToJSON(lastTransaction).description; } /** * Initialize and start all listeners to varying events (DOM, * Performance Observer, Recording, Sentry SDK, etc) */ _initializeRecording() { this.setInitialState(); // this method is generally called on page load or manually - in both cases // we should treat it as an activity this._updateSessionActivity(); this.eventBuffer = createEventBuffer({ useCompression: this._options.useCompression, workerUrl: this._options.workerUrl, }); this._removeListeners(); this._addListeners(); // Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout this._isEnabled = true; this._isPaused = false; this.startRecording(); } /** A wrapper to conditionally capture exceptions. */ _handleException(error) { DEBUG_BUILD && logger.error('[Replay]', error); if (DEBUG_BUILD && this._options._experiments && this._options._experiments.captureExceptions) { captureException(error); } } /** * Loads (or refreshes) the current session. */ _initializeSessionForSampling(previousSessionId) { // Whenever there is _any_ error sample rate, we always allow buffering // Because we decide on sampling when an error occurs, we need to buffer at all times if sampling for errors const allowBuffering = this._options.errorSampleRate > 0; const session = loadOrCreateSession( { sessionIdleExpire: this.timeouts.sessionIdleExpire, maxReplayDuration: this._options.maxReplayDuration, traceInternals: this._options._experiments.traceInternals, previousSessionId, }, { stickySession: this._options.stickySession, sessionSampleRate: this._options.sessionSampleRate, allowBuffering, }, ); this.session = session; } /** * Checks and potentially refreshes the current session. * Returns false if session is not recorded. */ _checkSession() { // If there is no session yet, we do not want to refresh anything // This should generally not happen, but to be safe.... if (!this.session) { return false; } const currentSession = this.session; if ( shouldRefreshSession(currentSession, { sessionIdleExpire: this.timeouts.sessionIdleExpire, maxReplayDuration: this._options.maxReplayDuration, }) ) { // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises this._refreshSession(currentSession); return false; } return true; } /** * Refresh a session with a new one. * This stops the current session (without forcing a flush, as that would never work since we are expired), * and then does a new sampling based on the refreshed session. */ async _refreshSession(session) { if (!this._isEnabled) { return; } await this.stop({ reason: 'refresh session' }); this.initializeSampling(session.id); } /** * Adds listeners to record events for the replay */ _addListeners() { try { WINDOW.document.addEventListener('visibilitychange', this._handleVisibilityChange); WINDOW.addEventListener('blur', this._handleWindowBlur); WINDOW.addEventListener('focus', this._handleWindowFocus); WINDOW.addEventListener('keydown', this._handleKeyboardEvent); if (this.clickDetector) { this.clickDetector.addListeners(); } // There is no way to remove these listeners, so ensure they are only added once if (!this._hasInitializedCoreListeners) { addGlobalListeners(this); this._hasInitializedCoreListeners = true; } } catch (err) { this._handleException(err); } this._performanceCleanupCallback = setupPerformanceObserver(this); } /** * Cleans up listeners that were created in `_addListeners` */ _removeListeners() { try { WINDOW.document.removeEventListener('visibilitychange', this._handleVisibilityChange); WINDOW.removeEventListener('blur', this._handleWindowBlur); WINDOW.removeEventListener('focus', this._handleWindowFocus); WINDOW.removeEventListener('keydown', this._handleKeyboardEvent); if (this.clickDetector) { this.clickDetector.removeListeners(); } if (this._performanceCleanupCallback) { this._performanceCleanupCallback(); } } catch (err) { this._handleException(err); } } /** * Handle when visibility of the page content changes. Opening a new tab will * cause the state to change to hidden because of content of current page will * be hidden. Likewise, moving a different window to cover the contents of the * page will also trigger a change to a hidden state. */ __init() {this._handleVisibilityChange = () => { if (WINDOW.document.visibilityState === 'visible') { this._doChangeToForegroundTasks(); } else { this._doChangeToBackgroundTasks(); } };} /** * Handle when page is blurred */ __init2() {this._handleWindowBlur = () => { const breadcrumb = createBreadcrumb({ category: 'ui.blur', }); // Do not count blur as a user action -- it's part of the process of them // leaving the page this._doChangeToBackgroundTasks(breadcrumb); };} /** * Handle when page is focused */ __init3() {this._handleWindowFocus = () => { const breadcrumb = createBreadcrumb({ category: 'ui.focus', }); // Do not count focus as a user action -- instead wait until they focus and // interactive with page this._doChangeToForegroundTasks(breadcrumb); };} /** Ensure page remains active when a key is pressed. */ __init4() {this._handleKeyboardEvent = (event) => { handleKeyboardEvent(this, event); };} /** * Tasks to run when we consider a page to be hidden (via blurring and/or visibility) */ _doChangeToBackgroundTasks(breadcrumb) { if (!this.session) { return; } const expired = isSessionExpired(this.session, { maxReplayDuration: this._options.maxReplayDuration, sessionIdleExpire: this.timeouts.sessionIdleExpire, }); if (expired) { return; } if (breadcrumb) { this._createCustomBreadcrumb(breadcrumb); } // Send replay when the page/tab becomes hidden. There is no reason to send // replay if it becomes visible, since no actions we care about were done // while it was hidden // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises void this.conditionalFlush(); } /** * Tasks to run when we consider a page to be visible (via focus and/or visibility) */ _doChangeToForegroundTasks(breadcrumb) { if (!this.session) { return; } const isSessionActive = this.checkAndHandleExpiredSession(); if (!isSessionActive) { // If the user has come back to the page within SESSION_IDLE_PAUSE_DURATION // ms, we will re-use the existing session, otherwise create a new // session logInfo('[Replay] Document has become active, but session has expired'); return; } if (breadcrumb) { this._createCustomBreadcrumb(breadcrumb); } } /** * Update user activity (across session lifespans) */ _updateUserActivity(_lastActivity = Date.now()) { this._lastActivity = _lastActivity; } /** * Updates the session's last activity timestamp */ _updateSessionActivity(_lastActivity = Date.now()) { if (this.session) { this.session.lastActivity = _lastActivity; this._maybeSaveSession(); } } /** * Helper to create (and buffer) a replay breadcrumb from a core SDK breadcrumb */ _createCustomBreadcrumb(breadcrumb) { this.addUpdate(() => { // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises this.throttledAddEvent({ type: EventType.Custom, timestamp: breadcrumb.timestamp || 0, data: { tag: 'breadcrumb', payload: breadcrumb, }, }); }); } /** * Observed performance events are added to `this.performanceEntries`. These * are included in the replay event before it is finished and sent to Sentry. */ _addPerformanceEntries() { const performanceEntries = createPerformanceEntries(this.performanceEntries).concat(this.replayPerformanceEntries); this.performanceEntries = []; this.replayPerformanceEntries = []; return Promise.all(createPerformanceSpans(this, performanceEntries)); } /** * Clear _context */ _clearContext() { // XXX: `initialTimestamp` and `initialUrl` do not get cleared this._context.errorIds.clear(); this._context.traceIds.clear(); this._context.urls = []; } /** Update the initial timestamp based on the buffer content. */ _updateInitialTimestampFromEventBuffer() { const { session, eventBuffer } = this; if (!session || !eventBuffer) { return; } // we only ever update this on the initial segment if (session.segmentId) { return; } const earliestEvent = eventBuffer.getEarliestTimestamp(); if (earliestEvent && earliestEvent < this._context.initialTimestamp) { this._context.initialTimestamp = earliestEvent; } } /** * Return and clear _context */ _popEventContext() { const _context = { initialTimestamp: this._context.initialTimestamp, initialUrl: this._context.initialUrl, errorIds: Array.from(this._context.errorIds), traceIds: Array.from(this._context.traceIds), urls: this._context.urls, }; this._clearContext(); return _context; } /** * Flushes replay event buffer to Sentry. * * Performance events are only added right before flushing - this is * due to the buffered performance observer events. * * Should never be called directly, only by `flush` */ async _runFlush() { const replayId = this.getSessionId(); if (!this.session || !this.eventBuffer || !replayId) { DEBUG_BUILD && logger.error('[Replay] No session or eventBuffer found to flush.'); return; } await this._addPerformanceEntries(); // Check eventBuffer again, as it could have been stopped in the meanwhile if (!this.eventBuffer || !this.eventBuffer.hasEvents) { return; } // Only attach memory event if eventBuffer is not empty await addMemoryEntry(this); // Check eventBuffer again, as it could have been stopped in the meanwhile if (!this.eventBuffer) { return; } // if this changed in the meanwhile, e.g. because the session was refreshed or similar, we abort here if (replayId !== this.getSessionId()) { return; } try { // This uses the data from the eventBuffer, so we need to call this before `finish() this._updateInitialTimestampFromEventBuffer(); const timestamp = Date.now(); // Check total duration again, to avoid sending outdated stuff // We leave 30s wiggle room to accomodate late flushing etc. // This _could_ happen when the browser is suspended during flushing, in which case we just want to stop if (timestamp - this._context.initialTimestamp > this._options.maxReplayDuration + 30000) { throw new Error('Session is too long, not sending replay'); } const eventContext = this._popEventContext(); // Always increment segmentId regardless of outcome of sending replay const segmentId = this.session.segmentId++; this._maybeSaveSession(); // Note this empties the event buffer regardless of outcome of sending replay const recordingData = await this.eventBuffer.finish(); await sendReplay({ replayId, recordingData, segmentId, eventContext, session: this.session, options: this.getOptions(), timestamp, }); } catch (err) { this._handleException(err); // This means we retried 3 times and all of them failed, // or we ran into a problem we don't want to retry, like rate limiting. // In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises this.stop({ reason: 'sendReplay' }); const client = getClient(); if (client) { client.recordDroppedEvent('send_error', 'replay'); } } } /** * Flush recording data to Sentry. Creates a lock so that only a single flush * can be active at a time. Do not call this directly. */ __init5() {this._flush = async ({ force = false, } = {}) => { if (!this._isEnabled && !force) { // This can happen if e.g. the replay was stopped because of exceeding the retry limit return; } if (!this.checkAndHandleExpiredSession()) { DEBUG_BUILD && logger.error('[Replay] Attempting to finish replay event after session expired.'); return; } if (!this.session) { // should never happen, as we would have bailed out before return; } const start = this.session.started; const now = Date.now(); const duration = now - start; // A flush is about to happen, cancel any queued flushes this._debouncedFlush.cancel(); // If session is too short, or too long (allow some wiggle room over maxReplayDuration), do not send it // This _should_ not happen, but it may happen if flush is triggered due to a page activity change or similar const tooShort = duration < this._options.minReplayDuration; const tooLong = duration > this._options.maxReplayDuration + 5000; if (tooShort || tooLong) { logInfo( `[Replay] Session duration (${Math.floor(duration / 1000)}s) is too ${ tooShort ? 'short' : 'long' }, not sending replay.`, this._options._experiments.traceInternals, ); if (tooShort) { this._debouncedFlush(); } return; } const eventBuffer = this.eventBuffer; if (eventBuffer && this.session.segmentId === 0 && !eventBuffer.hasCheckout) { logInfo('[Replay] Flushing initial segment without checkout.', this._options._experiments.traceInternals); // TODO FN: Evaluate if we want to stop here, or remove this again? } // this._flushLock acts as a lock so that future calls to `_flush()` // will be blocked until this promise resolves if (!this._flushLock) { this._flushLock = this._runFlush(); await this._flushLock; this._flushLock = undefined; return; } // Wait for previous flush to finish, then call the debounced `_flush()`. // It's possible there are other flush requests queued and waiting for it // to resolve. We want to reduce all outstanding requests (as well as any // new flush requests that occur within a second of the locked flush // completing) into a single flush. try { await this._flushLock; } catch (err) { DEBUG_BUILD && logger.error(err); } finally { this._debouncedFlush(); } };} /** Save the session, if it is sticky */ _maybeSaveSession() { if (this.session && this._options.stickySession) { saveSession(this.session); } } /** Handler for rrweb.record.onMutation */ __init6() {this._onMutationHandler = (mutations) => { const count = mutations.length; const mutationLimit = this._options.mutationLimit; const mutationBreadcrumbLimit = this._options.mutationBreadcrumbLimit; const overMutationLimit = mutationLimit && count > mutationLimit; // Create a breadcrumb if a lot of mutations happen at the same time // We can show this in the UI as an information with potential performance improvements if (count > mutationBreadcrumbLimit || overMutationLimit) { const breadcrumb = createBreadcrumb({ category: 'replay.mutations', data: { count, limit: overMutationLimit, }, }); this._createCustomBreadcrumb(breadcrumb); } // Stop replay if over the mutation limit if (overMutationLimit) { // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises this.stop({ reason: 'mutationLimit', forceFlush: this.recordingMode === 'session' }); return false; } // `true` means we use the regular mutation handling by rrweb return true; };} } function getOption( selectors, defaultSelectors, deprecatedClassOption, deprecatedSelectorOption, ) { const deprecatedSelectors = typeof deprecatedSelectorOption === 'string' ? deprecatedSelectorOption.split(',') : []; const allSelectors = [ ...selectors, // @deprecated ...deprecatedSelectors, // sentry defaults ...defaultSelectors, ]; // @deprecated if (typeof deprecatedClassOption !== 'undefined') { // NOTE: No support for RegExp if (typeof deprecatedClassOption === 'string') { allSelectors.push(`.${deprecatedClassOption}`); } consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( '[Replay] You are using a deprecated configuration item for privacy. Read the documentation on how to use the new privacy configuration.', ); }); } return allSelectors.join(','); } /** * Returns privacy related configuration for use in rrweb */ function getPrivacyOptions({ mask, unmask, block, unblock, ignore, // eslint-disable-next-line deprecation/deprecation blockClass, // eslint-disable-next-line deprecation/deprecation blockSelector, // eslint-disable-next-line deprecation/deprecation maskTextClass, // eslint-disable-next-line deprecation/deprecation maskTextSelector, // eslint-disable-next-line deprecation/deprecation ignoreClass, }) { const defaultBlockedElements = ['base[href="/"]']; const maskSelector = getOption(mask, ['.sentry-mask', '[data-sentry-mask]'], maskTextClass, maskTextSelector); const unmaskSelector = getOption(unmask, ['.sentry-unmask', '[data-sentry-unmask]']); const options = { // We are making the decision to make text and input selectors the same maskTextSelector: maskSelector, unmaskTextSelector: unmaskSelector, blockSelector: getOption( block, ['.sentry-block', '[data-sentry-block]', ...defaultBlockedElements], blockClass, blockSelector, ), unblockSelector: getOption(unblock, ['.sentry-unblock', '[data-sentry-unblock]']), ignoreSelector: getOption(ignore, ['.sentry-ignore', '[data-sentry-ignore]', 'input[type="file"]'], ignoreClass), }; if (blockClass instanceof RegExp) { options.blockClass = blockClass; } if (maskTextClass instanceof RegExp) { options.maskTextClass = maskTextClass; } return options; } /** * Masks an attribute if necessary, otherwise return attribute value as-is. */ function maskAttribute({ el, key, maskAttributes, maskAllText, privacyOptions, value, }) { // We only mask attributes if `maskAllText` is true if (!maskAllText) { return value; } // unmaskTextSelector takes precendence if (privacyOptions.unmaskTextSelector && el.matches(privacyOptions.unmaskTextSelector)) { return value; } if ( maskAttributes.includes(key) || // Need to mask `value` attribute for `` if it's a button-like // type (key === 'value' && el.tagName === 'INPUT' && ['submit', 'button'].includes(el.getAttribute('type') || '')) ) { return value.replace(/[\S]/g, '*'); } return value; } const MEDIA_SELECTORS = 'img,image,svg,video,object,picture,embed,map,audio,link[rel="icon"],link[rel="apple-touch-icon"]'; const DEFAULT_NETWORK_HEADERS = ['content-length', 'content-type', 'accept']; let _initialized = false; const replayIntegration = ((options) => { // eslint-disable-next-line deprecation/deprecation return new Replay(options); }) ; /** * The main replay integration class, to be passed to `init({ integrations: [] })`. * @deprecated Use `replayIntegration()` instead. */ class Replay { /** * @inheritDoc */ static __initStatic() {this.id = 'Replay';} /** * @inheritDoc */ /** * Options to pass to `rrweb.record()` */ /** * Initial options passed to the replay integration, merged with default values. * Note: `sessionSampleRate` and `errorSampleRate` are not required here, as they * can only be finally set when setupOnce() is called. * * @private */ constructor({ flushMinDelay = DEFAULT_FLUSH_MIN_DELAY, flushMaxDelay = DEFAULT_FLUSH_MAX_DELAY, minReplayDuration = MIN_REPLAY_DURATION, maxReplayDuration = MAX_REPLAY_DURATION, stickySession = true, useCompression = true, workerUrl, _experiments = {}, sessionSampleRate, errorSampleRate, maskAllText = true, maskAllInputs = true, blockAllMedia = true, mutationBreadcrumbLimit = 750, mutationLimit = 10000, slowClickTimeout = 7000, slowClickIgnoreSelectors = [], networkDetailAllowUrls = [], networkDetailDenyUrls = [], networkCaptureBodies = true, networkRequestHeaders = [], networkResponseHeaders = [], mask = [], maskAttributes = ['title', 'placeholder'], unmask = [], block = [], unblock = [], ignore = [], maskFn, beforeAddRecordingEvent, beforeErrorSampling, // eslint-disable-next-line deprecation/deprecation blockClass, // eslint-disable-next-line deprecation/deprecation blockSelector, // eslint-disable-next-line deprecation/deprecation maskInputOptions, // eslint-disable-next-line deprecation/deprecation maskTextClass, // eslint-disable-next-line deprecation/deprecation maskTextSelector, // eslint-disable-next-line deprecation/deprecation ignoreClass, } = {}) { // eslint-disable-next-line deprecation/deprecation this.name = Replay.id; const privacyOptions = getPrivacyOptions({ mask, unmask, block, unblock, ignore, blockClass, blockSelector, maskTextClass, maskTextSelector, ignoreClass, }); this._recordingOptions = { maskAllInputs, maskAllText, maskInputOptions: { ...(maskInputOptions || {}), password: true }, maskTextFn: maskFn, maskInputFn: maskFn, maskAttributeFn: (key, value, el) => maskAttribute({ maskAttributes, maskAllText, privacyOptions, key, value, el, }), ...privacyOptions, // Our defaults slimDOMOptions: 'all', inlineStylesheet: true, // Disable inline images as it will increase segment/replay size inlineImages: false, // collect fonts, but be aware that `sentry.io` needs to be an allowed // origin for playback collectFonts: true, errorHandler: (err) => { try { err.__rrweb__ = true; } catch (error) { // ignore errors here // this can happen if the error is frozen or does not allow mutation for other reasons } }, }; this._initialOptions = { flushMinDelay, flushMaxDelay, minReplayDuration: Math.min(minReplayDuration, MIN_REPLAY_DURATION_LIMIT), maxReplayDuration: Math.min(maxReplayDuration, MAX_REPLAY_DURATION), stickySession, sessionSampleRate, errorSampleRate, useCompression, workerUrl, blockAllMedia, maskAllInputs, maskAllText, mutationBreadcrumbLimit, mutationLimit, slowClickTimeout, slowClickIgnoreSelectors, networkDetailAllowUrls, networkDetailDenyUrls, networkCaptureBodies, networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders), networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders), beforeAddRecordingEvent, beforeErrorSampling, _experiments, }; if (typeof sessionSampleRate === 'number') { // eslint-disable-next-line console.warn( `[Replay] You are passing \`sessionSampleRate\` to the Replay integration. This option is deprecated and will be removed soon. Instead, configure \`replaysSessionSampleRate\` directly in the SDK init options, e.g.: Sentry.init({ replaysSessionSampleRate: ${sessionSampleRate} })`, ); this._initialOptions.sessionSampleRate = sessionSampleRate; } if (typeof errorSampleRate === 'number') { // eslint-disable-next-line console.warn( `[Replay] You are passing \`errorSampleRate\` to the Replay integration. This option is deprecated and will be removed soon. Instead, configure \`replaysOnErrorSampleRate\` directly in the SDK init options, e.g.: Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, ); this._initialOptions.errorSampleRate = errorSampleRate; } if (this._initialOptions.blockAllMedia) { // `blockAllMedia` is a more user friendly option to configure blocking // embedded media elements this._recordingOptions.blockSelector = !this._recordingOptions.blockSelector ? MEDIA_SELECTORS : `${this._recordingOptions.blockSelector},${MEDIA_SELECTORS}`; } if (this._isInitialized && isBrowser()) { throw new Error('Multiple Sentry Session Replay instances are not supported'); } this._isInitialized = true; } /** If replay has already been initialized */ get _isInitialized() { return _initialized; } /** Update _isInitialized */ set _isInitialized(value) { _initialized = value; } /** * Setup and initialize replay container */ setupOnce() { if (!isBrowser()) { return; } this._setup(); // Once upon a time, we tried to create a transaction in `setupOnce` and it would // potentially create a transaction before some native SDK integrations have run // and applied their own global event processor. An example is: // https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts // // So we call `this._initialize()` in next event loop as a workaround to wait for other // global event processors to finish. This is no longer needed, but keeping it // here to avoid any future issues. setTimeout(() => this._initialize()); } /** * Start a replay regardless of sampling rate. Calling this will always * create a new session. Will throw an error if replay is already in progress. * * Creates or loads a session, attaches listeners to varying events (DOM, * PerformanceObserver, Recording, Sentry SDK, etc) */ start() { if (!this._replay) { return; } this._replay.start(); } /** * Start replay buffering. Buffers until `flush()` is called or, if * `replaysOnErrorSampleRate` > 0, until an error occurs. */ startBuffering() { if (!this._replay) { return; } this._replay.startBuffering(); } /** * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown */ stop() { if (!this._replay) { return Promise.resolve(); } return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session' }); } /** * If not in "session" recording mode, flush event buffer which will create a new replay. * Unless `continueRecording` is false, the replay will continue to record and * behave as a "session"-based replay. * * Otherwise, queue up a flush. */ flush(options) { if (!this._replay || !this._replay.isEnabled()) { return Promise.resolve(); } return this._replay.sendBufferedReplayOrFlush(options); } /** * Get the current session ID. */ getReplayId() { if (!this._replay || !this._replay.isEnabled()) { return; } return this._replay.getSessionId(); } /** * Initializes replay. */ _initialize() { if (!this._replay) { return; } // We have to run this in _initialize, because this runs in setTimeout // So when this runs all integrations have been added // Before this, we cannot access integrations on the client, // so we need to mutate the options here this._maybeLoadFromReplayCanvasIntegration(); this._replay.initializeSampling(); } /** Setup the integration. */ _setup() { // Client is not available in constructor, so we need to wait until setupOnce const finalOptions = loadReplayOptionsFromClient(this._initialOptions); this._replay = new ReplayContainer({ options: finalOptions, recordingOptions: this._recordingOptions, }); } /** Get canvas options from ReplayCanvas integration, if it is also added. */ _maybeLoadFromReplayCanvasIntegration() { // To save bundle size, we skip checking for stuff here // and instead just try-catch everything - as generally this should all be defined /* eslint-disable @typescript-eslint/no-non-null-assertion */ try { const client = getClient(); const canvasIntegration = client.getIntegrationByName('ReplayCanvas') ; if (!canvasIntegration) { return; } this._replay['_canvas'] = canvasIntegration.getOptions(); } catch (e) { // ignore errors here } /* eslint-enable @typescript-eslint/no-non-null-assertion */ } }Replay.__initStatic(); /** Parse Replay-related options from SDK options */ function loadReplayOptionsFromClient(initialOptions) { const client = getClient(); const opt = client && (client.getOptions() ); const finalOptions = { sessionSampleRate: 0, errorSampleRate: 0, ...dropUndefinedKeys(initialOptions) }; if (!opt) { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn('SDK client is not available.'); }); return finalOptions; } if ( initialOptions.sessionSampleRate == null && // TODO remove once deprecated rates are removed initialOptions.errorSampleRate == null && // TODO remove once deprecated rates are removed opt.replaysSessionSampleRate == null && opt.replaysOnErrorSampleRate == null ) { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( 'Replay is disabled because neither `replaysSessionSampleRate` nor `replaysOnErrorSampleRate` are set.', ); }); } if (typeof opt.replaysSessionSampleRate === 'number') { finalOptions.sessionSampleRate = opt.replaysSessionSampleRate; } if (typeof opt.replaysOnErrorSampleRate === 'number') { finalOptions.errorSampleRate = opt.replaysOnErrorSampleRate; } return finalOptions; } function _getMergedNetworkHeaders(headers) { return [...DEFAULT_NETWORK_HEADERS, ...headers.map(header => header.toLowerCase())]; } /** * This is a small utility to get a type-safe instance of the Replay integration. */ // eslint-disable-next-line deprecation/deprecation function getReplay() { const client = getClient(); return ( client && client.getIntegrationByName && client.getIntegrationByName('Replay') ); } export { Replay, getReplay, replayIntegration }; //# sourceMappingURL=index.js.map