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