@sentry/replay 7.59.0-beta.1 → 7.59.1

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