@sentry/replay 7.61.1 → 7.63.0

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
@@ -3496,6 +3496,14 @@ class ClickDetector {
3496
3496
  clickCount: 0,
3497
3497
  node,
3498
3498
  };
3499
+
3500
+ // If there was a click in the last 1s on the same element, ignore it - only keep a single reference per second
3501
+ if (
3502
+ this._clicks.some(click => click.node === newClick.node && Math.abs(click.timestamp - newClick.timestamp) < 1)
3503
+ ) {
3504
+ return;
3505
+ }
3506
+
3499
3507
  this._clicks.push(newClick);
3500
3508
 
3501
3509
  // If this is the first new click, set a timeout to check for multi clicks
@@ -4025,21 +4033,45 @@ function logInfo(message, shouldAddBreadcrumb) {
4025
4033
  logger.info(message);
4026
4034
 
4027
4035
  if (shouldAddBreadcrumb) {
4028
- const hub = getCurrentHub();
4029
- hub.addBreadcrumb(
4030
- {
4031
- category: 'console',
4032
- data: {
4033
- logger: 'replay',
4034
- },
4035
- level: 'info',
4036
- message,
4037
- },
4038
- { level: 'info' },
4039
- );
4036
+ addBreadcrumb(message);
4037
+ }
4038
+ }
4039
+
4040
+ /**
4041
+ * Log a message, and add a breadcrumb in the next tick.
4042
+ * This is necessary when the breadcrumb may be added before the replay is initialized.
4043
+ */
4044
+ function logInfoNextTick(message, shouldAddBreadcrumb) {
4045
+ if (!(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) {
4046
+ return;
4047
+ }
4048
+
4049
+ logger.info(message);
4050
+
4051
+ if (shouldAddBreadcrumb) {
4052
+ // Wait a tick here to avoid race conditions for some initial logs
4053
+ // which may be added before replay is initialized
4054
+ setTimeout(() => {
4055
+ addBreadcrumb(message);
4056
+ }, 0);
4040
4057
  }
4041
4058
  }
4042
4059
 
4060
+ function addBreadcrumb(message) {
4061
+ const hub = getCurrentHub();
4062
+ hub.addBreadcrumb(
4063
+ {
4064
+ category: 'console',
4065
+ data: {
4066
+ logger: 'replay',
4067
+ },
4068
+ level: 'info',
4069
+ message,
4070
+ },
4071
+ { level: 'info' },
4072
+ );
4073
+ }
4074
+
4043
4075
  /** This error indicates that the event buffer size exceeded the limit.. */
4044
4076
  class EventBufferSizeExceededError extends Error {
4045
4077
  constructor() {
@@ -4054,9 +4086,12 @@ class EventBufferSizeExceededError extends Error {
4054
4086
  class EventBufferArray {
4055
4087
  /** All the events that are buffered to be sent. */
4056
4088
 
4089
+ /** @inheritdoc */
4090
+
4057
4091
  constructor() {
4058
4092
  this.events = [];
4059
4093
  this._totalSize = 0;
4094
+ this.hasCheckout = false;
4060
4095
  }
4061
4096
 
4062
4097
  /** @inheritdoc */
@@ -4101,6 +4136,7 @@ class EventBufferArray {
4101
4136
  clear() {
4102
4137
  this.events = [];
4103
4138
  this._totalSize = 0;
4139
+ this.hasCheckout = false;
4104
4140
  }
4105
4141
 
4106
4142
  /** @inheritdoc */
@@ -4220,11 +4256,13 @@ class WorkerHandler {
4220
4256
  * Exported only for testing.
4221
4257
  */
4222
4258
  class EventBufferCompressionWorker {
4259
+ /** @inheritdoc */
4223
4260
 
4224
4261
  constructor(worker) {
4225
4262
  this._worker = new WorkerHandler(worker);
4226
4263
  this._earliestTimestamp = null;
4227
4264
  this._totalSize = 0;
4265
+ this.hasCheckout = false;
4228
4266
  }
4229
4267
 
4230
4268
  /** @inheritdoc */
@@ -4284,6 +4322,8 @@ class EventBufferCompressionWorker {
4284
4322
  clear() {
4285
4323
  this._earliestTimestamp = null;
4286
4324
  this._totalSize = 0;
4325
+ this.hasCheckout = false;
4326
+
4287
4327
  // We do not wait on this, as we assume the order of messages is consistent for the worker
4288
4328
  void this._worker.postMessage('clear');
4289
4329
  }
@@ -4338,6 +4378,15 @@ class EventBufferProxy {
4338
4378
  return this._used.hasEvents;
4339
4379
  }
4340
4380
 
4381
+ /** @inheritdoc */
4382
+ get hasCheckout() {
4383
+ return this._used.hasCheckout;
4384
+ }
4385
+ /** @inheritdoc */
4386
+ set hasCheckout(value) {
4387
+ this._used.hasCheckout = value;
4388
+ }
4389
+
4341
4390
  /** @inheritDoc */
4342
4391
  destroy() {
4343
4392
  this._fallback.destroy();
@@ -4393,13 +4442,15 @@ class EventBufferProxy {
4393
4442
 
4394
4443
  /** Switch the used buffer to the compression worker. */
4395
4444
  async _switchToCompressionWorker() {
4396
- const { events } = this._fallback;
4445
+ const { events, hasCheckout } = this._fallback;
4397
4446
 
4398
4447
  const addEventPromises = [];
4399
4448
  for (const event of events) {
4400
4449
  addEventPromises.push(this._compression.addEvent(event));
4401
4450
  }
4402
4451
 
4452
+ this._compression.hasCheckout = hasCheckout;
4453
+
4403
4454
  // We switch over to the new buffer immediately - any further events will be added
4404
4455
  // after the previously buffered ones
4405
4456
  this._used = this._compression;
@@ -4599,7 +4650,7 @@ function fetchSession(traceInternals) {
4599
4650
 
4600
4651
  const sessionObj = JSON.parse(sessionStringFromStorage) ;
4601
4652
 
4602
- logInfo('[Replay] Loading existing session', traceInternals);
4653
+ logInfoNextTick('[Replay] Loading existing session', traceInternals);
4603
4654
 
4604
4655
  return makeSession(sessionObj);
4605
4656
  } catch (e) {
@@ -4635,10 +4686,10 @@ function getSession({
4635
4686
  // and when this session is expired, it will not be renewed until user
4636
4687
  // reloads.
4637
4688
  const discardedSession = makeSession({ sampled: false });
4638
- logInfo('[Replay] Session should not be refreshed', traceInternals);
4689
+ logInfoNextTick('[Replay] Session should not be refreshed', traceInternals);
4639
4690
  return { type: 'new', session: discardedSession };
4640
4691
  } else {
4641
- logInfo('[Replay] Session has expired', traceInternals);
4692
+ logInfoNextTick('[Replay] Session has expired', traceInternals);
4642
4693
  }
4643
4694
  // Otherwise continue to create a new session
4644
4695
  }
@@ -4648,7 +4699,7 @@ function getSession({
4648
4699
  sessionSampleRate,
4649
4700
  allowBuffering,
4650
4701
  });
4651
- logInfo('[Replay] Created new session', traceInternals);
4702
+ logInfoNextTick('[Replay] Created new session', traceInternals);
4652
4703
 
4653
4704
  return { type: 'new', session: newSession };
4654
4705
  }
@@ -4686,11 +4737,24 @@ async function addEvent(
4686
4737
  return null;
4687
4738
  }
4688
4739
 
4740
+ // Throw out events that are +60min from the initial timestamp
4741
+ if (timestampInMs > replay.getContext().initialTimestamp + replay.timeouts.maxSessionLife) {
4742
+ logInfo(
4743
+ `[Replay] Skipping event with timestamp ${timestampInMs} because it is after maxSessionLife`,
4744
+ replay.getOptions()._experiments.traceInternals,
4745
+ );
4746
+ return null;
4747
+ }
4748
+
4689
4749
  try {
4690
- if (isCheckout) {
4750
+ if (isCheckout && replay.recordingMode === 'buffer') {
4691
4751
  replay.eventBuffer.clear();
4692
4752
  }
4693
4753
 
4754
+ if (isCheckout) {
4755
+ replay.eventBuffer.hasCheckout = true;
4756
+ }
4757
+
4694
4758
  const replayOptions = replay.getOptions();
4695
4759
 
4696
4760
  const eventAfterPossibleCallback = maybeApplyCallback(event, replayOptions.beforeAddRecordingEvent);
@@ -4704,7 +4768,7 @@ async function addEvent(
4704
4768
  const reason = error && error instanceof EventBufferSizeExceededError ? 'addEventSizeExceeded' : 'addEvent';
4705
4769
 
4706
4770
  (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error(error);
4707
- await replay.stop(reason);
4771
+ await replay.stop({ reason });
4708
4772
 
4709
4773
  const client = getCurrentHub().getClient();
4710
4774
 
@@ -7264,7 +7328,10 @@ class ReplayContainer {
7264
7328
  this.recordingMode = 'buffer';
7265
7329
  }
7266
7330
 
7267
- logInfo(`[Replay] Starting replay in ${this.recordingMode} mode`, this._options._experiments.traceInternals);
7331
+ logInfoNextTick(
7332
+ `[Replay] Starting replay in ${this.recordingMode} mode`,
7333
+ this._options._experiments.traceInternals,
7334
+ );
7268
7335
 
7269
7336
  this._initializeRecording();
7270
7337
  }
@@ -7285,7 +7352,7 @@ class ReplayContainer {
7285
7352
  throw new Error('Replay buffering is in progress, call `flush()` to save the replay');
7286
7353
  }
7287
7354
 
7288
- logInfo('[Replay] Starting replay in session mode', this._options._experiments.traceInternals);
7355
+ logInfoNextTick('[Replay] Starting replay in session mode', this._options._experiments.traceInternals);
7289
7356
 
7290
7357
  const previousSessionId = this.session && this.session.id;
7291
7358
 
@@ -7314,7 +7381,7 @@ class ReplayContainer {
7314
7381
  throw new Error('Replay recording is already in progress');
7315
7382
  }
7316
7383
 
7317
- logInfo('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals);
7384
+ logInfoNextTick('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals);
7318
7385
 
7319
7386
  const previousSessionId = this.session && this.session.id;
7320
7387
 
@@ -7379,7 +7446,7 @@ class ReplayContainer {
7379
7446
  * Currently, this needs to be manually called (e.g. for tests). Sentry SDK
7380
7447
  * does not support a teardown
7381
7448
  */
7382
- async stop(reason) {
7449
+ async stop({ forceFlush = false, reason } = {}) {
7383
7450
  if (!this._isEnabled) {
7384
7451
  return;
7385
7452
  }
@@ -7399,7 +7466,7 @@ class ReplayContainer {
7399
7466
  this._debouncedFlush.cancel();
7400
7467
  // See comment above re: `_isEnabled`, we "force" a flush, ignoring the
7401
7468
  // `_isEnabled` state of the plugin since it was disabled above.
7402
- if (this.recordingMode === 'session') {
7469
+ if (forceFlush) {
7403
7470
  await this._flush({ force: true });
7404
7471
  }
7405
7472
 
@@ -7788,7 +7855,7 @@ class ReplayContainer {
7788
7855
  this.session = session;
7789
7856
 
7790
7857
  if (!this.session.sampled) {
7791
- void this.stop('session not refreshed');
7858
+ void this.stop({ reason: 'session not refreshed' });
7792
7859
  return false;
7793
7860
  }
7794
7861
 
@@ -8078,6 +8145,15 @@ class ReplayContainer {
8078
8145
  // Note this empties the event buffer regardless of outcome of sending replay
8079
8146
  const recordingData = await this.eventBuffer.finish();
8080
8147
 
8148
+ const timestamp = Date.now();
8149
+
8150
+ // Check total duration again, to avoid sending outdated stuff
8151
+ // We leave 30s wiggle room to accomodate late flushing etc.
8152
+ // This _could_ happen when the browser is suspended during flushing, in which case we just want to stop
8153
+ if (timestamp - this._context.initialTimestamp > this.timeouts.maxSessionLife + 30000) {
8154
+ throw new Error('Session is too long, not sending replay');
8155
+ }
8156
+
8081
8157
  // NOTE: Copy values from instance members, as it's possible they could
8082
8158
  // change before the flush finishes.
8083
8159
  const replayId = this.session.id;
@@ -8093,7 +8169,7 @@ class ReplayContainer {
8093
8169
  eventContext,
8094
8170
  session: this.session,
8095
8171
  options: this.getOptions(),
8096
- timestamp: Date.now(),
8172
+ timestamp,
8097
8173
  });
8098
8174
  } catch (err) {
8099
8175
  this._handleException(err);
@@ -8101,7 +8177,7 @@ class ReplayContainer {
8101
8177
  // This means we retried 3 times and all of them failed,
8102
8178
  // or we ran into a problem we don't want to retry, like rate limiting.
8103
8179
  // In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
8104
- void this.stop('sendReplay');
8180
+ void this.stop({ reason: 'sendReplay' });
8105
8181
 
8106
8182
  const client = getCurrentHub().getClient();
8107
8183
 
@@ -8139,6 +8215,9 @@ class ReplayContainer {
8139
8215
  const now = Date.now();
8140
8216
  const duration = now - start;
8141
8217
 
8218
+ // A flush is about to happen, cancel any queued flushes
8219
+ this._debouncedFlush.cancel();
8220
+
8142
8221
  // If session is too short, or too long (allow some wiggle room over maxSessionLife), do not send it
8143
8222
  // This _should_ not happen, but it may happen if flush is triggered due to a page activity change or similar
8144
8223
  const tooShort = duration < this._options.minReplayDuration;
@@ -8148,6 +8227,7 @@ class ReplayContainer {
8148
8227
  `[Replay] Session duration (${Math.floor(duration / 1000)}s) is too ${
8149
8228
  tooShort ? 'short' : 'long'
8150
8229
  }, not sending replay.`,
8230
+ this._options._experiments.traceInternals,
8151
8231
  );
8152
8232
 
8153
8233
  if (tooShort) {
@@ -8156,8 +8236,11 @@ class ReplayContainer {
8156
8236
  return;
8157
8237
  }
8158
8238
 
8159
- // A flush is about to happen, cancel any queued flushes
8160
- this._debouncedFlush.cancel();
8239
+ const eventBuffer = this.eventBuffer;
8240
+ if (eventBuffer && this.session.segmentId === 0 && !eventBuffer.hasCheckout) {
8241
+ logInfo('[Replay] Flushing initial segment without checkout.', this._options._experiments.traceInternals);
8242
+ // TODO FN: Evaluate if we want to stop here, or remove this again?
8243
+ }
8161
8244
 
8162
8245
  // this._flushLock acts as a lock so that future calls to `_flush()`
8163
8246
  // will be blocked until this promise resolves
@@ -8213,7 +8296,7 @@ class ReplayContainer {
8213
8296
 
8214
8297
  // Stop replay if over the mutation limit
8215
8298
  if (overMutationLimit) {
8216
- void this.stop('mutationLimit');
8299
+ void this.stop({ reason: 'mutationLimit', forceFlush: this.recordingMode === 'session' });
8217
8300
  return false;
8218
8301
  }
8219
8302
 
@@ -8564,7 +8647,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
8564
8647
  return Promise.resolve();
8565
8648
  }
8566
8649
 
8567
- return this._replay.stop();
8650
+ return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session' });
8568
8651
  }
8569
8652
 
8570
8653
  /**