@sentry/replay 7.59.0-beta.1 → 7.59.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/index.js CHANGED
@@ -14,6 +14,9 @@ const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay';
14
14
  // The idle limit for a session after which recording is paused.
15
15
  const SESSION_IDLE_PAUSE_DURATION = 300000; // 5 minutes in ms
16
16
 
17
+ // The idle limit for a session after which the session expires.
18
+ const SESSION_IDLE_EXPIRE_DURATION = 900000; // 15 minutes in ms
19
+
17
20
  // The maximum length of a session
18
21
  const MAX_SESSION_LIFE = 3600000; // 60 minutes in ms
19
22
 
@@ -3276,26 +3279,26 @@ function addBreadcrumbEvent(replay, breadcrumb) {
3276
3279
  }
3277
3280
 
3278
3281
  if (['ui.click', 'ui.input'].includes(breadcrumb.category )) {
3279
- replay.updateUserActivity();
3282
+ replay.triggerUserActivity();
3283
+ } else {
3284
+ replay.checkAndHandleExpiredSession();
3280
3285
  }
3281
3286
 
3282
- replay.checkSessionState(() => {
3283
- replay.addUpdate(() => {
3284
- void replay.throttledAddEvent({
3285
- type: EventType.Custom,
3286
- // TODO: We were converting from ms to seconds for breadcrumbs, spans,
3287
- // but maybe we should just keep them as milliseconds
3288
- timestamp: (breadcrumb.timestamp || 0) * 1000,
3289
- data: {
3290
- tag: 'breadcrumb',
3291
- // normalize to max. 10 depth and 1_000 properties per object
3292
- payload: normalize(breadcrumb, 10, 1000),
3293
- },
3294
- });
3295
-
3296
- // Do not flush after console log messages
3297
- return breadcrumb.category === 'console';
3287
+ replay.addUpdate(() => {
3288
+ void replay.throttledAddEvent({
3289
+ type: EventType.Custom,
3290
+ // TODO: We were converting from ms to seconds for breadcrumbs, spans,
3291
+ // but maybe we should just keep them as milliseconds
3292
+ timestamp: (breadcrumb.timestamp || 0) * 1000,
3293
+ data: {
3294
+ tag: 'breadcrumb',
3295
+ // normalize to max. 10 depth and 1_000 properties per object
3296
+ payload: normalize(breadcrumb, 10, 1000),
3297
+ },
3298
3298
  });
3299
+
3300
+ // Do not flush after console log messages
3301
+ return breadcrumb.category === 'console';
3299
3302
  });
3300
3303
  }
3301
3304
 
@@ -4386,69 +4389,14 @@ function createEventBuffer({ useCompression }) {
4386
4389
  return new EventBufferArray();
4387
4390
  }
4388
4391
 
4389
- /**
4390
- * Given an initial timestamp and an expiry duration, checks to see if current
4391
- * time should be considered as expired.
4392
- */
4393
- function isExpired(
4394
- initialTime,
4395
- expiry,
4396
- targetTime = +new Date(),
4397
- ) {
4398
- // Always expired if < 0
4399
- if (initialTime === null || expiry === undefined || expiry < 0) {
4400
- return true;
4401
- }
4402
-
4403
- // Never expires if == 0
4404
- if (expiry === 0) {
4405
- return false;
4406
- }
4407
-
4408
- return initialTime + expiry <= targetTime;
4409
- }
4410
-
4411
- /** Check the state of the session. */
4412
- function checkSessionState(
4413
- session,
4414
- recordingMode,
4415
- timeouts,
4416
- callbacks
4417
-
4418
- ,
4419
- ) {
4420
- const _isIdle = () => {
4421
- return isExpired(session.lastActivity, timeouts.sessionIdlePause);
4422
- };
4423
-
4424
- const _exceedsMaxLength = () => {
4425
- return isExpired(session.started, timeouts.maxSessionLife);
4426
- };
4427
-
4428
- // When buffering, we never want to expire/end/pause/restart the recording
4429
- if (recordingMode === 'buffer') {
4430
- callbacks.onContinue();
4431
- return;
4432
- }
4433
-
4434
- if (_exceedsMaxLength()) {
4435
- callbacks.onEnd();
4436
- return;
4437
- }
4438
-
4439
- if (_isIdle()) {
4440
- callbacks.onPause();
4441
- return;
4442
- }
4443
-
4444
- callbacks.ensureResumed();
4445
-
4446
- callbacks.onContinue();
4447
- }
4448
-
4449
4392
  /** If sessionStorage is available. */
4450
4393
  function hasSessionStorage() {
4451
- return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage;
4394
+ try {
4395
+ // This can throw, e.g. when being accessed in a sandboxed iframe
4396
+ return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage;
4397
+ } catch (e) {
4398
+ return false;
4399
+ }
4452
4400
  }
4453
4401
 
4454
4402
  /**
@@ -4474,6 +4422,41 @@ function deleteSession() {
4474
4422
  }
4475
4423
  }
4476
4424
 
4425
+ /**
4426
+ * Given an initial timestamp and an expiry duration, checks to see if current
4427
+ * time should be considered as expired.
4428
+ */
4429
+ function isExpired(
4430
+ initialTime,
4431
+ expiry,
4432
+ targetTime = +new Date(),
4433
+ ) {
4434
+ // Always expired if < 0
4435
+ if (initialTime === null || expiry === undefined || expiry < 0) {
4436
+ return true;
4437
+ }
4438
+
4439
+ // Never expires if == 0
4440
+ if (expiry === 0) {
4441
+ return false;
4442
+ }
4443
+
4444
+ return initialTime + expiry <= targetTime;
4445
+ }
4446
+
4447
+ /**
4448
+ * Checks to see if session is expired
4449
+ */
4450
+ function isSessionExpired(session, timeouts, targetTime = +new Date()) {
4451
+ return (
4452
+ // First, check that maximum session length has not been exceeded
4453
+ isExpired(session.started, timeouts.maxSessionLife, targetTime) ||
4454
+ // check that the idle timeout has not been exceeded (i.e. user has
4455
+ // performed an action within the last `sessionIdleExpire` ms)
4456
+ isExpired(session.lastActivity, timeouts.sessionIdleExpire, targetTime)
4457
+ );
4458
+ }
4459
+
4477
4460
  /**
4478
4461
  * Given a sample rate, returns true if replay should be sampled.
4479
4462
  *
@@ -4522,20 +4505,24 @@ function makeSession(session) {
4522
4505
  lastActivity,
4523
4506
  segmentId,
4524
4507
  sampled,
4508
+ shouldRefresh: true,
4525
4509
  };
4526
4510
  }
4527
4511
 
4512
+ /**
4513
+ * Get the sampled status for a session based on sample rates & current sampled status.
4514
+ */
4515
+ function getSessionSampleType(sessionSampleRate, allowBuffering) {
4516
+ return isSampled(sessionSampleRate) ? 'session' : allowBuffering ? 'buffer' : false;
4517
+ }
4518
+
4528
4519
  /**
4529
4520
  * Create a new session, which in its current implementation is a Sentry event
4530
4521
  * that all replays will be saved to as attachments. Currently, we only expect
4531
4522
  * one of these Sentry events per "replay session".
4532
4523
  */
4533
- function createSession({
4534
- sampled,
4535
- stickySession = false,
4536
- }
4537
-
4538
- ) {
4524
+ function createSession({ sessionSampleRate, allowBuffering, stickySession = false }) {
4525
+ const sampled = getSessionSampleType(sessionSampleRate, allowBuffering);
4539
4526
  const session = makeSession({
4540
4527
  sampled,
4541
4528
  });
@@ -4573,31 +4560,47 @@ function fetchSession() {
4573
4560
  }
4574
4561
  }
4575
4562
 
4576
- /** Either restore a session from sessionStorage, or create a new one. */
4577
- function restoreOrCreateSession({
4563
+ /**
4564
+ * Get or create a session
4565
+ */
4566
+ function getSession({
4567
+ timeouts,
4568
+ currentSession,
4578
4569
  stickySession,
4579
- sampled,
4580
- forceSampled,
4581
- }
4582
-
4583
- ) {
4584
- const currentSession = stickySession && fetchSession();
4585
-
4586
- if (currentSession && currentSession.sampled !== sampled && forceSampled) {
4587
- // If for whatever reason the session from sessionStorage has a different sampling, we force to the new sampling
4588
- (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) &&
4589
- logger.log(`[Replay] Tried to restore session with different sampling: ${currentSession.sampled} !== ${sampled}`);
4590
- } else if (currentSession) {
4591
- (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log(`[Replay] Loaded session from sessionStorage: id=${currentSession.id}`);
4592
- return currentSession;
4570
+ sessionSampleRate,
4571
+ allowBuffering,
4572
+ }) {
4573
+ // If session exists and is passed, use it instead of always hitting session storage
4574
+ const session = currentSession || (stickySession && fetchSession());
4575
+
4576
+ if (session) {
4577
+ // If there is a session, check if it is valid (e.g. "last activity" time
4578
+ // should be within the "session idle time", and "session started" time is
4579
+ // within "max session time").
4580
+ const isExpired = isSessionExpired(session, timeouts);
4581
+
4582
+ if (!isExpired || (allowBuffering && session.shouldRefresh)) {
4583
+ return { type: 'saved', session };
4584
+ } else if (!session.shouldRefresh) {
4585
+ // This is the case if we have an error session that is completed
4586
+ // (=triggered an error). Session will continue as session-based replay,
4587
+ // and when this session is expired, it will not be renewed until user
4588
+ // reloads.
4589
+ const discardedSession = makeSession({ sampled: false });
4590
+ return { type: 'new', session: discardedSession };
4591
+ } else {
4592
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Session has expired');
4593
+ }
4594
+ // Otherwise continue to create a new session
4593
4595
  }
4594
4596
 
4595
4597
  const newSession = createSession({
4596
4598
  stickySession,
4597
- sampled,
4599
+ sessionSampleRate,
4600
+ allowBuffering,
4598
4601
  });
4599
4602
 
4600
- return newSession;
4603
+ return { type: 'new', session: newSession };
4601
4604
  }
4602
4605
 
4603
4606
  function isCustomEvent(event) {
@@ -4640,10 +4643,7 @@ async function addEvent(
4640
4643
 
4641
4644
  const replayOptions = replay.getOptions();
4642
4645
 
4643
- const eventAfterPossibleCallback =
4644
- typeof replayOptions.beforeAddRecordingEvent === 'function' && isCustomEvent(event)
4645
- ? replayOptions.beforeAddRecordingEvent(event)
4646
- : event;
4646
+ const eventAfterPossibleCallback = maybeApplyCallback(event, replayOptions.beforeAddRecordingEvent);
4647
4647
 
4648
4648
  if (!eventAfterPossibleCallback) {
4649
4649
  return;
@@ -4664,6 +4664,23 @@ async function addEvent(
4664
4664
  }
4665
4665
  }
4666
4666
 
4667
+ function maybeApplyCallback(
4668
+ event,
4669
+ callback,
4670
+ ) {
4671
+ try {
4672
+ if (typeof callback === 'function' && isCustomEvent(event)) {
4673
+ return callback(event);
4674
+ }
4675
+ } catch (error) {
4676
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) &&
4677
+ logger.error('[Replay] An error occured in the `beforeAddRecordingEvent` callback, skipping the event...', error);
4678
+ return null;
4679
+ }
4680
+
4681
+ return event;
4682
+ }
4683
+
4667
4684
  /** If the event is an error event */
4668
4685
  function isErrorEvent(event) {
4669
4686
  return !event.type;
@@ -4910,14 +4927,12 @@ function handleHistorySpanListener(replay) {
4910
4927
 
4911
4928
  // Need to collect visited URLs
4912
4929
  replay.getContext().urls.push(result.name);
4913
- replay.updateUserActivity();
4930
+ replay.triggerUserActivity();
4914
4931
 
4915
- replay.checkSessionState(() => {
4916
- replay.addUpdate(() => {
4917
- createPerformanceSpans(replay, [result]);
4918
- // Returning false to flush
4919
- return false;
4920
- });
4932
+ replay.addUpdate(() => {
4933
+ createPerformanceSpans(replay, [result]);
4934
+ // Returning false to flush
4935
+ return false;
4921
4936
  });
4922
4937
  };
4923
4938
  }
@@ -6533,64 +6548,79 @@ function getHandleRecordingEmit(replay) {
6533
6548
  let hadFirstEvent = false;
6534
6549
 
6535
6550
  return (event, _isCheckout) => {
6536
- replay.checkSessionState(() => {
6537
- // `_isCheckout` is only set when the checkout is due to `checkoutEveryNms`
6538
- // We also want to treat the first event as a checkout, so we handle this specifically here
6539
- const isCheckout = _isCheckout || !hadFirstEvent;
6540
- hadFirstEvent = true;
6541
-
6542
- // The handler returns `true` if we do not want to trigger debounced flush, `false` if we want to debounce flush.
6543
- replay.addUpdate(() => {
6544
- // The session is always started immediately on pageload/init, but for
6545
- // error-only replays, it should reflect the most recent checkout
6546
- // when an error occurs. Clear any state that happens before this current
6547
- // checkout. This needs to happen before `addEvent()` which updates state
6548
- // dependent on this reset.
6549
- if (replay.recordingMode === 'buffer' && isCheckout) {
6550
- replay.setInitialState();
6551
- }
6551
+ // If this is false, it means session is expired, create and a new session and wait for checkout
6552
+ if (!replay.checkAndHandleExpiredSession()) {
6553
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.warn('[Replay] Received replay event after session expired.');
6552
6554
 
6553
- // We need to clear existing events on a checkout, otherwise they are
6554
- // incremental event updates and should be appended
6555
- void addEvent(replay, event, isCheckout);
6555
+ return;
6556
+ }
6556
6557
 
6557
- // Different behavior for full snapshots (type=2), ignore other event types
6558
- // See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16
6559
- if (!isCheckout) {
6560
- return false;
6561
- }
6558
+ // `_isCheckout` is only set when the checkout is due to `checkoutEveryNms`
6559
+ // We also want to treat the first event as a checkout, so we handle this specifically here
6560
+ const isCheckout = _isCheckout || !hadFirstEvent;
6561
+ hadFirstEvent = true;
6562
6562
 
6563
- // Additionally, create a meta event that will capture certain SDK settings.
6564
- // In order to handle buffer mode, this needs to either be done when we
6565
- // receive checkout events or at flush time.
6566
- //
6567
- // `isCheckout` is always true, but want to be explicit that it should
6568
- // only be added for checkouts
6569
- void addSettingsEvent(replay, isCheckout);
6570
-
6571
- // When in buffer mode, make sure we adjust the session started date to the current earliest event of the buffer
6572
- // this should usually be the timestamp of the checkout event, but to be safe...
6573
- if (replay.recordingMode === 'buffer' && replay.session && replay.eventBuffer) {
6574
- const earliestEvent = replay.eventBuffer.getEarliestTimestamp();
6575
- if (earliestEvent) {
6576
- replay.session.started = earliestEvent;
6577
-
6578
- if (replay.getOptions().stickySession) {
6579
- saveSession(replay.session);
6580
- }
6563
+ // The handler returns `true` if we do not want to trigger debounced flush, `false` if we want to debounce flush.
6564
+ replay.addUpdate(() => {
6565
+ // The session is always started immediately on pageload/init, but for
6566
+ // error-only replays, it should reflect the most recent checkout
6567
+ // when an error occurs. Clear any state that happens before this current
6568
+ // checkout. This needs to happen before `addEvent()` which updates state
6569
+ // dependent on this reset.
6570
+ if (replay.recordingMode === 'buffer' && isCheckout) {
6571
+ replay.setInitialState();
6572
+ }
6573
+
6574
+ // We need to clear existing events on a checkout, otherwise they are
6575
+ // incremental event updates and should be appended
6576
+ void addEvent(replay, event, isCheckout);
6577
+
6578
+ // Different behavior for full snapshots (type=2), ignore other event types
6579
+ // See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16
6580
+ if (!isCheckout) {
6581
+ return false;
6582
+ }
6583
+
6584
+ // Additionally, create a meta event that will capture certain SDK settings.
6585
+ // In order to handle buffer mode, this needs to either be done when we
6586
+ // receive checkout events or at flush time.
6587
+ //
6588
+ // `isCheckout` is always true, but want to be explicit that it should
6589
+ // only be added for checkouts
6590
+ void addSettingsEvent(replay, isCheckout);
6591
+
6592
+ // If there is a previousSessionId after a full snapshot occurs, then
6593
+ // the replay session was started due to session expiration. The new session
6594
+ // is started before triggering a new checkout and contains the id
6595
+ // of the previous session. Do not immediately flush in this case
6596
+ // to avoid capturing only the checkout and instead the replay will
6597
+ // be captured if they perform any follow-up actions.
6598
+ if (replay.session && replay.session.previousSessionId) {
6599
+ return true;
6600
+ }
6601
+
6602
+ // When in buffer mode, make sure we adjust the session started date to the current earliest event of the buffer
6603
+ // this should usually be the timestamp of the checkout event, but to be safe...
6604
+ if (replay.recordingMode === 'buffer' && replay.session && replay.eventBuffer) {
6605
+ const earliestEvent = replay.eventBuffer.getEarliestTimestamp();
6606
+ if (earliestEvent) {
6607
+ replay.session.started = earliestEvent;
6608
+
6609
+ if (replay.getOptions().stickySession) {
6610
+ saveSession(replay.session);
6581
6611
  }
6582
6612
  }
6613
+ }
6583
6614
 
6584
- if (replay.recordingMode === 'session') {
6585
- // If the full snapshot is due to an initial load, we will not have
6586
- // a previous session ID. In this case, we want to buffer events
6587
- // for a set amount of time before flushing. This can help avoid
6588
- // capturing replays of users that immediately close the window.
6589
- void replay.flush();
6590
- }
6615
+ if (replay.recordingMode === 'session') {
6616
+ // If the full snapshot is due to an initial load, we will not have
6617
+ // a previous session ID. In this case, we want to buffer events
6618
+ // for a set amount of time before flushing. This can help avoid
6619
+ // capturing replays of users that immediately close the window.
6620
+ void replay.flush();
6621
+ }
6591
6622
 
6592
- return true;
6593
- });
6623
+ return true;
6594
6624
  });
6595
6625
  };
6596
6626
  }
@@ -6635,26 +6665,6 @@ function addSettingsEvent(replay, isCheckout) {
6635
6665
  return addEvent(replay, createOptionsEvent(replay), false);
6636
6666
  }
6637
6667
 
6638
- /**
6639
- * Sample a session based on the provided sample rates.
6640
- */
6641
- function sampleSession({
6642
- errorSampleRate,
6643
- sessionSampleRate,
6644
- }
6645
-
6646
- ) {
6647
- if (errorSampleRate <= 0 && sessionSampleRate <= 0) {
6648
- return false;
6649
- }
6650
-
6651
- if (isSampled(sessionSampleRate)) {
6652
- return 'session';
6653
- }
6654
-
6655
- return errorSampleRate > 0 ? 'buffer' : false;
6656
- }
6657
-
6658
6668
  /**
6659
6669
  * Create a replay envelope ready to be sent.
6660
6670
  * This includes both the replay event, as well as the recording data.
@@ -7046,6 +7056,7 @@ class ReplayContainer {
7046
7056
  */
7047
7057
  __init4() {this.timeouts = {
7048
7058
  sessionIdlePause: SESSION_IDLE_PAUSE_DURATION,
7059
+ sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION,
7049
7060
  maxSessionLife: MAX_SESSION_LIFE,
7050
7061
  }; }
7051
7062
 
@@ -7057,30 +7068,35 @@ class ReplayContainer {
7057
7068
 
7058
7069
  __init6() {this._flushLock = null;}
7059
7070
 
7071
+ /**
7072
+ * Timestamp of the last user activity. This lives across sessions.
7073
+ */
7074
+ __init7() {this._lastActivity = Date.now();}
7075
+
7060
7076
  /**
7061
7077
  * Is the integration currently active?
7062
7078
  */
7063
- __init7() {this._isEnabled = false;}
7079
+ __init8() {this._isEnabled = false;}
7064
7080
 
7065
7081
  /**
7066
7082
  * Paused is a state where:
7067
7083
  * - DOM Recording is not listening at all
7068
7084
  * - Nothing will be added to event buffer (e.g. core SDK events)
7069
7085
  */
7070
- __init8() {this._isPaused = false;}
7086
+ __init9() {this._isPaused = false;}
7071
7087
 
7072
7088
  /**
7073
7089
  * Have we attached listeners to the core SDK?
7074
7090
  * Note we have to track this as there is no way to remove instrumentation handlers.
7075
7091
  */
7076
- __init9() {this._hasInitializedCoreListeners = false;}
7092
+ __init10() {this._hasInitializedCoreListeners = false;}
7077
7093
 
7078
7094
  /**
7079
7095
  * Function to stop recording
7080
7096
  */
7081
- __init10() {this._stopRecording = null;}
7097
+ __init11() {this._stopRecording = null;}
7082
7098
 
7083
- __init11() {this._context = {
7099
+ __init12() {this._context = {
7084
7100
  errorIds: new Set(),
7085
7101
  traceIds: new Set(),
7086
7102
  urls: [],
@@ -7093,7 +7109,7 @@ class ReplayContainer {
7093
7109
  recordingOptions,
7094
7110
  }
7095
7111
 
7096
- ) {ReplayContainer.prototype.__init.call(this);ReplayContainer.prototype.__init2.call(this);ReplayContainer.prototype.__init3.call(this);ReplayContainer.prototype.__init4.call(this);ReplayContainer.prototype.__init5.call(this);ReplayContainer.prototype.__init6.call(this);ReplayContainer.prototype.__init7.call(this);ReplayContainer.prototype.__init8.call(this);ReplayContainer.prototype.__init9.call(this);ReplayContainer.prototype.__init10.call(this);ReplayContainer.prototype.__init11.call(this);ReplayContainer.prototype.__init12.call(this);ReplayContainer.prototype.__init13.call(this);ReplayContainer.prototype.__init14.call(this);ReplayContainer.prototype.__init15.call(this);ReplayContainer.prototype.__init16.call(this);ReplayContainer.prototype.__init17.call(this);
7112
+ ) {ReplayContainer.prototype.__init.call(this);ReplayContainer.prototype.__init2.call(this);ReplayContainer.prototype.__init3.call(this);ReplayContainer.prototype.__init4.call(this);ReplayContainer.prototype.__init5.call(this);ReplayContainer.prototype.__init6.call(this);ReplayContainer.prototype.__init7.call(this);ReplayContainer.prototype.__init8.call(this);ReplayContainer.prototype.__init9.call(this);ReplayContainer.prototype.__init10.call(this);ReplayContainer.prototype.__init11.call(this);ReplayContainer.prototype.__init12.call(this);ReplayContainer.prototype.__init13.call(this);ReplayContainer.prototype.__init14.call(this);ReplayContainer.prototype.__init15.call(this);ReplayContainer.prototype.__init16.call(this);ReplayContainer.prototype.__init17.call(this);ReplayContainer.prototype.__init18.call(this);
7097
7113
  this._recordingOptions = recordingOptions;
7098
7114
  this._options = options;
7099
7115
 
@@ -7151,13 +7167,37 @@ class ReplayContainer {
7151
7167
  */
7152
7168
  initializeSampling() {
7153
7169
  const { errorSampleRate, sessionSampleRate } = this._options;
7154
- const sampled = sampleSession({ errorSampleRate, sessionSampleRate });
7155
7170
 
7156
- if (!sampled) {
7171
+ // If neither sample rate is > 0, then do nothing - user will need to call one of
7172
+ // `start()` or `startBuffering` themselves.
7173
+ if (errorSampleRate <= 0 && sessionSampleRate <= 0) {
7174
+ return;
7175
+ }
7176
+
7177
+ // Otherwise if there is _any_ sample rate set, try to load an existing
7178
+ // session, or create a new one.
7179
+ const isSessionSampled = this._loadAndCheckSession();
7180
+
7181
+ if (!isSessionSampled) {
7182
+ // This should only occur if `errorSampleRate` is 0 and was unsampled for
7183
+ // session-based replay. In this case there is nothing to do.
7184
+ return;
7185
+ }
7186
+
7187
+ if (!this.session) {
7188
+ // This should not happen, something wrong has occurred
7189
+ this._handleException(new Error('Unable to initialize and create session'));
7157
7190
  return;
7158
7191
  }
7159
7192
 
7160
- this._initializeSession(sampled);
7193
+ if (this.session.sampled && this.session.sampled !== 'session') {
7194
+ // If not sampled as session-based, then recording mode will be `buffer`
7195
+ // Note that we don't explicitly check if `sampled === 'buffer'` because we
7196
+ // could have sessions from Session storage that are still `error` from
7197
+ // prior SDK version.
7198
+ this.recordingMode = 'buffer';
7199
+ }
7200
+
7161
7201
  this._initializeRecording();
7162
7202
  }
7163
7203
 
@@ -7177,7 +7217,20 @@ class ReplayContainer {
7177
7217
  throw new Error('Replay buffering is in progress, call `flush()` to save the replay');
7178
7218
  }
7179
7219
 
7180
- this._initializeSession('session', { forceSampled: true });
7220
+ const previousSessionId = this.session && this.session.id;
7221
+
7222
+ const { session } = getSession({
7223
+ timeouts: this.timeouts,
7224
+ stickySession: Boolean(this._options.stickySession),
7225
+ currentSession: this.session,
7226
+ // This is intentional: create a new session-based replay when calling `start()`
7227
+ sessionSampleRate: 1,
7228
+ allowBuffering: false,
7229
+ });
7230
+
7231
+ session.previousSessionId = previousSessionId;
7232
+ this.session = session;
7233
+
7181
7234
  this._initializeRecording();
7182
7235
  }
7183
7236
 
@@ -7190,7 +7243,20 @@ class ReplayContainer {
7190
7243
  throw new Error('Replay recording is already in progress');
7191
7244
  }
7192
7245
 
7193
- this._initializeSession('buffer', { forceSampled: true });
7246
+ const previousSessionId = this.session && this.session.id;
7247
+
7248
+ const { session } = getSession({
7249
+ timeouts: this.timeouts,
7250
+ stickySession: Boolean(this._options.stickySession),
7251
+ currentSession: this.session,
7252
+ sessionSampleRate: 0,
7253
+ allowBuffering: true,
7254
+ });
7255
+
7256
+ session.previousSessionId = previousSessionId;
7257
+ this.session = session;
7258
+
7259
+ this.recordingMode = 'buffer';
7194
7260
  this._initializeRecording();
7195
7261
  }
7196
7262
 
@@ -7272,7 +7338,8 @@ class ReplayContainer {
7272
7338
  this.eventBuffer && this.eventBuffer.destroy();
7273
7339
  this.eventBuffer = null;
7274
7340
 
7275
- // Clear session from session storage
7341
+ // Clear session from session storage, note this means if a new session
7342
+ // is started after, it will not have `previousSessionId`
7276
7343
  clearSession(this);
7277
7344
  } catch (err) {
7278
7345
  this._handleException(err);
@@ -7285,10 +7352,6 @@ class ReplayContainer {
7285
7352
  * not as thorough of a shutdown as `stop()`.
7286
7353
  */
7287
7354
  pause() {
7288
- if (this.isPaused()) {
7289
- return;
7290
- }
7291
-
7292
7355
  this._isPaused = true;
7293
7356
  this.stopRecording();
7294
7357
  }
@@ -7300,14 +7363,12 @@ class ReplayContainer {
7300
7363
  * new DOM checkout.`
7301
7364
  */
7302
7365
  resume() {
7303
- if (!this.isPaused()) {
7366
+ if (!this._loadAndCheckSession()) {
7304
7367
  return;
7305
7368
  }
7306
7369
 
7307
7370
  this._isPaused = false;
7308
- this.checkSessionState(() => {
7309
- this.startRecording();
7310
- });
7371
+ this.startRecording();
7311
7372
  }
7312
7373
 
7313
7374
  /**
@@ -7342,8 +7403,20 @@ class ReplayContainer {
7342
7403
  // starting a new recording
7343
7404
  this.recordingMode = 'session';
7344
7405
 
7406
+ // Once this session ends, we do not want to refresh it
7345
7407
  if (this.session) {
7346
- this.session.lastActivity = activityTime;
7408
+ this.session.shouldRefresh = false;
7409
+
7410
+ // It's possible that the session lifespan is > max session lifespan
7411
+ // because we have been buffering beyond max session lifespan (we ignore
7412
+ // expiration given that `shouldRefresh` is true). Since we flip
7413
+ // `shouldRefresh`, the session could be considered expired due to
7414
+ // lifespan, which is not what we want. Update session start date to be
7415
+ // the current timestamp, so that session is not considered to be
7416
+ // expired. This means that max replay duration can be MAX_SESSION_LIFE +
7417
+ // (length of buffer), which we are ok with.
7418
+ this._updateUserActivity(activityTime);
7419
+ this._updateSessionActivity(activityTime);
7347
7420
  this.session.started = activityTime;
7348
7421
  this._maybeSaveSession();
7349
7422
  }
@@ -7381,9 +7454,42 @@ class ReplayContainer {
7381
7454
  }
7382
7455
 
7383
7456
  /**
7384
- * Updates the user activity timestamp.
7457
+ * Updates the user activity timestamp and resumes recording. This should be
7458
+ * called in an event handler for a user action that we consider as the user
7459
+ * being "active" (e.g. a mouse click).
7460
+ */
7461
+ triggerUserActivity() {
7462
+ this._updateUserActivity();
7463
+
7464
+ // This case means that recording was once stopped due to inactivity.
7465
+ // Ensure that recording is resumed.
7466
+ if (!this._stopRecording) {
7467
+ // Create a new session, otherwise when the user action is flushed, it
7468
+ // will get rejected due to an expired session.
7469
+ if (!this._loadAndCheckSession()) {
7470
+ return;
7471
+ }
7472
+
7473
+ // Note: This will cause a new DOM checkout
7474
+ this.resume();
7475
+ return;
7476
+ }
7477
+
7478
+ // Otherwise... recording was never suspended, continue as normalish
7479
+ this.checkAndHandleExpiredSession();
7480
+
7481
+ this._updateSessionActivity();
7482
+ }
7483
+
7484
+ /**
7485
+ * Updates the user activity timestamp *without* resuming
7486
+ * recording. Some user events (e.g. keydown) can be create
7487
+ * low-value replays that only contain the keypress as a
7488
+ * breadcrumb. Instead this would require other events to
7489
+ * create a new replay after a session has expired.
7385
7490
  */
7386
7491
  updateUserActivity() {
7492
+ this._updateUserActivity();
7387
7493
  this._updateSessionActivity();
7388
7494
  }
7389
7495
 
@@ -7428,6 +7534,54 @@ class ReplayContainer {
7428
7534
  return this.session && this.session.id;
7429
7535
  }
7430
7536
 
7537
+ /**
7538
+ * Checks if recording should be stopped due to user inactivity. Otherwise
7539
+ * check if session is expired and create a new session if so. Triggers a new
7540
+ * full snapshot on new session.
7541
+ *
7542
+ * Returns true if session is not expired, false otherwise.
7543
+ * @hidden
7544
+ */
7545
+ checkAndHandleExpiredSession() {
7546
+ const oldSessionId = this.getSessionId();
7547
+
7548
+ // Prevent starting a new session if the last user activity is older than
7549
+ // SESSION_IDLE_PAUSE_DURATION. Otherwise non-user activity can trigger a new
7550
+ // session+recording. This creates noisy replays that do not have much
7551
+ // content in them.
7552
+ if (
7553
+ this._lastActivity &&
7554
+ isExpired(this._lastActivity, this.timeouts.sessionIdlePause) &&
7555
+ this.session &&
7556
+ this.session.sampled === 'session'
7557
+ ) {
7558
+ // Pause recording only for session-based replays. Otherwise, resuming
7559
+ // will create a new replay and will conflict with users who only choose
7560
+ // to record error-based replays only. (e.g. the resumed replay will not
7561
+ // contain a reference to an error)
7562
+ this.pause();
7563
+ return;
7564
+ }
7565
+
7566
+ // --- There is recent user activity --- //
7567
+ // This will create a new session if expired, based on expiry length
7568
+ if (!this._loadAndCheckSession()) {
7569
+ return;
7570
+ }
7571
+
7572
+ // Session was expired if session ids do not match
7573
+ const expired = oldSessionId !== this.getSessionId();
7574
+
7575
+ if (!expired) {
7576
+ return true;
7577
+ }
7578
+
7579
+ // Session is expired, trigger a full snapshot (which will create a new session)
7580
+ this._triggerFullSnapshot();
7581
+
7582
+ return false;
7583
+ }
7584
+
7431
7585
  /**
7432
7586
  * Capture some initial state that can change throughout the lifespan of the
7433
7587
  * replay. This is required because otherwise they would be captured at the
@@ -7480,23 +7634,6 @@ class ReplayContainer {
7480
7634
  return res;
7481
7635
  }
7482
7636
 
7483
- /**
7484
- * Check the state/expiration of the session.
7485
- * The callback is called when the session is neither paused nor expired.
7486
- */
7487
- checkSessionState(onContinue) {
7488
- if (!this.session || !this.session.sampled) {
7489
- return;
7490
- }
7491
-
7492
- checkSessionState(this.session, this.recordingMode, this.timeouts, {
7493
- onPause: () => this.pause(),
7494
- ensureResumed: () => this.resume(),
7495
- onEnd: () => this._refreshSession(),
7496
- onContinue,
7497
- });
7498
- }
7499
-
7500
7637
  /**
7501
7638
  * This will get the parametrized route name of the current page.
7502
7639
  * This is only available if performance is enabled, and if an instrumented router is used.
@@ -7510,31 +7647,6 @@ class ReplayContainer {
7510
7647
  return lastTransaction.name;
7511
7648
  }
7512
7649
 
7513
- /**
7514
- * Initialize a new session.
7515
- */
7516
- _initializeSession(sampled, { forceSampled = false } = {}) {
7517
- const { stickySession } = this._options;
7518
-
7519
- // If neither sample rate is > 0, then do nothing - user will need to call one of
7520
- // `start()` or `startBuffering` themselves.
7521
- if (!sampled) {
7522
- return;
7523
- }
7524
-
7525
- const session = restoreOrCreateSession({ stickySession, sampled, forceSampled });
7526
-
7527
- // This shouldn't be possible really, but just in case...
7528
- if (!session.sampled) {
7529
- return;
7530
- }
7531
-
7532
- this.session = session;
7533
- this.recordingMode = this.session.sampled === 'buffer' ? 'buffer' : 'session';
7534
-
7535
- return;
7536
- }
7537
-
7538
7650
  /**
7539
7651
  * Initialize and start all listeners to varying events (DOM,
7540
7652
  * Performance Observer, Recording, Sentry SDK, etc)
@@ -7544,7 +7656,7 @@ class ReplayContainer {
7544
7656
 
7545
7657
  // this method is generally called on page load or manually - in both cases
7546
7658
  // we should treat it as an activity
7547
- this.updateUserActivity();
7659
+ this._updateSessionActivity();
7548
7660
 
7549
7661
  this.eventBuffer = createEventBuffer({
7550
7662
  useCompression: this._options.useCompression,
@@ -7555,7 +7667,6 @@ class ReplayContainer {
7555
7667
 
7556
7668
  // Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
7557
7669
  this._isEnabled = true;
7558
- this._isPaused = false;
7559
7670
 
7560
7671
  this.startRecording();
7561
7672
  }
@@ -7570,31 +7681,37 @@ class ReplayContainer {
7570
7681
  }
7571
7682
 
7572
7683
  /**
7573
- * Refresh a session that has ended, either when it exceeded the max. age or when it was inactive for too long.
7574
- * This means there was a sampled & sent session before - this will never be called while the session is buffering.
7684
+ * Loads (or refreshes) the current session.
7685
+ * Returns false if session is not recorded.
7575
7686
  */
7576
- _refreshSession() {
7577
- // To avoid firing this multiple times, we check if we are even recording
7578
- if (!this.isEnabled()) {
7579
- return;
7687
+ _loadAndCheckSession() {
7688
+ const { type, session } = getSession({
7689
+ timeouts: this.timeouts,
7690
+ stickySession: Boolean(this._options.stickySession),
7691
+ currentSession: this.session,
7692
+ sessionSampleRate: this._options.sessionSampleRate,
7693
+ allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer',
7694
+ });
7695
+
7696
+ // If session was newly created (i.e. was not loaded from storage), then
7697
+ // enable flag to create the root replay
7698
+ if (type === 'new') {
7699
+ this.setInitialState();
7580
7700
  }
7581
7701
 
7582
- const { errorSampleRate, sessionSampleRate } = this._options;
7583
- const sampled = sampleSession({ errorSampleRate, sessionSampleRate });
7702
+ const currentSessionId = this.getSessionId();
7703
+ if (session.id !== currentSessionId) {
7704
+ session.previousSessionId = currentSessionId;
7705
+ }
7584
7706
 
7585
- if (!sampled || !this.session) {
7586
- void this.stop('session expired without new session');
7587
- return;
7707
+ this.session = session;
7708
+
7709
+ if (!this.session.sampled) {
7710
+ void this.stop('session unsampled');
7711
+ return false;
7588
7712
  }
7589
7713
 
7590
- void this.stop('session expired with refreshing').then(() => {
7591
- if (sampled === 'session') {
7592
- return this.start();
7593
- }
7594
- if (sampled === 'buffer') {
7595
- return this.startBuffering();
7596
- }
7597
- });
7714
+ return true;
7598
7715
  }
7599
7716
 
7600
7717
  /**
@@ -7659,7 +7776,7 @@ class ReplayContainer {
7659
7776
  * be hidden. Likewise, moving a different window to cover the contents of the
7660
7777
  * page will also trigger a change to a hidden state.
7661
7778
  */
7662
- __init12() {this._handleVisibilityChange = () => {
7779
+ __init13() {this._handleVisibilityChange = () => {
7663
7780
  if (WINDOW.document.visibilityState === 'visible') {
7664
7781
  this._doChangeToForegroundTasks();
7665
7782
  } else {
@@ -7670,7 +7787,7 @@ class ReplayContainer {
7670
7787
  /**
7671
7788
  * Handle when page is blurred
7672
7789
  */
7673
- __init13() {this._handleWindowBlur = () => {
7790
+ __init14() {this._handleWindowBlur = () => {
7674
7791
  const breadcrumb = createBreadcrumb({
7675
7792
  category: 'ui.blur',
7676
7793
  });
@@ -7683,7 +7800,7 @@ class ReplayContainer {
7683
7800
  /**
7684
7801
  * Handle when page is focused
7685
7802
  */
7686
- __init14() {this._handleWindowFocus = () => {
7803
+ __init15() {this._handleWindowFocus = () => {
7687
7804
  const breadcrumb = createBreadcrumb({
7688
7805
  category: 'ui.focus',
7689
7806
  });
@@ -7694,7 +7811,7 @@ class ReplayContainer {
7694
7811
  };}
7695
7812
 
7696
7813
  /** Ensure page remains active when a key is pressed. */
7697
- __init15() {this._handleKeyboardEvent = (event) => {
7814
+ __init16() {this._handleKeyboardEvent = (event) => {
7698
7815
  handleKeyboardEvent(this, event);
7699
7816
  };}
7700
7817
 
@@ -7702,35 +7819,71 @@ class ReplayContainer {
7702
7819
  * Tasks to run when we consider a page to be hidden (via blurring and/or visibility)
7703
7820
  */
7704
7821
  _doChangeToBackgroundTasks(breadcrumb) {
7705
- this.checkSessionState(() => {
7706
- if (breadcrumb) {
7707
- this._createCustomBreadcrumb(breadcrumb);
7708
- }
7822
+ if (!this.session) {
7823
+ return;
7824
+ }
7709
7825
 
7710
- // Send replay when the page/tab becomes hidden. There is no reason to send
7711
- // replay if it becomes visible, since no actions we care about were done
7712
- // while it was hidden
7713
- void this.conditionalFlush();
7714
- });
7826
+ const expired = isSessionExpired(this.session, this.timeouts);
7827
+
7828
+ if (breadcrumb && !expired) {
7829
+ this._createCustomBreadcrumb(breadcrumb);
7830
+ }
7831
+
7832
+ // Send replay when the page/tab becomes hidden. There is no reason to send
7833
+ // replay if it becomes visible, since no actions we care about were done
7834
+ // while it was hidden
7835
+ void this.conditionalFlush();
7715
7836
  }
7716
7837
 
7717
7838
  /**
7718
7839
  * Tasks to run when we consider a page to be visible (via focus and/or visibility)
7719
7840
  */
7720
7841
  _doChangeToForegroundTasks(breadcrumb) {
7721
- this.checkSessionState(() => {
7722
- if (breadcrumb) {
7723
- this._createCustomBreadcrumb(breadcrumb);
7724
- }
7725
- });
7842
+ if (!this.session) {
7843
+ return;
7844
+ }
7845
+
7846
+ const isSessionActive = this.checkAndHandleExpiredSession();
7847
+
7848
+ if (!isSessionActive) {
7849
+ // If the user has come back to the page within SESSION_IDLE_PAUSE_DURATION
7850
+ // ms, we will re-use the existing session, otherwise create a new
7851
+ // session
7852
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Document has become active, but session has expired');
7853
+ return;
7854
+ }
7855
+
7856
+ if (breadcrumb) {
7857
+ this._createCustomBreadcrumb(breadcrumb);
7858
+ }
7859
+ }
7860
+
7861
+ /**
7862
+ * Trigger rrweb to take a full snapshot which will cause this plugin to
7863
+ * create a new Replay event.
7864
+ */
7865
+ _triggerFullSnapshot(checkout = true) {
7866
+ try {
7867
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Replay] Taking full rrweb snapshot');
7868
+ record.takeFullSnapshot(checkout);
7869
+ } catch (err) {
7870
+ this._handleException(err);
7871
+ }
7872
+ }
7873
+
7874
+ /**
7875
+ * Update user activity (across session lifespans)
7876
+ */
7877
+ _updateUserActivity(_lastActivity = Date.now()) {
7878
+ this._lastActivity = _lastActivity;
7726
7879
  }
7727
7880
 
7728
7881
  /**
7729
7882
  * Updates the session's last activity timestamp
7730
7883
  */
7731
- _updateSessionActivity(lastActivity = Date.now()) {
7884
+ _updateSessionActivity(_lastActivity = Date.now()) {
7732
7885
  if (this.session) {
7733
- this.session.lastActivity = lastActivity;
7886
+ this.session.lastActivity = _lastActivity;
7734
7887
  this._maybeSaveSession();
7735
7888
  }
7736
7889
  }
@@ -7881,7 +8034,7 @@ class ReplayContainer {
7881
8034
  * Flush recording data to Sentry. Creates a lock so that only a single flush
7882
8035
  * can be active at a time. Do not call this directly.
7883
8036
  */
7884
- __init16() {this._flush = async ({
8037
+ __init17() {this._flush = async ({
7885
8038
  force = false,
7886
8039
  }
7887
8040
 
@@ -7891,58 +8044,42 @@ class ReplayContainer {
7891
8044
  return;
7892
8045
  }
7893
8046
 
7894
- return new Promise(resolve => {
7895
- if (!this.session) {
7896
- resolve();
7897
- return;
7898
- }
7899
- checkSessionState(this.session, this.recordingMode, this.timeouts, {
7900
- onPause: () => {
7901
- this.pause();
7902
- resolve();
7903
- },
7904
- ensureResumed: () => this.resume(),
7905
- onEnd: () => {
7906
- (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error('[Replay] Attempting to finish replay event after session expired.');
7907
- this._refreshSession();
7908
- resolve();
7909
- },
7910
- onContinue: () => {
7911
- void this._flushLocked().then(resolve);
7912
- },
7913
- });
7914
- });
7915
- };}
8047
+ if (!this.checkAndHandleExpiredSession()) {
8048
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error('[Replay] Attempting to finish replay event after session expired.');
8049
+ return;
8050
+ }
7916
8051
 
7917
- /**
7918
- * Flush, while locking so any future flush attemps while this is ongoing are queued.
7919
- */
7920
- async _flushLocked() {
7921
- try {
7922
- // A flush is about to happen, cancel any queued flushes
7923
- this._debouncedFlush.cancel();
8052
+ if (!this.session) {
8053
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error('[Replay] No session found to flush.');
8054
+ return;
8055
+ }
7924
8056
 
7925
- // this._flushLock acts as a lock so that future calls to `_flush()`
7926
- // will be blocked until this promise resolves
7927
- if (!this._flushLock) {
7928
- this._flushLock = this._runFlush();
7929
- await this._flushLock;
7930
- this._flushLock = null;
7931
- return;
7932
- }
8057
+ // A flush is about to happen, cancel any queued flushes
8058
+ this._debouncedFlush.cancel();
7933
8059
 
7934
- // Wait for previous flush to finish, then call the debounced `_flush()`.
7935
- // It's possible there are other flush requests queued and waiting for it
7936
- // to resolve. We want to reduce all outstanding requests (as well as any
7937
- // new flush requests that occur within a second of the locked flush
7938
- // completing) into a single flush.
8060
+ // this._flushLock acts as a lock so that future calls to `_flush()`
8061
+ // will be blocked until this promise resolves
8062
+ if (!this._flushLock) {
8063
+ this._flushLock = this._runFlush();
7939
8064
  await this._flushLock;
7940
- } catch (error) {
7941
- (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error(error);
8065
+ this._flushLock = null;
8066
+ return;
7942
8067
  }
7943
8068
 
7944
- this._debouncedFlush();
7945
- }
8069
+ // Wait for previous flush to finish, then call the debounced `_flush()`.
8070
+ // It's possible there are other flush requests queued and waiting for it
8071
+ // to resolve. We want to reduce all outstanding requests (as well as any
8072
+ // new flush requests that occur within a second of the locked flush
8073
+ // completing) into a single flush.
8074
+
8075
+ try {
8076
+ await this._flushLock;
8077
+ } catch (err) {
8078
+ (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error(err);
8079
+ } finally {
8080
+ this._debouncedFlush();
8081
+ }
8082
+ };}
7946
8083
 
7947
8084
  /** Save the session, if it is sticky */
7948
8085
  _maybeSaveSession() {
@@ -7952,7 +8089,7 @@ class ReplayContainer {
7952
8089
  }
7953
8090
 
7954
8091
  /** Handler for rrweb.record.onMutation */
7955
- __init17() {this._onMutationHandler = (mutations) => {
8092
+ __init18() {this._onMutationHandler = (mutations) => {
7956
8093
  const count = mutations.length;
7957
8094
 
7958
8095
  const mutationLimit = this._options.mutationLimit;