@sentry/replay
Advanced tools
Comparing version 0.2.0-0 to 0.2.0-1
import * as Sentry from '@sentry/browser'; | ||
import { isDebugBuild, logger, uuid4 } from '@sentry/utils'; | ||
import { record } from 'rrweb'; | ||
import { logger as logger$1, isDebugBuild } from '@sentry/utils'; | ||
@@ -161,3 +161,145 @@ /*! ***************************************************************************** | ||
var VISIBILITY_CHANGE_TIMEOUT = 5000; | ||
/** | ||
* Given an initial timestamp and an expiry duration, checks to see if current | ||
* time should be considered as expired. | ||
*/ | ||
function isExpired(initialTime, expiry, targetTime) { | ||
if (targetTime === void 0) { targetTime = +new Date(); } | ||
// Always expired if < 0 | ||
if (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, expiry, targetTime) { | ||
if (targetTime === void 0) { targetTime = +new Date(); } | ||
return isExpired(session.lastActivity, expiry, targetTime); | ||
} | ||
function wrapLogger(logFn) { | ||
return function wrappedLog() { | ||
var args = []; | ||
for (var _i = 0; _i < arguments.length; _i++) { | ||
args[_i] = arguments[_i]; | ||
} | ||
if (!isDebugBuild()) { | ||
return; | ||
} | ||
return logFn.call.apply(logFn, __spreadArray([logger$1, '[Replay]'], args, false)); | ||
}; | ||
} | ||
var logger = __assign(__assign({}, logger$1), { error: wrapLogger(logger$1.error), warn: wrapLogger(logger$1.warn), log: wrapLogger(logger$1.log) }); | ||
var REPLAY_SESSION_KEY = 'sentryReplaySession'; | ||
function saveSession(session) { | ||
var hasSessionStorage = 'sessionStorage' in window; | ||
if (!hasSessionStorage) { | ||
return; | ||
} | ||
try { | ||
window.sessionStorage.setItem(REPLAY_SESSION_KEY, JSON.stringify(session)); | ||
} | ||
catch (_a) { | ||
// this shouldn't happen | ||
} | ||
} | ||
/** | ||
* 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(_a) { | ||
var _b = _a.stickySession, stickySession = _b === void 0 ? false : _b; | ||
var currentDate = new Date().getTime(); | ||
// Create root replay event, this is where attachments will be saved | ||
var transaction = Sentry.getCurrentHub().startTransaction({ | ||
name: 'sentry-replay', | ||
tags: { | ||
isReplayRoot: 'yes', | ||
}, | ||
}); | ||
// We have to finish the transaction to get an event ID to be able to | ||
// upload an attachment for that event | ||
// @ts-expect-error This returns an eventId (string), but is not typed as such | ||
var id = transaction.finish(); | ||
logger.log("Creating new session: ".concat(id)); | ||
var session = { | ||
id: id, | ||
spanId: transaction.spanId, | ||
traceId: transaction.traceId, | ||
started: currentDate, | ||
lastActivity: currentDate, | ||
}; | ||
if (stickySession) { | ||
saveSession(session); | ||
} | ||
return session; | ||
} | ||
function fetchSession() { | ||
var hasSessionStorage = 'sessionStorage' in window; | ||
if (!hasSessionStorage) { | ||
return null; | ||
} | ||
try { | ||
return JSON.parse(window.sessionStorage.getItem(REPLAY_SESSION_KEY)); | ||
} | ||
catch (_a) { | ||
return null; | ||
} | ||
} | ||
/** | ||
* Get or create a session | ||
*/ | ||
function getSession(_a) { | ||
var expiry = _a.expiry, stickySession = _a.stickySession; | ||
var session = stickySession && fetchSession(); | ||
if (session) { | ||
// If there is a session, check if it is valid (e.g. "last activity" time should be within the "session idle time") | ||
// TODO: We should probably set a max age on this as well | ||
var isExpired = isSessionExpired(session, expiry); | ||
if (!isExpired) { | ||
logger.log("Using existing session: ".concat(session.id)); | ||
return session; | ||
} | ||
else { | ||
logger.log("Session has expired"); | ||
} | ||
// Otherwise continue to create a new session | ||
} | ||
var newSession = createSession({ stickySession: stickySession }); | ||
return newSession; | ||
} | ||
function updateSessionActivity(_a) { | ||
var stickySession = _a.stickySession; | ||
// Nothing to do if there are no sticky sessions | ||
if (!stickySession) { | ||
return; | ||
} | ||
var existingSession = fetchSession(); | ||
// If user manually deleted from session storage, create a new session | ||
if (!existingSession) { | ||
// TBD: There was an issue here where sessions weren't saving and this | ||
// caused lots of transactions to be created | ||
return createSession({ stickySession: stickySession }); | ||
} | ||
var newSession = __assign(__assign({}, existingSession), { lastActivity: new Date().getTime() }); | ||
saveSession(newSession); | ||
return newSession; | ||
} | ||
var VISIBILITY_CHANGE_TIMEOUT = 60000; // 1 minute | ||
var SESSION_IDLE_DURATION = 900000; // 15 minutes | ||
var SentryReplay = /** @class */ (function () { | ||
@@ -167,3 +309,5 @@ function SentryReplay(_a) { | ||
var _this = this; | ||
var _b = _a.idleTimeout, idleTimeout = _b === void 0 ? 15000 : _b, _c = _a.rrwebConfig, _d = _c === void 0 ? {} : _c, _e = _d.maskAllInputs, maskAllInputs = _e === void 0 ? true : _e, rrwebRecordOptions = __rest(_d, ["maskAllInputs"]); | ||
var _b = _a.uploadMinDelay, uploadMinDelay = _b === void 0 ? 5000 : _b, _c = _a.uploadMaxDelay, uploadMaxDelay = _c === void 0 ? 15000 : _c, _d = _a.stickySession, stickySession = _d === void 0 ? false : _d, // TBD: Making this opt-in for now | ||
_e = _a.rrwebConfig, // TBD: Making this opt-in for now | ||
_f = _e === void 0 ? {} : _e, _g = _f.maskAllInputs, maskAllInputs = _g === void 0 ? true : _g, rrwebRecordOptions = __rest(_f, ["maskAllInputs"]); | ||
/** | ||
@@ -178,2 +322,7 @@ * @inheritDoc | ||
this.performanceEvents = []; | ||
/** | ||
* The timestamp of the first event since the last flush. | ||
* This is used to determine if the maximum allowed time has passed before we should flush events again. | ||
*/ | ||
this.initialEventTimestampSinceFlush = null; | ||
this.performanceObserver = null; | ||
@@ -192,54 +341,36 @@ /** | ||
this.handleVisibilityChange = function () { | ||
if (document.visibilityState === 'visible' && | ||
_this.visibilityChangeTimer === null) { | ||
// Page has become active/visible again after `VISIBILITY_CHANGE_TIMEOUT` | ||
// ms have elapsed, which means we will consider this a new session | ||
// | ||
// TBD if this is the behavior we want | ||
_this.isDebug && | ||
logger.log('[Replay] document has become active, creating new "session"'); | ||
_this.triggerNewSession(); | ||
var isExpired = isSessionExpired(_this.session, VISIBILITY_CHANGE_TIMEOUT); | ||
if (isExpired) { | ||
if (document.visibilityState === 'visible') { | ||
// If the user has come back to the page within VISIBILITY_CHANGE_TIMEOUT | ||
// ms, we will re-use the existing session, otherwise create a new | ||
// session | ||
logger.log('Document has become active, but session has expired'); | ||
_this.triggerFullSnapshot(); | ||
} | ||
// We definitely want to return if visibilityState is "visible", and I | ||
// think we also want to do the same when "hidden", as there shouldn't be | ||
// any action to take if user has gone idle for a long period and then | ||
// comes back to hide the tab. We don't trigger a full snapshot because | ||
// we don't want to start a new session as they immediately have hidden | ||
// the tab. | ||
return; | ||
} | ||
// Send replay when the page/tab becomes hidden | ||
_this.finishReplayEvent(); | ||
// VISIBILITY_CHANGE_TIMEOUT gives the user buffer room to come back to the | ||
// page before we create a new session. | ||
_this.visibilityChangeTimer = window.setTimeout(function () { | ||
_this.visibilityChangeTimer = null; | ||
}, VISIBILITY_CHANGE_TIMEOUT); | ||
// Otherwise if session is not expired... | ||
// Update with current timestamp as the last session activity | ||
// Only updating session on visibility change to be conservative about | ||
// writing to session storage. This could be changed in the future. | ||
updateSessionActivity({ | ||
stickySession: _this.options.stickySession, | ||
}); | ||
// 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 | ||
if (document.visibilityState !== 'visible') { | ||
_this.finishReplayEvent(); | ||
} | ||
}; | ||
this.rrwebRecordOptions = __assign({ maskAllInputs: maskAllInputs }, rrwebRecordOptions); | ||
// Creates a new replay ID everytime we initialize the plugin (e.g. on every pageload). | ||
// TBD on behavior here (e.g. should this be saved to localStorage/cookies) | ||
this.replayId = uuid4(); | ||
this.options = { uploadMinDelay: uploadMinDelay, uploadMaxDelay: uploadMaxDelay, stickySession: stickySession }; | ||
this.events = []; | ||
record(__assign(__assign({}, this.rrwebRecordOptions), { emit: function (event, isCheckout) { | ||
// "debounce" by `idleTimeout`, how often we save replay events i.e. we | ||
// will save events only if 15 seconds have elapsed since the last | ||
// event | ||
// | ||
// TODO: We probably want to have a hard timeout where we save | ||
// so that it does not grow infinitely and we never have a replay | ||
// saved | ||
if (_this.timeout) { | ||
window.clearTimeout(_this.timeout); | ||
} | ||
// Always create a new Sentry event on checkouts and clear existing rrweb events | ||
if (isCheckout) { | ||
console.log('$$$$$ IS CHECKOUT'); | ||
_this.events = [event]; | ||
} | ||
else { | ||
_this.events.push(event); | ||
} | ||
// Set timer to send attachment to Sentry, will be cancelled if an | ||
// event happens before `idleTimeout` elapses | ||
_this.timeout = window.setTimeout(function () { | ||
_this.isDebug && | ||
logger.log('[Replay] rrweb timeout hit, finishing replay event'); | ||
_this.finishReplayEvent(); | ||
}, idleTimeout); | ||
} })); | ||
this.addListeners(); | ||
} | ||
@@ -250,9 +381,2 @@ SentryReplay.attachmentUrlFromDsn = function (dsn, eventId) { | ||
}; | ||
Object.defineProperty(SentryReplay.prototype, "isDebug", { | ||
get: function () { | ||
return isDebugBuild(); | ||
}, | ||
enumerable: false, | ||
configurable: true | ||
}); | ||
Object.defineProperty(SentryReplay.prototype, "instance", { | ||
@@ -270,13 +394,71 @@ /** | ||
var _this = this; | ||
this.loadSession({ expiry: SESSION_IDLE_DURATION }); | ||
// If there is no session, then something bad has happened - can't continue | ||
if (!this.session) { | ||
throw new Error('Invalid session'); | ||
} | ||
// 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 | ||
Sentry.addGlobalEventProcessor(function (event) { | ||
event.tags = __assign(__assign({}, event.tags), { replayId: _this.replayId }); | ||
event.tags = __assign(__assign({}, event.tags), { replayId: _this.session.id }); | ||
return event; | ||
}); | ||
record(__assign(__assign({}, this.rrwebRecordOptions), { emit: function (event, isCheckout) { | ||
// We want to batch uploads of replay events. Save events only if | ||
// `<uploadMinDelay>` milliseconds have elapsed since the last event | ||
// *OR* if `<uploadMaxDelay>` milliseconds have elapsed. | ||
var now = new Date().getTime(); | ||
// Timestamp of the first replay event since the last flush, this gets | ||
// reset when we finish the replay event | ||
if (!_this.initialEventTimestampSinceFlush) { | ||
_this.initialEventTimestampSinceFlush = now; | ||
} | ||
var uploadMaxDelayExceeded = isExpired(_this.initialEventTimestampSinceFlush, _this.options.uploadMaxDelay, now); | ||
// Do not finish the replay event if we receive a new replay event | ||
// unless `<uploadMaxDelay>` ms have elapsed since the last time we | ||
// finished the replay | ||
if (_this.timeout && !uploadMaxDelayExceeded) { | ||
window.clearTimeout(_this.timeout); | ||
} | ||
// We need to clear existing events on a checkout, otherwise they are | ||
// incremental event updates and should be appended | ||
if (isCheckout) { | ||
_this.events = [event]; | ||
} | ||
else { | ||
_this.events.push(event); | ||
} | ||
// This event type is a fullsnapshot, we should save immediately when this occurs | ||
// See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16 | ||
if (event.type === 2) { | ||
// A fullsnapshot happens on initial load and if we need to start a | ||
// new replay due to idle timeout. In the later case we will need to | ||
// create a new session before finishing the replay. | ||
_this.loadSession({ expiry: SESSION_IDLE_DURATION }); | ||
_this.finishReplayEvent(); | ||
return; | ||
} | ||
// Set timer to finish replay event and send replay attachment to | ||
// Sentry. Will be cancelled if an event happens before `uploadMinDelay` | ||
// elapses. | ||
_this.timeout = window.setTimeout(function () { | ||
logger.log('rrweb timeout hit, finishing replay event'); | ||
_this.finishReplayEvent(); | ||
}, _this.options.uploadMinDelay); | ||
} })); | ||
this.addListeners(); | ||
// XXX: this needs to be in `setupOnce` vs `constructor`, otherwise SDK is | ||
// not fully initialized and the event will not get properly sent to Sentry | ||
this.createRootEvent(); | ||
this.createReplayEvent(); | ||
}; | ||
/** | ||
* Loads a session from storage, or creates a new one | ||
*/ | ||
SentryReplay.prototype.loadSession = function (_a) { | ||
var expiry = _a.expiry; | ||
this.session = getSession({ | ||
expiry: expiry, | ||
stickySession: this.options.stickySession, | ||
}); | ||
}; | ||
SentryReplay.prototype.addListeners = function () { | ||
@@ -293,33 +475,10 @@ document.addEventListener('visibilitychange', this.handleVisibilityChange); | ||
/** | ||
* Creates a new replay "session". This will create a new Sentry event and | ||
* then trigger rrweb to take a full snapshot. | ||
* Trigger rrweb to take a full snapshot which will cause this plugin to | ||
* create a new Replay event. | ||
*/ | ||
SentryReplay.prototype.triggerNewSession = function () { | ||
this.isDebug && logger.log('[Replay] taking full rrweb snapshot'); | ||
SentryReplay.prototype.triggerFullSnapshot = function () { | ||
logger.log('Taking full rrweb snapshot'); | ||
record.takeFullSnapshot(true); | ||
}; | ||
/** | ||
* Creates the Sentry event that all replays will be saved to as attachments. | ||
* Currently, we only expect one of these per "replay session" (which is not | ||
* explicitly defined yet). | ||
*/ | ||
SentryReplay.prototype.createRootEvent = function () { | ||
// TODO: Figure out if we need to do this, when this gets called from `setupOnce`, `this.instance` is still undefined. | ||
// if (!this.instance) return; | ||
this.isDebug && logger.log("[Replay] creating root replay event"); | ||
// Create a transaction to attach event to | ||
var transaction = Sentry.getCurrentHub().startTransaction({ | ||
name: 'sentry-replay', | ||
tags: { | ||
hasReplay: 'yes', | ||
replayId: this.replayId, | ||
}, | ||
}); | ||
// We have to finish the transaction to get an event ID to be able to | ||
// upload an attachment for that event | ||
// @ts-expect-error This returns an eventId (string), but is not typed as such | ||
this.eventId = transaction.finish(); | ||
return this.eventId; | ||
}; | ||
/** | ||
* This is our pseudo replay event disguised as a transaction. It will be | ||
@@ -331,8 +490,9 @@ * used to store performance entries and breadcrumbs for every incremental | ||
var _this = this; | ||
console.log('createReplayEvent rootReplayId', this.eventId); | ||
logger.log('CreateReplayEvent rootReplayId', this.session.id); | ||
this.replayEvent = Sentry.startTransaction({ | ||
name: 'sentry-replay-event', | ||
parentSpanId: this.session.spanId, | ||
traceId: this.session.traceId, | ||
tags: { | ||
rootReplayId: this.eventId, | ||
replayId: this.replayId, | ||
replayId: this.session.id, | ||
}, | ||
@@ -376,8 +536,18 @@ }); | ||
var _a; | ||
var eventId = this.eventId || this.createRootEvent(); | ||
if (!eventId) { | ||
console.error('[Sentry]: No transaction, no replay'); | ||
// Ensure that our existing session has not expired | ||
var isExpired = isSessionExpired(this.session, SESSION_IDLE_DURATION); | ||
if (isExpired) { | ||
// TBD: If it is expired, we do not send any events...we could send to | ||
// the expired session, but not sure if that's great | ||
console.error(new Error('Attempting to finish replay event after session expired.')); | ||
return; | ||
} | ||
this.sendReplay(eventId); | ||
if (!this.session.id) { | ||
console.error(new Error('[Sentry]: No transaction, no replay')); | ||
return; | ||
} | ||
this.sendReplay(this.session.id); | ||
this.initialEventTimestampSinceFlush = null; | ||
// TBD: Alternatively we could update this after every rrweb event | ||
this.session.lastActivity = new Date().getTime(); | ||
// include performance entries | ||
@@ -411,4 +581,3 @@ this.addPerformanceEntries(); | ||
if (this.hasSendBeacon() && stringifiedPayload.length <= 65536) { | ||
this.isDebug && | ||
logger.log("[Replay] uploading attachment via sendBeacon()"); | ||
logger.log("uploading attachment via sendBeacon()"); | ||
window.navigator.sendBeacon(endpoint, formData); | ||
@@ -420,3 +589,3 @@ return [2 /*return*/]; | ||
_a.trys.push([1, 3, , 4]); | ||
this.isDebug && logger.log("[Replay] uploading attachment via fetch()"); | ||
logger.log("uploading attachment via fetch()"); | ||
// Otherwise use `fetch`, which *WILL* get cancelled on page reloads/unloads | ||
@@ -423,0 +592,0 @@ return [4 /*yield*/, fetch(endpoint, { |
@@ -6,4 +6,4 @@ 'use strict'; | ||
var Sentry = require('@sentry/browser'); | ||
var rrweb = require('rrweb'); | ||
var utils = require('@sentry/utils'); | ||
var rrweb = require('rrweb'); | ||
@@ -186,3 +186,145 @@ function _interopNamespace(e) { | ||
var VISIBILITY_CHANGE_TIMEOUT = 5000; | ||
/** | ||
* Given an initial timestamp and an expiry duration, checks to see if current | ||
* time should be considered as expired. | ||
*/ | ||
function isExpired(initialTime, expiry, targetTime) { | ||
if (targetTime === void 0) { targetTime = +new Date(); } | ||
// Always expired if < 0 | ||
if (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, expiry, targetTime) { | ||
if (targetTime === void 0) { targetTime = +new Date(); } | ||
return isExpired(session.lastActivity, expiry, targetTime); | ||
} | ||
function wrapLogger(logFn) { | ||
return function wrappedLog() { | ||
var args = []; | ||
for (var _i = 0; _i < arguments.length; _i++) { | ||
args[_i] = arguments[_i]; | ||
} | ||
if (!utils.isDebugBuild()) { | ||
return; | ||
} | ||
return logFn.call.apply(logFn, __spreadArray([utils.logger, '[Replay]'], args, false)); | ||
}; | ||
} | ||
var logger = __assign(__assign({}, utils.logger), { error: wrapLogger(utils.logger.error), warn: wrapLogger(utils.logger.warn), log: wrapLogger(utils.logger.log) }); | ||
var REPLAY_SESSION_KEY = 'sentryReplaySession'; | ||
function saveSession(session) { | ||
var hasSessionStorage = 'sessionStorage' in window; | ||
if (!hasSessionStorage) { | ||
return; | ||
} | ||
try { | ||
window.sessionStorage.setItem(REPLAY_SESSION_KEY, JSON.stringify(session)); | ||
} | ||
catch (_a) { | ||
// this shouldn't happen | ||
} | ||
} | ||
/** | ||
* 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(_a) { | ||
var _b = _a.stickySession, stickySession = _b === void 0 ? false : _b; | ||
var currentDate = new Date().getTime(); | ||
// Create root replay event, this is where attachments will be saved | ||
var transaction = Sentry__namespace.getCurrentHub().startTransaction({ | ||
name: 'sentry-replay', | ||
tags: { | ||
isReplayRoot: 'yes', | ||
}, | ||
}); | ||
// We have to finish the transaction to get an event ID to be able to | ||
// upload an attachment for that event | ||
// @ts-expect-error This returns an eventId (string), but is not typed as such | ||
var id = transaction.finish(); | ||
logger.log("Creating new session: ".concat(id)); | ||
var session = { | ||
id: id, | ||
spanId: transaction.spanId, | ||
traceId: transaction.traceId, | ||
started: currentDate, | ||
lastActivity: currentDate, | ||
}; | ||
if (stickySession) { | ||
saveSession(session); | ||
} | ||
return session; | ||
} | ||
function fetchSession() { | ||
var hasSessionStorage = 'sessionStorage' in window; | ||
if (!hasSessionStorage) { | ||
return null; | ||
} | ||
try { | ||
return JSON.parse(window.sessionStorage.getItem(REPLAY_SESSION_KEY)); | ||
} | ||
catch (_a) { | ||
return null; | ||
} | ||
} | ||
/** | ||
* Get or create a session | ||
*/ | ||
function getSession(_a) { | ||
var expiry = _a.expiry, stickySession = _a.stickySession; | ||
var session = stickySession && fetchSession(); | ||
if (session) { | ||
// If there is a session, check if it is valid (e.g. "last activity" time should be within the "session idle time") | ||
// TODO: We should probably set a max age on this as well | ||
var isExpired = isSessionExpired(session, expiry); | ||
if (!isExpired) { | ||
logger.log("Using existing session: ".concat(session.id)); | ||
return session; | ||
} | ||
else { | ||
logger.log("Session has expired"); | ||
} | ||
// Otherwise continue to create a new session | ||
} | ||
var newSession = createSession({ stickySession: stickySession }); | ||
return newSession; | ||
} | ||
function updateSessionActivity(_a) { | ||
var stickySession = _a.stickySession; | ||
// Nothing to do if there are no sticky sessions | ||
if (!stickySession) { | ||
return; | ||
} | ||
var existingSession = fetchSession(); | ||
// If user manually deleted from session storage, create a new session | ||
if (!existingSession) { | ||
// TBD: There was an issue here where sessions weren't saving and this | ||
// caused lots of transactions to be created | ||
return createSession({ stickySession: stickySession }); | ||
} | ||
var newSession = __assign(__assign({}, existingSession), { lastActivity: new Date().getTime() }); | ||
saveSession(newSession); | ||
return newSession; | ||
} | ||
var VISIBILITY_CHANGE_TIMEOUT = 60000; // 1 minute | ||
var SESSION_IDLE_DURATION = 900000; // 15 minutes | ||
var SentryReplay = /** @class */ (function () { | ||
@@ -192,3 +334,5 @@ function SentryReplay(_a) { | ||
var _this = this; | ||
var _b = _a.idleTimeout, idleTimeout = _b === void 0 ? 15000 : _b, _c = _a.rrwebConfig, _d = _c === void 0 ? {} : _c, _e = _d.maskAllInputs, maskAllInputs = _e === void 0 ? true : _e, rrwebRecordOptions = __rest(_d, ["maskAllInputs"]); | ||
var _b = _a.uploadMinDelay, uploadMinDelay = _b === void 0 ? 5000 : _b, _c = _a.uploadMaxDelay, uploadMaxDelay = _c === void 0 ? 15000 : _c, _d = _a.stickySession, stickySession = _d === void 0 ? false : _d, // TBD: Making this opt-in for now | ||
_e = _a.rrwebConfig, // TBD: Making this opt-in for now | ||
_f = _e === void 0 ? {} : _e, _g = _f.maskAllInputs, maskAllInputs = _g === void 0 ? true : _g, rrwebRecordOptions = __rest(_f, ["maskAllInputs"]); | ||
/** | ||
@@ -203,2 +347,7 @@ * @inheritDoc | ||
this.performanceEvents = []; | ||
/** | ||
* The timestamp of the first event since the last flush. | ||
* This is used to determine if the maximum allowed time has passed before we should flush events again. | ||
*/ | ||
this.initialEventTimestampSinceFlush = null; | ||
this.performanceObserver = null; | ||
@@ -217,54 +366,36 @@ /** | ||
this.handleVisibilityChange = function () { | ||
if (document.visibilityState === 'visible' && | ||
_this.visibilityChangeTimer === null) { | ||
// Page has become active/visible again after `VISIBILITY_CHANGE_TIMEOUT` | ||
// ms have elapsed, which means we will consider this a new session | ||
// | ||
// TBD if this is the behavior we want | ||
_this.isDebug && | ||
utils.logger.log('[Replay] document has become active, creating new "session"'); | ||
_this.triggerNewSession(); | ||
var isExpired = isSessionExpired(_this.session, VISIBILITY_CHANGE_TIMEOUT); | ||
if (isExpired) { | ||
if (document.visibilityState === 'visible') { | ||
// If the user has come back to the page within VISIBILITY_CHANGE_TIMEOUT | ||
// ms, we will re-use the existing session, otherwise create a new | ||
// session | ||
logger.log('Document has become active, but session has expired'); | ||
_this.triggerFullSnapshot(); | ||
} | ||
// We definitely want to return if visibilityState is "visible", and I | ||
// think we also want to do the same when "hidden", as there shouldn't be | ||
// any action to take if user has gone idle for a long period and then | ||
// comes back to hide the tab. We don't trigger a full snapshot because | ||
// we don't want to start a new session as they immediately have hidden | ||
// the tab. | ||
return; | ||
} | ||
// Send replay when the page/tab becomes hidden | ||
_this.finishReplayEvent(); | ||
// VISIBILITY_CHANGE_TIMEOUT gives the user buffer room to come back to the | ||
// page before we create a new session. | ||
_this.visibilityChangeTimer = window.setTimeout(function () { | ||
_this.visibilityChangeTimer = null; | ||
}, VISIBILITY_CHANGE_TIMEOUT); | ||
// Otherwise if session is not expired... | ||
// Update with current timestamp as the last session activity | ||
// Only updating session on visibility change to be conservative about | ||
// writing to session storage. This could be changed in the future. | ||
updateSessionActivity({ | ||
stickySession: _this.options.stickySession, | ||
}); | ||
// 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 | ||
if (document.visibilityState !== 'visible') { | ||
_this.finishReplayEvent(); | ||
} | ||
}; | ||
this.rrwebRecordOptions = __assign({ maskAllInputs: maskAllInputs }, rrwebRecordOptions); | ||
// Creates a new replay ID everytime we initialize the plugin (e.g. on every pageload). | ||
// TBD on behavior here (e.g. should this be saved to localStorage/cookies) | ||
this.replayId = utils.uuid4(); | ||
this.options = { uploadMinDelay: uploadMinDelay, uploadMaxDelay: uploadMaxDelay, stickySession: stickySession }; | ||
this.events = []; | ||
rrweb.record(__assign(__assign({}, this.rrwebRecordOptions), { emit: function (event, isCheckout) { | ||
// "debounce" by `idleTimeout`, how often we save replay events i.e. we | ||
// will save events only if 15 seconds have elapsed since the last | ||
// event | ||
// | ||
// TODO: We probably want to have a hard timeout where we save | ||
// so that it does not grow infinitely and we never have a replay | ||
// saved | ||
if (_this.timeout) { | ||
window.clearTimeout(_this.timeout); | ||
} | ||
// Always create a new Sentry event on checkouts and clear existing rrweb events | ||
if (isCheckout) { | ||
console.log('$$$$$ IS CHECKOUT'); | ||
_this.events = [event]; | ||
} | ||
else { | ||
_this.events.push(event); | ||
} | ||
// Set timer to send attachment to Sentry, will be cancelled if an | ||
// event happens before `idleTimeout` elapses | ||
_this.timeout = window.setTimeout(function () { | ||
_this.isDebug && | ||
utils.logger.log('[Replay] rrweb timeout hit, finishing replay event'); | ||
_this.finishReplayEvent(); | ||
}, idleTimeout); | ||
} })); | ||
this.addListeners(); | ||
} | ||
@@ -275,9 +406,2 @@ SentryReplay.attachmentUrlFromDsn = function (dsn, eventId) { | ||
}; | ||
Object.defineProperty(SentryReplay.prototype, "isDebug", { | ||
get: function () { | ||
return utils.isDebugBuild(); | ||
}, | ||
enumerable: false, | ||
configurable: true | ||
}); | ||
Object.defineProperty(SentryReplay.prototype, "instance", { | ||
@@ -295,13 +419,71 @@ /** | ||
var _this = this; | ||
this.loadSession({ expiry: SESSION_IDLE_DURATION }); | ||
// If there is no session, then something bad has happened - can't continue | ||
if (!this.session) { | ||
throw new Error('Invalid session'); | ||
} | ||
// 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 | ||
Sentry__namespace.addGlobalEventProcessor(function (event) { | ||
event.tags = __assign(__assign({}, event.tags), { replayId: _this.replayId }); | ||
event.tags = __assign(__assign({}, event.tags), { replayId: _this.session.id }); | ||
return event; | ||
}); | ||
rrweb.record(__assign(__assign({}, this.rrwebRecordOptions), { emit: function (event, isCheckout) { | ||
// We want to batch uploads of replay events. Save events only if | ||
// `<uploadMinDelay>` milliseconds have elapsed since the last event | ||
// *OR* if `<uploadMaxDelay>` milliseconds have elapsed. | ||
var now = new Date().getTime(); | ||
// Timestamp of the first replay event since the last flush, this gets | ||
// reset when we finish the replay event | ||
if (!_this.initialEventTimestampSinceFlush) { | ||
_this.initialEventTimestampSinceFlush = now; | ||
} | ||
var uploadMaxDelayExceeded = isExpired(_this.initialEventTimestampSinceFlush, _this.options.uploadMaxDelay, now); | ||
// Do not finish the replay event if we receive a new replay event | ||
// unless `<uploadMaxDelay>` ms have elapsed since the last time we | ||
// finished the replay | ||
if (_this.timeout && !uploadMaxDelayExceeded) { | ||
window.clearTimeout(_this.timeout); | ||
} | ||
// We need to clear existing events on a checkout, otherwise they are | ||
// incremental event updates and should be appended | ||
if (isCheckout) { | ||
_this.events = [event]; | ||
} | ||
else { | ||
_this.events.push(event); | ||
} | ||
// This event type is a fullsnapshot, we should save immediately when this occurs | ||
// See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16 | ||
if (event.type === 2) { | ||
// A fullsnapshot happens on initial load and if we need to start a | ||
// new replay due to idle timeout. In the later case we will need to | ||
// create a new session before finishing the replay. | ||
_this.loadSession({ expiry: SESSION_IDLE_DURATION }); | ||
_this.finishReplayEvent(); | ||
return; | ||
} | ||
// Set timer to finish replay event and send replay attachment to | ||
// Sentry. Will be cancelled if an event happens before `uploadMinDelay` | ||
// elapses. | ||
_this.timeout = window.setTimeout(function () { | ||
logger.log('rrweb timeout hit, finishing replay event'); | ||
_this.finishReplayEvent(); | ||
}, _this.options.uploadMinDelay); | ||
} })); | ||
this.addListeners(); | ||
// XXX: this needs to be in `setupOnce` vs `constructor`, otherwise SDK is | ||
// not fully initialized and the event will not get properly sent to Sentry | ||
this.createRootEvent(); | ||
this.createReplayEvent(); | ||
}; | ||
/** | ||
* Loads a session from storage, or creates a new one | ||
*/ | ||
SentryReplay.prototype.loadSession = function (_a) { | ||
var expiry = _a.expiry; | ||
this.session = getSession({ | ||
expiry: expiry, | ||
stickySession: this.options.stickySession, | ||
}); | ||
}; | ||
SentryReplay.prototype.addListeners = function () { | ||
@@ -318,33 +500,10 @@ document.addEventListener('visibilitychange', this.handleVisibilityChange); | ||
/** | ||
* Creates a new replay "session". This will create a new Sentry event and | ||
* then trigger rrweb to take a full snapshot. | ||
* Trigger rrweb to take a full snapshot which will cause this plugin to | ||
* create a new Replay event. | ||
*/ | ||
SentryReplay.prototype.triggerNewSession = function () { | ||
this.isDebug && utils.logger.log('[Replay] taking full rrweb snapshot'); | ||
SentryReplay.prototype.triggerFullSnapshot = function () { | ||
logger.log('Taking full rrweb snapshot'); | ||
rrweb.record.takeFullSnapshot(true); | ||
}; | ||
/** | ||
* Creates the Sentry event that all replays will be saved to as attachments. | ||
* Currently, we only expect one of these per "replay session" (which is not | ||
* explicitly defined yet). | ||
*/ | ||
SentryReplay.prototype.createRootEvent = function () { | ||
// TODO: Figure out if we need to do this, when this gets called from `setupOnce`, `this.instance` is still undefined. | ||
// if (!this.instance) return; | ||
this.isDebug && utils.logger.log("[Replay] creating root replay event"); | ||
// Create a transaction to attach event to | ||
var transaction = Sentry__namespace.getCurrentHub().startTransaction({ | ||
name: 'sentry-replay', | ||
tags: { | ||
hasReplay: 'yes', | ||
replayId: this.replayId, | ||
}, | ||
}); | ||
// We have to finish the transaction to get an event ID to be able to | ||
// upload an attachment for that event | ||
// @ts-expect-error This returns an eventId (string), but is not typed as such | ||
this.eventId = transaction.finish(); | ||
return this.eventId; | ||
}; | ||
/** | ||
* This is our pseudo replay event disguised as a transaction. It will be | ||
@@ -356,8 +515,9 @@ * used to store performance entries and breadcrumbs for every incremental | ||
var _this = this; | ||
console.log('createReplayEvent rootReplayId', this.eventId); | ||
logger.log('CreateReplayEvent rootReplayId', this.session.id); | ||
this.replayEvent = Sentry__namespace.startTransaction({ | ||
name: 'sentry-replay-event', | ||
parentSpanId: this.session.spanId, | ||
traceId: this.session.traceId, | ||
tags: { | ||
rootReplayId: this.eventId, | ||
replayId: this.replayId, | ||
replayId: this.session.id, | ||
}, | ||
@@ -401,8 +561,18 @@ }); | ||
var _a; | ||
var eventId = this.eventId || this.createRootEvent(); | ||
if (!eventId) { | ||
console.error('[Sentry]: No transaction, no replay'); | ||
// Ensure that our existing session has not expired | ||
var isExpired = isSessionExpired(this.session, SESSION_IDLE_DURATION); | ||
if (isExpired) { | ||
// TBD: If it is expired, we do not send any events...we could send to | ||
// the expired session, but not sure if that's great | ||
console.error(new Error('Attempting to finish replay event after session expired.')); | ||
return; | ||
} | ||
this.sendReplay(eventId); | ||
if (!this.session.id) { | ||
console.error(new Error('[Sentry]: No transaction, no replay')); | ||
return; | ||
} | ||
this.sendReplay(this.session.id); | ||
this.initialEventTimestampSinceFlush = null; | ||
// TBD: Alternatively we could update this after every rrweb event | ||
this.session.lastActivity = new Date().getTime(); | ||
// include performance entries | ||
@@ -436,4 +606,3 @@ this.addPerformanceEntries(); | ||
if (this.hasSendBeacon() && stringifiedPayload.length <= 65536) { | ||
this.isDebug && | ||
utils.logger.log("[Replay] uploading attachment via sendBeacon()"); | ||
logger.log("uploading attachment via sendBeacon()"); | ||
window.navigator.sendBeacon(endpoint, formData); | ||
@@ -445,3 +614,3 @@ return [2 /*return*/]; | ||
_a.trys.push([1, 3, , 4]); | ||
this.isDebug && utils.logger.log("[Replay] uploading attachment via fetch()"); | ||
logger.log("uploading attachment via fetch()"); | ||
// Otherwise use `fetch`, which *WILL* get cancelled on page reloads/unloads | ||
@@ -448,0 +617,0 @@ return [4 /*yield*/, fetch(endpoint, { |
{ | ||
"name": "@sentry/replay", | ||
"version": "0.2.0-0", | ||
"version": "0.2.0-1", | ||
"description": "User replays for Sentry", | ||
@@ -5,0 +5,0 @@ "main": "dist/index.js", |
@@ -16,3 +16,3 @@ # sentry-replay | ||
```shell | ||
npm install --save @sentry/replay rrweb | ||
npm install --save @sentry/browser @sentry/replay @sentry/tracing rrweb | ||
``` | ||
@@ -23,3 +23,3 @@ | ||
```shell | ||
yarn add @sentry/replay rrweb | ||
yarn add @sentry/replay @sentry/browser @sentry/replay @sentry/tracing rrweb | ||
``` | ||
@@ -29,24 +29,10 @@ | ||
To set up the integration add the following to your Sentry initialization: | ||
To set up the integration add the following to your Sentry initialization. Several options are supported and passable via the integration constructor. | ||
See the rrweb documentation for advice on configuring these values. | ||
```javascript | ||
import * as Sentry from '@sentry/browser'; | ||
import { SentryReplay } from '@sentry/replay'; | ||
Sentry.init({ | ||
dsn: '__DSN__', | ||
integrations: [ | ||
new SentryReplay({ | ||
// ...options | ||
}), | ||
], | ||
// ... | ||
}); | ||
``` | ||
Several options are supported and passable via the integration constructor: | ||
```javascript | ||
import * as Sentry from '@sentry/browser'; | ||
import { SentryReplay } from '@sentry/replay'; | ||
import '@sentry/tracing'; | ||
@@ -57,8 +43,6 @@ Sentry.init({ | ||
new SentryReplay({ | ||
// default is empty | ||
checkoutEveryNth: 100, | ||
// default is 5 minutes | ||
checkoutEveryNms: 15 * 60 * 1000, | ||
// on by default | ||
maskAllInputs: false, | ||
stickySession: true, // Default is false | ||
rrwebOptions: { | ||
maskAllInputs: false, // Default is true | ||
}, | ||
}), | ||
@@ -70,2 +54,1 @@ ], | ||
See the rrweb documentation for advice on configuring these values. |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
127799
1251
50