mirror of
https://github.com/rxliuli/apps.apple.com.git
synced 2025-11-09 23:00:32 +00:00
8543 lines
306 KiB
JavaScript
8543 lines
306 KiB
JavaScript
import { getCurrentHub, addGlobalEventProcessor, prepareEvent, setContext, captureException } from '@sentry/core';
|
|
import { GLOBAL_OBJ, normalize, fill, htmlTreeAsString, logger, uuid4, SENTRY_XHR_DATA_KEY, dropUndefinedKeys, stringMatchesSomePattern, addInstrumentationHandler, browserPerformanceTimeOrigin, createEnvelope, createEventEnvelopeHeaders, getSdkMetadataForEnvelopeHeader, isNodeEnv } from '@sentry/utils';
|
|
|
|
// 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
|
|
|
|
// The maximum length of a session
|
|
const MAX_SESSION_LIFE = 3600000; // 60 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;
|
|
/* Clicks in this time period are considered e.g. double/triple clicks. */
|
|
const MULTI_CLICK_TIMEOUT = 1000;
|
|
|
|
/** 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
|
|
|
|
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(n) {
|
|
return n.nodeType === n.ELEMENT_NODE;
|
|
}
|
|
function isShadowRoot(n) {
|
|
const host = n === null || n === void 0 ? void 0 : n.host;
|
|
return Boolean(host && host.shadowRoot && host.shadowRoot === n);
|
|
}
|
|
function isInputTypeMasked({ maskInputOptions, tagName, type, }) {
|
|
if (tagName.toLowerCase() === 'option') {
|
|
tagName = 'select';
|
|
}
|
|
const actualType = typeof type === 'string' ? type.toLowerCase() : undefined;
|
|
return (maskInputOptions[tagName.toLowerCase()] ||
|
|
(actualType && maskInputOptions[actualType]) ||
|
|
actualType === 'password' ||
|
|
(tagName === 'input' && !type && maskInputOptions['text']));
|
|
}
|
|
function hasInputMaskOptions({ tagName, type, maskInputOptions, maskInputSelector, }) {
|
|
return (maskInputSelector || isInputTypeMasked({ maskInputOptions, tagName, type }));
|
|
}
|
|
function maskInputValue({ input, maskInputSelector, unmaskInputSelector, maskInputOptions, tagName, type, value, maskInputFn, }) {
|
|
let text = value || '';
|
|
if (unmaskInputSelector && input.matches(unmaskInputSelector)) {
|
|
return text;
|
|
}
|
|
if (input.hasAttribute('data-rr-is-password')) {
|
|
type = 'password';
|
|
}
|
|
if (isInputTypeMasked({ maskInputOptions, tagName, type }) ||
|
|
(maskInputSelector && input.matches(maskInputSelector))) {
|
|
if (maskInputFn) {
|
|
text = maskInputFn(text);
|
|
}
|
|
else {
|
|
text = '*'.repeat(text.length);
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
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
|
|
? type.toLowerCase()
|
|
: null;
|
|
}
|
|
function getInputValue(el, tagName, type) {
|
|
typeof type === 'string' ? type.toLowerCase() : '';
|
|
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 defaultMaskFn(str) {
|
|
return str ? str.replace(/[\S]/g, '*') : '';
|
|
}
|
|
function genId() {
|
|
return _id++;
|
|
}
|
|
function getValidTagName(element) {
|
|
if (element instanceof HTMLFormElement) {
|
|
return 'form';
|
|
}
|
|
const processedTagName = element.tagName.toLowerCase().trim();
|
|
if (tagNameRegex.test(processedTagName)) {
|
|
return 'div';
|
|
}
|
|
return processedTagName;
|
|
}
|
|
function getCssRulesString(s) {
|
|
try {
|
|
const rules = s.rules || s.cssRules;
|
|
return rules ? Array.from(rules).map(getCssRuleString).join('') : null;
|
|
}
|
|
catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
function getCssRuleString(rule) {
|
|
let cssStringified = rule.cssText;
|
|
if (isCSSImportRule(rule)) {
|
|
try {
|
|
cssStringified = getCssRulesString(rule.styleSheet) || cssStringified;
|
|
}
|
|
catch (_a) {
|
|
}
|
|
}
|
|
return validateStringifiedCssRule(cssStringified);
|
|
}
|
|
function validateStringifiedCssRule(cssStringified) {
|
|
if (cssStringified.indexOf(':') > -1) {
|
|
const regex = /(\[(?:[\w-]+)[^\\])(:(?:[\w-]+)\])/gm;
|
|
return cssStringified.replace(regex, '$1\\$2');
|
|
}
|
|
return cssStringified;
|
|
}
|
|
function isCSSImportRule(rule) {
|
|
return 'styleSheet' in rule;
|
|
}
|
|
function stringifyStyleSheet(sheet) {
|
|
return sheet.cssRules
|
|
? Array.from(sheet.cssRules)
|
|
.map((rule) => rule.cssText ? validateStringifiedCssRule(rule.cssText) : '')
|
|
.join('')
|
|
: '';
|
|
}
|
|
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 RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/;
|
|
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 (!RELATIVE_PATH.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;
|
|
let match = regEx.exec(attributeValue.substring(pos));
|
|
if (match) {
|
|
chars = match[0];
|
|
pos += chars.length;
|
|
return chars;
|
|
}
|
|
return '';
|
|
}
|
|
let 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) {
|
|
let 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, element, _tagName, _name, value, maskAllText, unmaskTextSelector, maskTextFn) {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
const name = _name.toLowerCase();
|
|
const tagName = _tagName.toLowerCase();
|
|
if (name === 'src' || name === 'href') {
|
|
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);
|
|
}
|
|
else if (maskAllText &&
|
|
_shouldMaskAttribute(element, name, tagName, unmaskTextSelector)) {
|
|
return maskTextFn ? maskTextFn(value) : defaultMaskFn(value);
|
|
}
|
|
return value;
|
|
}
|
|
function _shouldMaskAttribute(element, attribute, tagName, unmaskTextSelector) {
|
|
if (unmaskTextSelector && element.matches(unmaskTextSelector)) {
|
|
return false;
|
|
}
|
|
return (['placeholder', 'title', 'aria-label'].indexOf(attribute) > -1 ||
|
|
(tagName === 'input' &&
|
|
attribute === 'value' &&
|
|
element.hasAttribute('type') &&
|
|
['submit', 'button'].indexOf(element.getAttribute('type').toLowerCase()) > -1));
|
|
}
|
|
function _isBlockedElement(element, blockClass, blockSelector, unblockSelector) {
|
|
if (unblockSelector && element.matches(unblockSelector)) {
|
|
return false;
|
|
}
|
|
if (typeof blockClass === 'string') {
|
|
if (element.classList.contains(blockClass)) {
|
|
return true;
|
|
}
|
|
}
|
|
else {
|
|
for (let eIndex = 0; eIndex < element.classList.length; eIndex++) {
|
|
const className = element.classList[eIndex];
|
|
if (blockClass.test(className)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
if (blockSelector) {
|
|
return element.matches(blockSelector);
|
|
}
|
|
return false;
|
|
}
|
|
function needMaskingText(node, maskTextClass, maskTextSelector, unmaskTextSelector, maskAllText) {
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
if (node.nodeType !== node.ELEMENT_NODE) {
|
|
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector, unmaskTextSelector, maskAllText);
|
|
}
|
|
if (unmaskTextSelector) {
|
|
if (node.matches(unmaskTextSelector) ||
|
|
node.closest(unmaskTextSelector)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (maskAllText) {
|
|
return true;
|
|
}
|
|
if (typeof maskTextClass === 'string') {
|
|
if (node.classList.contains(maskTextClass)) {
|
|
return true;
|
|
}
|
|
}
|
|
else {
|
|
for (let eIndex = 0; eIndex < node.classList.length; eIndex++) {
|
|
const className = node.classList[eIndex];
|
|
if (maskTextClass.test(className)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
if (maskTextSelector) {
|
|
if (node.matches(maskTextSelector)) {
|
|
return true;
|
|
}
|
|
}
|
|
return needMaskingText(node.parentNode, maskTextClass, maskTextSelector, unmaskTextSelector, 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);
|
|
}
|
|
function serializeNode(n, options) {
|
|
var _a;
|
|
const { doc, blockClass, blockSelector, unblockSelector, maskTextClass, maskTextSelector, unmaskTextSelector, inlineStylesheet, maskInputSelector, unmaskInputSelector, maskAllText, maskInputOptions = {}, maskTextFn, maskInputFn, dataURLOptions = {}, inlineImages, recordCanvas, keepIframeSrcFn, } = options;
|
|
let rootId;
|
|
if (doc.__sn) {
|
|
const docId = doc.__sn.id;
|
|
rootId = docId === 1 ? undefined : docId;
|
|
}
|
|
switch (n.nodeType) {
|
|
case n.DOCUMENT_NODE:
|
|
if (n.compatMode !== 'CSS1Compat') {
|
|
return {
|
|
type: NodeType$1.Document,
|
|
childNodes: [],
|
|
compatMode: n.compatMode,
|
|
rootId,
|
|
};
|
|
}
|
|
else {
|
|
return {
|
|
type: NodeType$1.Document,
|
|
childNodes: [],
|
|
rootId,
|
|
};
|
|
}
|
|
case n.DOCUMENT_TYPE_NODE:
|
|
return {
|
|
type: NodeType$1.DocumentType,
|
|
name: n.name,
|
|
publicId: n.publicId,
|
|
systemId: n.systemId,
|
|
rootId,
|
|
};
|
|
case n.ELEMENT_NODE:
|
|
const needBlock = _isBlockedElement(n, blockClass, blockSelector, unblockSelector);
|
|
const tagName = getValidTagName(n);
|
|
let attributes = {};
|
|
for (const { name, value } of Array.from(n.attributes)) {
|
|
if (!skipAttribute(tagName, name)) {
|
|
attributes[name] = transformAttribute(doc, n, tagName, name, value, maskAllText, unmaskTextSelector, maskTextFn);
|
|
}
|
|
}
|
|
if (tagName === 'link' && inlineStylesheet) {
|
|
const stylesheet = Array.from(doc.styleSheets).find((s) => {
|
|
return s.href === n.href;
|
|
});
|
|
let cssText = null;
|
|
if (stylesheet) {
|
|
cssText = getCssRulesString(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 = getCssRulesString(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, tagName.toUpperCase(), type);
|
|
const checked = n.checked;
|
|
if (type !== 'submit' &&
|
|
type !== 'button' &&
|
|
value) {
|
|
attributes.value = maskInputValue({
|
|
input: el,
|
|
type,
|
|
tagName,
|
|
value,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
maskInputOptions,
|
|
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 = () => {
|
|
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)
|
|
: delete attributes.crossOrigin;
|
|
};
|
|
if (image.complete && image.naturalWidth !== 0)
|
|
recordInlineImage();
|
|
else
|
|
image.onload = recordInlineImage;
|
|
}
|
|
if (tagName === 'audio' || tagName === 'video') {
|
|
attributes.rr_mediaState = n.paused
|
|
? 'paused'
|
|
: 'played';
|
|
attributes.rr_mediaCurrentTime = n.currentTime;
|
|
}
|
|
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;
|
|
}
|
|
return {
|
|
type: NodeType$1.Element,
|
|
tagName,
|
|
attributes,
|
|
childNodes: [],
|
|
isSVG: isSVGElement(n) || undefined,
|
|
needBlock,
|
|
rootId,
|
|
};
|
|
case n.TEXT_NODE:
|
|
const parentTagName = n.parentNode && n.parentNode.tagName;
|
|
let textContent = n.textContent;
|
|
const isStyle = parentTagName === 'STYLE' ? true : undefined;
|
|
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
|
|
if (isStyle && textContent) {
|
|
try {
|
|
if (n.nextSibling || n.previousSibling) {
|
|
}
|
|
else if ((_a = n.parentNode.sheet) === null || _a === void 0 ? void 0 : _a.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';
|
|
}
|
|
if (parentTagName === 'TEXTAREA' && textContent) {
|
|
textContent = '';
|
|
}
|
|
else if (parentTagName === 'OPTION' && textContent) {
|
|
const option = n.parentNode;
|
|
textContent = maskInputValue({
|
|
input: option,
|
|
type: null,
|
|
tagName: parentTagName,
|
|
value: textContent,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
maskInputOptions,
|
|
maskInputFn,
|
|
});
|
|
}
|
|
else if (!isStyle &&
|
|
!isScript &&
|
|
needMaskingText(n, maskTextClass, maskTextSelector, unmaskTextSelector, maskAllText) &&
|
|
textContent) {
|
|
textContent = maskTextFn
|
|
? maskTextFn(textContent)
|
|
: defaultMaskFn(textContent);
|
|
}
|
|
return {
|
|
type: NodeType$1.Text,
|
|
textContent: textContent || '',
|
|
isStyle,
|
|
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 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, map, blockClass, blockSelector, unblockSelector, maskTextClass, maskTextSelector, unmaskTextSelector, skipChild = false, inlineStylesheet = true, maskInputSelector, unmaskInputSelector, maskAllText, maskInputOptions = {}, maskTextFn, maskInputFn, slimDOMOptions, dataURLOptions = {}, inlineImages = false, recordCanvas = false, onSerialize, onIframeLoad, iframeLoadTimeout = 5000, keepIframeSrcFn = () => false, } = options;
|
|
let { preserveWhiteSpace = true } = options;
|
|
const _serializedNode = serializeNode(n, {
|
|
doc,
|
|
blockClass,
|
|
blockSelector,
|
|
unblockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
unmaskTextSelector,
|
|
inlineStylesheet,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
maskAllText,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
keepIframeSrcFn,
|
|
});
|
|
if (!_serializedNode) {
|
|
console.warn(n, 'not serialized');
|
|
return null;
|
|
}
|
|
let id;
|
|
if ('__sn' in n) {
|
|
id = n.__sn.id;
|
|
}
|
|
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 });
|
|
n.__sn = serializedNode;
|
|
if (id === IGNORED_NODE) {
|
|
return null;
|
|
}
|
|
map[id] = n;
|
|
if (onSerialize) {
|
|
onSerialize(n);
|
|
}
|
|
let recordChild = !skipChild;
|
|
if (serializedNode.type === NodeType$1.Element) {
|
|
recordChild = recordChild && !serializedNode.needBlock;
|
|
delete serializedNode.needBlock;
|
|
if (n.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,
|
|
map,
|
|
blockClass,
|
|
blockSelector,
|
|
unblockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
unmaskTextSelector,
|
|
skipChild,
|
|
inlineStylesheet,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
maskAllText,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
keepIframeSrcFn,
|
|
};
|
|
for (const childN of Array.from(n.childNodes)) {
|
|
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
|
if (serializedChildNode) {
|
|
serializedNode.childNodes.push(serializedChildNode);
|
|
}
|
|
}
|
|
if (isElement(n) && n.shadowRoot) {
|
|
for (const childN of Array.from(n.shadowRoot.childNodes)) {
|
|
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
|
if (serializedChildNode) {
|
|
serializedChildNode.isShadow = true;
|
|
serializedNode.childNodes.push(serializedChildNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (n.parentNode && isShadowRoot(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,
|
|
map,
|
|
blockClass,
|
|
blockSelector,
|
|
unblockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
unmaskTextSelector,
|
|
skipChild: false,
|
|
inlineStylesheet,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
maskAllText,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
keepIframeSrcFn,
|
|
});
|
|
if (serializedIframeNode) {
|
|
onIframeLoad(n, serializedIframeNode);
|
|
}
|
|
}
|
|
}, iframeLoadTimeout);
|
|
}
|
|
return serializedNode;
|
|
}
|
|
function snapshot(n, options) {
|
|
const { blockClass = 'rr-block', blockSelector = null, unblockSelector = null, maskTextClass = 'rr-mask', maskTextSelector = null, unmaskTextSelector = null, inlineStylesheet = true, inlineImages = false, recordCanvas = false, maskInputSelector = null, unmaskInputSelector = null, maskAllText = false, maskAllInputs = false, maskTextFn, maskInputFn, slimDOM = false, dataURLOptions, preserveWhiteSpace, onSerialize, onIframeLoad, iframeLoadTimeout, keepIframeSrcFn = () => false, } = options || {};
|
|
const idNodeMap = {};
|
|
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,
|
|
map: idNodeMap,
|
|
blockClass,
|
|
blockSelector,
|
|
unblockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
unmaskTextSelector,
|
|
skipChild: false,
|
|
inlineStylesheet,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
maskAllText,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
keepIframeSrcFn,
|
|
}),
|
|
idNodeMap,
|
|
];
|
|
}
|
|
function skipAttribute(tagName, attributeName, value) {
|
|
return ((tagName === 'video' || tagName === 'audio') && attributeName === 'autoplay');
|
|
}
|
|
|
|
var EventType;
|
|
(function (EventType) {
|
|
EventType[EventType["DomContentLoaded"] = 0] = "DomContentLoaded";
|
|
EventType[EventType["Load"] = 1] = "Load";
|
|
EventType[EventType["FullSnapshot"] = 2] = "FullSnapshot";
|
|
EventType[EventType["IncrementalSnapshot"] = 3] = "IncrementalSnapshot";
|
|
EventType[EventType["Meta"] = 4] = "Meta";
|
|
EventType[EventType["Custom"] = 5] = "Custom";
|
|
EventType[EventType["Plugin"] = 6] = "Plugin";
|
|
})(EventType || (EventType = {}));
|
|
var IncrementalSource;
|
|
(function (IncrementalSource) {
|
|
IncrementalSource[IncrementalSource["Mutation"] = 0] = "Mutation";
|
|
IncrementalSource[IncrementalSource["MouseMove"] = 1] = "MouseMove";
|
|
IncrementalSource[IncrementalSource["MouseInteraction"] = 2] = "MouseInteraction";
|
|
IncrementalSource[IncrementalSource["Scroll"] = 3] = "Scroll";
|
|
IncrementalSource[IncrementalSource["ViewportResize"] = 4] = "ViewportResize";
|
|
IncrementalSource[IncrementalSource["Input"] = 5] = "Input";
|
|
IncrementalSource[IncrementalSource["TouchMove"] = 6] = "TouchMove";
|
|
IncrementalSource[IncrementalSource["MediaInteraction"] = 7] = "MediaInteraction";
|
|
IncrementalSource[IncrementalSource["StyleSheetRule"] = 8] = "StyleSheetRule";
|
|
IncrementalSource[IncrementalSource["CanvasMutation"] = 9] = "CanvasMutation";
|
|
IncrementalSource[IncrementalSource["Font"] = 10] = "Font";
|
|
IncrementalSource[IncrementalSource["Log"] = 11] = "Log";
|
|
IncrementalSource[IncrementalSource["Drag"] = 12] = "Drag";
|
|
IncrementalSource[IncrementalSource["StyleDeclaration"] = 13] = "StyleDeclaration";
|
|
})(IncrementalSource || (IncrementalSource = {}));
|
|
var MouseInteractions;
|
|
(function (MouseInteractions) {
|
|
MouseInteractions[MouseInteractions["MouseUp"] = 0] = "MouseUp";
|
|
MouseInteractions[MouseInteractions["MouseDown"] = 1] = "MouseDown";
|
|
MouseInteractions[MouseInteractions["Click"] = 2] = "Click";
|
|
MouseInteractions[MouseInteractions["ContextMenu"] = 3] = "ContextMenu";
|
|
MouseInteractions[MouseInteractions["DblClick"] = 4] = "DblClick";
|
|
MouseInteractions[MouseInteractions["Focus"] = 5] = "Focus";
|
|
MouseInteractions[MouseInteractions["Blur"] = 6] = "Blur";
|
|
MouseInteractions[MouseInteractions["TouchStart"] = 7] = "TouchStart";
|
|
MouseInteractions[MouseInteractions["TouchMove_Departed"] = 8] = "TouchMove_Departed";
|
|
MouseInteractions[MouseInteractions["TouchEnd"] = 9] = "TouchEnd";
|
|
MouseInteractions[MouseInteractions["TouchCancel"] = 10] = "TouchCancel";
|
|
})(MouseInteractions || (MouseInteractions = {}));
|
|
var CanvasContext;
|
|
(function (CanvasContext) {
|
|
CanvasContext[CanvasContext["2D"] = 0] = "2D";
|
|
CanvasContext[CanvasContext["WebGL"] = 1] = "WebGL";
|
|
CanvasContext[CanvasContext["WebGL2"] = 2] = "WebGL2";
|
|
})(CanvasContext || (CanvasContext = {}));
|
|
var MediaInteractions;
|
|
(function (MediaInteractions) {
|
|
MediaInteractions[MediaInteractions["Play"] = 0] = "Play";
|
|
MediaInteractions[MediaInteractions["Pause"] = 1] = "Pause";
|
|
MediaInteractions[MediaInteractions["Seeked"] = 2] = "Seeked";
|
|
MediaInteractions[MediaInteractions["VolumeChange"] = 3] = "VolumeChange";
|
|
})(MediaInteractions || (MediaInteractions = {}));
|
|
var ReplayerEvents;
|
|
(function (ReplayerEvents) {
|
|
ReplayerEvents["Start"] = "start";
|
|
ReplayerEvents["Pause"] = "pause";
|
|
ReplayerEvents["Resume"] = "resume";
|
|
ReplayerEvents["Resize"] = "resize";
|
|
ReplayerEvents["Finish"] = "finish";
|
|
ReplayerEvents["FullsnapshotRebuilded"] = "fullsnapshot-rebuilded";
|
|
ReplayerEvents["LoadStylesheetStart"] = "load-stylesheet-start";
|
|
ReplayerEvents["LoadStylesheetEnd"] = "load-stylesheet-end";
|
|
ReplayerEvents["SkipStart"] = "skip-start";
|
|
ReplayerEvents["SkipEnd"] = "skip-end";
|
|
ReplayerEvents["MouseInteraction"] = "mouse-interaction";
|
|
ReplayerEvents["EventCast"] = "event-cast";
|
|
ReplayerEvents["CustomEvent"] = "custom-event";
|
|
ReplayerEvents["Flush"] = "flush";
|
|
ReplayerEvents["StateChange"] = "state-change";
|
|
ReplayerEvents["PlayBack"] = "play-back";
|
|
})(ReplayerEvents || (ReplayerEvents = {}));
|
|
|
|
function on(type, fn, target = document) {
|
|
const options = { capture: true, passive: true };
|
|
target.addEventListener(type, fn, options);
|
|
return () => target.removeEventListener(type, fn, options);
|
|
}
|
|
function createMirror() {
|
|
return {
|
|
map: {},
|
|
getId(n) {
|
|
if (!n || !n.__sn) {
|
|
return -1;
|
|
}
|
|
return n.__sn.id;
|
|
},
|
|
getNode(id) {
|
|
return this.map[id] || null;
|
|
},
|
|
removeNodeFromMap(n) {
|
|
const id = n.__sn && n.__sn.id;
|
|
delete this.map[id];
|
|
if (n.childNodes) {
|
|
n.childNodes.forEach((child) => this.removeNodeFromMap(child));
|
|
}
|
|
},
|
|
has(id) {
|
|
return this.map.hasOwnProperty(id);
|
|
},
|
|
reset() {
|
|
this.map = {};
|
|
},
|
|
};
|
|
}
|
|
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 (arg) {
|
|
let now = Date.now();
|
|
if (!previous && options.leading === false) {
|
|
previous = now;
|
|
}
|
|
let remaining = wait - (now - previous);
|
|
let context = this;
|
|
let args = arguments;
|
|
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 (_a) {
|
|
return () => { };
|
|
}
|
|
}
|
|
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) {
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
if (node.nodeType === node.ELEMENT_NODE) {
|
|
let needBlock = false;
|
|
const needUnblock = unblockSelector && node.matches(unblockSelector);
|
|
if (typeof blockClass === 'string') {
|
|
if (node.closest !== undefined) {
|
|
needBlock =
|
|
!needUnblock &&
|
|
node.closest('.' + blockClass) !== null;
|
|
}
|
|
else {
|
|
needBlock =
|
|
!needUnblock && node.classList.contains(blockClass);
|
|
}
|
|
}
|
|
else {
|
|
!needUnblock &&
|
|
node.classList.forEach((className) => {
|
|
if (blockClass.test(className)) {
|
|
needBlock = true;
|
|
}
|
|
});
|
|
}
|
|
if (!needBlock && blockSelector) {
|
|
needBlock = node.matches(blockSelector);
|
|
}
|
|
return ((!needUnblock && needBlock) ||
|
|
isBlocked(node.parentNode, blockClass, blockSelector, unblockSelector));
|
|
}
|
|
if (node.nodeType === node.TEXT_NODE) {
|
|
return isBlocked(node.parentNode, blockClass, blockSelector, unblockSelector);
|
|
}
|
|
return isBlocked(node.parentNode, blockClass, blockSelector, unblockSelector);
|
|
}
|
|
function isIgnored(n) {
|
|
if ('__sn' in n) {
|
|
return n.__sn.id === IGNORED_NODE;
|
|
}
|
|
return false;
|
|
}
|
|
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 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 = function contains(node) {
|
|
if (!(0 in arguments)) {
|
|
throw new TypeError('1 argument is required');
|
|
}
|
|
do {
|
|
if (this === node) {
|
|
return true;
|
|
}
|
|
} while ((node = node && node.parentNode));
|
|
return false;
|
|
};
|
|
}
|
|
}
|
|
function isIframeINode(node) {
|
|
if ('__sn' in node) {
|
|
return (node.__sn.type === NodeType$1.Element && node.__sn.tagName === 'iframe');
|
|
}
|
|
return false;
|
|
}
|
|
function hasShadowRoot(n) {
|
|
return Boolean(n === null || n === void 0 ? void 0 : n.shadowRoot);
|
|
}
|
|
|
|
function isNodeInLinkedList(n) {
|
|
return '__ln' in n;
|
|
}
|
|
class DoubleLinkedList {
|
|
constructor() {
|
|
this.length = 0;
|
|
this.head = 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 = (current === null || current === void 0 ? void 0 : current.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;
|
|
}
|
|
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 {
|
|
current.previous.next = current.next;
|
|
if (current.next) {
|
|
current.next.previous = current.previous;
|
|
}
|
|
}
|
|
if (n.__ln) {
|
|
delete n.__ln;
|
|
}
|
|
this.length--;
|
|
}
|
|
}
|
|
const moveKey = (id, parentId) => `${id}@${parentId}`;
|
|
function isINode(n) {
|
|
return '__sn' in n;
|
|
}
|
|
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 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) => {
|
|
var _a, _b, _c, _d, _e;
|
|
const shadowHost = n.getRootNode
|
|
? (_a = n.getRootNode()) === null || _a === void 0 ? void 0 : _a.host
|
|
: null;
|
|
let rootShadowHost = shadowHost;
|
|
while ((_c = (_b = rootShadowHost === null || rootShadowHost === void 0 ? void 0 : rootShadowHost.getRootNode) === null || _b === void 0 ? void 0 : _b.call(rootShadowHost)) === null || _c === void 0 ? void 0 : _c.host)
|
|
rootShadowHost =
|
|
((_e = (_d = rootShadowHost === null || rootShadowHost === void 0 ? void 0 : rootShadowHost.getRootNode) === null || _d === void 0 ? void 0 : _d.call(rootShadowHost)) === null || _e === void 0 ? void 0 : _e.host) ||
|
|
null;
|
|
const notInDoc = !this.doc.contains(n) &&
|
|
(!rootShadowHost || !this.doc.contains(rootShadowHost));
|
|
if (!n.parentNode || notInDoc) {
|
|
return;
|
|
}
|
|
const parentId = isShadowRoot(n.parentNode)
|
|
? this.mirror.getId(shadowHost)
|
|
: this.mirror.getId(n.parentNode);
|
|
const nextId = getNextId(n);
|
|
if (parentId === -1 || nextId === -1) {
|
|
return addList.addNode(n);
|
|
}
|
|
let sn = serializeNodeWithId(n, {
|
|
doc: this.doc,
|
|
map: this.mirror.map,
|
|
blockClass: this.blockClass,
|
|
blockSelector: this.blockSelector,
|
|
unblockSelector: this.unblockSelector,
|
|
maskTextClass: this.maskTextClass,
|
|
maskTextSelector: this.maskTextSelector,
|
|
unmaskTextSelector: this.unmaskTextSelector,
|
|
maskInputSelector: this.maskInputSelector,
|
|
unmaskInputSelector: this.unmaskInputSelector,
|
|
skipChild: true,
|
|
inlineStylesheet: this.inlineStylesheet,
|
|
maskAllText: this.maskAllText,
|
|
maskInputOptions: this.maskInputOptions,
|
|
maskTextFn: this.maskTextFn,
|
|
maskInputFn: this.maskInputFn,
|
|
slimDOMOptions: this.slimDOMOptions,
|
|
recordCanvas: this.recordCanvas,
|
|
inlineImages: this.inlineImages,
|
|
onSerialize: (currentN) => {
|
|
if (isIframeINode(currentN)) {
|
|
this.iframeManager.addIframe(currentN);
|
|
}
|
|
if (hasShadowRoot(n)) {
|
|
this.shadowDomManager.addShadowRoot(n.shadowRoot, document);
|
|
}
|
|
},
|
|
onIframeLoad: (iframe, childSn) => {
|
|
this.iframeManager.attachIframe(iframe, childSn);
|
|
this.shadowDomManager.observeAttachShadow(iframe);
|
|
},
|
|
});
|
|
if (sn) {
|
|
adds.push({
|
|
parentId,
|
|
nextId,
|
|
node: sn,
|
|
});
|
|
}
|
|
};
|
|
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) {
|
|
for (let index = addList.length - 1; index >= 0; index--) {
|
|
const _node = addList.get(index);
|
|
if (_node) {
|
|
const parentId = this.mirror.getId(_node.value.parentNode);
|
|
const nextId = getNextId(_node.value);
|
|
if (parentId !== -1 && nextId !== -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) => this.mirror.has(text.id)),
|
|
attributes: this.attributes
|
|
.map((attribute) => ({
|
|
id: this.mirror.getId(attribute.node),
|
|
attributes: attribute.attributes,
|
|
}))
|
|
.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)) {
|
|
return;
|
|
}
|
|
switch (m.type) {
|
|
case 'characterData': {
|
|
const value = m.target.textContent;
|
|
if (!isBlocked(m.target, this.blockClass, this.blockSelector, this.unblockSelector) && value !== m.oldValue) {
|
|
this.texts.push({
|
|
value: needMaskingText(m.target, this.maskTextClass, this.maskTextSelector, 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 value = target.getAttribute(m.attributeName);
|
|
if (m.attributeName === 'value') {
|
|
value = maskInputValue({
|
|
input: target,
|
|
maskInputSelector: this.maskInputSelector,
|
|
unmaskInputSelector: this.unmaskInputSelector,
|
|
maskInputOptions: this.maskInputOptions,
|
|
tagName: target.tagName,
|
|
type: target.getAttribute('type'),
|
|
value,
|
|
maskInputFn: this.maskInputFn,
|
|
});
|
|
}
|
|
if (isBlocked(m.target, this.blockClass, this.blockSelector, this.unblockSelector) || value === m.oldValue) {
|
|
return;
|
|
}
|
|
let item = this.attributes.find((a) => a.node === m.target);
|
|
if (!item) {
|
|
item = {
|
|
node: m.target,
|
|
attributes: {},
|
|
};
|
|
this.attributes.push(item);
|
|
}
|
|
if (m.attributeName === 'type' &&
|
|
target.tagName === 'INPUT' &&
|
|
(m.oldValue || '').toLowerCase() === 'password') {
|
|
target.setAttribute('data-rr-is-password', 'true');
|
|
}
|
|
if (m.attributeName === 'style') {
|
|
const old = this.doc.createElement('span');
|
|
if (m.oldValue) {
|
|
old.setAttribute('style', m.oldValue);
|
|
}
|
|
if (item.attributes.style === undefined ||
|
|
item.attributes.style === null) {
|
|
item.attributes.style = {};
|
|
}
|
|
try {
|
|
const styleObj = item.attributes.style;
|
|
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 === '') {
|
|
styleObj[pname] = newValue;
|
|
}
|
|
else {
|
|
styleObj[pname] = [newValue, newPriority];
|
|
}
|
|
}
|
|
}
|
|
for (const pname of Array.from(old.style)) {
|
|
if (target.style.getPropertyValue(pname) === '') {
|
|
styleObj[pname] = false;
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.warn('[rrweb] Error when parsing update to style attribute:', error);
|
|
}
|
|
}
|
|
else {
|
|
const element = m.target;
|
|
item.attributes[m.attributeName] = transformAttribute(this.doc, element, element.tagName, m.attributeName, value, this.maskAllText, this.unmaskTextSelector, this.maskTextFn);
|
|
}
|
|
break;
|
|
}
|
|
case 'childList': {
|
|
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) || isIgnored(n)) {
|
|
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) ? true : undefined,
|
|
});
|
|
}
|
|
this.mapRemoves.push(n);
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
this.genAdds = (n, target) => {
|
|
if (target && isBlocked(target, this.blockClass, this.blockSelector, this.unblockSelector)) {
|
|
return;
|
|
}
|
|
if (isINode(n)) {
|
|
if (isIgnored(n)) {
|
|
return;
|
|
}
|
|
this.movedSet.add(n);
|
|
let targetId = null;
|
|
if (target && isINode(target)) {
|
|
targetId = target.__sn.id;
|
|
}
|
|
if (targetId) {
|
|
this.movedMap[moveKey(n.__sn.id, targetId)] = true;
|
|
}
|
|
}
|
|
else {
|
|
this.addedSet.add(n);
|
|
this.droppedSet.delete(n);
|
|
}
|
|
if (!isBlocked(n, this.blockClass, this.blockSelector, this.unblockSelector))
|
|
n.childNodes.forEach((childN) => this.genAdds(childN));
|
|
};
|
|
}
|
|
init(options) {
|
|
[
|
|
'mutationCb',
|
|
'blockClass',
|
|
'blockSelector',
|
|
'unblockSelector',
|
|
'maskTextClass',
|
|
'maskTextSelector',
|
|
'unmaskTextSelector',
|
|
'maskInputSelector',
|
|
'unmaskInputSelector',
|
|
'inlineStylesheet',
|
|
'maskAllText',
|
|
'maskInputOptions',
|
|
'maskTextFn',
|
|
'maskInputFn',
|
|
'recordCanvas',
|
|
'inlineImages',
|
|
'slimDOMOptions',
|
|
'doc',
|
|
'mirror',
|
|
'iframeManager',
|
|
'shadowDomManager',
|
|
'canvasManager',
|
|
].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) {
|
|
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) {
|
|
const { parentNode } = n;
|
|
if (!parentNode) {
|
|
return false;
|
|
}
|
|
if (set.has(parentNode)) {
|
|
return true;
|
|
}
|
|
return isAncestorInSet(set, parentNode);
|
|
}
|
|
|
|
const callbackWrapper = (cb) => {
|
|
const rrwebWrapped = (...rest) => {
|
|
try {
|
|
return cb(...rest);
|
|
}
|
|
catch (error) {
|
|
try {
|
|
error.__rrweb__ = true;
|
|
}
|
|
catch (_a) {
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
return rrwebWrapped;
|
|
};
|
|
|
|
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 (_a) { }
|
|
return event && event.target;
|
|
}
|
|
function initMutationObserver(options, rootEl) {
|
|
var _a, _b;
|
|
const mutationBuffer = new MutationBuffer();
|
|
mutationBuffers.push(mutationBuffer);
|
|
mutationBuffer.init(options);
|
|
let mutationObserverCtor = window.MutationObserver ||
|
|
window.__rrMutationObserver;
|
|
const angularZoneSymbol = (_b = (_a = window === null || window === void 0 ? void 0 : window.Zone) === null || _a === void 0 ? void 0 : _a.__symbol__) === null || _b === void 0 ? void 0 : _b.call(_a, 'MutationObserver');
|
|
if (angularZoneSymbol &&
|
|
window[angularZoneSymbol]) {
|
|
mutationObserverCtor = window[angularZoneSymbol];
|
|
}
|
|
const observer = new mutationObserverCtor(callbackWrapper((mutations) => {
|
|
if (options.onMutation && options.onMutation(mutations) === false) {
|
|
return;
|
|
}
|
|
mutationBuffer.processMutations(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((source) => {
|
|
const totalOffset = Date.now() - timeBaseline;
|
|
callbackWrapper(mousemoveCb)(positions.map((p) => {
|
|
p.timeOffset -= totalOffset;
|
|
return p;
|
|
}), source);
|
|
positions = [];
|
|
timeBaseline = null;
|
|
}, callbackThreshold);
|
|
const updatePosition = throttle$1((evt) => {
|
|
const target = getEventTarget(evt);
|
|
const { clientX, clientY } = isTouchEvent(evt)
|
|
? evt.changedTouches[0]
|
|
: evt;
|
|
if (!timeBaseline) {
|
|
timeBaseline = Date.now();
|
|
}
|
|
positions.push({
|
|
x: clientX,
|
|
y: clientY,
|
|
id: mirror.getId(target),
|
|
timeOffset: Date.now() - timeBaseline,
|
|
});
|
|
wrappedCb(typeof DragEvent !== 'undefined' && evt instanceof DragEvent
|
|
? IncrementalSource.Drag
|
|
: evt instanceof MouseEvent
|
|
? IncrementalSource.MouseMove
|
|
: IncrementalSource.TouchMove);
|
|
}, threshold, {
|
|
trailing: false,
|
|
});
|
|
const handlers = [
|
|
on('mousemove', callbackWrapper(updatePosition), doc),
|
|
on('touchmove', callbackWrapper(updatePosition), doc),
|
|
on('drag', callbackWrapper(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 = [];
|
|
const getHandler = (eventKey) => {
|
|
return (event) => {
|
|
const target = getEventTarget(event);
|
|
if (isBlocked(target, blockClass, blockSelector, unblockSelector)) {
|
|
return;
|
|
}
|
|
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
|
|
if (!e) {
|
|
return;
|
|
}
|
|
const id = mirror.getId(target);
|
|
const { clientX, clientY } = e;
|
|
callbackWrapper(mouseInteractionCb)({
|
|
type: MouseInteractions[eventKey],
|
|
id,
|
|
x: clientX,
|
|
y: clientY,
|
|
});
|
|
};
|
|
};
|
|
Object.keys(MouseInteractions)
|
|
.filter((key) => Number.isNaN(Number(key)) &&
|
|
!key.endsWith('_Departed') &&
|
|
disableMap[key] !== false)
|
|
.forEach((eventKey) => {
|
|
const eventName = eventKey.toLowerCase();
|
|
const handler = callbackWrapper(getHandler(eventKey));
|
|
handlers.push(on(eventName, handler, doc));
|
|
});
|
|
return callbackWrapper(() => {
|
|
handlers.forEach((h) => h());
|
|
});
|
|
}
|
|
function initScrollObserver({ scrollCb, doc, mirror, blockClass, blockSelector, unblockSelector, sampling, }) {
|
|
const updatePosition = throttle$1((evt) => {
|
|
const target = getEventTarget(evt);
|
|
if (!target ||
|
|
isBlocked(target, blockClass, blockSelector, unblockSelector)) {
|
|
return;
|
|
}
|
|
const id = mirror.getId(target);
|
|
if (target === doc) {
|
|
const scrollEl = (doc.scrollingElement || doc.documentElement);
|
|
callbackWrapper(scrollCb)({
|
|
id,
|
|
x: scrollEl.scrollLeft,
|
|
y: scrollEl.scrollTop,
|
|
});
|
|
}
|
|
else {
|
|
callbackWrapper(scrollCb)({
|
|
id,
|
|
x: target.scrollLeft,
|
|
y: target.scrollTop,
|
|
});
|
|
}
|
|
}, sampling.scroll || 100);
|
|
return on('scroll', callbackWrapper(updatePosition), doc);
|
|
}
|
|
function initViewportResizeObserver({ viewportResizeCb, }) {
|
|
let lastH = -1;
|
|
let lastW = -1;
|
|
const updateDimension = throttle$1(() => {
|
|
const height = getWindowHeight();
|
|
const width = getWindowWidth();
|
|
if (lastH !== height || lastW !== width) {
|
|
callbackWrapper(viewportResizeCb)({
|
|
width: Number(width),
|
|
height: Number(height),
|
|
});
|
|
lastH = height;
|
|
lastW = width;
|
|
}
|
|
}, 200);
|
|
return on('resize', callbackWrapper(updateDimension), window);
|
|
}
|
|
function wrapEventWithUserTriggeredFlag(v, enable) {
|
|
const value = Object.assign({}, v);
|
|
if (!enable)
|
|
delete value.userTriggered;
|
|
return value;
|
|
}
|
|
const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
|
|
const lastInputValueMap = new WeakMap();
|
|
function initInputObserver({ inputCb, doc, mirror, blockClass, blockSelector, unblockSelector, ignoreClass, ignoreSelector, maskInputSelector, unmaskInputSelector, maskInputOptions, maskInputFn, sampling, userTriggeredOnInput, }) {
|
|
function eventHandler(event) {
|
|
let target = getEventTarget(event);
|
|
const tagName = target && target.tagName;
|
|
const userTriggered = event.isTrusted;
|
|
if (tagName === 'OPTION')
|
|
target = target.parentElement;
|
|
if (!target ||
|
|
!tagName ||
|
|
INPUT_TAGS.indexOf(tagName) < 0 ||
|
|
isBlocked(target, blockClass, blockSelector, unblockSelector)) {
|
|
return;
|
|
}
|
|
const el = target;
|
|
const type = getInputType(el);
|
|
if (el.classList.contains(ignoreClass) ||
|
|
(ignoreSelector && el.matches(ignoreSelector))) {
|
|
return;
|
|
}
|
|
let text = getInputValue(el, tagName, type);
|
|
let isChecked = false;
|
|
if (type === 'radio' || type === 'checkbox') {
|
|
isChecked = target.checked;
|
|
}
|
|
if (hasInputMaskOptions({
|
|
maskInputOptions,
|
|
maskInputSelector,
|
|
tagName,
|
|
type,
|
|
})) {
|
|
text = maskInputValue({
|
|
input: el,
|
|
maskInputOptions,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
tagName,
|
|
type,
|
|
value: text,
|
|
maskInputFn,
|
|
});
|
|
}
|
|
cbWithDedup(target, callbackWrapper(wrapEventWithUserTriggeredFlag)({ text, isChecked, userTriggered }, userTriggeredOnInput));
|
|
const name = target.name;
|
|
if (type === 'radio' && name && isChecked) {
|
|
doc
|
|
.querySelectorAll(`input[type="radio"][name="${name}"]`)
|
|
.forEach((el) => {
|
|
if (el !== target) {
|
|
const text = maskInputValue({
|
|
input: el,
|
|
maskInputOptions,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
tagName,
|
|
type,
|
|
value: getInputValue(el, tagName, type),
|
|
maskInputFn,
|
|
});
|
|
cbWithDedup(el, callbackWrapper(wrapEventWithUserTriggeredFlag)({
|
|
text,
|
|
isChecked: !isChecked,
|
|
userTriggered: false,
|
|
}, userTriggeredOnInput));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
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);
|
|
inputCb(Object.assign(Object.assign({}, v), { id }));
|
|
}
|
|
}
|
|
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
|
|
const handlers = events.map((eventName) => on(eventName, callbackWrapper(eventHandler), doc));
|
|
const propertyDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
|
const hookProperties = [
|
|
[HTMLInputElement.prototype, 'value'],
|
|
[HTMLInputElement.prototype, 'checked'],
|
|
[HTMLSelectElement.prototype, 'value'],
|
|
[HTMLTextAreaElement.prototype, 'value'],
|
|
[HTMLSelectElement.prototype, 'selectedIndex'],
|
|
[HTMLOptionElement.prototype, 'selected'],
|
|
];
|
|
if (propertyDescriptor && propertyDescriptor.set) {
|
|
handlers.push(...hookProperties.map((p) => hookSetter(p[0], p[1], {
|
|
set() {
|
|
callbackWrapper(eventHandler)({ target: this });
|
|
},
|
|
})));
|
|
}
|
|
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 {
|
|
const rules = Array.from(childRule.parentStyleSheet.cssRules);
|
|
const index = rules.indexOf(childRule);
|
|
pos.unshift(index);
|
|
}
|
|
return pos;
|
|
}
|
|
return recurse(rule, positions);
|
|
}
|
|
function initStyleSheetObserver({ styleSheetRuleCb, mirror }, { 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 = mirror.getId(thisArg.ownerNode);
|
|
if (id !== -1) {
|
|
styleSheetRuleCb({
|
|
id,
|
|
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 = mirror.getId(thisArg.ownerNode);
|
|
if (id !== -1) {
|
|
styleSheetRuleCb({
|
|
id,
|
|
removes: [{ index }],
|
|
});
|
|
}
|
|
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 = mirror.getId(thisArg.parentStyleSheet.ownerNode);
|
|
if (id !== -1) {
|
|
styleSheetRuleCb({
|
|
id,
|
|
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 = mirror.getId(thisArg.parentStyleSheet.ownerNode);
|
|
if (id !== -1) {
|
|
styleSheetRuleCb({
|
|
id,
|
|
removes: [
|
|
{ index: [...getNestedCSSRulePositions(thisArg), index] },
|
|
],
|
|
});
|
|
}
|
|
return target.apply(thisArg, argumentsList);
|
|
}),
|
|
});
|
|
});
|
|
return callbackWrapper(() => {
|
|
win.CSSStyleSheet.prototype.insertRule = insertRule;
|
|
win.CSSStyleSheet.prototype.deleteRule = deleteRule;
|
|
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
|
|
type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule;
|
|
type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule;
|
|
});
|
|
});
|
|
}
|
|
function initStyleDeclarationObserver({ styleDeclarationCb, mirror }, { win }) {
|
|
const setProperty = win.CSSStyleDeclaration.prototype.setProperty;
|
|
win.CSSStyleDeclaration.prototype.setProperty = new Proxy(setProperty, {
|
|
apply: callbackWrapper((target, thisArg, argumentsList) => {
|
|
var _a, _b;
|
|
const [property, value, priority] = argumentsList;
|
|
const id = mirror.getId((_b = (_a = thisArg.parentRule) === null || _a === void 0 ? void 0 : _a.parentStyleSheet) === null || _b === void 0 ? void 0 : _b.ownerNode);
|
|
if (id !== -1) {
|
|
styleDeclarationCb({
|
|
id,
|
|
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) => {
|
|
var _a, _b;
|
|
const [property] = argumentsList;
|
|
const id = mirror.getId((_b = (_a = thisArg.parentRule) === null || _a === void 0 ? void 0 : _a.parentStyleSheet) === null || _b === void 0 ? void 0 : _b.ownerNode);
|
|
if (id !== -1) {
|
|
styleDeclarationCb({
|
|
id,
|
|
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, }) {
|
|
const handler = (type) => throttle$1(callbackWrapper((event) => {
|
|
const target = getEventTarget(event);
|
|
if (!target ||
|
|
isBlocked(target, blockClass, blockSelector, unblockSelector)) {
|
|
return;
|
|
}
|
|
const { currentTime, volume, muted } = target;
|
|
mediaInteractionCb({
|
|
type,
|
|
id: mirror.getId(target),
|
|
currentTime,
|
|
volume,
|
|
muted,
|
|
});
|
|
}), sampling.media || 500);
|
|
const handlers = [
|
|
on('play', handler(0)),
|
|
on('pause', handler(1)),
|
|
on('seeked', handler(2)),
|
|
on('volumechange', handler(3)),
|
|
];
|
|
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(() => {
|
|
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 mergeHooks(o, hooks) {
|
|
const { mutationCb, mousemoveCb, mouseInteractionCb, scrollCb, viewportResizeCb, inputCb, mediaInteractionCb, styleSheetRuleCb, styleDeclarationCb, canvasMutationCb, fontCb, } = o;
|
|
o.mutationCb = (...p) => {
|
|
if (hooks.mutation) {
|
|
hooks.mutation(...p);
|
|
}
|
|
mutationCb(...p);
|
|
};
|
|
o.mousemoveCb = (...p) => {
|
|
if (hooks.mousemove) {
|
|
hooks.mousemove(...p);
|
|
}
|
|
mousemoveCb(...p);
|
|
};
|
|
o.mouseInteractionCb = (...p) => {
|
|
if (hooks.mouseInteraction) {
|
|
hooks.mouseInteraction(...p);
|
|
}
|
|
mouseInteractionCb(...p);
|
|
};
|
|
o.scrollCb = (...p) => {
|
|
if (hooks.scroll) {
|
|
hooks.scroll(...p);
|
|
}
|
|
scrollCb(...p);
|
|
};
|
|
o.viewportResizeCb = (...p) => {
|
|
if (hooks.viewportResize) {
|
|
hooks.viewportResize(...p);
|
|
}
|
|
viewportResizeCb(...p);
|
|
};
|
|
o.inputCb = (...p) => {
|
|
if (hooks.input) {
|
|
hooks.input(...p);
|
|
}
|
|
inputCb(...p);
|
|
};
|
|
o.mediaInteractionCb = (...p) => {
|
|
if (hooks.mediaInteaction) {
|
|
hooks.mediaInteaction(...p);
|
|
}
|
|
mediaInteractionCb(...p);
|
|
};
|
|
o.styleSheetRuleCb = (...p) => {
|
|
if (hooks.styleSheetRule) {
|
|
hooks.styleSheetRule(...p);
|
|
}
|
|
styleSheetRuleCb(...p);
|
|
};
|
|
o.styleDeclarationCb = (...p) => {
|
|
if (hooks.styleDeclaration) {
|
|
hooks.styleDeclaration(...p);
|
|
}
|
|
styleDeclarationCb(...p);
|
|
};
|
|
o.canvasMutationCb = (...p) => {
|
|
if (hooks.canvasMutation) {
|
|
hooks.canvasMutation(...p);
|
|
}
|
|
canvasMutationCb(...p);
|
|
};
|
|
o.fontCb = (...p) => {
|
|
if (hooks.font) {
|
|
hooks.font(...p);
|
|
}
|
|
fontCb(...p);
|
|
};
|
|
}
|
|
function initObservers(o, hooks = {}) {
|
|
const currentWindow = o.doc.defaultView;
|
|
if (!currentWindow) {
|
|
return () => { };
|
|
}
|
|
mergeHooks(o, hooks);
|
|
const mutationObserver = initMutationObserver(o, o.doc);
|
|
const mousemoveHandler = initMoveObserver(o);
|
|
const mouseInteractionHandler = initMouseInteractionObserver(o);
|
|
const scrollHandler = initScrollObserver(o);
|
|
const viewportResizeHandler = initViewportResizeObserver(o);
|
|
const inputHandler = initInputObserver(o);
|
|
const mediaInteractionHandler = initMediaInteractionObserver(o);
|
|
const styleSheetObserver = initStyleSheetObserver(o, { win: currentWindow });
|
|
const styleDeclarationObserver = initStyleDeclarationObserver(o, {
|
|
win: currentWindow,
|
|
});
|
|
const fontObserver = o.collectFonts ? initFontObserver(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();
|
|
try {
|
|
styleSheetObserver();
|
|
styleDeclarationObserver();
|
|
}
|
|
catch (e) {
|
|
}
|
|
fontObserver();
|
|
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 IframeManager {
|
|
constructor(options) {
|
|
this.iframes = new WeakMap();
|
|
this.mutationCb = options.mutationCb;
|
|
}
|
|
addIframe(iframeEl) {
|
|
this.iframes.set(iframeEl, true);
|
|
}
|
|
addLoadListener(cb) {
|
|
this.loadListener = cb;
|
|
}
|
|
attachIframe(iframeEl, childSn) {
|
|
var _a;
|
|
this.mutationCb({
|
|
adds: [
|
|
{
|
|
parentId: iframeEl.__sn.id,
|
|
nextId: null,
|
|
node: childSn,
|
|
},
|
|
],
|
|
removes: [],
|
|
texts: [],
|
|
attributes: [],
|
|
isAttachIframe: true,
|
|
});
|
|
(_a = this.loadListener) === null || _a === void 0 ? void 0 : _a.call(this, iframeEl);
|
|
}
|
|
}
|
|
|
|
class ShadowDomManager {
|
|
constructor(options) {
|
|
this.restorePatches = [];
|
|
this.mutationCb = options.mutationCb;
|
|
this.scrollCb = options.scrollCb;
|
|
this.bypassOptions = options.bypassOptions;
|
|
this.mirror = options.mirror;
|
|
const manager = this;
|
|
this.restorePatches.push(patch(HTMLElement.prototype, 'attachShadow', function (original) {
|
|
return function () {
|
|
const shadowRoot = original.apply(this, arguments);
|
|
if (this.shadowRoot)
|
|
manager.addShadowRoot(this.shadowRoot, this.ownerDocument);
|
|
return shadowRoot;
|
|
};
|
|
}));
|
|
}
|
|
addShadowRoot(shadowRoot, doc) {
|
|
initMutationObserver(Object.assign(Object.assign({}, this.bypassOptions), { doc, mutationCb: this.mutationCb, mirror: this.mirror, shadowDomManager: this }), shadowRoot);
|
|
initScrollObserver(Object.assign(Object.assign({}, this.bypassOptions), { scrollCb: this.scrollCb, doc: shadowRoot, mirror: this.mirror }));
|
|
}
|
|
observeAttachShadow(iframeElement) {
|
|
if (iframeElement.contentWindow) {
|
|
const manager = this;
|
|
this.restorePatches.push(patch(iframeElement.contentWindow.HTMLElement.prototype, 'attachShadow', function (original) {
|
|
return function () {
|
|
const shadowRoot = original.apply(this, arguments);
|
|
if (this.shadowRoot)
|
|
manager.addShadowRoot(this.shadowRoot, iframeElement.contentDocument);
|
|
return shadowRoot;
|
|
};
|
|
}));
|
|
}
|
|
}
|
|
reset() {
|
|
this.restorePatches.forEach((restorePatch) => restorePatch());
|
|
}
|
|
}
|
|
|
|
/******************************************************************************
|
|
Copyright (c) Microsoft Corporation.
|
|
|
|
Permission to use, copy, modify, and/or distribute this software for any
|
|
purpose with or without fee is hereby granted.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
PERFORMANCE OF THIS SOFTWARE.
|
|
***************************************************************************** */
|
|
|
|
function __rest(s, e) {
|
|
var t = {};
|
|
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
t[p] = s[p];
|
|
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
t[p[i]] = s[p[i]];
|
|
}
|
|
return t;
|
|
}
|
|
|
|
function initCanvas2DMutationObserver(cb, win, blockClass, unblockSelector, blockSelector, mirror) {
|
|
const handlers = [];
|
|
const props2D = Object.getOwnPropertyNames(win.CanvasRenderingContext2D.prototype);
|
|
for (const prop of props2D) {
|
|
try {
|
|
if (typeof win.CanvasRenderingContext2D.prototype[prop] !== 'function') {
|
|
continue;
|
|
}
|
|
const restoreHandler = patch(win.CanvasRenderingContext2D.prototype, prop, function (original) {
|
|
return function (...args) {
|
|
if (!isBlocked(this.canvas, blockClass, blockSelector, unblockSelector)) {
|
|
setTimeout(() => {
|
|
const recordArgs = [...args];
|
|
if (prop === 'drawImage') {
|
|
if (recordArgs[0] &&
|
|
recordArgs[0] instanceof HTMLCanvasElement) {
|
|
const canvas = recordArgs[0];
|
|
const ctx = canvas.getContext('2d');
|
|
let imgd = ctx === null || ctx === void 0 ? void 0 : ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
let pix = imgd === null || imgd === void 0 ? void 0 : imgd.data;
|
|
recordArgs[0] = JSON.stringify(pix);
|
|
}
|
|
}
|
|
cb(this.canvas, {
|
|
type: CanvasContext['2D'],
|
|
property: prop,
|
|
args: recordArgs,
|
|
});
|
|
}, 0);
|
|
}
|
|
return original.apply(this, args);
|
|
};
|
|
});
|
|
handlers.push(restoreHandler);
|
|
}
|
|
catch (_a) {
|
|
const hookHandler = hookSetter(win.CanvasRenderingContext2D.prototype, prop, {
|
|
set(v) {
|
|
cb(this.canvas, {
|
|
type: CanvasContext['2D'],
|
|
property: prop,
|
|
args: [v],
|
|
setter: true,
|
|
});
|
|
},
|
|
});
|
|
handlers.push(hookHandler);
|
|
}
|
|
}
|
|
return () => {
|
|
handlers.forEach((h) => h());
|
|
};
|
|
}
|
|
|
|
function initCanvasContextObserver(win, blockClass, blockSelector, unblockSelector) {
|
|
const handlers = [];
|
|
try {
|
|
const restoreHandler = patch(win.HTMLCanvasElement.prototype, 'getContext', function (original) {
|
|
return function (contextType, ...args) {
|
|
if (!isBlocked(this, blockClass, blockSelector, unblockSelector)) {
|
|
if (!('__context' in this))
|
|
this.__context = contextType;
|
|
}
|
|
return original.apply(this, [contextType, ...args]);
|
|
};
|
|
});
|
|
handlers.push(restoreHandler);
|
|
}
|
|
catch (_a) {
|
|
console.error('failed to patch HTMLCanvasElement.prototype.getContext');
|
|
}
|
|
return () => {
|
|
handlers.forEach((h) => h());
|
|
};
|
|
}
|
|
|
|
/*
|
|
* base64-arraybuffer 1.0.2 <https://github.com/niklasvh/base64-arraybuffer>
|
|
* Copyright (c) 2022 Niklas von Hertzen <https://hertzen.com>
|
|
* Released under MIT License
|
|
*/
|
|
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
// Use a lookup table to find the index.
|
|
var lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256);
|
|
for (var i = 0; i < chars.length; i++) {
|
|
lookup[chars.charCodeAt(i)] = i;
|
|
}
|
|
var encode = function (arraybuffer) {
|
|
var bytes = new Uint8Array(arraybuffer), i, len = bytes.length, base64 = '';
|
|
for (i = 0; i < len; i += 3) {
|
|
base64 += chars[bytes[i] >> 2];
|
|
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
|
|
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
|
|
base64 += chars[bytes[i + 2] & 63];
|
|
}
|
|
if (len % 3 === 2) {
|
|
base64 = base64.substring(0, base64.length - 1) + '=';
|
|
}
|
|
else if (len % 3 === 1) {
|
|
base64 = base64.substring(0, base64.length - 2) + '==';
|
|
}
|
|
return base64;
|
|
};
|
|
|
|
const webGLVarMap = new Map();
|
|
function variableListFor(ctx, ctor) {
|
|
let contextMap = webGLVarMap.get(ctx);
|
|
if (!contextMap) {
|
|
contextMap = new Map();
|
|
webGLVarMap.set(ctx, contextMap);
|
|
}
|
|
if (!contextMap.has(ctor)) {
|
|
contextMap.set(ctor, []);
|
|
}
|
|
return contextMap.get(ctor);
|
|
}
|
|
const saveWebGLVar = (value, win, ctx) => {
|
|
if (!value ||
|
|
!(isInstanceOfWebGLObject(value, win) || typeof value === 'object'))
|
|
return;
|
|
const name = value.constructor.name;
|
|
const list = variableListFor(ctx, name);
|
|
let index = list.indexOf(value);
|
|
if (index === -1) {
|
|
index = list.length;
|
|
list.push(value);
|
|
}
|
|
return index;
|
|
};
|
|
function serializeArg(value, win, ctx) {
|
|
if (value instanceof Array) {
|
|
return value.map((arg) => serializeArg(arg, win, ctx));
|
|
}
|
|
else if (value === null) {
|
|
return value;
|
|
}
|
|
else if (value instanceof Float32Array ||
|
|
value instanceof Float64Array ||
|
|
value instanceof Int32Array ||
|
|
value instanceof Uint32Array ||
|
|
value instanceof Uint8Array ||
|
|
value instanceof Uint16Array ||
|
|
value instanceof Int16Array ||
|
|
value instanceof Int8Array ||
|
|
value instanceof Uint8ClampedArray) {
|
|
const name = value.constructor.name;
|
|
return {
|
|
rr_type: name,
|
|
args: [Object.values(value)],
|
|
};
|
|
}
|
|
else if (value instanceof ArrayBuffer) {
|
|
const name = value.constructor.name;
|
|
const base64 = encode(value);
|
|
return {
|
|
rr_type: name,
|
|
base64,
|
|
};
|
|
}
|
|
else if (value instanceof DataView) {
|
|
const name = value.constructor.name;
|
|
return {
|
|
rr_type: name,
|
|
args: [
|
|
serializeArg(value.buffer, win, ctx),
|
|
value.byteOffset,
|
|
value.byteLength,
|
|
],
|
|
};
|
|
}
|
|
else if (value instanceof HTMLImageElement) {
|
|
const name = value.constructor.name;
|
|
const { src } = value;
|
|
return {
|
|
rr_type: name,
|
|
src,
|
|
};
|
|
}
|
|
else if (value instanceof ImageData) {
|
|
const name = value.constructor.name;
|
|
return {
|
|
rr_type: name,
|
|
args: [serializeArg(value.data, win, ctx), value.width, value.height],
|
|
};
|
|
}
|
|
else if (isInstanceOfWebGLObject(value, win) || typeof value === 'object') {
|
|
const name = value.constructor.name;
|
|
const index = saveWebGLVar(value, win, ctx);
|
|
return {
|
|
rr_type: name,
|
|
index: index,
|
|
};
|
|
}
|
|
return value;
|
|
}
|
|
const serializeArgs = (args, win, ctx) => {
|
|
return [...args].map((arg) => serializeArg(arg, win, ctx));
|
|
};
|
|
const isInstanceOfWebGLObject = (value, win) => {
|
|
const webGLConstructorNames = [
|
|
'WebGLActiveInfo',
|
|
'WebGLBuffer',
|
|
'WebGLFramebuffer',
|
|
'WebGLProgram',
|
|
'WebGLRenderbuffer',
|
|
'WebGLShader',
|
|
'WebGLShaderPrecisionFormat',
|
|
'WebGLTexture',
|
|
'WebGLUniformLocation',
|
|
'WebGLVertexArrayObject',
|
|
'WebGLVertexArrayObjectOES',
|
|
];
|
|
const supportedWebGLConstructorNames = webGLConstructorNames.filter((name) => typeof win[name] === 'function');
|
|
return Boolean(supportedWebGLConstructorNames.find((name) => value instanceof win[name]));
|
|
};
|
|
|
|
function patchGLPrototype(prototype, type, cb, blockClass, unblockSelector, blockSelector, mirror, win) {
|
|
const handlers = [];
|
|
const props = Object.getOwnPropertyNames(prototype);
|
|
for (const prop of props) {
|
|
try {
|
|
if (typeof prototype[prop] !== 'function') {
|
|
continue;
|
|
}
|
|
const restoreHandler = patch(prototype, prop, function (original) {
|
|
return function (...args) {
|
|
const result = original.apply(this, args);
|
|
saveWebGLVar(result, win, prototype);
|
|
if (!isBlocked(this.canvas, blockClass, blockSelector, unblockSelector)) {
|
|
const id = mirror.getId(this.canvas);
|
|
const recordArgs = serializeArgs([...args], win, prototype);
|
|
const mutation = {
|
|
type,
|
|
property: prop,
|
|
args: recordArgs,
|
|
};
|
|
cb(this.canvas, mutation);
|
|
}
|
|
return result;
|
|
};
|
|
});
|
|
handlers.push(restoreHandler);
|
|
}
|
|
catch (_a) {
|
|
const hookHandler = hookSetter(prototype, prop, {
|
|
set(v) {
|
|
cb(this.canvas, {
|
|
type,
|
|
property: prop,
|
|
args: [v],
|
|
setter: true,
|
|
});
|
|
},
|
|
});
|
|
handlers.push(hookHandler);
|
|
}
|
|
}
|
|
return handlers;
|
|
}
|
|
function initCanvasWebGLMutationObserver(cb, win, blockClass, blockSelector, unblockSelector, mirror) {
|
|
const handlers = [];
|
|
handlers.push(...patchGLPrototype(win.WebGLRenderingContext.prototype, CanvasContext.WebGL, cb, blockClass, blockSelector, unblockSelector, mirror, win));
|
|
if (typeof win.WebGL2RenderingContext !== 'undefined') {
|
|
handlers.push(...patchGLPrototype(win.WebGL2RenderingContext.prototype, CanvasContext.WebGL2, cb, blockClass, blockSelector, unblockSelector, mirror, win));
|
|
}
|
|
return () => {
|
|
handlers.forEach((h) => h());
|
|
};
|
|
}
|
|
|
|
class CanvasManager {
|
|
reset() {
|
|
this.pendingCanvasMutations.clear();
|
|
this.resetObservers && this.resetObservers();
|
|
}
|
|
freeze() {
|
|
this.frozen = true;
|
|
}
|
|
unfreeze() {
|
|
this.frozen = false;
|
|
}
|
|
lock() {
|
|
this.locked = true;
|
|
}
|
|
unlock() {
|
|
this.locked = false;
|
|
}
|
|
constructor(options) {
|
|
this.pendingCanvasMutations = new Map();
|
|
this.rafStamps = { latestId: 0, invokeId: null };
|
|
this.frozen = false;
|
|
this.locked = false;
|
|
this.processMutation = function (target, mutation) {
|
|
const newFrame = this.rafStamps.invokeId &&
|
|
this.rafStamps.latestId !== this.rafStamps.invokeId;
|
|
if (newFrame || !this.rafStamps.invokeId)
|
|
this.rafStamps.invokeId = this.rafStamps.latestId;
|
|
if (!this.pendingCanvasMutations.has(target)) {
|
|
this.pendingCanvasMutations.set(target, []);
|
|
}
|
|
this.pendingCanvasMutations.get(target).push(mutation);
|
|
};
|
|
this.mutationCb = options.mutationCb;
|
|
this.mirror = options.mirror;
|
|
if (options.recordCanvas === true)
|
|
this.initCanvasMutationObserver(options.win, options.blockClass, options.blockSelector, options.unblockSelector);
|
|
}
|
|
initCanvasMutationObserver(win, blockClass, unblockSelector, blockSelector) {
|
|
this.startRAFTimestamping();
|
|
this.startPendingCanvasMutationFlusher();
|
|
const canvasContextReset = initCanvasContextObserver(win, blockClass, blockSelector, unblockSelector);
|
|
const canvas2DReset = initCanvas2DMutationObserver(this.processMutation.bind(this), win, blockClass, blockSelector, unblockSelector, this.mirror);
|
|
const canvasWebGL1and2Reset = initCanvasWebGLMutationObserver(this.processMutation.bind(this), win, blockClass, blockSelector, unblockSelector, this.mirror);
|
|
this.resetObservers = () => {
|
|
canvasContextReset();
|
|
canvas2DReset();
|
|
canvasWebGL1and2Reset();
|
|
};
|
|
}
|
|
startPendingCanvasMutationFlusher() {
|
|
requestAnimationFrame(() => this.flushPendingCanvasMutations());
|
|
}
|
|
startRAFTimestamping() {
|
|
const setLatestRAFTimestamp = (timestamp) => {
|
|
this.rafStamps.latestId = timestamp;
|
|
requestAnimationFrame(setLatestRAFTimestamp);
|
|
};
|
|
requestAnimationFrame(setLatestRAFTimestamp);
|
|
}
|
|
flushPendingCanvasMutations() {
|
|
this.pendingCanvasMutations.forEach((values, canvas) => {
|
|
const id = this.mirror.getId(canvas);
|
|
this.flushPendingCanvasMutationFor(canvas, id);
|
|
});
|
|
requestAnimationFrame(() => this.flushPendingCanvasMutations());
|
|
}
|
|
flushPendingCanvasMutationFor(canvas, id) {
|
|
if (this.frozen || this.locked) {
|
|
return;
|
|
}
|
|
const valuesWithType = this.pendingCanvasMutations.get(canvas);
|
|
if (!valuesWithType || id === -1)
|
|
return;
|
|
const values = valuesWithType.map((value) => {
|
|
const rest = __rest(value, ["type"]);
|
|
return rest;
|
|
});
|
|
const { type } = valuesWithType[0];
|
|
this.mutationCb({ id, type, commands: values });
|
|
this.pendingCanvasMutations.delete(canvas);
|
|
}
|
|
}
|
|
|
|
function wrapEvent(e) {
|
|
return Object.assign(Object.assign({}, e), { timestamp: Date.now() });
|
|
}
|
|
let wrappedEmit;
|
|
let takeFullSnapshot;
|
|
const mirror = createMirror();
|
|
function record(options = {}) {
|
|
const { emit, checkoutEveryNms, checkoutEveryNth, blockClass = 'rr-block', blockSelector = null, unblockSelector = null, ignoreClass = 'rr-ignore', ignoreSelector = null, maskTextClass = 'rr-mask', maskTextSelector = null, maskInputSelector = null, unmaskTextSelector = null, unmaskInputSelector = null, inlineStylesheet = true, maskAllText = false, maskAllInputs, maskInputOptions: _maskInputOptions, slimDOMOptions: _slimDOMOptions, maskInputFn, maskTextFn, hooks, packFn, sampling = {}, mousemoveWait, recordCanvas = false, userTriggeredOnInput = false, collectFonts = false, inlineImages = false, plugins, keepIframeSrcFn = () => false, onMutation, } = options;
|
|
if (!emit) {
|
|
throw new Error('emit function is required');
|
|
}
|
|
if (mousemoveWait !== undefined && sampling.mousemove === undefined) {
|
|
sampling.mousemove = mousemoveWait;
|
|
}
|
|
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) {
|
|
e = packFn(e);
|
|
}
|
|
return e;
|
|
};
|
|
wrappedEmit = (e, isCheckout) => {
|
|
var _a;
|
|
if (((_a = mutationBuffers[0]) === null || _a === void 0 ? void 0 : _a.isFrozen()) &&
|
|
e.type !== EventType.FullSnapshot &&
|
|
!(e.type === EventType.IncrementalSnapshot &&
|
|
e.data.source === IncrementalSource.Mutation)) {
|
|
mutationBuffers.forEach((buf) => buf.unfreeze());
|
|
}
|
|
emit(eventProcessor(e), isCheckout);
|
|
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: Object.assign({ source: IncrementalSource.Mutation }, m),
|
|
}));
|
|
};
|
|
const wrappedScrollEmit = (p) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.Scroll }, p),
|
|
}));
|
|
const wrappedCanvasMutationEmit = (p) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.CanvasMutation }, p),
|
|
}));
|
|
const iframeManager = new IframeManager({
|
|
mutationCb: wrappedMutationEmit,
|
|
});
|
|
const canvasManager = new CanvasManager({
|
|
recordCanvas,
|
|
mutationCb: wrappedCanvasMutationEmit,
|
|
win: window,
|
|
blockClass,
|
|
blockSelector,
|
|
unblockSelector,
|
|
mirror,
|
|
});
|
|
const shadowDomManager = new ShadowDomManager({
|
|
mutationCb: wrappedMutationEmit,
|
|
scrollCb: wrappedScrollEmit,
|
|
bypassOptions: {
|
|
onMutation,
|
|
blockClass,
|
|
blockSelector,
|
|
unblockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
unmaskTextSelector,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
inlineStylesheet,
|
|
maskAllText,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
recordCanvas,
|
|
inlineImages,
|
|
sampling,
|
|
slimDOMOptions,
|
|
iframeManager,
|
|
canvasManager,
|
|
},
|
|
mirror,
|
|
});
|
|
takeFullSnapshot = (isCheckout = false) => {
|
|
var _a, _b, _c, _d;
|
|
wrappedEmit(wrapEvent({
|
|
type: EventType.Meta,
|
|
data: {
|
|
href: window.location.href,
|
|
width: getWindowWidth(),
|
|
height: getWindowHeight(),
|
|
},
|
|
}), isCheckout);
|
|
mutationBuffers.forEach((buf) => buf.lock());
|
|
const [node, idNodeMap] = snapshot(document, {
|
|
blockClass,
|
|
blockSelector,
|
|
unblockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
unmaskTextSelector,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
inlineStylesheet,
|
|
maskAllText,
|
|
maskAllInputs: maskInputOptions,
|
|
maskTextFn,
|
|
slimDOM: slimDOMOptions,
|
|
recordCanvas,
|
|
inlineImages,
|
|
onSerialize: (n) => {
|
|
if (isIframeINode(n)) {
|
|
iframeManager.addIframe(n);
|
|
}
|
|
if (hasShadowRoot(n)) {
|
|
shadowDomManager.addShadowRoot(n.shadowRoot, document);
|
|
}
|
|
},
|
|
onIframeLoad: (iframe, childSn) => {
|
|
iframeManager.attachIframe(iframe, childSn);
|
|
shadowDomManager.observeAttachShadow(iframe);
|
|
},
|
|
keepIframeSrcFn,
|
|
});
|
|
if (!node) {
|
|
return console.warn('Failed to snapshot the document');
|
|
}
|
|
mirror.map = idNodeMap;
|
|
wrappedEmit(wrapEvent({
|
|
type: EventType.FullSnapshot,
|
|
data: {
|
|
node,
|
|
initialOffset: {
|
|
left: window.pageXOffset !== undefined
|
|
? window.pageXOffset
|
|
: (document === null || document === void 0 ? void 0 : document.documentElement.scrollLeft) ||
|
|
((_b = (_a = document === null || document === void 0 ? void 0 : document.body) === null || _a === void 0 ? void 0 : _a.parentElement) === null || _b === void 0 ? void 0 : _b.scrollLeft) ||
|
|
(document === null || document === void 0 ? void 0 : document.body.scrollLeft) ||
|
|
0,
|
|
top: window.pageYOffset !== undefined
|
|
? window.pageYOffset
|
|
: (document === null || document === void 0 ? void 0 : document.documentElement.scrollTop) ||
|
|
((_d = (_c = document === null || document === void 0 ? void 0 : document.body) === null || _c === void 0 ? void 0 : _c.parentElement) === null || _d === void 0 ? void 0 : _d.scrollTop) ||
|
|
(document === null || document === void 0 ? void 0 : document.body.scrollTop) ||
|
|
0,
|
|
},
|
|
},
|
|
}));
|
|
mutationBuffers.forEach((buf) => buf.unlock());
|
|
};
|
|
try {
|
|
const handlers = [];
|
|
handlers.push(on('DOMContentLoaded', () => {
|
|
wrappedEmit(wrapEvent({
|
|
type: EventType.DomContentLoaded,
|
|
data: {},
|
|
}));
|
|
}));
|
|
const observe = (doc) => {
|
|
var _a;
|
|
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: Object.assign({ source: IncrementalSource.MouseInteraction }, d),
|
|
})),
|
|
scrollCb: wrappedScrollEmit,
|
|
viewportResizeCb: (d) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.ViewportResize }, d),
|
|
})),
|
|
inputCb: (v) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.Input }, v),
|
|
})),
|
|
mediaInteractionCb: (p) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.MediaInteraction }, p),
|
|
})),
|
|
styleSheetRuleCb: (r) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.StyleSheetRule }, r),
|
|
})),
|
|
styleDeclarationCb: (r) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.StyleDeclaration }, r),
|
|
})),
|
|
canvasMutationCb: wrappedCanvasMutationEmit,
|
|
fontCb: (p) => wrappedEmit(wrapEvent({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: Object.assign({ source: IncrementalSource.Font }, p),
|
|
})),
|
|
blockClass,
|
|
ignoreClass,
|
|
ignoreSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
unmaskTextSelector,
|
|
maskInputSelector,
|
|
unmaskInputSelector,
|
|
maskInputOptions,
|
|
inlineStylesheet,
|
|
sampling,
|
|
recordCanvas,
|
|
inlineImages,
|
|
userTriggeredOnInput,
|
|
collectFonts,
|
|
doc,
|
|
maskAllText,
|
|
maskInputFn,
|
|
maskTextFn,
|
|
blockSelector,
|
|
unblockSelector,
|
|
slimDOMOptions,
|
|
mirror,
|
|
iframeManager,
|
|
shadowDomManager,
|
|
canvasManager,
|
|
plugins: ((_a = plugins === null || plugins === void 0 ? void 0 : plugins.filter((p) => p.observer)) === null || _a === void 0 ? void 0 : _a.map((p) => ({
|
|
observer: p.observer,
|
|
options: p.options,
|
|
callback: (payload) => wrappedEmit(wrapEvent({
|
|
type: EventType.Plugin,
|
|
data: {
|
|
plugin: p.name,
|
|
payload,
|
|
},
|
|
})),
|
|
}))) || [],
|
|
}, hooks);
|
|
};
|
|
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('load', () => {
|
|
wrappedEmit(wrapEvent({
|
|
type: EventType.Load,
|
|
data: {},
|
|
}));
|
|
init();
|
|
}, window));
|
|
}
|
|
return () => {
|
|
handlers.forEach((h) => h());
|
|
};
|
|
}
|
|
catch (error) {
|
|
console.warn(error);
|
|
}
|
|
}
|
|
record.addCustomEvent = (tag, payload) => {
|
|
if (!wrappedEmit) {
|
|
throw new Error('please add custom event after start recording');
|
|
}
|
|
wrappedEmit(wrapEvent({
|
|
type: EventType.Custom,
|
|
data: {
|
|
tag,
|
|
payload,
|
|
},
|
|
}));
|
|
};
|
|
record.freezePage = () => {
|
|
mutationBuffers.forEach((buf) => buf.freeze());
|
|
};
|
|
record.takeFullSnapshot = (isCheckout) => {
|
|
if (!takeFullSnapshot) {
|
|
throw new Error('please take full snapshot after start recording');
|
|
}
|
|
takeFullSnapshot(isCheckout);
|
|
};
|
|
record.mirror = mirror;
|
|
|
|
/**
|
|
* 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(() => {
|
|
void 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';
|
|
|
|
/**
|
|
* 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 <button><img></button>,
|
|
* 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;
|
|
}
|
|
|
|
const closestInteractive = target.closest(INTERACTIVE_SELECTOR);
|
|
return closestInteractive || 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
|
|
__init() {this._lastMutation = 0;}
|
|
__init2() {this._lastScroll = 0;}
|
|
|
|
__init3() {this._clicks = [];}
|
|
|
|
constructor(
|
|
replay,
|
|
slowClickConfig,
|
|
// Just for easier testing
|
|
_addBreadcrumbEvent = addBreadcrumbEvent,
|
|
) {ClickDetector.prototype.__init.call(this);ClickDetector.prototype.__init2.call(this);ClickDetector.prototype.__init3.call(this);
|
|
// We want everything in s, but options are in ms
|
|
this._timeout = slowClickConfig.timeout / 1000;
|
|
this._multiClickTimeout = slowClickConfig.multiClickTimeout / 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 mutationHandler = () => {
|
|
this._lastMutation = nowInSeconds();
|
|
};
|
|
|
|
const scrollHandler = () => {
|
|
this._lastScroll = nowInSeconds();
|
|
};
|
|
|
|
const cleanupWindowOpen = onWindowOpen(() => {
|
|
// Treat window.open as mutation
|
|
this._lastMutation = nowInSeconds();
|
|
});
|
|
|
|
const clickHandler = (event) => {
|
|
if (!event.target) {
|
|
return;
|
|
}
|
|
|
|
const node = getClickTargetNode(event);
|
|
if (node) {
|
|
this._handleMultiClick(node );
|
|
}
|
|
};
|
|
|
|
const obs = new MutationObserver(mutationHandler);
|
|
|
|
obs.observe(WINDOW.document.documentElement, {
|
|
attributes: true,
|
|
characterData: true,
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
|
|
WINDOW.addEventListener('scroll', scrollHandler, { passive: true });
|
|
WINDOW.addEventListener('click', clickHandler, { passive: true });
|
|
|
|
this._teardown = () => {
|
|
WINDOW.removeEventListener('scroll', scrollHandler);
|
|
WINDOW.removeEventListener('click', clickHandler);
|
|
cleanupWindowOpen();
|
|
|
|
obs.disconnect();
|
|
this._clicks = [];
|
|
this._lastMutation = 0;
|
|
this._lastScroll = 0;
|
|
};
|
|
}
|
|
|
|
/** Clean up listeners. */
|
|
removeListeners() {
|
|
if (this._teardown) {
|
|
this._teardown();
|
|
}
|
|
|
|
if (this._checkClickTimeout) {
|
|
clearTimeout(this._checkClickTimeout);
|
|
}
|
|
}
|
|
|
|
/** Handle a click */
|
|
handleClick(breadcrumb, node) {
|
|
if (ignoreElement(node, this._ignoreSelector) || !isClickBreadcrumb(breadcrumb)) {
|
|
return;
|
|
}
|
|
|
|
const click = this._getClick(node);
|
|
|
|
if (click) {
|
|
// this means a click on the same element was captured in the last 1s, so we consider this a multi click
|
|
return;
|
|
}
|
|
|
|
const newClick = {
|
|
timestamp: breadcrumb.timestamp,
|
|
clickBreadcrumb: breadcrumb,
|
|
// Set this to 0 so we know it originates from the click breadcrumb
|
|
clickCount: 0,
|
|
node,
|
|
};
|
|
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();
|
|
}
|
|
}
|
|
|
|
/** Count multiple clicks on elements. */
|
|
_handleMultiClick(node) {
|
|
const click = this._getClick(node);
|
|
|
|
if (!click) {
|
|
return;
|
|
}
|
|
|
|
click.clickCount++;
|
|
}
|
|
|
|
/** Try to get an existing click on the given element. */
|
|
_getClick(node) {
|
|
const now = nowInSeconds();
|
|
|
|
// Find any click on the same element in the last second
|
|
// If one exists, we consider this click as a double/triple/etc click
|
|
return this._clicks.find(click => click.node === node && now - click.timestamp < this._multiClickTimeout);
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
// If an action happens after the multi click threshold, we can skip waiting and handle the click right away
|
|
const actionTime = click.scrollAfter || click.mutationAfter || 0;
|
|
if (actionTime && actionTime >= this._multiClickTimeout) {
|
|
timedOutClicks.push(click);
|
|
return;
|
|
}
|
|
|
|
if (click.timestamp + this._timeout <= now) {
|
|
timedOutClicks.push(click);
|
|
}
|
|
});
|
|
|
|
// Remove "old" clicks
|
|
for (const click of timedOutClicks) {
|
|
this._generateBreadcrumbs(click);
|
|
|
|
const pos = this._clicks.indexOf(click);
|
|
if (pos !== -1) {
|
|
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() {
|
|
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 <input> 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 <a> 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;
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
]);
|
|
|
|
/**
|
|
* 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 );
|
|
// Ignore clicks if ctrl/alt/meta keys are held down as they alter behavior of clicks (e.g. open in new tab)
|
|
if (isClick && replay.clickDetector && event && !event.altKey && !event.metaKey && !event.ctrlKey) {
|
|
handleClick(
|
|
replay.clickDetector,
|
|
result ,
|
|
getClickTargetNode(handlerData.event) ,
|
|
);
|
|
}
|
|
|
|
addBreadcrumbEvent(replay, result);
|
|
};
|
|
};
|
|
|
|
/** Get the base DOM breadcrumb. */
|
|
function getBaseDomBreadcrumb(target, message) {
|
|
// `__sn` property is the serialized node created by rrweb
|
|
const serializedNode = target && isRrwebNode(target) && target.__sn.type === NodeType.Element ? target.__sn : null;
|
|
|
|
return {
|
|
message,
|
|
data: serializedNode
|
|
? {
|
|
nodeId: serializedNode.id,
|
|
node: {
|
|
id: serializedNode.id,
|
|
tagName: serializedNode.tagName,
|
|
textContent: target
|
|
? Array.from(target.childNodes)
|
|
.map(
|
|
(node) => '__sn' in node && node.__sn.type === NodeType.Text && node.__sn.textContent,
|
|
)
|
|
.filter(Boolean) // filter out empty values
|
|
.map(text => (text ).trim())
|
|
.join('')
|
|
: '',
|
|
attributes: getAttributesToRecord(serializedNode.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 }) || '<unknown>';
|
|
} catch (e) {
|
|
message = '<unknown>';
|
|
}
|
|
|
|
return { target, message };
|
|
}
|
|
|
|
function isRrwebNode(node) {
|
|
return '__sn' in node;
|
|
}
|
|
|
|
/** 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 }) || '<unknown>';
|
|
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;
|
|
}
|
|
|
|
const NAVIGATION_ENTRY_KEYS = [
|
|
'name',
|
|
'type',
|
|
'startTime',
|
|
'transferSize',
|
|
'duration',
|
|
];
|
|
|
|
function isNavigationEntryEqual(a) {
|
|
return function (b) {
|
|
return NAVIGATION_ENTRY_KEYS.every(key => a[key] === b[key]);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* There are some difficulties diagnosing why there are duplicate navigation
|
|
* entries. We've witnessed several intermittent results:
|
|
* - duplicate entries have duration = 0
|
|
* - duplicate entries are the same object reference
|
|
* - none of the above
|
|
*
|
|
* Compare the values of several keys to determine if the entries are duplicates or not.
|
|
*/
|
|
// TODO (high-prio): Figure out wth is returned here
|
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
function dedupePerformanceEntries(
|
|
currentList,
|
|
newList,
|
|
) {
|
|
// Partition `currentList` into 3 different lists based on entryType
|
|
const [existingNavigationEntries, existingLcpEntries, existingEntries] = currentList.reduce(
|
|
(acc, entry) => {
|
|
if (entry.entryType === 'navigation') {
|
|
acc[0].push(entry );
|
|
} else if (entry.entryType === 'largest-contentful-paint') {
|
|
acc[1].push(entry );
|
|
} else {
|
|
acc[2].push(entry);
|
|
}
|
|
return acc;
|
|
},
|
|
[[], [], []],
|
|
);
|
|
|
|
const newEntries = [];
|
|
const newNavigationEntries = [];
|
|
let newLcpEntry = existingLcpEntries.length
|
|
? existingLcpEntries[existingLcpEntries.length - 1] // Take the last element as list is sorted
|
|
: undefined;
|
|
|
|
newList.forEach(entry => {
|
|
if (entry.entryType === 'largest-contentful-paint') {
|
|
// We want the latest LCP event only
|
|
if (!newLcpEntry || newLcpEntry.startTime < entry.startTime) {
|
|
newLcpEntry = entry;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (entry.entryType === 'navigation') {
|
|
const navigationEntry = entry ;
|
|
|
|
// Check if the navigation entry is contained in currentList or newList
|
|
if (
|
|
// Ignore any navigation entries with duration 0, as they are likely duplicates
|
|
entry.duration > 0 &&
|
|
// Ensure new entry does not already exist in existing entries
|
|
!existingNavigationEntries.find(isNavigationEntryEqual(navigationEntry)) &&
|
|
// Ensure new entry does not already exist in new list of navigation entries
|
|
!newNavigationEntries.find(isNavigationEntryEqual(navigationEntry))
|
|
) {
|
|
newNavigationEntries.push(navigationEntry);
|
|
}
|
|
|
|
// Otherwise this navigation entry is considered a duplicate and is thrown away
|
|
return;
|
|
}
|
|
|
|
newEntries.push(entry);
|
|
});
|
|
|
|
// Re-combine and sort by startTime
|
|
return [
|
|
...(newLcpEntry ? [newLcpEntry] : []),
|
|
...existingNavigationEntries,
|
|
...existingEntries,
|
|
...newEntries,
|
|
...newNavigationEntries,
|
|
].sort((a, b) => a.startTime - b.startTime);
|
|
}
|
|
|
|
/**
|
|
* Sets up a PerformanceObserver to listen to all performance entry types.
|
|
*/
|
|
function setupPerformanceObserver(replay) {
|
|
const performanceObserverHandler = (list) => {
|
|
// For whatever reason the observer was returning duplicate navigation
|
|
// entries (the other entry types were not duplicated).
|
|
const newPerformanceEntries = dedupePerformanceEntries(
|
|
replay.performanceEvents,
|
|
list.getEntries() ,
|
|
);
|
|
replay.performanceEvents = newPerformanceEntries;
|
|
};
|
|
|
|
const performanceObserver = new PerformanceObserver(performanceObserverHandler);
|
|
|
|
[
|
|
'element',
|
|
'event',
|
|
'first-input',
|
|
'largest-contentful-paint',
|
|
'layout-shift',
|
|
'longtask',
|
|
'navigation',
|
|
'paint',
|
|
'resource',
|
|
].forEach(type => {
|
|
try {
|
|
performanceObserver.observe({
|
|
type,
|
|
buffered: true,
|
|
});
|
|
} catch (e) {
|
|
// This can throw if an entry type is not supported in the browser.
|
|
// Ignore these errors.
|
|
}
|
|
});
|
|
|
|
return performanceObserver;
|
|
}
|
|
|
|
const r = `/*! pako 2.1.0 https://github.com/nodeca/pako @license (MIT AND Zlib) */
|
|
function t(t){let e=t.length;for(;--e>=0;)t[e]=0}const e=new Uint8Array([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]),a=new Uint8Array([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]),i=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7]),n=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),s=new Array(576);t(s);const r=new Array(60);t(r);const o=new Array(512);t(o);const l=new Array(256);t(l);const h=new Array(29);t(h);const d=new Array(30);function _(t,e,a,i,n){this.static_tree=t,this.extra_bits=e,this.extra_base=a,this.elems=i,this.max_length=n,this.has_stree=t&&t.length}let f,c,u;function w(t,e){this.dyn_tree=t,this.max_code=0,this.stat_desc=e}t(d);const m=t=>t<256?o[t]:o[256+(t>>>7)],b=(t,e)=>{t.pending_buf[t.pending++]=255&e,t.pending_buf[t.pending++]=e>>>8&255},g=(t,e,a)=>{t.bi_valid>16-a?(t.bi_buf|=e<<t.bi_valid&65535,b(t,t.bi_buf),t.bi_buf=e>>16-t.bi_valid,t.bi_valid+=a-16):(t.bi_buf|=e<<t.bi_valid&65535,t.bi_valid+=a)},p=(t,e,a)=>{g(t,a[2*e],a[2*e+1])},k=(t,e)=>{let a=0;do{a|=1&t,t>>>=1,a<<=1}while(--e>0);return a>>>1},v=(t,e,a)=>{const i=new Array(16);let n,s,r=0;for(n=1;n<=15;n++)r=r+a[n-1]<<1,i[n]=r;for(s=0;s<=e;s++){let e=t[2*s+1];0!==e&&(t[2*s]=k(i[e]++,e))}},y=t=>{let e;for(e=0;e<286;e++)t.dyn_ltree[2*e]=0;for(e=0;e<30;e++)t.dyn_dtree[2*e]=0;for(e=0;e<19;e++)t.bl_tree[2*e]=0;t.dyn_ltree[512]=1,t.opt_len=t.static_len=0,t.sym_next=t.matches=0},x=t=>{t.bi_valid>8?b(t,t.bi_buf):t.bi_valid>0&&(t.pending_buf[t.pending++]=t.bi_buf),t.bi_buf=0,t.bi_valid=0},z=(t,e,a,i)=>{const n=2*e,s=2*a;return t[n]<t[s]||t[n]===t[s]&&i[e]<=i[a]},A=(t,e,a)=>{const i=t.heap[a];let n=a<<1;for(;n<=t.heap_len&&(n<t.heap_len&&z(e,t.heap[n+1],t.heap[n],t.depth)&&n++,!z(e,i,t.heap[n],t.depth));)t.heap[a]=t.heap[n],a=n,n<<=1;t.heap[a]=i},E=(t,i,n)=>{let s,r,o,_,f=0;if(0!==t.sym_next)do{s=255&t.pending_buf[t.sym_buf+f++],s+=(255&t.pending_buf[t.sym_buf+f++])<<8,r=t.pending_buf[t.sym_buf+f++],0===s?p(t,r,i):(o=l[r],p(t,o+256+1,i),_=e[o],0!==_&&(r-=h[o],g(t,r,_)),s--,o=m(s),p(t,o,n),_=a[o],0!==_&&(s-=d[o],g(t,s,_)))}while(f<t.sym_next);p(t,256,i)},R=(t,e)=>{const a=e.dyn_tree,i=e.stat_desc.static_tree,n=e.stat_desc.has_stree,s=e.stat_desc.elems;let r,o,l,h=-1;for(t.heap_len=0,t.heap_max=573,r=0;r<s;r++)0!==a[2*r]?(t.heap[++t.heap_len]=h=r,t.depth[r]=0):a[2*r+1]=0;for(;t.heap_len<2;)l=t.heap[++t.heap_len]=h<2?++h:0,a[2*l]=1,t.depth[l]=0,t.opt_len--,n&&(t.static_len-=i[2*l+1]);for(e.max_code=h,r=t.heap_len>>1;r>=1;r--)A(t,a,r);l=s;do{r=t.heap[1],t.heap[1]=t.heap[t.heap_len--],A(t,a,1),o=t.heap[1],t.heap[--t.heap_max]=r,t.heap[--t.heap_max]=o,a[2*l]=a[2*r]+a[2*o],t.depth[l]=(t.depth[r]>=t.depth[o]?t.depth[r]:t.depth[o])+1,a[2*r+1]=a[2*o+1]=l,t.heap[1]=l++,A(t,a,1)}while(t.heap_len>=2);t.heap[--t.heap_max]=t.heap[1],((t,e)=>{const a=e.dyn_tree,i=e.max_code,n=e.stat_desc.static_tree,s=e.stat_desc.has_stree,r=e.stat_desc.extra_bits,o=e.stat_desc.extra_base,l=e.stat_desc.max_length;let h,d,_,f,c,u,w=0;for(f=0;f<=15;f++)t.bl_count[f]=0;for(a[2*t.heap[t.heap_max]+1]=0,h=t.heap_max+1;h<573;h++)d=t.heap[h],f=a[2*a[2*d+1]+1]+1,f>l&&(f=l,w++),a[2*d+1]=f,d>i||(t.bl_count[f]++,c=0,d>=o&&(c=r[d-o]),u=a[2*d],t.opt_len+=u*(f+c),s&&(t.static_len+=u*(n[2*d+1]+c)));if(0!==w){do{for(f=l-1;0===t.bl_count[f];)f--;t.bl_count[f]--,t.bl_count[f+1]+=2,t.bl_count[l]--,w-=2}while(w>0);for(f=l;0!==f;f--)for(d=t.bl_count[f];0!==d;)_=t.heap[--h],_>i||(a[2*_+1]!==f&&(t.opt_len+=(f-a[2*_+1])*a[2*_],a[2*_+1]=f),d--)}})(t,e),v(a,h,t.bl_count)},Z=(t,e,a)=>{let i,n,s=-1,r=e[1],o=0,l=7,h=4;for(0===r&&(l=138,h=3),e[2*(a+1)+1]=65535,i=0;i<=a;i++)n=r,r=e[2*(i+1)+1],++o<l&&n===r||(o<h?t.bl_tree[2*n]+=o:0!==n?(n!==s&&t.bl_tree[2*n]++,t.bl_tree[32]++):o<=10?t.bl_tree[34]++:t.bl_tree[36]++,o=0,s=n,0===r?(l=138,h=3):n===r?(l=6,h=3):(l=7,h=4))},U=(t,e,a)=>{let i,n,s=-1,r=e[1],o=0,l=7,h=4;for(0===r&&(l=138,h=3),i=0;i<=a;i++)if(n=r,r=e[2*(i+1)+1],!(++o<l&&n===r)){if(o<h)do{p(t,n,t.bl_tree)}while(0!=--o);else 0!==n?(n!==s&&(p(t,n,t.bl_tree),o--),p(t,16,t.bl_tree),g(t,o-3,2)):o<=10?(p(t,17,t.bl_tree),g(t,o-3,3)):(p(t,18,t.bl_tree),g(t,o-11,7));o=0,s=n,0===r?(l=138,h=3):n===r?(l=6,h=3):(l=7,h=4)}};let S=!1;const D=(t,e,a,i)=>{g(t,0+(i?1:0),3),x(t),b(t,a),b(t,~a),a&&t.pending_buf.set(t.window.subarray(e,e+a),t.pending),t.pending+=a};var T=(t,e,a,i)=>{let o,l,h=0;t.level>0?(2===t.strm.data_type&&(t.strm.data_type=(t=>{let e,a=4093624447;for(e=0;e<=31;e++,a>>>=1)if(1&a&&0!==t.dyn_ltree[2*e])return 0;if(0!==t.dyn_ltree[18]||0!==t.dyn_ltree[20]||0!==t.dyn_ltree[26])return 1;for(e=32;e<256;e++)if(0!==t.dyn_ltree[2*e])return 1;return 0})(t)),R(t,t.l_desc),R(t,t.d_desc),h=(t=>{let e;for(Z(t,t.dyn_ltree,t.l_desc.max_code),Z(t,t.dyn_dtree,t.d_desc.max_code),R(t,t.bl_desc),e=18;e>=3&&0===t.bl_tree[2*n[e]+1];e--);return t.opt_len+=3*(e+1)+5+5+4,e})(t),o=t.opt_len+3+7>>>3,l=t.static_len+3+7>>>3,l<=o&&(o=l)):o=l=a+5,a+4<=o&&-1!==e?D(t,e,a,i):4===t.strategy||l===o?(g(t,2+(i?1:0),3),E(t,s,r)):(g(t,4+(i?1:0),3),((t,e,a,i)=>{let s;for(g(t,e-257,5),g(t,a-1,5),g(t,i-4,4),s=0;s<i;s++)g(t,t.bl_tree[2*n[s]+1],3);U(t,t.dyn_ltree,e-1),U(t,t.dyn_dtree,a-1)})(t,t.l_desc.max_code+1,t.d_desc.max_code+1,h+1),E(t,t.dyn_ltree,t.dyn_dtree)),y(t),i&&x(t)},O={_tr_init:t=>{S||((()=>{let t,n,w,m,b;const g=new Array(16);for(w=0,m=0;m<28;m++)for(h[m]=w,t=0;t<1<<e[m];t++)l[w++]=m;for(l[w-1]=m,b=0,m=0;m<16;m++)for(d[m]=b,t=0;t<1<<a[m];t++)o[b++]=m;for(b>>=7;m<30;m++)for(d[m]=b<<7,t=0;t<1<<a[m]-7;t++)o[256+b++]=m;for(n=0;n<=15;n++)g[n]=0;for(t=0;t<=143;)s[2*t+1]=8,t++,g[8]++;for(;t<=255;)s[2*t+1]=9,t++,g[9]++;for(;t<=279;)s[2*t+1]=7,t++,g[7]++;for(;t<=287;)s[2*t+1]=8,t++,g[8]++;for(v(s,287,g),t=0;t<30;t++)r[2*t+1]=5,r[2*t]=k(t,5);f=new _(s,e,257,286,15),c=new _(r,a,0,30,15),u=new _(new Array(0),i,0,19,7)})(),S=!0),t.l_desc=new w(t.dyn_ltree,f),t.d_desc=new w(t.dyn_dtree,c),t.bl_desc=new w(t.bl_tree,u),t.bi_buf=0,t.bi_valid=0,y(t)},_tr_stored_block:D,_tr_flush_block:T,_tr_tally:(t,e,a)=>(t.pending_buf[t.sym_buf+t.sym_next++]=e,t.pending_buf[t.sym_buf+t.sym_next++]=e>>8,t.pending_buf[t.sym_buf+t.sym_next++]=a,0===e?t.dyn_ltree[2*a]++:(t.matches++,e--,t.dyn_ltree[2*(l[a]+256+1)]++,t.dyn_dtree[2*m(e)]++),t.sym_next===t.sym_end),_tr_align:t=>{g(t,2,3),p(t,256,s),(t=>{16===t.bi_valid?(b(t,t.bi_buf),t.bi_buf=0,t.bi_valid=0):t.bi_valid>=8&&(t.pending_buf[t.pending++]=255&t.bi_buf,t.bi_buf>>=8,t.bi_valid-=8)})(t)}};var F=(t,e,a,i)=>{let n=65535&t|0,s=t>>>16&65535|0,r=0;for(;0!==a;){r=a>2e3?2e3:a,a-=r;do{n=n+e[i++]|0,s=s+n|0}while(--r);n%=65521,s%=65521}return n|s<<16|0};const L=new Uint32Array((()=>{let t,e=[];for(var a=0;a<256;a++){t=a;for(var i=0;i<8;i++)t=1&t?3988292384^t>>>1:t>>>1;e[a]=t}return e})());var N=(t,e,a,i)=>{const n=L,s=i+a;t^=-1;for(let a=i;a<s;a++)t=t>>>8^n[255&(t^e[a])];return-1^t},I={2:"need dictionary",1:"stream end",0:"","-1":"file error","-2":"stream error","-3":"data error","-4":"insufficient memory","-5":"buffer error","-6":"incompatible version"},B={Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_TREES:6,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_MEM_ERROR:-4,Z_BUF_ERROR:-5,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,Z_BINARY:0,Z_TEXT:1,Z_UNKNOWN:2,Z_DEFLATED:8};const{_tr_init:C,_tr_stored_block:H,_tr_flush_block:M,_tr_tally:j,_tr_align:K}=O,{Z_NO_FLUSH:P,Z_PARTIAL_FLUSH:Y,Z_FULL_FLUSH:G,Z_FINISH:X,Z_BLOCK:W,Z_OK:q,Z_STREAM_END:J,Z_STREAM_ERROR:Q,Z_DATA_ERROR:V,Z_BUF_ERROR:$,Z_DEFAULT_COMPRESSION:tt,Z_FILTERED:et,Z_HUFFMAN_ONLY:at,Z_RLE:it,Z_FIXED:nt,Z_DEFAULT_STRATEGY:st,Z_UNKNOWN:rt,Z_DEFLATED:ot}=B,lt=(t,e)=>(t.msg=I[e],e),ht=t=>2*t-(t>4?9:0),dt=t=>{let e=t.length;for(;--e>=0;)t[e]=0},_t=t=>{let e,a,i,n=t.w_size;e=t.hash_size,i=e;do{a=t.head[--i],t.head[i]=a>=n?a-n:0}while(--e);e=n,i=e;do{a=t.prev[--i],t.prev[i]=a>=n?a-n:0}while(--e)};let ft=(t,e,a)=>(e<<t.hash_shift^a)&t.hash_mask;const ct=t=>{const e=t.state;let a=e.pending;a>t.avail_out&&(a=t.avail_out),0!==a&&(t.output.set(e.pending_buf.subarray(e.pending_out,e.pending_out+a),t.next_out),t.next_out+=a,e.pending_out+=a,t.total_out+=a,t.avail_out-=a,e.pending-=a,0===e.pending&&(e.pending_out=0))},ut=(t,e)=>{M(t,t.block_start>=0?t.block_start:-1,t.strstart-t.block_start,e),t.block_start=t.strstart,ct(t.strm)},wt=(t,e)=>{t.pending_buf[t.pending++]=e},mt=(t,e)=>{t.pending_buf[t.pending++]=e>>>8&255,t.pending_buf[t.pending++]=255&e},bt=(t,e,a,i)=>{let n=t.avail_in;return n>i&&(n=i),0===n?0:(t.avail_in-=n,e.set(t.input.subarray(t.next_in,t.next_in+n),a),1===t.state.wrap?t.adler=F(t.adler,e,n,a):2===t.state.wrap&&(t.adler=N(t.adler,e,n,a)),t.next_in+=n,t.total_in+=n,n)},gt=(t,e)=>{let a,i,n=t.max_chain_length,s=t.strstart,r=t.prev_length,o=t.nice_match;const l=t.strstart>t.w_size-262?t.strstart-(t.w_size-262):0,h=t.window,d=t.w_mask,_=t.prev,f=t.strstart+258;let c=h[s+r-1],u=h[s+r];t.prev_length>=t.good_match&&(n>>=2),o>t.lookahead&&(o=t.lookahead);do{if(a=e,h[a+r]===u&&h[a+r-1]===c&&h[a]===h[s]&&h[++a]===h[s+1]){s+=2,a++;do{}while(h[++s]===h[++a]&&h[++s]===h[++a]&&h[++s]===h[++a]&&h[++s]===h[++a]&&h[++s]===h[++a]&&h[++s]===h[++a]&&h[++s]===h[++a]&&h[++s]===h[++a]&&s<f);if(i=258-(f-s),s=f-258,i>r){if(t.match_start=e,r=i,i>=o)break;c=h[s+r-1],u=h[s+r]}}}while((e=_[e&d])>l&&0!=--n);return r<=t.lookahead?r:t.lookahead},pt=t=>{const e=t.w_size;let a,i,n;do{if(i=t.window_size-t.lookahead-t.strstart,t.strstart>=e+(e-262)&&(t.window.set(t.window.subarray(e,e+e-i),0),t.match_start-=e,t.strstart-=e,t.block_start-=e,t.insert>t.strstart&&(t.insert=t.strstart),_t(t),i+=e),0===t.strm.avail_in)break;if(a=bt(t.strm,t.window,t.strstart+t.lookahead,i),t.lookahead+=a,t.lookahead+t.insert>=3)for(n=t.strstart-t.insert,t.ins_h=t.window[n],t.ins_h=ft(t,t.ins_h,t.window[n+1]);t.insert&&(t.ins_h=ft(t,t.ins_h,t.window[n+3-1]),t.prev[n&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=n,n++,t.insert--,!(t.lookahead+t.insert<3)););}while(t.lookahead<262&&0!==t.strm.avail_in)},kt=(t,e)=>{let a,i,n,s=t.pending_buf_size-5>t.w_size?t.w_size:t.pending_buf_size-5,r=0,o=t.strm.avail_in;do{if(a=65535,n=t.bi_valid+42>>3,t.strm.avail_out<n)break;if(n=t.strm.avail_out-n,i=t.strstart-t.block_start,a>i+t.strm.avail_in&&(a=i+t.strm.avail_in),a>n&&(a=n),a<s&&(0===a&&e!==X||e===P||a!==i+t.strm.avail_in))break;r=e===X&&a===i+t.strm.avail_in?1:0,H(t,0,0,r),t.pending_buf[t.pending-4]=a,t.pending_buf[t.pending-3]=a>>8,t.pending_buf[t.pending-2]=~a,t.pending_buf[t.pending-1]=~a>>8,ct(t.strm),i&&(i>a&&(i=a),t.strm.output.set(t.window.subarray(t.block_start,t.block_start+i),t.strm.next_out),t.strm.next_out+=i,t.strm.avail_out-=i,t.strm.total_out+=i,t.block_start+=i,a-=i),a&&(bt(t.strm,t.strm.output,t.strm.next_out,a),t.strm.next_out+=a,t.strm.avail_out-=a,t.strm.total_out+=a)}while(0===r);return o-=t.strm.avail_in,o&&(o>=t.w_size?(t.matches=2,t.window.set(t.strm.input.subarray(t.strm.next_in-t.w_size,t.strm.next_in),0),t.strstart=t.w_size,t.insert=t.strstart):(t.window_size-t.strstart<=o&&(t.strstart-=t.w_size,t.window.set(t.window.subarray(t.w_size,t.w_size+t.strstart),0),t.matches<2&&t.matches++,t.insert>t.strstart&&(t.insert=t.strstart)),t.window.set(t.strm.input.subarray(t.strm.next_in-o,t.strm.next_in),t.strstart),t.strstart+=o,t.insert+=o>t.w_size-t.insert?t.w_size-t.insert:o),t.block_start=t.strstart),t.high_water<t.strstart&&(t.high_water=t.strstart),r?4:e!==P&&e!==X&&0===t.strm.avail_in&&t.strstart===t.block_start?2:(n=t.window_size-t.strstart,t.strm.avail_in>n&&t.block_start>=t.w_size&&(t.block_start-=t.w_size,t.strstart-=t.w_size,t.window.set(t.window.subarray(t.w_size,t.w_size+t.strstart),0),t.matches<2&&t.matches++,n+=t.w_size,t.insert>t.strstart&&(t.insert=t.strstart)),n>t.strm.avail_in&&(n=t.strm.avail_in),n&&(bt(t.strm,t.window,t.strstart,n),t.strstart+=n,t.insert+=n>t.w_size-t.insert?t.w_size-t.insert:n),t.high_water<t.strstart&&(t.high_water=t.strstart),n=t.bi_valid+42>>3,n=t.pending_buf_size-n>65535?65535:t.pending_buf_size-n,s=n>t.w_size?t.w_size:n,i=t.strstart-t.block_start,(i>=s||(i||e===X)&&e!==P&&0===t.strm.avail_in&&i<=n)&&(a=i>n?n:i,r=e===X&&0===t.strm.avail_in&&a===i?1:0,H(t,t.block_start,a,r),t.block_start+=a,ct(t.strm)),r?3:1)},vt=(t,e)=>{let a,i;for(;;){if(t.lookahead<262){if(pt(t),t.lookahead<262&&e===P)return 1;if(0===t.lookahead)break}if(a=0,t.lookahead>=3&&(t.ins_h=ft(t,t.ins_h,t.window[t.strstart+3-1]),a=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),0!==a&&t.strstart-a<=t.w_size-262&&(t.match_length=gt(t,a)),t.match_length>=3)if(i=j(t,t.strstart-t.match_start,t.match_length-3),t.lookahead-=t.match_length,t.match_length<=t.max_lazy_match&&t.lookahead>=3){t.match_length--;do{t.strstart++,t.ins_h=ft(t,t.ins_h,t.window[t.strstart+3-1]),a=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart}while(0!=--t.match_length);t.strstart++}else t.strstart+=t.match_length,t.match_length=0,t.ins_h=t.window[t.strstart],t.ins_h=ft(t,t.ins_h,t.window[t.strstart+1]);else i=j(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++;if(i&&(ut(t,!1),0===t.strm.avail_out))return 1}return t.insert=t.strstart<2?t.strstart:2,e===X?(ut(t,!0),0===t.strm.avail_out?3:4):t.sym_next&&(ut(t,!1),0===t.strm.avail_out)?1:2},yt=(t,e)=>{let a,i,n;for(;;){if(t.lookahead<262){if(pt(t),t.lookahead<262&&e===P)return 1;if(0===t.lookahead)break}if(a=0,t.lookahead>=3&&(t.ins_h=ft(t,t.ins_h,t.window[t.strstart+3-1]),a=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),t.prev_length=t.match_length,t.prev_match=t.match_start,t.match_length=2,0!==a&&t.prev_length<t.max_lazy_match&&t.strstart-a<=t.w_size-262&&(t.match_length=gt(t,a),t.match_length<=5&&(t.strategy===et||3===t.match_length&&t.strstart-t.match_start>4096)&&(t.match_length=2)),t.prev_length>=3&&t.match_length<=t.prev_length){n=t.strstart+t.lookahead-3,i=j(t,t.strstart-1-t.prev_match,t.prev_length-3),t.lookahead-=t.prev_length-1,t.prev_length-=2;do{++t.strstart<=n&&(t.ins_h=ft(t,t.ins_h,t.window[t.strstart+3-1]),a=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart)}while(0!=--t.prev_length);if(t.match_available=0,t.match_length=2,t.strstart++,i&&(ut(t,!1),0===t.strm.avail_out))return 1}else if(t.match_available){if(i=j(t,0,t.window[t.strstart-1]),i&&ut(t,!1),t.strstart++,t.lookahead--,0===t.strm.avail_out)return 1}else t.match_available=1,t.strstart++,t.lookahead--}return t.match_available&&(i=j(t,0,t.window[t.strstart-1]),t.match_available=0),t.insert=t.strstart<2?t.strstart:2,e===X?(ut(t,!0),0===t.strm.avail_out?3:4):t.sym_next&&(ut(t,!1),0===t.strm.avail_out)?1:2};function xt(t,e,a,i,n){this.good_length=t,this.max_lazy=e,this.nice_length=a,this.max_chain=i,this.func=n}const zt=[new xt(0,0,0,0,kt),new xt(4,4,8,4,vt),new xt(4,5,16,8,vt),new xt(4,6,32,32,vt),new xt(4,4,16,16,yt),new xt(8,16,32,32,yt),new xt(8,16,128,128,yt),new xt(8,32,128,256,yt),new xt(32,128,258,1024,yt),new xt(32,258,258,4096,yt)];function At(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=ot,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new Uint16Array(1146),this.dyn_dtree=new Uint16Array(122),this.bl_tree=new Uint16Array(78),dt(this.dyn_ltree),dt(this.dyn_dtree),dt(this.bl_tree),this.l_desc=null,this.d_desc=null,this.bl_desc=null,this.bl_count=new Uint16Array(16),this.heap=new Uint16Array(573),dt(this.heap),this.heap_len=0,this.heap_max=0,this.depth=new Uint16Array(573),dt(this.depth),this.sym_buf=0,this.lit_bufsize=0,this.sym_next=0,this.sym_end=0,this.opt_len=0,this.static_len=0,this.matches=0,this.insert=0,this.bi_buf=0,this.bi_valid=0}const Et=t=>{if(!t)return 1;const e=t.state;return!e||e.strm!==t||42!==e.status&&57!==e.status&&69!==e.status&&73!==e.status&&91!==e.status&&103!==e.status&&113!==e.status&&666!==e.status?1:0},Rt=t=>{if(Et(t))return lt(t,Q);t.total_in=t.total_out=0,t.data_type=rt;const e=t.state;return e.pending=0,e.pending_out=0,e.wrap<0&&(e.wrap=-e.wrap),e.status=2===e.wrap?57:e.wrap?42:113,t.adler=2===e.wrap?0:1,e.last_flush=-2,C(e),q},Zt=t=>{const e=Rt(t);var a;return e===q&&((a=t.state).window_size=2*a.w_size,dt(a.head),a.max_lazy_match=zt[a.level].max_lazy,a.good_match=zt[a.level].good_length,a.nice_match=zt[a.level].nice_length,a.max_chain_length=zt[a.level].max_chain,a.strstart=0,a.block_start=0,a.lookahead=0,a.insert=0,a.match_length=a.prev_length=2,a.match_available=0,a.ins_h=0),e},Ut=(t,e,a,i,n,s)=>{if(!t)return Q;let r=1;if(e===tt&&(e=6),i<0?(r=0,i=-i):i>15&&(r=2,i-=16),n<1||n>9||a!==ot||i<8||i>15||e<0||e>9||s<0||s>nt||8===i&&1!==r)return lt(t,Q);8===i&&(i=9);const o=new At;return t.state=o,o.strm=t,o.status=42,o.wrap=r,o.gzhead=null,o.w_bits=i,o.w_size=1<<o.w_bits,o.w_mask=o.w_size-1,o.hash_bits=n+7,o.hash_size=1<<o.hash_bits,o.hash_mask=o.hash_size-1,o.hash_shift=~~((o.hash_bits+3-1)/3),o.window=new Uint8Array(2*o.w_size),o.head=new Uint16Array(o.hash_size),o.prev=new Uint16Array(o.w_size),o.lit_bufsize=1<<n+6,o.pending_buf_size=4*o.lit_bufsize,o.pending_buf=new Uint8Array(o.pending_buf_size),o.sym_buf=o.lit_bufsize,o.sym_end=3*(o.lit_bufsize-1),o.level=e,o.strategy=s,o.method=a,Zt(t)};var St={deflateInit:(t,e)=>Ut(t,e,ot,15,8,st),deflateInit2:Ut,deflateReset:Zt,deflateResetKeep:Rt,deflateSetHeader:(t,e)=>Et(t)||2!==t.state.wrap?Q:(t.state.gzhead=e,q),deflate:(t,e)=>{if(Et(t)||e>W||e<0)return t?lt(t,Q):Q;const a=t.state;if(!t.output||0!==t.avail_in&&!t.input||666===a.status&&e!==X)return lt(t,0===t.avail_out?$:Q);const i=a.last_flush;if(a.last_flush=e,0!==a.pending){if(ct(t),0===t.avail_out)return a.last_flush=-1,q}else if(0===t.avail_in&&ht(e)<=ht(i)&&e!==X)return lt(t,$);if(666===a.status&&0!==t.avail_in)return lt(t,$);if(42===a.status&&0===a.wrap&&(a.status=113),42===a.status){let e=ot+(a.w_bits-8<<4)<<8,i=-1;if(i=a.strategy>=at||a.level<2?0:a.level<6?1:6===a.level?2:3,e|=i<<6,0!==a.strstart&&(e|=32),e+=31-e%31,mt(a,e),0!==a.strstart&&(mt(a,t.adler>>>16),mt(a,65535&t.adler)),t.adler=1,a.status=113,ct(t),0!==a.pending)return a.last_flush=-1,q}if(57===a.status)if(t.adler=0,wt(a,31),wt(a,139),wt(a,8),a.gzhead)wt(a,(a.gzhead.text?1:0)+(a.gzhead.hcrc?2:0)+(a.gzhead.extra?4:0)+(a.gzhead.name?8:0)+(a.gzhead.comment?16:0)),wt(a,255&a.gzhead.time),wt(a,a.gzhead.time>>8&255),wt(a,a.gzhead.time>>16&255),wt(a,a.gzhead.time>>24&255),wt(a,9===a.level?2:a.strategy>=at||a.level<2?4:0),wt(a,255&a.gzhead.os),a.gzhead.extra&&a.gzhead.extra.length&&(wt(a,255&a.gzhead.extra.length),wt(a,a.gzhead.extra.length>>8&255)),a.gzhead.hcrc&&(t.adler=N(t.adler,a.pending_buf,a.pending,0)),a.gzindex=0,a.status=69;else if(wt(a,0),wt(a,0),wt(a,0),wt(a,0),wt(a,0),wt(a,9===a.level?2:a.strategy>=at||a.level<2?4:0),wt(a,3),a.status=113,ct(t),0!==a.pending)return a.last_flush=-1,q;if(69===a.status){if(a.gzhead.extra){let e=a.pending,i=(65535&a.gzhead.extra.length)-a.gzindex;for(;a.pending+i>a.pending_buf_size;){let n=a.pending_buf_size-a.pending;if(a.pending_buf.set(a.gzhead.extra.subarray(a.gzindex,a.gzindex+n),a.pending),a.pending=a.pending_buf_size,a.gzhead.hcrc&&a.pending>e&&(t.adler=N(t.adler,a.pending_buf,a.pending-e,e)),a.gzindex+=n,ct(t),0!==a.pending)return a.last_flush=-1,q;e=0,i-=n}let n=new Uint8Array(a.gzhead.extra);a.pending_buf.set(n.subarray(a.gzindex,a.gzindex+i),a.pending),a.pending+=i,a.gzhead.hcrc&&a.pending>e&&(t.adler=N(t.adler,a.pending_buf,a.pending-e,e)),a.gzindex=0}a.status=73}if(73===a.status){if(a.gzhead.name){let e,i=a.pending;do{if(a.pending===a.pending_buf_size){if(a.gzhead.hcrc&&a.pending>i&&(t.adler=N(t.adler,a.pending_buf,a.pending-i,i)),ct(t),0!==a.pending)return a.last_flush=-1,q;i=0}e=a.gzindex<a.gzhead.name.length?255&a.gzhead.name.charCodeAt(a.gzindex++):0,wt(a,e)}while(0!==e);a.gzhead.hcrc&&a.pending>i&&(t.adler=N(t.adler,a.pending_buf,a.pending-i,i)),a.gzindex=0}a.status=91}if(91===a.status){if(a.gzhead.comment){let e,i=a.pending;do{if(a.pending===a.pending_buf_size){if(a.gzhead.hcrc&&a.pending>i&&(t.adler=N(t.adler,a.pending_buf,a.pending-i,i)),ct(t),0!==a.pending)return a.last_flush=-1,q;i=0}e=a.gzindex<a.gzhead.comment.length?255&a.gzhead.comment.charCodeAt(a.gzindex++):0,wt(a,e)}while(0!==e);a.gzhead.hcrc&&a.pending>i&&(t.adler=N(t.adler,a.pending_buf,a.pending-i,i))}a.status=103}if(103===a.status){if(a.gzhead.hcrc){if(a.pending+2>a.pending_buf_size&&(ct(t),0!==a.pending))return a.last_flush=-1,q;wt(a,255&t.adler),wt(a,t.adler>>8&255),t.adler=0}if(a.status=113,ct(t),0!==a.pending)return a.last_flush=-1,q}if(0!==t.avail_in||0!==a.lookahead||e!==P&&666!==a.status){let i=0===a.level?kt(a,e):a.strategy===at?((t,e)=>{let a;for(;;){if(0===t.lookahead&&(pt(t),0===t.lookahead)){if(e===P)return 1;break}if(t.match_length=0,a=j(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++,a&&(ut(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===X?(ut(t,!0),0===t.strm.avail_out?3:4):t.sym_next&&(ut(t,!1),0===t.strm.avail_out)?1:2})(a,e):a.strategy===it?((t,e)=>{let a,i,n,s;const r=t.window;for(;;){if(t.lookahead<=258){if(pt(t),t.lookahead<=258&&e===P)return 1;if(0===t.lookahead)break}if(t.match_length=0,t.lookahead>=3&&t.strstart>0&&(n=t.strstart-1,i=r[n],i===r[++n]&&i===r[++n]&&i===r[++n])){s=t.strstart+258;do{}while(i===r[++n]&&i===r[++n]&&i===r[++n]&&i===r[++n]&&i===r[++n]&&i===r[++n]&&i===r[++n]&&i===r[++n]&&n<s);t.match_length=258-(s-n),t.match_length>t.lookahead&&(t.match_length=t.lookahead)}if(t.match_length>=3?(a=j(t,1,t.match_length-3),t.lookahead-=t.match_length,t.strstart+=t.match_length,t.match_length=0):(a=j(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++),a&&(ut(t,!1),0===t.strm.avail_out))return 1}return t.insert=0,e===X?(ut(t,!0),0===t.strm.avail_out?3:4):t.sym_next&&(ut(t,!1),0===t.strm.avail_out)?1:2})(a,e):zt[a.level].func(a,e);if(3!==i&&4!==i||(a.status=666),1===i||3===i)return 0===t.avail_out&&(a.last_flush=-1),q;if(2===i&&(e===Y?K(a):e!==W&&(H(a,0,0,!1),e===G&&(dt(a.head),0===a.lookahead&&(a.strstart=0,a.block_start=0,a.insert=0))),ct(t),0===t.avail_out))return a.last_flush=-1,q}return e!==X?q:a.wrap<=0?J:(2===a.wrap?(wt(a,255&t.adler),wt(a,t.adler>>8&255),wt(a,t.adler>>16&255),wt(a,t.adler>>24&255),wt(a,255&t.total_in),wt(a,t.total_in>>8&255),wt(a,t.total_in>>16&255),wt(a,t.total_in>>24&255)):(mt(a,t.adler>>>16),mt(a,65535&t.adler)),ct(t),a.wrap>0&&(a.wrap=-a.wrap),0!==a.pending?q:J)},deflateEnd:t=>{if(Et(t))return Q;const e=t.state.status;return t.state=null,113===e?lt(t,V):q},deflateSetDictionary:(t,e)=>{let a=e.length;if(Et(t))return Q;const i=t.state,n=i.wrap;if(2===n||1===n&&42!==i.status||i.lookahead)return Q;if(1===n&&(t.adler=F(t.adler,e,a,0)),i.wrap=0,a>=i.w_size){0===n&&(dt(i.head),i.strstart=0,i.block_start=0,i.insert=0);let t=new Uint8Array(i.w_size);t.set(e.subarray(a-i.w_size,a),0),e=t,a=i.w_size}const s=t.avail_in,r=t.next_in,o=t.input;for(t.avail_in=a,t.next_in=0,t.input=e,pt(i);i.lookahead>=3;){let t=i.strstart,e=i.lookahead-2;do{i.ins_h=ft(i,i.ins_h,i.window[t+3-1]),i.prev[t&i.w_mask]=i.head[i.ins_h],i.head[i.ins_h]=t,t++}while(--e);i.strstart=t,i.lookahead=2,pt(i)}return i.strstart+=i.lookahead,i.block_start=i.strstart,i.insert=i.lookahead,i.lookahead=0,i.match_length=i.prev_length=2,i.match_available=0,t.next_in=r,t.input=o,t.avail_in=s,i.wrap=n,q},deflateInfo:"pako deflate (from Nodeca project)"};const Dt=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var Tt=function(t){const e=Array.prototype.slice.call(arguments,1);for(;e.length;){const a=e.shift();if(a){if("object"!=typeof a)throw new TypeError(a+"must be non-object");for(const e in a)Dt(a,e)&&(t[e]=a[e])}}return t},Ot=t=>{let e=0;for(let a=0,i=t.length;a<i;a++)e+=t[a].length;const a=new Uint8Array(e);for(let e=0,i=0,n=t.length;e<n;e++){let n=t[e];a.set(n,i),i+=n.length}return a};let Ft=!0;try{String.fromCharCode.apply(null,new Uint8Array(1))}catch(t){Ft=!1}const Lt=new Uint8Array(256);for(let t=0;t<256;t++)Lt[t]=t>=252?6:t>=248?5:t>=240?4:t>=224?3:t>=192?2:1;Lt[254]=Lt[254]=1;var Nt=t=>{if("function"==typeof TextEncoder&&TextEncoder.prototype.encode)return(new TextEncoder).encode(t);let e,a,i,n,s,r=t.length,o=0;for(n=0;n<r;n++)a=t.charCodeAt(n),55296==(64512&a)&&n+1<r&&(i=t.charCodeAt(n+1),56320==(64512&i)&&(a=65536+(a-55296<<10)+(i-56320),n++)),o+=a<128?1:a<2048?2:a<65536?3:4;for(e=new Uint8Array(o),s=0,n=0;s<o;n++)a=t.charCodeAt(n),55296==(64512&a)&&n+1<r&&(i=t.charCodeAt(n+1),56320==(64512&i)&&(a=65536+(a-55296<<10)+(i-56320),n++)),a<128?e[s++]=a:a<2048?(e[s++]=192|a>>>6,e[s++]=128|63&a):a<65536?(e[s++]=224|a>>>12,e[s++]=128|a>>>6&63,e[s++]=128|63&a):(e[s++]=240|a>>>18,e[s++]=128|a>>>12&63,e[s++]=128|a>>>6&63,e[s++]=128|63&a);return e},It=(t,e)=>{const a=e||t.length;if("function"==typeof TextDecoder&&TextDecoder.prototype.decode)return(new TextDecoder).decode(t.subarray(0,e));let i,n;const s=new Array(2*a);for(n=0,i=0;i<a;){let e=t[i++];if(e<128){s[n++]=e;continue}let r=Lt[e];if(r>4)s[n++]=65533,i+=r-1;else{for(e&=2===r?31:3===r?15:7;r>1&&i<a;)e=e<<6|63&t[i++],r--;r>1?s[n++]=65533:e<65536?s[n++]=e:(e-=65536,s[n++]=55296|e>>10&1023,s[n++]=56320|1023&e)}}return((t,e)=>{if(e<65534&&t.subarray&&Ft)return String.fromCharCode.apply(null,t.length===e?t:t.subarray(0,e));let a="";for(let i=0;i<e;i++)a+=String.fromCharCode(t[i]);return a})(s,n)},Bt=(t,e)=>{(e=e||t.length)>t.length&&(e=t.length);let a=e-1;for(;a>=0&&128==(192&t[a]);)a--;return a<0||0===a?e:a+Lt[t[a]]>e?a:e};var Ct=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0};const Ht=Object.prototype.toString,{Z_NO_FLUSH:Mt,Z_SYNC_FLUSH:jt,Z_FULL_FLUSH:Kt,Z_FINISH:Pt,Z_OK:Yt,Z_STREAM_END:Gt,Z_DEFAULT_COMPRESSION:Xt,Z_DEFAULT_STRATEGY:Wt,Z_DEFLATED:qt}=B;function Jt(t){this.options=Tt({level:Xt,method:qt,chunkSize:16384,windowBits:15,memLevel:8,strategy:Wt},t||{});let e=this.options;e.raw&&e.windowBits>0?e.windowBits=-e.windowBits:e.gzip&&e.windowBits>0&&e.windowBits<16&&(e.windowBits+=16),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new Ct,this.strm.avail_out=0;let a=St.deflateInit2(this.strm,e.level,e.method,e.windowBits,e.memLevel,e.strategy);if(a!==Yt)throw new Error(I[a]);if(e.header&&St.deflateSetHeader(this.strm,e.header),e.dictionary){let t;if(t="string"==typeof e.dictionary?Nt(e.dictionary):"[object ArrayBuffer]"===Ht.call(e.dictionary)?new Uint8Array(e.dictionary):e.dictionary,a=St.deflateSetDictionary(this.strm,t),a!==Yt)throw new Error(I[a]);this._dict_set=!0}}function Qt(t,e){const a=new Jt(e);if(a.push(t,!0),a.err)throw a.msg||I[a.err];return a.result}Jt.prototype.push=function(t,e){const a=this.strm,i=this.options.chunkSize;let n,s;if(this.ended)return!1;for(s=e===~~e?e:!0===e?Pt:Mt,"string"==typeof t?a.input=Nt(t):"[object ArrayBuffer]"===Ht.call(t)?a.input=new Uint8Array(t):a.input=t,a.next_in=0,a.avail_in=a.input.length;;)if(0===a.avail_out&&(a.output=new Uint8Array(i),a.next_out=0,a.avail_out=i),(s===jt||s===Kt)&&a.avail_out<=6)this.onData(a.output.subarray(0,a.next_out)),a.avail_out=0;else{if(n=St.deflate(a,s),n===Gt)return a.next_out>0&&this.onData(a.output.subarray(0,a.next_out)),n=St.deflateEnd(this.strm),this.onEnd(n),this.ended=!0,n===Yt;if(0!==a.avail_out){if(s>0&&a.next_out>0)this.onData(a.output.subarray(0,a.next_out)),a.avail_out=0;else if(0===a.avail_in)break}else this.onData(a.output)}return!0},Jt.prototype.onData=function(t){this.chunks.push(t)},Jt.prototype.onEnd=function(t){t===Yt&&(this.result=Ot(this.chunks)),this.chunks=[],this.err=t,this.msg=this.strm.msg};var Vt={Deflate:Jt,deflate:Qt,deflateRaw:function(t,e){return(e=e||{}).raw=!0,Qt(t,e)},gzip:function(t,e){return(e=e||{}).gzip=!0,Qt(t,e)},constants:B};var $t=function(t,e){let a,i,n,s,r,o,l,h,d,_,f,c,u,w,m,b,g,p,k,v,y,x,z,A;const E=t.state;a=t.next_in,z=t.input,i=a+(t.avail_in-5),n=t.next_out,A=t.output,s=n-(e-t.avail_out),r=n+(t.avail_out-257),o=E.dmax,l=E.wsize,h=E.whave,d=E.wnext,_=E.window,f=E.hold,c=E.bits,u=E.lencode,w=E.distcode,m=(1<<E.lenbits)-1,b=(1<<E.distbits)-1;t:do{c<15&&(f+=z[a++]<<c,c+=8,f+=z[a++]<<c,c+=8),g=u[f&m];e:for(;;){if(p=g>>>24,f>>>=p,c-=p,p=g>>>16&255,0===p)A[n++]=65535&g;else{if(!(16&p)){if(0==(64&p)){g=u[(65535&g)+(f&(1<<p)-1)];continue e}if(32&p){E.mode=16191;break t}t.msg="invalid literal/length code",E.mode=16209;break t}k=65535&g,p&=15,p&&(c<p&&(f+=z[a++]<<c,c+=8),k+=f&(1<<p)-1,f>>>=p,c-=p),c<15&&(f+=z[a++]<<c,c+=8,f+=z[a++]<<c,c+=8),g=w[f&b];a:for(;;){if(p=g>>>24,f>>>=p,c-=p,p=g>>>16&255,!(16&p)){if(0==(64&p)){g=w[(65535&g)+(f&(1<<p)-1)];continue a}t.msg="invalid distance code",E.mode=16209;break t}if(v=65535&g,p&=15,c<p&&(f+=z[a++]<<c,c+=8,c<p&&(f+=z[a++]<<c,c+=8)),v+=f&(1<<p)-1,v>o){t.msg="invalid distance too far back",E.mode=16209;break t}if(f>>>=p,c-=p,p=n-s,v>p){if(p=v-p,p>h&&E.sane){t.msg="invalid distance too far back",E.mode=16209;break t}if(y=0,x=_,0===d){if(y+=l-p,p<k){k-=p;do{A[n++]=_[y++]}while(--p);y=n-v,x=A}}else if(d<p){if(y+=l+d-p,p-=d,p<k){k-=p;do{A[n++]=_[y++]}while(--p);if(y=0,d<k){p=d,k-=p;do{A[n++]=_[y++]}while(--p);y=n-v,x=A}}}else if(y+=d-p,p<k){k-=p;do{A[n++]=_[y++]}while(--p);y=n-v,x=A}for(;k>2;)A[n++]=x[y++],A[n++]=x[y++],A[n++]=x[y++],k-=3;k&&(A[n++]=x[y++],k>1&&(A[n++]=x[y++]))}else{y=n-v;do{A[n++]=A[y++],A[n++]=A[y++],A[n++]=A[y++],k-=3}while(k>2);k&&(A[n++]=A[y++],k>1&&(A[n++]=A[y++]))}break}}break}}while(a<i&&n<r);k=c>>3,a-=k,c-=k<<3,f&=(1<<c)-1,t.next_in=a,t.next_out=n,t.avail_in=a<i?i-a+5:5-(a-i),t.avail_out=n<r?r-n+257:257-(n-r),E.hold=f,E.bits=c};const te=new Uint16Array([3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0]),ee=new Uint8Array([16,16,16,16,16,16,16,16,17,17,17,17,18,18,18,18,19,19,19,19,20,20,20,20,21,21,21,21,16,72,78]),ae=new Uint16Array([1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,0,0]),ie=new Uint8Array([16,16,16,16,17,17,18,18,19,19,20,20,21,21,22,22,23,23,24,24,25,25,26,26,27,27,28,28,29,29,64,64]);var ne=(t,e,a,i,n,s,r,o)=>{const l=o.bits;let h,d,_,f,c,u,w=0,m=0,b=0,g=0,p=0,k=0,v=0,y=0,x=0,z=0,A=null;const E=new Uint16Array(16),R=new Uint16Array(16);let Z,U,S,D=null;for(w=0;w<=15;w++)E[w]=0;for(m=0;m<i;m++)E[e[a+m]]++;for(p=l,g=15;g>=1&&0===E[g];g--);if(p>g&&(p=g),0===g)return n[s++]=20971520,n[s++]=20971520,o.bits=1,0;for(b=1;b<g&&0===E[b];b++);for(p<b&&(p=b),y=1,w=1;w<=15;w++)if(y<<=1,y-=E[w],y<0)return-1;if(y>0&&(0===t||1!==g))return-1;for(R[1]=0,w=1;w<15;w++)R[w+1]=R[w]+E[w];for(m=0;m<i;m++)0!==e[a+m]&&(r[R[e[a+m]]++]=m);if(0===t?(A=D=r,u=20):1===t?(A=te,D=ee,u=257):(A=ae,D=ie,u=0),z=0,m=0,w=b,c=s,k=p,v=0,_=-1,x=1<<p,f=x-1,1===t&&x>852||2===t&&x>592)return 1;for(;;){Z=w-v,r[m]+1<u?(U=0,S=r[m]):r[m]>=u?(U=D[r[m]-u],S=A[r[m]-u]):(U=96,S=0),h=1<<w-v,d=1<<k,b=d;do{d-=h,n[c+(z>>v)+d]=Z<<24|U<<16|S|0}while(0!==d);for(h=1<<w-1;z&h;)h>>=1;if(0!==h?(z&=h-1,z+=h):z=0,m++,0==--E[w]){if(w===g)break;w=e[a+r[m]]}if(w>p&&(z&f)!==_){for(0===v&&(v=p),c+=b,k=w-v,y=1<<k;k+v<g&&(y-=E[k+v],!(y<=0));)k++,y<<=1;if(x+=1<<k,1===t&&x>852||2===t&&x>592)return 1;_=z&f,n[_]=p<<24|k<<16|c-s|0}}return 0!==z&&(n[c+z]=w-v<<24|64<<16|0),o.bits=p,0};const{Z_FINISH:se,Z_BLOCK:re,Z_TREES:oe,Z_OK:le,Z_STREAM_END:he,Z_NEED_DICT:de,Z_STREAM_ERROR:_e,Z_DATA_ERROR:fe,Z_MEM_ERROR:ce,Z_BUF_ERROR:ue,Z_DEFLATED:we}=B,me=16209,be=t=>(t>>>24&255)+(t>>>8&65280)+((65280&t)<<8)+((255&t)<<24);function ge(){this.strm=null,this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new Uint16Array(320),this.work=new Uint16Array(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}const pe=t=>{if(!t)return 1;const e=t.state;return!e||e.strm!==t||e.mode<16180||e.mode>16211?1:0},ke=t=>{if(pe(t))return _e;const e=t.state;return t.total_in=t.total_out=e.total=0,t.msg="",e.wrap&&(t.adler=1&e.wrap),e.mode=16180,e.last=0,e.havedict=0,e.flags=-1,e.dmax=32768,e.head=null,e.hold=0,e.bits=0,e.lencode=e.lendyn=new Int32Array(852),e.distcode=e.distdyn=new Int32Array(592),e.sane=1,e.back=-1,le},ve=t=>{if(pe(t))return _e;const e=t.state;return e.wsize=0,e.whave=0,e.wnext=0,ke(t)},ye=(t,e)=>{let a;if(pe(t))return _e;const i=t.state;return e<0?(a=0,e=-e):(a=5+(e>>4),e<48&&(e&=15)),e&&(e<8||e>15)?_e:(null!==i.window&&i.wbits!==e&&(i.window=null),i.wrap=a,i.wbits=e,ve(t))},xe=(t,e)=>{if(!t)return _e;const a=new ge;t.state=a,a.strm=t,a.window=null,a.mode=16180;const i=ye(t,e);return i!==le&&(t.state=null),i};let ze,Ae,Ee=!0;const Re=t=>{if(Ee){ze=new Int32Array(512),Ae=new Int32Array(32);let e=0;for(;e<144;)t.lens[e++]=8;for(;e<256;)t.lens[e++]=9;for(;e<280;)t.lens[e++]=7;for(;e<288;)t.lens[e++]=8;for(ne(1,t.lens,0,288,ze,0,t.work,{bits:9}),e=0;e<32;)t.lens[e++]=5;ne(2,t.lens,0,32,Ae,0,t.work,{bits:5}),Ee=!1}t.lencode=ze,t.lenbits=9,t.distcode=Ae,t.distbits=5},Ze=(t,e,a,i)=>{let n;const s=t.state;return null===s.window&&(s.wsize=1<<s.wbits,s.wnext=0,s.whave=0,s.window=new Uint8Array(s.wsize)),i>=s.wsize?(s.window.set(e.subarray(a-s.wsize,a),0),s.wnext=0,s.whave=s.wsize):(n=s.wsize-s.wnext,n>i&&(n=i),s.window.set(e.subarray(a-i,a-i+n),s.wnext),(i-=n)?(s.window.set(e.subarray(a-i,a),0),s.wnext=i,s.whave=s.wsize):(s.wnext+=n,s.wnext===s.wsize&&(s.wnext=0),s.whave<s.wsize&&(s.whave+=n))),0};var Ue={inflateReset:ve,inflateReset2:ye,inflateResetKeep:ke,inflateInit:t=>xe(t,15),inflateInit2:xe,inflate:(t,e)=>{let a,i,n,s,r,o,l,h,d,_,f,c,u,w,m,b,g,p,k,v,y,x,z=0;const A=new Uint8Array(4);let E,R;const Z=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]);if(pe(t)||!t.output||!t.input&&0!==t.avail_in)return _e;a=t.state,16191===a.mode&&(a.mode=16192),r=t.next_out,n=t.output,l=t.avail_out,s=t.next_in,i=t.input,o=t.avail_in,h=a.hold,d=a.bits,_=o,f=l,x=le;t:for(;;)switch(a.mode){case 16180:if(0===a.wrap){a.mode=16192;break}for(;d<16;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(2&a.wrap&&35615===h){0===a.wbits&&(a.wbits=15),a.check=0,A[0]=255&h,A[1]=h>>>8&255,a.check=N(a.check,A,2,0),h=0,d=0,a.mode=16181;break}if(a.head&&(a.head.done=!1),!(1&a.wrap)||(((255&h)<<8)+(h>>8))%31){t.msg="incorrect header check",a.mode=me;break}if((15&h)!==we){t.msg="unknown compression method",a.mode=me;break}if(h>>>=4,d-=4,y=8+(15&h),0===a.wbits&&(a.wbits=y),y>15||y>a.wbits){t.msg="invalid window size",a.mode=me;break}a.dmax=1<<a.wbits,a.flags=0,t.adler=a.check=1,a.mode=512&h?16189:16191,h=0,d=0;break;case 16181:for(;d<16;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(a.flags=h,(255&a.flags)!==we){t.msg="unknown compression method",a.mode=me;break}if(57344&a.flags){t.msg="unknown header flags set",a.mode=me;break}a.head&&(a.head.text=h>>8&1),512&a.flags&&4&a.wrap&&(A[0]=255&h,A[1]=h>>>8&255,a.check=N(a.check,A,2,0)),h=0,d=0,a.mode=16182;case 16182:for(;d<32;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}a.head&&(a.head.time=h),512&a.flags&&4&a.wrap&&(A[0]=255&h,A[1]=h>>>8&255,A[2]=h>>>16&255,A[3]=h>>>24&255,a.check=N(a.check,A,4,0)),h=0,d=0,a.mode=16183;case 16183:for(;d<16;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}a.head&&(a.head.xflags=255&h,a.head.os=h>>8),512&a.flags&&4&a.wrap&&(A[0]=255&h,A[1]=h>>>8&255,a.check=N(a.check,A,2,0)),h=0,d=0,a.mode=16184;case 16184:if(1024&a.flags){for(;d<16;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}a.length=h,a.head&&(a.head.extra_len=h),512&a.flags&&4&a.wrap&&(A[0]=255&h,A[1]=h>>>8&255,a.check=N(a.check,A,2,0)),h=0,d=0}else a.head&&(a.head.extra=null);a.mode=16185;case 16185:if(1024&a.flags&&(c=a.length,c>o&&(c=o),c&&(a.head&&(y=a.head.extra_len-a.length,a.head.extra||(a.head.extra=new Uint8Array(a.head.extra_len)),a.head.extra.set(i.subarray(s,s+c),y)),512&a.flags&&4&a.wrap&&(a.check=N(a.check,i,c,s)),o-=c,s+=c,a.length-=c),a.length))break t;a.length=0,a.mode=16186;case 16186:if(2048&a.flags){if(0===o)break t;c=0;do{y=i[s+c++],a.head&&y&&a.length<65536&&(a.head.name+=String.fromCharCode(y))}while(y&&c<o);if(512&a.flags&&4&a.wrap&&(a.check=N(a.check,i,c,s)),o-=c,s+=c,y)break t}else a.head&&(a.head.name=null);a.length=0,a.mode=16187;case 16187:if(4096&a.flags){if(0===o)break t;c=0;do{y=i[s+c++],a.head&&y&&a.length<65536&&(a.head.comment+=String.fromCharCode(y))}while(y&&c<o);if(512&a.flags&&4&a.wrap&&(a.check=N(a.check,i,c,s)),o-=c,s+=c,y)break t}else a.head&&(a.head.comment=null);a.mode=16188;case 16188:if(512&a.flags){for(;d<16;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(4&a.wrap&&h!==(65535&a.check)){t.msg="header crc mismatch",a.mode=me;break}h=0,d=0}a.head&&(a.head.hcrc=a.flags>>9&1,a.head.done=!0),t.adler=a.check=0,a.mode=16191;break;case 16189:for(;d<32;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}t.adler=a.check=be(h),h=0,d=0,a.mode=16190;case 16190:if(0===a.havedict)return t.next_out=r,t.avail_out=l,t.next_in=s,t.avail_in=o,a.hold=h,a.bits=d,de;t.adler=a.check=1,a.mode=16191;case 16191:if(e===re||e===oe)break t;case 16192:if(a.last){h>>>=7&d,d-=7&d,a.mode=16206;break}for(;d<3;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}switch(a.last=1&h,h>>>=1,d-=1,3&h){case 0:a.mode=16193;break;case 1:if(Re(a),a.mode=16199,e===oe){h>>>=2,d-=2;break t}break;case 2:a.mode=16196;break;case 3:t.msg="invalid block type",a.mode=me}h>>>=2,d-=2;break;case 16193:for(h>>>=7&d,d-=7&d;d<32;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if((65535&h)!=(h>>>16^65535)){t.msg="invalid stored block lengths",a.mode=me;break}if(a.length=65535&h,h=0,d=0,a.mode=16194,e===oe)break t;case 16194:a.mode=16195;case 16195:if(c=a.length,c){if(c>o&&(c=o),c>l&&(c=l),0===c)break t;n.set(i.subarray(s,s+c),r),o-=c,s+=c,l-=c,r+=c,a.length-=c;break}a.mode=16191;break;case 16196:for(;d<14;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(a.nlen=257+(31&h),h>>>=5,d-=5,a.ndist=1+(31&h),h>>>=5,d-=5,a.ncode=4+(15&h),h>>>=4,d-=4,a.nlen>286||a.ndist>30){t.msg="too many length or distance symbols",a.mode=me;break}a.have=0,a.mode=16197;case 16197:for(;a.have<a.ncode;){for(;d<3;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}a.lens[Z[a.have++]]=7&h,h>>>=3,d-=3}for(;a.have<19;)a.lens[Z[a.have++]]=0;if(a.lencode=a.lendyn,a.lenbits=7,E={bits:a.lenbits},x=ne(0,a.lens,0,19,a.lencode,0,a.work,E),a.lenbits=E.bits,x){t.msg="invalid code lengths set",a.mode=me;break}a.have=0,a.mode=16198;case 16198:for(;a.have<a.nlen+a.ndist;){for(;z=a.lencode[h&(1<<a.lenbits)-1],m=z>>>24,b=z>>>16&255,g=65535&z,!(m<=d);){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(g<16)h>>>=m,d-=m,a.lens[a.have++]=g;else{if(16===g){for(R=m+2;d<R;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(h>>>=m,d-=m,0===a.have){t.msg="invalid bit length repeat",a.mode=me;break}y=a.lens[a.have-1],c=3+(3&h),h>>>=2,d-=2}else if(17===g){for(R=m+3;d<R;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}h>>>=m,d-=m,y=0,c=3+(7&h),h>>>=3,d-=3}else{for(R=m+7;d<R;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}h>>>=m,d-=m,y=0,c=11+(127&h),h>>>=7,d-=7}if(a.have+c>a.nlen+a.ndist){t.msg="invalid bit length repeat",a.mode=me;break}for(;c--;)a.lens[a.have++]=y}}if(a.mode===me)break;if(0===a.lens[256]){t.msg="invalid code -- missing end-of-block",a.mode=me;break}if(a.lenbits=9,E={bits:a.lenbits},x=ne(1,a.lens,0,a.nlen,a.lencode,0,a.work,E),a.lenbits=E.bits,x){t.msg="invalid literal/lengths set",a.mode=me;break}if(a.distbits=6,a.distcode=a.distdyn,E={bits:a.distbits},x=ne(2,a.lens,a.nlen,a.ndist,a.distcode,0,a.work,E),a.distbits=E.bits,x){t.msg="invalid distances set",a.mode=me;break}if(a.mode=16199,e===oe)break t;case 16199:a.mode=16200;case 16200:if(o>=6&&l>=258){t.next_out=r,t.avail_out=l,t.next_in=s,t.avail_in=o,a.hold=h,a.bits=d,$t(t,f),r=t.next_out,n=t.output,l=t.avail_out,s=t.next_in,i=t.input,o=t.avail_in,h=a.hold,d=a.bits,16191===a.mode&&(a.back=-1);break}for(a.back=0;z=a.lencode[h&(1<<a.lenbits)-1],m=z>>>24,b=z>>>16&255,g=65535&z,!(m<=d);){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(b&&0==(240&b)){for(p=m,k=b,v=g;z=a.lencode[v+((h&(1<<p+k)-1)>>p)],m=z>>>24,b=z>>>16&255,g=65535&z,!(p+m<=d);){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}h>>>=p,d-=p,a.back+=p}if(h>>>=m,d-=m,a.back+=m,a.length=g,0===b){a.mode=16205;break}if(32&b){a.back=-1,a.mode=16191;break}if(64&b){t.msg="invalid literal/length code",a.mode=me;break}a.extra=15&b,a.mode=16201;case 16201:if(a.extra){for(R=a.extra;d<R;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}a.length+=h&(1<<a.extra)-1,h>>>=a.extra,d-=a.extra,a.back+=a.extra}a.was=a.length,a.mode=16202;case 16202:for(;z=a.distcode[h&(1<<a.distbits)-1],m=z>>>24,b=z>>>16&255,g=65535&z,!(m<=d);){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(0==(240&b)){for(p=m,k=b,v=g;z=a.distcode[v+((h&(1<<p+k)-1)>>p)],m=z>>>24,b=z>>>16&255,g=65535&z,!(p+m<=d);){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}h>>>=p,d-=p,a.back+=p}if(h>>>=m,d-=m,a.back+=m,64&b){t.msg="invalid distance code",a.mode=me;break}a.offset=g,a.extra=15&b,a.mode=16203;case 16203:if(a.extra){for(R=a.extra;d<R;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}a.offset+=h&(1<<a.extra)-1,h>>>=a.extra,d-=a.extra,a.back+=a.extra}if(a.offset>a.dmax){t.msg="invalid distance too far back",a.mode=me;break}a.mode=16204;case 16204:if(0===l)break t;if(c=f-l,a.offset>c){if(c=a.offset-c,c>a.whave&&a.sane){t.msg="invalid distance too far back",a.mode=me;break}c>a.wnext?(c-=a.wnext,u=a.wsize-c):u=a.wnext-c,c>a.length&&(c=a.length),w=a.window}else w=n,u=r-a.offset,c=a.length;c>l&&(c=l),l-=c,a.length-=c;do{n[r++]=w[u++]}while(--c);0===a.length&&(a.mode=16200);break;case 16205:if(0===l)break t;n[r++]=a.length,l--,a.mode=16200;break;case 16206:if(a.wrap){for(;d<32;){if(0===o)break t;o--,h|=i[s++]<<d,d+=8}if(f-=l,t.total_out+=f,a.total+=f,4&a.wrap&&f&&(t.adler=a.check=a.flags?N(a.check,n,f,r-f):F(a.check,n,f,r-f)),f=l,4&a.wrap&&(a.flags?h:be(h))!==a.check){t.msg="incorrect data check",a.mode=me;break}h=0,d=0}a.mode=16207;case 16207:if(a.wrap&&a.flags){for(;d<32;){if(0===o)break t;o--,h+=i[s++]<<d,d+=8}if(4&a.wrap&&h!==(4294967295&a.total)){t.msg="incorrect length check",a.mode=me;break}h=0,d=0}a.mode=16208;case 16208:x=he;break t;case me:x=fe;break t;case 16210:return ce;default:return _e}return t.next_out=r,t.avail_out=l,t.next_in=s,t.avail_in=o,a.hold=h,a.bits=d,(a.wsize||f!==t.avail_out&&a.mode<me&&(a.mode<16206||e!==se))&&Ze(t,t.output,t.next_out,f-t.avail_out),_-=t.avail_in,f-=t.avail_out,t.total_in+=_,t.total_out+=f,a.total+=f,4&a.wrap&&f&&(t.adler=a.check=a.flags?N(a.check,n,f,t.next_out-f):F(a.check,n,f,t.next_out-f)),t.data_type=a.bits+(a.last?64:0)+(16191===a.mode?128:0)+(16199===a.mode||16194===a.mode?256:0),(0===_&&0===f||e===se)&&x===le&&(x=ue),x},inflateEnd:t=>{if(pe(t))return _e;let e=t.state;return e.window&&(e.window=null),t.state=null,le},inflateGetHeader:(t,e)=>{if(pe(t))return _e;const a=t.state;return 0==(2&a.wrap)?_e:(a.head=e,e.done=!1,le)},inflateSetDictionary:(t,e)=>{const a=e.length;let i,n,s;return pe(t)?_e:(i=t.state,0!==i.wrap&&16190!==i.mode?_e:16190===i.mode&&(n=1,n=F(n,e,a,0),n!==i.check)?fe:(s=Ze(t,e,a,a),s?(i.mode=16210,ce):(i.havedict=1,le)))},inflateInfo:"pako inflate (from Nodeca project)"};var Se=function(){this.text=0,this.time=0,this.xflags=0,this.os=0,this.extra=null,this.extra_len=0,this.name="",this.comment="",this.hcrc=0,this.done=!1};const De=Object.prototype.toString,{Z_NO_FLUSH:Te,Z_FINISH:Oe,Z_OK:Fe,Z_STREAM_END:Le,Z_NEED_DICT:Ne,Z_STREAM_ERROR:Ie,Z_DATA_ERROR:Be,Z_MEM_ERROR:Ce}=B;function He(t){this.options=Tt({chunkSize:65536,windowBits:15,to:""},t||{});const e=this.options;e.raw&&e.windowBits>=0&&e.windowBits<16&&(e.windowBits=-e.windowBits,0===e.windowBits&&(e.windowBits=-15)),!(e.windowBits>=0&&e.windowBits<16)||t&&t.windowBits||(e.windowBits+=32),e.windowBits>15&&e.windowBits<48&&0==(15&e.windowBits)&&(e.windowBits|=15),this.err=0,this.msg="",this.ended=!1,this.chunks=[],this.strm=new Ct,this.strm.avail_out=0;let a=Ue.inflateInit2(this.strm,e.windowBits);if(a!==Fe)throw new Error(I[a]);if(this.header=new Se,Ue.inflateGetHeader(this.strm,this.header),e.dictionary&&("string"==typeof e.dictionary?e.dictionary=Nt(e.dictionary):"[object ArrayBuffer]"===De.call(e.dictionary)&&(e.dictionary=new Uint8Array(e.dictionary)),e.raw&&(a=Ue.inflateSetDictionary(this.strm,e.dictionary),a!==Fe)))throw new Error(I[a])}He.prototype.push=function(t,e){const a=this.strm,i=this.options.chunkSize,n=this.options.dictionary;let s,r,o;if(this.ended)return!1;for(r=e===~~e?e:!0===e?Oe:Te,"[object ArrayBuffer]"===De.call(t)?a.input=new Uint8Array(t):a.input=t,a.next_in=0,a.avail_in=a.input.length;;){for(0===a.avail_out&&(a.output=new Uint8Array(i),a.next_out=0,a.avail_out=i),s=Ue.inflate(a,r),s===Ne&&n&&(s=Ue.inflateSetDictionary(a,n),s===Fe?s=Ue.inflate(a,r):s===Be&&(s=Ne));a.avail_in>0&&s===Le&&a.state.wrap>0&&0!==t[a.next_in];)Ue.inflateReset(a),s=Ue.inflate(a,r);switch(s){case Ie:case Be:case Ne:case Ce:return this.onEnd(s),this.ended=!0,!1}if(o=a.avail_out,a.next_out&&(0===a.avail_out||s===Le))if("string"===this.options.to){let t=Bt(a.output,a.next_out),e=a.next_out-t,n=It(a.output,t);a.next_out=e,a.avail_out=i-e,e&&a.output.set(a.output.subarray(t,t+e),0),this.onData(n)}else this.onData(a.output.length===a.next_out?a.output:a.output.subarray(0,a.next_out));if(s!==Fe||0!==o){if(s===Le)return s=Ue.inflateEnd(this.strm),this.onEnd(s),this.ended=!0,!0;if(0===a.avail_in)break}}return!0},He.prototype.onData=function(t){this.chunks.push(t)},He.prototype.onEnd=function(t){t===Fe&&("string"===this.options.to?this.result=this.chunks.join(""):this.result=Ot(this.chunks)),this.chunks=[],this.err=t,this.msg=this.strm.msg};const{Deflate:Me,deflate:je,deflateRaw:Ke,gzip:Pe}=Vt;var Ye=Me,Ge=je,Xe=B;const We=new class{constructor(){this._init()}clear(){this._init()}addEvent(t){if(!t)throw new Error("Adding invalid event");const e=this._hasEvents?",":"";this.deflate.push(e+t,Xe.Z_SYNC_FLUSH),this._hasEvents=!0}finish(){if(this.deflate.push("]",Xe.Z_FINISH),this.deflate.err)throw this.deflate.err;const t=this.deflate.result;return this._init(),t}_init(){this._hasEvents=!1,this.deflate=new Ye,this.deflate.push("[",Xe.Z_NO_FLUSH)}},qe={clear:()=>{We.clear()},addEvent:t=>We.addEvent(t),finish:()=>We.finish(),compress:t=>function(t){return Ge(t)}(t)};addEventListener("message",(function(t){const e=t.data.method,a=t.data.id,i=t.data.arg;if(e in qe&&"function"==typeof qe[e])try{const t=qe[e](i);postMessage({id:a,method:e,success:!0,response:t})}catch(t){postMessage({id:a,method:e,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)}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/** 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. */
|
|
|
|
__init() {this._totalSize = 0;}
|
|
|
|
constructor() {EventBufferArray.prototype.__init.call(this);
|
|
this.events = [];
|
|
}
|
|
|
|
/** @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;
|
|
}
|
|
|
|
/** @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() {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[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
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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 {
|
|
|
|
__init() {this._totalSize = 0;}
|
|
|
|
constructor(worker) {EventBufferCompressionWorker.prototype.__init.call(this);
|
|
this._worker = new WorkerHandler(worker);
|
|
this._earliestTimestamp = null;
|
|
}
|
|
|
|
/** @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;
|
|
// We do not wait on this, as we assume the order of messages is consistent for the worker
|
|
void this._worker.postMessage('clear');
|
|
}
|
|
|
|
/** @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 */
|
|
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
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[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 } = this._fallback;
|
|
|
|
const addEventPromises = [];
|
|
for (const event of events) {
|
|
addEventPromises.push(this._compression.addEvent(event));
|
|
}
|
|
|
|
// 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) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.warn('[Replay] Failed to add events when switching buffers.', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an event buffer for replays.
|
|
*/
|
|
function createEventBuffer({ useCompression }) {
|
|
// eslint-disable-next-line no-restricted-globals
|
|
if (useCompression && window.Worker) {
|
|
try {
|
|
const workerUrl = e();
|
|
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Using compression worker');
|
|
const worker = new Worker(workerUrl);
|
|
return new EventBufferProxy(worker);
|
|
} catch (error) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Failed to create compression worker');
|
|
// Fall back to use simple event buffer array
|
|
}
|
|
}
|
|
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Using simple buffer');
|
|
return new EventBufferArray();
|
|
}
|
|
|
|
/** If sessionStorage is available. */
|
|
function hasSessionStorage() {
|
|
return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage;
|
|
}
|
|
|
|
/**
|
|
* 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 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, timeouts, targetTime = +new Date()) {
|
|
return (
|
|
// First, check that maximum session length has not been exceeded
|
|
isExpired(session.started, timeouts.maxSessionLife, 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, timeouts.sessionIdleExpire, targetTime)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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 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;
|
|
|
|
return {
|
|
id,
|
|
started,
|
|
lastActivity,
|
|
segmentId,
|
|
sampled,
|
|
shouldRefresh: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 }) {
|
|
const sampled = getSessionSampleType(sessionSampleRate, allowBuffering);
|
|
const session = makeSession({
|
|
sampled,
|
|
});
|
|
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log(`[Replay] Creating new session: ${session.id}`);
|
|
|
|
if (stickySession) {
|
|
saveSession(session);
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Fetches a session from storage
|
|
*/
|
|
function fetchSession() {
|
|
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) ;
|
|
|
|
return makeSession(sessionObj);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create a session
|
|
*/
|
|
function getSession({
|
|
timeouts,
|
|
currentSession,
|
|
stickySession,
|
|
sessionSampleRate,
|
|
allowBuffering,
|
|
}) {
|
|
// If session exists and is passed, use it instead of always hitting session storage
|
|
const session = currentSession || (stickySession && fetchSession());
|
|
|
|
if (session) {
|
|
// If there is a session, check if it is valid (e.g. "last activity" time
|
|
// should be within the "session idle time", and "session started" time is
|
|
// within "max session time").
|
|
const isExpired = isSessionExpired(session, timeouts);
|
|
|
|
if (!isExpired || (allowBuffering && session.shouldRefresh)) {
|
|
return { type: 'saved', session };
|
|
} else if (!session.shouldRefresh) {
|
|
// This is the case if we have an error session that is completed
|
|
// (=triggered an error). Session will continue as session-based replay,
|
|
// and when this session is expired, it will not be renewed until user
|
|
// reloads.
|
|
const discardedSession = makeSession({ sampled: false });
|
|
return { type: 'new', session: discardedSession };
|
|
} else {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Session has expired');
|
|
}
|
|
// Otherwise continue to create a new session
|
|
}
|
|
|
|
const newSession = createSession({
|
|
stickySession,
|
|
sessionSampleRate,
|
|
allowBuffering,
|
|
});
|
|
|
|
return { type: 'new', session: newSession };
|
|
}
|
|
|
|
function isCustomEvent(event) {
|
|
return event.type === EventType.Custom;
|
|
}
|
|
|
|
/**
|
|
* Add an event to the event buffer.
|
|
* `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`.
|
|
*/
|
|
async function addEvent(
|
|
replay,
|
|
event,
|
|
isCheckout,
|
|
) {
|
|
if (!replay.eventBuffer) {
|
|
// This implies that `_isEnabled` is false
|
|
return null;
|
|
}
|
|
|
|
if (replay.isPaused()) {
|
|
// Do not add to event buffer when recording is paused
|
|
return null;
|
|
}
|
|
|
|
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 null;
|
|
}
|
|
|
|
try {
|
|
if (isCheckout) {
|
|
replay.eventBuffer.clear();
|
|
}
|
|
|
|
const replayOptions = replay.getOptions();
|
|
|
|
const eventAfterPossibleCallback =
|
|
typeof replayOptions.beforeAddRecordingEvent === 'function' && isCustomEvent(event)
|
|
? replayOptions.beforeAddRecordingEvent(event)
|
|
: event;
|
|
|
|
if (!eventAfterPossibleCallback) {
|
|
return;
|
|
}
|
|
|
|
return await replay.eventBuffer.addEvent(eventAfterPossibleCallback);
|
|
} catch (error) {
|
|
const reason = error && error instanceof EventBufferSizeExceededError ? 'addEventSizeExceeded' : 'addEvent';
|
|
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error(error);
|
|
await replay.stop(reason);
|
|
|
|
const client = getCurrentHub().getClient();
|
|
|
|
if (client) {
|
|
client.recordDroppedEvent('internal_sdk_error', 'replay');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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';
|
|
}
|
|
|
|
/**
|
|
* Returns a listener to be added to `client.on('afterSendErrorEvent, listener)`.
|
|
*/
|
|
function handleAfterSendEvent(replay) {
|
|
// Custom transports may still be returning `Promise<void>`, 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 (!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;
|
|
}
|
|
|
|
// Collect traceIds in _context regardless of `recordingMode`
|
|
// In error mode, _context gets cleared on every checkout
|
|
if (isTransactionEvent(event) && event.contexts && event.contexts.trace && event.contexts.trace.trace_id) {
|
|
replay.getContext().traceIds.add(event.contexts.trace.trace_id );
|
|
return;
|
|
}
|
|
|
|
// Everything below is just for error events
|
|
if (!isErrorEvent(event)) {
|
|
return;
|
|
}
|
|
|
|
// 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.
|
|
if (event.event_id) {
|
|
replay.getContext().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) {
|
|
setTimeout(() => {
|
|
// Capture current event buffer as new replay
|
|
void replay.sendBufferedReplayOrFlush();
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function isBaseTransportSend() {
|
|
const client = getCurrentHub().getClient();
|
|
if (!client) {
|
|
return false;
|
|
}
|
|
|
|
const transport = client.getTransport();
|
|
if (!transport) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
(transport.send ).__sentry__baseTransport__ || false
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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-ignore this may be set by rrweb when it finds errors
|
|
if (hint.originalException && hint.originalException.__rrweb__) {
|
|
return true;
|
|
}
|
|
|
|
// Check if any exception originates from rrweb
|
|
return event.exception.values.some(exception => {
|
|
if (!exception.stacktrace || !exception.stacktrace.frames || !exception.stacktrace.frames.length) {
|
|
return false;
|
|
}
|
|
|
|
return exception.stacktrace.frames.some(frame => frame.filename && frame.filename.includes('/rrweb/src/'));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 `addGlobalEventProcessor(listener)`.
|
|
*/
|
|
function handleGlobalEventListener(
|
|
replay,
|
|
includeAfterSendEventHandling = false,
|
|
) {
|
|
const afterSendHandler = includeAfterSendEventHandling ? handleAfterSendEvent(replay) : undefined;
|
|
|
|
return (event, hint) => {
|
|
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, nothing else
|
|
if (!isErrorEvent(event) && !isTransactionEvent(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) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 `addInstrumentationHandler('history', 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 ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && replay.getOptions()._experiments.traceInternals) {
|
|
return false;
|
|
}
|
|
|
|
return _isSentryRequest(url);
|
|
}
|
|
|
|
/**
|
|
* Checks wether a given URL belongs to the configured Sentry DSN.
|
|
*/
|
|
function _isSentryRequest(url) {
|
|
const client = getCurrentHub().getClient();
|
|
const dsn = client && client.getDsn();
|
|
return dsn ? url.includes(dsn.host) : false;
|
|
}
|
|
|
|
/** 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 `addInstrumentationHandler('fetch', 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 `addInstrumentationHandler('xhr', listener)`.
|
|
*/
|
|
function handleXhrSpanListener(replay) {
|
|
return (handlerData) => {
|
|
if (!replay.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
const result = handleXhr(handlerData);
|
|
|
|
addNetworkBreadcrumb(replay, result);
|
|
};
|
|
}
|
|
|
|
const OBJ = 10;
|
|
const OBJ_KEY = 11;
|
|
const OBJ_KEY_STR = 12;
|
|
const OBJ_VAL = 13;
|
|
const OBJ_VAL_STR = 14;
|
|
const OBJ_VAL_COMPLETED = 15;
|
|
|
|
const ARR = 20;
|
|
const ARR_VAL = 21;
|
|
const ARR_VAL_STR = 22;
|
|
const ARR_VAL_COMPLETED = 23;
|
|
|
|
const ALLOWED_PRIMITIVES = ['true', 'false', 'null'];
|
|
|
|
/**
|
|
* Complete an incomplete JSON string.
|
|
* This will ensure that the last element always has a `"~~"` to indicate it was truncated.
|
|
* For example, `[1,2,` will be completed to `[1,2,"~~"]`
|
|
* and `{"aa":"b` will be completed to `{"aa":"b~~"}`
|
|
*/
|
|
function completeJson(incompleteJson, stack) {
|
|
if (!stack.length) {
|
|
return incompleteJson;
|
|
}
|
|
|
|
let json = incompleteJson;
|
|
|
|
// Most checks are only needed for the last step in the stack
|
|
const lastPos = stack.length - 1;
|
|
const lastStep = stack[lastPos];
|
|
|
|
json = _fixLastStep(json, lastStep);
|
|
|
|
// Complete remaining steps - just add closing brackets
|
|
for (let i = lastPos; i >= 0; i--) {
|
|
const step = stack[i];
|
|
|
|
switch (step) {
|
|
case OBJ:
|
|
json = `${json}}`;
|
|
break;
|
|
case ARR:
|
|
json = `${json}]`;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return json;
|
|
}
|
|
|
|
function _fixLastStep(json, lastStep) {
|
|
switch (lastStep) {
|
|
// Object cases
|
|
case OBJ:
|
|
return `${json}"~~":"~~"`;
|
|
case OBJ_KEY:
|
|
return `${json}:"~~"`;
|
|
case OBJ_KEY_STR:
|
|
return `${json}~~":"~~"`;
|
|
case OBJ_VAL:
|
|
return _maybeFixIncompleteObjValue(json);
|
|
case OBJ_VAL_STR:
|
|
return `${json}~~"`;
|
|
case OBJ_VAL_COMPLETED:
|
|
return `${json},"~~":"~~"`;
|
|
|
|
// Array cases
|
|
case ARR:
|
|
return `${json}"~~"`;
|
|
case ARR_VAL:
|
|
return _maybeFixIncompleteArrValue(json);
|
|
case ARR_VAL_STR:
|
|
return `${json}~~"`;
|
|
case ARR_VAL_COMPLETED:
|
|
return `${json},"~~"`;
|
|
}
|
|
|
|
return json;
|
|
}
|
|
|
|
function _maybeFixIncompleteArrValue(json) {
|
|
const pos = _findLastArrayDelimiter(json);
|
|
|
|
if (pos > -1) {
|
|
const part = json.slice(pos + 1);
|
|
|
|
if (ALLOWED_PRIMITIVES.includes(part.trim())) {
|
|
return `${json},"~~"`;
|
|
}
|
|
|
|
// Everything else is replaced with `"~~"`
|
|
return `${json.slice(0, pos + 1)}"~~"`;
|
|
}
|
|
|
|
// fallback, this shouldn't happen, to be save
|
|
return json;
|
|
}
|
|
|
|
function _findLastArrayDelimiter(json) {
|
|
for (let i = json.length - 1; i >= 0; i--) {
|
|
const char = json[i];
|
|
|
|
if (char === ',' || char === '[') {
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
function _maybeFixIncompleteObjValue(json) {
|
|
const startPos = json.lastIndexOf(':');
|
|
|
|
const part = json.slice(startPos + 1);
|
|
|
|
if (ALLOWED_PRIMITIVES.includes(part.trim())) {
|
|
return `${json},"~~":"~~"`;
|
|
}
|
|
|
|
// Everything else is replaced with `"~~"`
|
|
// This also means we do not have incomplete numbers, e.g `[1` is replaced with `["~~"]`
|
|
return `${json.slice(0, startPos + 1)}"~~"`;
|
|
}
|
|
|
|
/**
|
|
* Evaluate an (incomplete) JSON string.
|
|
*/
|
|
function evaluateJson(json) {
|
|
const stack = [];
|
|
|
|
for (let pos = 0; pos < json.length; pos++) {
|
|
_evaluateJsonPos(stack, json, pos);
|
|
}
|
|
|
|
return stack;
|
|
}
|
|
|
|
function _evaluateJsonPos(stack, json, pos) {
|
|
const curStep = stack[stack.length - 1];
|
|
|
|
const char = json[pos];
|
|
|
|
const whitespaceRegex = /\s/;
|
|
|
|
if (whitespaceRegex.test(char)) {
|
|
return;
|
|
}
|
|
|
|
if (char === '"' && !_isEscaped(json, pos)) {
|
|
_handleQuote(stack, curStep);
|
|
return;
|
|
}
|
|
|
|
switch (char) {
|
|
case '{':
|
|
_handleObj(stack, curStep);
|
|
break;
|
|
case '[':
|
|
_handleArr(stack, curStep);
|
|
break;
|
|
case ':':
|
|
_handleColon(stack, curStep);
|
|
break;
|
|
case ',':
|
|
_handleComma(stack, curStep);
|
|
break;
|
|
case '}':
|
|
_handleObjClose(stack, curStep);
|
|
break;
|
|
case ']':
|
|
_handleArrClose(stack, curStep);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function _handleQuote(stack, curStep) {
|
|
// End of obj value
|
|
if (curStep === OBJ_VAL_STR) {
|
|
stack.pop();
|
|
stack.push(OBJ_VAL_COMPLETED);
|
|
return;
|
|
}
|
|
|
|
// End of arr value
|
|
if (curStep === ARR_VAL_STR) {
|
|
stack.pop();
|
|
stack.push(ARR_VAL_COMPLETED);
|
|
return;
|
|
}
|
|
|
|
// Start of obj value
|
|
if (curStep === OBJ_VAL) {
|
|
stack.push(OBJ_VAL_STR);
|
|
return;
|
|
}
|
|
|
|
// Start of arr value
|
|
if (curStep === ARR_VAL) {
|
|
stack.push(ARR_VAL_STR);
|
|
return;
|
|
}
|
|
|
|
// Start of obj key
|
|
if (curStep === OBJ) {
|
|
stack.push(OBJ_KEY_STR);
|
|
return;
|
|
}
|
|
|
|
// End of obj key
|
|
if (curStep === OBJ_KEY_STR) {
|
|
stack.pop();
|
|
stack.push(OBJ_KEY);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function _handleObj(stack, curStep) {
|
|
// Initial object
|
|
if (!curStep) {
|
|
stack.push(OBJ);
|
|
return;
|
|
}
|
|
|
|
// New object as obj value
|
|
if (curStep === OBJ_VAL) {
|
|
stack.push(OBJ);
|
|
return;
|
|
}
|
|
|
|
// New object as array element
|
|
if (curStep === ARR_VAL) {
|
|
stack.push(OBJ);
|
|
}
|
|
|
|
// New object as first array element
|
|
if (curStep === ARR) {
|
|
stack.push(OBJ);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function _handleArr(stack, curStep) {
|
|
// Initial array
|
|
if (!curStep) {
|
|
stack.push(ARR);
|
|
stack.push(ARR_VAL);
|
|
return;
|
|
}
|
|
|
|
// New array as obj value
|
|
if (curStep === OBJ_VAL) {
|
|
stack.push(ARR);
|
|
stack.push(ARR_VAL);
|
|
return;
|
|
}
|
|
|
|
// New array as array element
|
|
if (curStep === ARR_VAL) {
|
|
stack.push(ARR);
|
|
stack.push(ARR_VAL);
|
|
}
|
|
|
|
// New array as first array element
|
|
if (curStep === ARR) {
|
|
stack.push(ARR);
|
|
stack.push(ARR_VAL);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function _handleColon(stack, curStep) {
|
|
if (curStep === OBJ_KEY) {
|
|
stack.pop();
|
|
stack.push(OBJ_VAL);
|
|
}
|
|
}
|
|
|
|
function _handleComma(stack, curStep) {
|
|
// Comma after obj value
|
|
if (curStep === OBJ_VAL) {
|
|
stack.pop();
|
|
return;
|
|
}
|
|
if (curStep === OBJ_VAL_COMPLETED) {
|
|
// Pop OBJ_VAL_COMPLETED & OBJ_VAL
|
|
stack.pop();
|
|
stack.pop();
|
|
return;
|
|
}
|
|
|
|
// Comma after arr value
|
|
if (curStep === ARR_VAL) {
|
|
// do nothing - basically we'd pop ARR_VAL but add it right back
|
|
return;
|
|
}
|
|
|
|
if (curStep === ARR_VAL_COMPLETED) {
|
|
// Pop ARR_VAL_COMPLETED
|
|
stack.pop();
|
|
|
|
// basically we'd pop ARR_VAL but add it right back
|
|
return;
|
|
}
|
|
}
|
|
|
|
function _handleObjClose(stack, curStep) {
|
|
// Empty object {}
|
|
if (curStep === OBJ) {
|
|
stack.pop();
|
|
}
|
|
|
|
// Object with element
|
|
if (curStep === OBJ_VAL) {
|
|
// Pop OBJ_VAL, OBJ
|
|
stack.pop();
|
|
stack.pop();
|
|
}
|
|
|
|
// Obj with element
|
|
if (curStep === OBJ_VAL_COMPLETED) {
|
|
// Pop OBJ_VAL_COMPLETED, OBJ_VAL, OBJ
|
|
stack.pop();
|
|
stack.pop();
|
|
stack.pop();
|
|
}
|
|
|
|
// if was obj value, complete it
|
|
if (stack[stack.length - 1] === OBJ_VAL) {
|
|
stack.push(OBJ_VAL_COMPLETED);
|
|
}
|
|
|
|
// if was arr value, complete it
|
|
if (stack[stack.length - 1] === ARR_VAL) {
|
|
stack.push(ARR_VAL_COMPLETED);
|
|
}
|
|
}
|
|
|
|
function _handleArrClose(stack, curStep) {
|
|
// Empty array []
|
|
if (curStep === ARR) {
|
|
stack.pop();
|
|
}
|
|
|
|
// Array with element
|
|
if (curStep === ARR_VAL) {
|
|
// Pop ARR_VAL, ARR
|
|
stack.pop();
|
|
stack.pop();
|
|
}
|
|
|
|
// Array with element
|
|
if (curStep === ARR_VAL_COMPLETED) {
|
|
// Pop ARR_VAL_COMPLETED, ARR_VAL, ARR
|
|
stack.pop();
|
|
stack.pop();
|
|
stack.pop();
|
|
}
|
|
|
|
// if was obj value, complete it
|
|
if (stack[stack.length - 1] === OBJ_VAL) {
|
|
stack.push(OBJ_VAL_COMPLETED);
|
|
}
|
|
|
|
// if was arr value, complete it
|
|
if (stack[stack.length - 1] === ARR_VAL) {
|
|
stack.push(ARR_VAL_COMPLETED);
|
|
}
|
|
}
|
|
|
|
function _isEscaped(str, pos) {
|
|
const previousChar = str[pos - 1];
|
|
|
|
return previousChar === '\\' && !_isEscaped(str, pos - 1);
|
|
}
|
|
|
|
/* eslint-disable max-lines */
|
|
|
|
/**
|
|
* Takes an incomplete JSON string, and returns a hopefully valid JSON string.
|
|
* Note that this _can_ fail, so you should check the return value is valid JSON.
|
|
*/
|
|
function fixJson(incompleteJson) {
|
|
const stack = evaluateJson(incompleteJson);
|
|
|
|
return completeJson(incompleteJson, stack);
|
|
}
|
|
|
|
/** 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) {
|
|
if (typeof body === 'string') {
|
|
return body;
|
|
}
|
|
|
|
if (body instanceof URLSearchParams) {
|
|
return body.toString();
|
|
}
|
|
|
|
if (body instanceof FormData) {
|
|
return _serializeFormData(body);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/** 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.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-ignore passing FormData to URLSearchParams actually works
|
|
return new URLSearchParams(formData).toString();
|
|
}
|
|
|
|
function normalizeNetworkBody(body)
|
|
|
|
{
|
|
if (!body || typeof body !== 'string') {
|
|
return {
|
|
body,
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
const exceedsSizeLimit = body.length > NETWORK_BODY_MAX_SIZE;
|
|
|
|
if (_strIsProbablyJson(body)) {
|
|
try {
|
|
const json = exceedsSizeLimit ? fixJson(body.slice(0, NETWORK_BODY_MAX_SIZE)) : body;
|
|
const normalizedBody = JSON.parse(json);
|
|
return {
|
|
body: normalizedBody,
|
|
warnings: exceedsSizeLimit ? ['JSON_TRUNCATED'] : [],
|
|
};
|
|
} catch (e3) {
|
|
return {
|
|
body: exceedsSizeLimit ? `${body.slice(0, NETWORK_BODY_MAX_SIZE)}…` : body,
|
|
warnings: exceedsSizeLimit ? ['INVALID_JSON', 'TEXT_TRUNCATED'] : ['INVALID_JSON'],
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
body: exceedsSizeLimit ? `${body.slice(0, NETWORK_BODY_MAX_SIZE)}…` : body,
|
|
warnings: exceedsSizeLimit ? ['TEXT_TRUNCATED'] : [],
|
|
};
|
|
}
|
|
|
|
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) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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 = _getFetchRequestArgBody(input);
|
|
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 { startTimestamp, endTimestamp } = hint;
|
|
|
|
const {
|
|
url,
|
|
method,
|
|
status_code: statusCode = 0,
|
|
request_body_size: requestBodySize,
|
|
response_body_size: responseBodySize,
|
|
} = breadcrumb.data;
|
|
|
|
const captureDetails = urlMatches(url, options.networkDetailAllowUrls);
|
|
|
|
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 = 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 = getBodyString(requestBody);
|
|
return buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr);
|
|
}
|
|
|
|
async function _getResponseInfo(
|
|
captureDetails,
|
|
{
|
|
networkCaptureBodies,
|
|
textEncoder,
|
|
networkResponseHeaders,
|
|
}
|
|
|
|
,
|
|
response,
|
|
responseBodySize,
|
|
) {
|
|
if (!captureDetails && responseBodySize !== undefined) {
|
|
return buildSkippedNetworkRequestOrResponse(responseBodySize);
|
|
}
|
|
|
|
const headers = getAllHeaders(response.headers, networkResponseHeaders);
|
|
|
|
if (!networkCaptureBodies && responseBodySize !== undefined) {
|
|
return buildNetworkRequestOrResponse(headers, responseBodySize, undefined);
|
|
}
|
|
|
|
// Only clone the response if we need to
|
|
try {
|
|
// We have to clone this, as the body can only be read once
|
|
const res = response.clone();
|
|
const bodyText = await _parseFetchBody(res);
|
|
|
|
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 (e) {
|
|
// fallback
|
|
return buildNetworkRequestOrResponse(headers, responseBodySize, undefined);
|
|
}
|
|
}
|
|
|
|
async function _parseFetchBody(response) {
|
|
try {
|
|
return await response.text();
|
|
} catch (e2) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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 enrichXhrBreadcrumb(
|
|
breadcrumb,
|
|
hint,
|
|
options,
|
|
) {
|
|
const { xhr, input } = hint;
|
|
|
|
const reqSize = getBodySize(input, options.textEncoder);
|
|
const resSize = xhr.getResponseHeader('content-length')
|
|
? parseContentLengthHeader(xhr.getResponseHeader('content-length'))
|
|
: getBodySize(xhr.response, 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 { startTimestamp, endTimestamp, 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 (!urlMatches(url, options.networkDetailAllowUrls)) {
|
|
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 request = buildNetworkRequestOrResponse(
|
|
networkRequestHeaders,
|
|
requestBodySize,
|
|
options.networkCaptureBodies ? getBodyString(input) : undefined,
|
|
);
|
|
const response = buildNetworkRequestOrResponse(
|
|
networkResponseHeaders,
|
|
responseBodySize,
|
|
options.networkCaptureBodies ? hint.xhr.responseText : undefined,
|
|
);
|
|
|
|
return {
|
|
startTimestamp,
|
|
endTimestamp,
|
|
url,
|
|
method,
|
|
statusCode,
|
|
request,
|
|
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;
|
|
}, {});
|
|
}
|
|
|
|
/**
|
|
* 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 = getCurrentHub().getClient();
|
|
|
|
try {
|
|
const textEncoder = new TextEncoder();
|
|
|
|
const { networkDetailAllowUrls, networkCaptureBodies, networkRequestHeaders, networkResponseHeaders } =
|
|
replay.getOptions();
|
|
|
|
const options = {
|
|
replay,
|
|
textEncoder,
|
|
networkDetailAllowUrls,
|
|
networkCaptureBodies,
|
|
networkRequestHeaders,
|
|
networkResponseHeaders,
|
|
};
|
|
|
|
if (client && client.on) {
|
|
client.on('beforeAddBreadcrumb', (breadcrumb, hint) => beforeAddNetworkBreadcrumb(options, breadcrumb, hint));
|
|
} else {
|
|
// Fallback behavior
|
|
addInstrumentationHandler('fetch', handleFetchSpanListener(replay));
|
|
addInstrumentationHandler('xhr', 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);
|
|
|
|
void 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);
|
|
|
|
void captureFetchBreadcrumbToReplay(breadcrumb, hint, options);
|
|
}
|
|
} catch (e) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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) {
|
|
const fixedJson = fixJson(stringified.slice(0, CONSOLE_ARG_MAX_SIZE));
|
|
const json = JSON.parse(fixedJson);
|
|
// We only set this after JSON.parse() was successfull, so we know we didn't run into `catch`
|
|
isTruncated = true;
|
|
return json;
|
|
}
|
|
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 = getCurrentHub().getScope();
|
|
const client = getCurrentHub().getClient();
|
|
|
|
if (scope) {
|
|
scope.addScopeListener(handleScopeListener(replay));
|
|
}
|
|
addInstrumentationHandler('dom', handleDomListener(replay));
|
|
addInstrumentationHandler('history', 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
|
|
addGlobalEventProcessor(handleGlobalEventListener(replay, !hasHooks(client)));
|
|
|
|
// If a custom client has no hooks yet, we continue to use the "old" implementation
|
|
if (hasHooks(client)) {
|
|
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') {
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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-ignore 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,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// Map entryType -> function to normalize data for event
|
|
// @ts-ignore TODO: entry type does not fit the create* functions entry type
|
|
const ENTRY_TYPES
|
|
|
|
= {
|
|
// @ts-ignore TODO: entry type does not fit the create* functions entry type
|
|
resource: createResourceEntry,
|
|
paint: createPaintEntry,
|
|
// @ts-ignore TODO: entry type does not fit the create* functions entry type
|
|
navigation: createNavigationEntry,
|
|
// @ts-ignore TODO: entry type does not fit the create* functions entry type
|
|
['largest-contentful-paint']: createLargestContentfulPaint,
|
|
};
|
|
|
|
/**
|
|
* 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] === undefined) {
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createLargestContentfulPaint(
|
|
entry,
|
|
) {
|
|
const { entryType, startTime, size } = entry;
|
|
|
|
let startTimeOrNavigationActivation = 0;
|
|
|
|
if (WINDOW.performance) {
|
|
const navEntry = WINDOW.performance.getEntriesByType('navigation')[0]
|
|
|
|
;
|
|
|
|
// See https://github.com/GoogleChrome/web-vitals/blob/9f11c4c6578fb4c5ee6fa4e32b9d1d756475f135/src/lib/getActivationStart.ts#L21
|
|
startTimeOrNavigationActivation = (navEntry && navEntry.activationStart) || 0;
|
|
}
|
|
|
|
// value is in ms
|
|
const value = Math.max(startTime - startTimeOrNavigationActivation, 0);
|
|
// LCP doesn't have a "duration", it just happens at a single point in time.
|
|
// But the UI expects both, so use end (in seconds) for both timestamps.
|
|
const end = getAbsoluteTime(startTimeOrNavigationActivation) + value / 1000;
|
|
|
|
return {
|
|
type: entryType,
|
|
name: entryType,
|
|
start: end,
|
|
end,
|
|
data: {
|
|
value, // LCP "duration" in ms
|
|
size,
|
|
// Not sure why this errors, Node should be correct (Argument of type 'Node' is not assignable to parameter of type 'INode')
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
nodeId: record.mirror.getId(entry.element ),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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()) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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;
|
|
|
|
// 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();
|
|
}
|
|
|
|
// We need to clear existing events on a checkout, otherwise they are
|
|
// incremental event updates and should be appended
|
|
void addEvent(replay, event, isCheckout);
|
|
|
|
// 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
|
|
void 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) {
|
|
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.
|
|
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: {
|
|
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 Promise.resolve(null);
|
|
}
|
|
|
|
return addEvent(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 preparedEvent = (await prepareEvent(
|
|
client.getOptions(),
|
|
event,
|
|
{ event_id, integrations },
|
|
scope,
|
|
)) ;
|
|
|
|
// 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 hub = getCurrentHub();
|
|
const client = hub.getClient();
|
|
const scope = hub.getScope();
|
|
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);
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('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": {
|
|
},
|
|
}
|
|
*/
|
|
|
|
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-ignore 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);
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
throw err;
|
|
}
|
|
|
|
// Capture error for every failed replay
|
|
setContext('Replays', {
|
|
_retryCount: retryConfig.count,
|
|
});
|
|
|
|
if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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-ignore 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 {
|
|
__init() {this.eventBuffer = null;}
|
|
|
|
/**
|
|
* List of PerformanceEntry from PerformanceObserver
|
|
*/
|
|
__init2() {this.performanceEvents = [];}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
__init3() {this.recordingMode = 'session';}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
__init4() {this.timeouts = {
|
|
sessionIdlePause: SESSION_IDLE_PAUSE_DURATION,
|
|
sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION,
|
|
maxSessionLife: MAX_SESSION_LIFE,
|
|
}; }
|
|
|
|
/**
|
|
* Options to pass to `rrweb.record()`
|
|
*/
|
|
|
|
__init5() {this._performanceObserver = null;}
|
|
|
|
__init6() {this._flushLock = null;}
|
|
|
|
/**
|
|
* Timestamp of the last user activity. This lives across sessions.
|
|
*/
|
|
__init7() {this._lastActivity = Date.now();}
|
|
|
|
/**
|
|
* Is the integration currently active?
|
|
*/
|
|
__init8() {this._isEnabled = false;}
|
|
|
|
/**
|
|
* Paused is a state where:
|
|
* - DOM Recording is not listening at all
|
|
* - Nothing will be added to event buffer (e.g. core SDK events)
|
|
*/
|
|
__init9() {this._isPaused = false;}
|
|
|
|
/**
|
|
* Have we attached listeners to the core SDK?
|
|
* Note we have to track this as there is no way to remove instrumentation handlers.
|
|
*/
|
|
__init10() {this._hasInitializedCoreListeners = false;}
|
|
|
|
/**
|
|
* Function to stop recording
|
|
*/
|
|
__init11() {this._stopRecording = null;}
|
|
|
|
__init12() {this._context = {
|
|
errorIds: new Set(),
|
|
traceIds: new Set(),
|
|
urls: [],
|
|
initialTimestamp: Date.now(),
|
|
initialUrl: '',
|
|
};}
|
|
|
|
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);ReplayContainer.prototype.__init7.call(this);ReplayContainer.prototype.__init8.call(this);ReplayContainer.prototype.__init9.call(this);ReplayContainer.prototype.__init10.call(this);ReplayContainer.prototype.__init11.call(this);ReplayContainer.prototype.__init12.call(this);ReplayContainer.prototype.__init13.call(this);ReplayContainer.prototype.__init14.call(this);ReplayContainer.prototype.__init15.call(this);ReplayContainer.prototype.__init16.call(this);ReplayContainer.prototype.__init17.call(this);ReplayContainer.prototype.__init18.call(this);
|
|
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(',') : '',
|
|
multiClickTimeout: MULTI_CLICK_TIMEOUT,
|
|
}
|
|
: 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;
|
|
}
|
|
|
|
/** Get the replay integration options. */
|
|
getOptions() {
|
|
return this._options;
|
|
}
|
|
|
|
/**
|
|
* Initializes the plugin based on sampling configuration. Should not be
|
|
* called outside of constructor.
|
|
*/
|
|
initializeSampling() {
|
|
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.
|
|
const isSessionSampled = this._loadAndCheckSession();
|
|
|
|
if (!isSessionSampled) {
|
|
// 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 (!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 && this.session.sampled !== 'session') {
|
|
// If not sampled as session-based, then recording mode will be `buffer`
|
|
// Note that we don't explicitly check if `sampled === 'buffer'` because we
|
|
// could have sessions from Session storage that are still `error` from
|
|
// prior SDK version.
|
|
this.recordingMode = 'buffer';
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
const previousSessionId = this.session && this.session.id;
|
|
|
|
const { session } = getSession({
|
|
timeouts: this.timeouts,
|
|
stickySession: Boolean(this._options.stickySession),
|
|
currentSession: this.session,
|
|
// This is intentional: create a new session-based replay when calling `start()`
|
|
sessionSampleRate: 1,
|
|
allowBuffering: false,
|
|
});
|
|
|
|
session.previousSessionId = previousSessionId;
|
|
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');
|
|
}
|
|
|
|
const previousSessionId = this.session && this.session.id;
|
|
|
|
const { session } = getSession({
|
|
timeouts: this.timeouts,
|
|
stickySession: Boolean(this._options.stickySession),
|
|
currentSession: this.session,
|
|
sessionSampleRate: 0,
|
|
allowBuffering: true,
|
|
});
|
|
|
|
session.previousSessionId = previousSessionId;
|
|
this.session = session;
|
|
|
|
this.recordingMode = 'buffer';
|
|
this._initializeRecording();
|
|
}
|
|
|
|
/**
|
|
* Start recording.
|
|
*
|
|
* Note that this will cause a new DOM checkout
|
|
*/
|
|
startRecording() {
|
|
try {
|
|
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,
|
|
});
|
|
} 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(reason) {
|
|
if (!this._isEnabled) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) {
|
|
const msg = `[Replay] Stopping Replay${reason ? ` triggered by ${reason}` : ''}`;
|
|
|
|
// When `traceInternals` is enabled, we want to log this to the console
|
|
// Else, use the regular debug output
|
|
// eslint-disable-next-line
|
|
const log = this.getOptions()._experiments.traceInternals ? console.warn : logger.log;
|
|
log(msg);
|
|
}
|
|
|
|
// 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;
|
|
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 (this.recordingMode === 'session') {
|
|
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() {
|
|
this._isPaused = true;
|
|
this.stopRecording();
|
|
}
|
|
|
|
/**
|
|
* Resumes recording, see notes for `pause().
|
|
*
|
|
* Note that calling `startRecording()` here will cause a
|
|
* new DOM checkout.`
|
|
*/
|
|
resume() {
|
|
if (!this._loadAndCheckSession()) {
|
|
return;
|
|
}
|
|
|
|
this._isPaused = false;
|
|
this.startRecording();
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Re-start recording, but in "session" recording mode
|
|
|
|
// Reset all "capture on error" configuration before
|
|
// starting a new recording
|
|
this.recordingMode = 'session';
|
|
|
|
// Once this session ends, we do not want to refresh it
|
|
if (this.session) {
|
|
this.session.shouldRefresh = false;
|
|
|
|
// It's possible that the session lifespan is > max session lifespan
|
|
// because we have been buffering beyond max session lifespan (we ignore
|
|
// expiration given that `shouldRefresh` is true). Since we flip
|
|
// `shouldRefresh`, the session could be considered expired due to
|
|
// lifespan, which is not what we want. Update session start date to be
|
|
// the current timestamp, so that session is not considered to be
|
|
// expired. This means that max replay duration can be MAX_SESSION_LIFE +
|
|
// (length of buffer), which we are ok with.
|
|
this._updateUserActivity(activityTime);
|
|
this._updateSessionActivity(activityTime);
|
|
this.session.started = activityTime;
|
|
this._maybeSaveSession();
|
|
}
|
|
|
|
this.startRecording();
|
|
}
|
|
|
|
/**
|
|
* We want to batch uploads of replay events. Save events only if
|
|
* `<flushMinDelay>` milliseconds have elapsed since the last event
|
|
* *OR* if `<flushMaxDelay>` 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._loadAndCheckSession()) {
|
|
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() {
|
|
const oldSessionId = this.getSessionId();
|
|
|
|
// 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._loadAndCheckSession()) {
|
|
return;
|
|
}
|
|
|
|
// Session was expired if session ids do not match
|
|
const expired = oldSessionId !== this.getSessionId();
|
|
|
|
if (!expired) {
|
|
return true;
|
|
}
|
|
|
|
// Session is expired, trigger a full snapshot (which will create a new session)
|
|
this._triggerFullSnapshot();
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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.performanceEvents = [];
|
|
|
|
// 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(() => {
|
|
void addEvent(this, {
|
|
type: EventType.Custom,
|
|
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() {
|
|
const lastTransaction = this.lastTransaction || getCurrentHub().getScope().getTransaction();
|
|
if (!lastTransaction || !['route', 'custom'].includes(lastTransaction.metadata.source)) {
|
|
return undefined;
|
|
}
|
|
|
|
return lastTransaction.name;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
|
|
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.startRecording();
|
|
}
|
|
|
|
/** A wrapper to conditionally capture exceptions. */
|
|
_handleException(error) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error('[Replay]', error);
|
|
|
|
if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && this._options._experiments && this._options._experiments.captureExceptions) {
|
|
captureException(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads (or refreshes) the current session.
|
|
* Returns false if session is not recorded.
|
|
*/
|
|
_loadAndCheckSession() {
|
|
const { type, session } = getSession({
|
|
timeouts: this.timeouts,
|
|
stickySession: Boolean(this._options.stickySession),
|
|
currentSession: this.session,
|
|
sessionSampleRate: this._options.sessionSampleRate,
|
|
allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer',
|
|
});
|
|
|
|
// If session was newly created (i.e. was not loaded from storage), then
|
|
// enable flag to create the root replay
|
|
if (type === 'new') {
|
|
this.setInitialState();
|
|
}
|
|
|
|
const currentSessionId = this.getSessionId();
|
|
if (session.id !== currentSessionId) {
|
|
session.previousSessionId = currentSessionId;
|
|
}
|
|
|
|
this.session = session;
|
|
|
|
if (!this.session.sampled) {
|
|
void this.stop('session unsampled');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// PerformanceObserver //
|
|
if (!('PerformanceObserver' in WINDOW)) {
|
|
return;
|
|
}
|
|
|
|
this._performanceObserver = 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._performanceObserver) {
|
|
this._performanceObserver.disconnect();
|
|
this._performanceObserver = null;
|
|
}
|
|
} 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.
|
|
*/
|
|
__init13() {this._handleVisibilityChange = () => {
|
|
if (WINDOW.document.visibilityState === 'visible') {
|
|
this._doChangeToForegroundTasks();
|
|
} else {
|
|
this._doChangeToBackgroundTasks();
|
|
}
|
|
};}
|
|
|
|
/**
|
|
* Handle when page is blurred
|
|
*/
|
|
__init14() {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
|
|
*/
|
|
__init15() {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. */
|
|
__init16() {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, this.timeouts);
|
|
|
|
if (breadcrumb && !expired) {
|
|
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
|
|
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
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Document has become active, but session has expired');
|
|
return;
|
|
}
|
|
|
|
if (breadcrumb) {
|
|
this._createCustomBreadcrumb(breadcrumb);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger rrweb to take a full snapshot which will cause this plugin to
|
|
* create a new Replay event.
|
|
*/
|
|
_triggerFullSnapshot(checkout = true) {
|
|
try {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Taking full rrweb snapshot');
|
|
record.takeFullSnapshot(checkout);
|
|
} catch (err) {
|
|
this._handleException(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(() => {
|
|
void this.throttledAddEvent({
|
|
type: EventType.Custom,
|
|
timestamp: breadcrumb.timestamp || 0,
|
|
data: {
|
|
tag: 'breadcrumb',
|
|
payload: breadcrumb,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Observed performance events are added to `this.performanceEvents`. These
|
|
* are included in the replay event before it is finished and sent to Sentry.
|
|
*/
|
|
_addPerformanceEntries() {
|
|
// Copy and reset entries before processing
|
|
const entries = [...this.performanceEvents];
|
|
this.performanceEvents = [];
|
|
|
|
return Promise.all(createPerformanceSpans(this, createPerformanceEntries(entries)));
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
if (!this.session || !this.eventBuffer) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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;
|
|
}
|
|
|
|
try {
|
|
// This uses the data from the eventBuffer, so we need to call this before `finish()
|
|
this._updateInitialTimestampFromEventBuffer();
|
|
|
|
// Note this empties the event buffer regardless of outcome of sending replay
|
|
const recordingData = await this.eventBuffer.finish();
|
|
|
|
// NOTE: Copy values from instance members, as it's possible they could
|
|
// change before the flush finishes.
|
|
const replayId = this.session.id;
|
|
const eventContext = this._popEventContext();
|
|
// Always increment segmentId regardless of outcome of sending replay
|
|
const segmentId = this.session.segmentId++;
|
|
this._maybeSaveSession();
|
|
|
|
await sendReplay({
|
|
replayId,
|
|
recordingData,
|
|
segmentId,
|
|
eventContext,
|
|
session: this.session,
|
|
options: this.getOptions(),
|
|
timestamp: Date.now(),
|
|
});
|
|
} 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
|
|
void this.stop('sendReplay');
|
|
|
|
const client = getCurrentHub().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.
|
|
*/
|
|
__init17() {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()) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error('[Replay] Attempting to finish replay event after session expired.');
|
|
return;
|
|
}
|
|
|
|
if (!this.session) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error('[Replay] No session found to flush.');
|
|
return;
|
|
}
|
|
|
|
// A flush is about to happen, cancel any queued flushes
|
|
this._debouncedFlush.cancel();
|
|
|
|
// 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 = null;
|
|
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) {
|
|
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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 */
|
|
__init18() {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) {
|
|
void this.stop('mutationLimit');
|
|
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}`);
|
|
}
|
|
|
|
// 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,
|
|
maskInputSelector: maskSelector,
|
|
unmaskInputSelector: 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;
|
|
}
|
|
|
|
/**
|
|
* Returns true if we are in the browser.
|
|
*/
|
|
function isBrowser() {
|
|
// eslint-disable-next-line no-restricted-globals
|
|
return typeof window !== 'undefined' && (!isNodeEnv() || isElectronNodeRenderer());
|
|
}
|
|
|
|
// Electron renderers with nodeIntegration enabled are detected as Node.js so we specifically test for them
|
|
function isElectronNodeRenderer() {
|
|
return typeof process !== 'undefined' && (process ).type === 'renderer';
|
|
}
|
|
|
|
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;
|
|
|
|
/**
|
|
* The main replay integration class, to be passed to `init({ integrations: [] })`.
|
|
*/
|
|
class Replay {
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
static __initStatic() {this.id = 'Replay';}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
__init() {this.name = Replay.id;}
|
|
|
|
/**
|
|
* 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,
|
|
stickySession = true,
|
|
useCompression = true,
|
|
_experiments = {},
|
|
sessionSampleRate,
|
|
errorSampleRate,
|
|
maskAllText = true,
|
|
maskAllInputs = true,
|
|
blockAllMedia = true,
|
|
|
|
mutationBreadcrumbLimit = 750,
|
|
mutationLimit = 10000,
|
|
|
|
slowClickTimeout = 7000,
|
|
slowClickIgnoreSelectors = [],
|
|
|
|
networkDetailAllowUrls = [],
|
|
networkCaptureBodies = true,
|
|
networkRequestHeaders = [],
|
|
networkResponseHeaders = [],
|
|
|
|
mask = [],
|
|
unmask = [],
|
|
block = [],
|
|
unblock = [],
|
|
ignore = [],
|
|
maskFn,
|
|
|
|
beforeAddRecordingEvent,
|
|
|
|
// 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,
|
|
} = {}) {Replay.prototype.__init.call(this);
|
|
this._recordingOptions = {
|
|
maskAllInputs,
|
|
maskAllText,
|
|
maskInputOptions: { ...(maskInputOptions || {}), password: true },
|
|
maskTextFn: maskFn,
|
|
maskInputFn: maskFn,
|
|
|
|
...getPrivacyOptions({
|
|
mask,
|
|
unmask,
|
|
block,
|
|
unblock,
|
|
ignore,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
ignoreClass,
|
|
}),
|
|
|
|
// 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,
|
|
};
|
|
|
|
this._initialOptions = {
|
|
flushMinDelay,
|
|
flushMaxDelay,
|
|
stickySession,
|
|
sessionSampleRate,
|
|
errorSampleRate,
|
|
useCompression,
|
|
blockAllMedia,
|
|
maskAllInputs,
|
|
maskAllText,
|
|
mutationBreadcrumbLimit,
|
|
mutationLimit,
|
|
slowClickTimeout,
|
|
slowClickIgnoreSelectors,
|
|
networkDetailAllowUrls,
|
|
networkCaptureBodies,
|
|
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
|
|
networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders),
|
|
beforeAddRecordingEvent,
|
|
|
|
_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();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
} Replay.__initStatic();
|
|
|
|
/** Parse Replay-related options from SDK options */
|
|
function loadReplayOptionsFromClient(initialOptions) {
|
|
const client = getCurrentHub().getClient();
|
|
const opt = client && (client.getOptions() );
|
|
|
|
const finalOptions = { sessionSampleRate: 0, errorSampleRate: 0, ...dropUndefinedKeys(initialOptions) };
|
|
|
|
if (!opt) {
|
|
// 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
|
|
) {
|
|
// 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())];
|
|
}
|
|
|
|
export { Replay };
|
|
//# sourceMappingURL=index.js.map
|