@sentry/replay 7.52.0 → 7.53.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
@@ -3285,6 +3285,23 @@ var NodeType;
3285
3285
  NodeType[NodeType["Comment"] = 5] = "Comment";
3286
3286
  })(NodeType || (NodeType = {}));
3287
3287
 
3288
+ /* eslint-disable @typescript-eslint/naming-convention */
3289
+
3290
+ var EventType; (function (EventType) {
3291
+ const DomContentLoaded = 0; EventType[EventType["DomContentLoaded"] = DomContentLoaded] = "DomContentLoaded";
3292
+ const Load = 1; EventType[EventType["Load"] = Load] = "Load";
3293
+ const FullSnapshot = 2; EventType[EventType["FullSnapshot"] = FullSnapshot] = "FullSnapshot";
3294
+ const IncrementalSnapshot = 3; EventType[EventType["IncrementalSnapshot"] = IncrementalSnapshot] = "IncrementalSnapshot";
3295
+ const Meta = 4; EventType[EventType["Meta"] = Meta] = "Meta";
3296
+ const Custom = 5; EventType[EventType["Custom"] = Custom] = "Custom";
3297
+ const Plugin = 6; EventType[EventType["Plugin"] = Plugin] = "Plugin";
3298
+ })(EventType || (EventType = {}));
3299
+
3300
+ /**
3301
+ * This is a partial copy of rrweb's eventWithTime type which only contains the properties
3302
+ * we specifcally need in the SDK.
3303
+ */
3304
+
3288
3305
  /**
3289
3306
  * Converts a timestamp to ms, if it was in s, or keeps it as ms.
3290
3307
  */
@@ -3327,7 +3344,18 @@ async function addEvent(
3327
3344
  replay.eventBuffer.clear();
3328
3345
  }
3329
3346
 
3330
- return await replay.eventBuffer.addEvent(event);
3347
+ const replayOptions = replay.getOptions();
3348
+
3349
+ const eventAfterPossibleCallback =
3350
+ typeof replayOptions.beforeAddRecordingEvent === 'function' && event.type === EventType.Custom
3351
+ ? replayOptions.beforeAddRecordingEvent(event)
3352
+ : event;
3353
+
3354
+ if (!eventAfterPossibleCallback) {
3355
+ return;
3356
+ }
3357
+
3358
+ return await replay.eventBuffer.addEvent(eventAfterPossibleCallback);
3331
3359
  } catch (error) {
3332
3360
  (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.error(error);
3333
3361
  await replay.stop('addEvent');
@@ -3372,6 +3400,141 @@ function addBreadcrumbEvent(replay, breadcrumb) {
3372
3400
  });
3373
3401
  }
3374
3402
 
3403
+ /**
3404
+ * Detect a slow click on a button/a tag,
3405
+ * and potentially create a corresponding breadcrumb.
3406
+ */
3407
+ function detectSlowClick(
3408
+ replay,
3409
+ config,
3410
+ clickBreadcrumb,
3411
+ node,
3412
+ ) {
3413
+ if (ignoreElement(node, config)) {
3414
+ return;
3415
+ }
3416
+
3417
+ /*
3418
+ We consider a slow click a click on a button/a, which does not trigger one of:
3419
+ - DOM mutation
3420
+ - Scroll (within 100ms)
3421
+ Within the given threshold time.
3422
+ After time timeout time, we stop listening and mark it as a slow click anyhow.
3423
+ */
3424
+
3425
+ let cleanup = () => {
3426
+ // replaced further down
3427
+ };
3428
+
3429
+ // After timeout time, def. consider this a slow click, and stop watching for mutations
3430
+ const timeout = setTimeout(() => {
3431
+ handleSlowClick(replay, clickBreadcrumb, config.timeout, 'timeout');
3432
+ cleanup();
3433
+ }, config.timeout);
3434
+
3435
+ const mutationHandler = () => {
3436
+ maybeHandleSlowClick(replay, clickBreadcrumb, config.threshold, config.timeout, 'mutation');
3437
+ cleanup();
3438
+ };
3439
+
3440
+ const scrollHandler = () => {
3441
+ maybeHandleSlowClick(replay, clickBreadcrumb, config.scrollTimeout, config.timeout, 'scroll');
3442
+ cleanup();
3443
+ };
3444
+
3445
+ const obs = new MutationObserver(mutationHandler);
3446
+
3447
+ obs.observe(WINDOW.document.documentElement, {
3448
+ attributes: true,
3449
+ characterData: true,
3450
+ childList: true,
3451
+ subtree: true,
3452
+ });
3453
+
3454
+ WINDOW.addEventListener('scroll', scrollHandler);
3455
+
3456
+ // Stop listening to scroll timeouts early
3457
+ const scrollTimeout = setTimeout(() => {
3458
+ WINDOW.removeEventListener('scroll', scrollHandler);
3459
+ }, config.scrollTimeout);
3460
+
3461
+ cleanup = () => {
3462
+ clearTimeout(timeout);
3463
+ clearTimeout(scrollTimeout);
3464
+ obs.disconnect();
3465
+ WINDOW.removeEventListener('scroll', scrollHandler);
3466
+ };
3467
+ }
3468
+
3469
+ function maybeHandleSlowClick(
3470
+ replay,
3471
+ clickBreadcrumb,
3472
+ threshold,
3473
+ timeout,
3474
+ endReason,
3475
+ ) {
3476
+ const now = Date.now();
3477
+ const timeAfterClickMs = now - clickBreadcrumb.timestamp * 1000;
3478
+
3479
+ if (timeAfterClickMs > threshold) {
3480
+ handleSlowClick(replay, clickBreadcrumb, Math.min(timeAfterClickMs, timeout), endReason);
3481
+ return true;
3482
+ }
3483
+
3484
+ return false;
3485
+ }
3486
+
3487
+ function handleSlowClick(
3488
+ replay,
3489
+ clickBreadcrumb,
3490
+ timeAfterClickMs,
3491
+ endReason,
3492
+ ) {
3493
+ const breadcrumb = {
3494
+ message: clickBreadcrumb.message,
3495
+ timestamp: clickBreadcrumb.timestamp,
3496
+ category: 'ui.slowClickDetected',
3497
+ data: {
3498
+ ...clickBreadcrumb.data,
3499
+ url: WINDOW.location.href,
3500
+ // TODO FN: add parametrized route, when possible
3501
+ timeAfterClickMs,
3502
+ endReason,
3503
+ },
3504
+ };
3505
+
3506
+ addBreadcrumbEvent(replay, breadcrumb);
3507
+ }
3508
+
3509
+ const SLOW_CLICK_IGNORE_TAGS = ['SELECT', 'OPTION'];
3510
+
3511
+ function ignoreElement(node, config) {
3512
+ // If <input> tag, we only want to consider input[type='submit'] & input[type='button']
3513
+ if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) {
3514
+ return true;
3515
+ }
3516
+
3517
+ if (SLOW_CLICK_IGNORE_TAGS.includes(node.tagName)) {
3518
+ return true;
3519
+ }
3520
+
3521
+ // If <a> tag, detect special variants that may not lead to an action
3522
+ // If target !== _self, we may open the link somewhere else, which would lead to no action
3523
+ // Also, when downloading a file, we may not leave the page, but still not trigger an action
3524
+ if (
3525
+ node.tagName === 'A' &&
3526
+ (node.hasAttribute('download') || (node.hasAttribute('target') && node.getAttribute('target') !== '_self'))
3527
+ ) {
3528
+ return true;
3529
+ }
3530
+
3531
+ if (config.ignoreSelector && node.matches(config.ignoreSelector)) {
3532
+ return true;
3533
+ }
3534
+
3535
+ return false;
3536
+ }
3537
+
3375
3538
  // Note that these are the serialized attributes and not attributes directly on
3376
3539
  // the DOM Node. Attributes we are interested in:
3377
3540
  const ATTRIBUTES_TO_RECORD = new Set([
@@ -3406,9 +3569,21 @@ function getAttributesToRecord(attributes) {
3406
3569
  return obj;
3407
3570
  }
3408
3571
 
3409
- const handleDomListener =
3410
- (replay) =>
3411
- (handlerData) => {
3572
+ const handleDomListener = (
3573
+ replay,
3574
+ ) => {
3575
+ const slowClickExperiment = replay.getOptions()._experiments.slowClicks;
3576
+
3577
+ const slowClickConfig = slowClickExperiment
3578
+ ? {
3579
+ threshold: slowClickExperiment.threshold,
3580
+ timeout: slowClickExperiment.timeout,
3581
+ scrollTimeout: slowClickExperiment.scrollTimeout,
3582
+ ignoreSelector: slowClickExperiment.ignoreSelectors ? slowClickExperiment.ignoreSelectors.join(',') : '',
3583
+ }
3584
+ : undefined;
3585
+
3586
+ return (handlerData) => {
3412
3587
  if (!replay.isEnabled()) {
3413
3588
  return;
3414
3589
  }
@@ -3419,8 +3594,21 @@ const handleDomListener =
3419
3594
  return;
3420
3595
  }
3421
3596
 
3597
+ const isClick = handlerData.name === 'click';
3598
+ const event = isClick && (handlerData.event );
3599
+ // Ignore clicks if ctrl/alt/meta keys are held down as they alter behavior of clicks (e.g. open in new tab)
3600
+ if (isClick && slowClickConfig && event && !event.altKey && !event.metaKey && !event.ctrlKey) {
3601
+ detectSlowClick(
3602
+ replay,
3603
+ slowClickConfig,
3604
+ result ,
3605
+ getClickTargetNode(handlerData.event) ,
3606
+ );
3607
+ }
3608
+
3422
3609
  addBreadcrumbEvent(replay, result);
3423
3610
  };
3611
+ };
3424
3612
 
3425
3613
  /** Get the base DOM breadcrumb. */
3426
3614
  function getBaseDomBreadcrumb(target, message) {
@@ -6143,23 +6331,6 @@ function debounce(func, wait, options) {
6143
6331
  return debounced;
6144
6332
  }
6145
6333
 
6146
- /* eslint-disable @typescript-eslint/naming-convention */
6147
-
6148
- var EventType; (function (EventType) {
6149
- const DomContentLoaded = 0; EventType[EventType["DomContentLoaded"] = DomContentLoaded] = "DomContentLoaded";
6150
- const Load = 1; EventType[EventType["Load"] = Load] = "Load";
6151
- const FullSnapshot = 2; EventType[EventType["FullSnapshot"] = FullSnapshot] = "FullSnapshot";
6152
- const IncrementalSnapshot = 3; EventType[EventType["IncrementalSnapshot"] = IncrementalSnapshot] = "IncrementalSnapshot";
6153
- const Meta = 4; EventType[EventType["Meta"] = Meta] = "Meta";
6154
- const Custom = 5; EventType[EventType["Custom"] = Custom] = "Custom";
6155
- const Plugin = 6; EventType[EventType["Plugin"] = Plugin] = "Plugin";
6156
- })(EventType || (EventType = {}));
6157
-
6158
- /**
6159
- * This is a partial copy of rrweb's eventWithTime type which only contains the properties
6160
- * we specifcally need in the SDK.
6161
- */
6162
-
6163
6334
  /**
6164
6335
  * Handler for recording events.
6165
6336
  *
@@ -6233,6 +6404,30 @@ function getHandleRecordingEmit(replay) {
6233
6404
  }
6234
6405
  }
6235
6406
 
6407
+ const options = replay.getOptions();
6408
+
6409
+ // TODO: We want this as an experiment so that we can test
6410
+ // internally and create metrics before making this the default
6411
+ if (options._experiments.delayFlushOnCheckout) {
6412
+ // If the full snapshot is due to an initial load, we will not have
6413
+ // a previous session ID. In this case, we want to buffer events
6414
+ // for a set amount of time before flushing. This can help avoid
6415
+ // capturing replays of users that immediately close the window.
6416
+ setTimeout(() => replay.conditionalFlush(), options._experiments.delayFlushOnCheckout);
6417
+
6418
+ // Cancel any previously debounced flushes to ensure there are no [near]
6419
+ // simultaneous flushes happening. The latter request should be
6420
+ // insignificant in this case, so wait for additional user interaction to
6421
+ // trigger a new flush.
6422
+ //
6423
+ // This can happen because there's no guarantee that a recording event
6424
+ // happens first. e.g. a mouse click can happen and trigger a debounced
6425
+ // flush before the checkout.
6426
+ replay.cancelFlush();
6427
+
6428
+ return true;
6429
+ }
6430
+
6236
6431
  // Flush immediately so that we do not miss the first segment, otherwise
6237
6432
  // it can prevent loading on the UI. This will cause an increase in short
6238
6433
  // replays (e.g. opening and closing a tab quickly), but these can be
@@ -6998,7 +7193,17 @@ class ReplayContainer {
6998
7193
  }
6999
7194
 
7000
7195
  /**
7001
- *
7196
+ * Only flush if `this.recordingMode === 'session'`
7197
+ */
7198
+ conditionalFlush() {
7199
+ if (this.recordingMode === 'buffer') {
7200
+ return Promise.resolve();
7201
+ }
7202
+
7203
+ return this.flushImmediate();
7204
+ }
7205
+
7206
+ /**
7002
7207
  * Always flush via `_debouncedFlush` so that we do not have flushes triggered
7003
7208
  * from calling both `flush` and `_debouncedFlush`. Otherwise, there could be
7004
7209
  * cases of mulitple flushes happening closely together.
@@ -7009,6 +7214,13 @@ class ReplayContainer {
7009
7214
  return this._debouncedFlush.flush() ;
7010
7215
  }
7011
7216
 
7217
+ /**
7218
+ * Cancels queued up flushes.
7219
+ */
7220
+ cancelFlush() {
7221
+ this._debouncedFlush.cancel();
7222
+ }
7223
+
7012
7224
  /** Get the current sesion (=replay) ID */
7013
7225
  getSessionId() {
7014
7226
  return this.session && this.session.id;
@@ -7258,7 +7470,7 @@ class ReplayContainer {
7258
7470
  // Send replay when the page/tab becomes hidden. There is no reason to send
7259
7471
  // replay if it becomes visible, since no actions we care about were done
7260
7472
  // while it was hidden
7261
- this._conditionalFlush();
7473
+ void this.conditionalFlush();
7262
7474
  }
7263
7475
 
7264
7476
  /**
@@ -7342,17 +7554,6 @@ class ReplayContainer {
7342
7554
  return Promise.all(createPerformanceSpans(this, createPerformanceEntries(entries)));
7343
7555
  }
7344
7556
 
7345
- /**
7346
- * Only flush if `this.recordingMode === 'session'`
7347
- */
7348
- _conditionalFlush() {
7349
- if (this.recordingMode === 'buffer') {
7350
- return;
7351
- }
7352
-
7353
- void this.flushImmediate();
7354
- }
7355
-
7356
7557
  /**
7357
7558
  * Clear _context
7358
7559
  */
@@ -7714,6 +7915,8 @@ class Replay {
7714
7915
  ignore = [],
7715
7916
  maskFn,
7716
7917
 
7918
+ beforeAddRecordingEvent,
7919
+
7717
7920
  // eslint-disable-next-line deprecation/deprecation
7718
7921
  blockClass,
7719
7922
  // eslint-disable-next-line deprecation/deprecation
@@ -7771,6 +7974,7 @@ class Replay {
7771
7974
  networkCaptureBodies,
7772
7975
  networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
7773
7976
  networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders),
7977
+ beforeAddRecordingEvent,
7774
7978
 
7775
7979
  _experiments,
7776
7980
  };